Compare commits

..

31 Commits

Author SHA1 Message Date
Rhet Turnbull
57485247fc Bumped version number 2019-12-22 13:02:30 -08:00
Rhet Turnbull
57f6a282d6 Added --export-edited to export 2019-12-22 13:00:03 -08:00
Rhet Turnbull
048e80599e Updated README 2019-12-22 10:47:29 -08:00
Rhet Turnbull
d73f6651f9 Test database updates 2019-12-22 10:45:23 -08:00
Rhet Turnbull
2519104928 Initial version of export added to command line 2019-12-22 10:43:45 -08:00
Rhet Turnbull
8a00318399 Updated README 2019-12-22 08:46:05 -08:00
Rhet Turnbull
9cdeeb389c Fixed bug in query related to refactoring 2019-12-22 08:35:47 -08:00
Rhet Turnbull
2a5f0a2299 Added --json to dump command 2019-12-22 08:15:21 -08:00
Rhet Turnbull
8ee8a38f0f fixed bug related to db_path properties re-factoring 2019-12-21 22:53:22 -08:00
Rhet Turnbull
2906773ba1 Updated README for sidecar usage 2019-12-21 22:18:15 -08:00
Rhet Turnbull
f643e79afd Added sidecar option to PhotoInfo.export() 2019-12-21 22:10:38 -08:00
Rhet Turnbull
e5c50fa944 Removed python3.8 -- pyobjc fails to run 2019-12-21 11:00:17 -08:00
Rhet Turnbull
9fcc7379e6 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-12-21 10:09:24 -08:00
Rhet Turnbull
d95acdf9f8 Moved PhotosDB attributes to properties instead of methods 2019-12-21 10:08:49 -08:00
Rhet Turnbull
1ddd90cbdc Refactored PhotoInfo to use properties instead of methods--major update 2019-12-21 09:38:54 -08:00
Rhet Turnbull
4f449087a6 Added python3.8 to workflow 2019-12-21 08:32:34 -08:00
Rhet Turnbull
2dc7bccfb7 Updated README 2019-12-21 08:30:39 -08:00
Rhet Turnbull
190adea3fc Renamed cmd_line so python3 -m osxphotos will work 2019-12-21 08:19:32 -08:00
Rhet Turnbull
4ac9c1a7a8 Updated doc strings 2019-12-21 08:12:34 -08:00
Rhet Turnbull
b794e226e3 Restructured entire code base to make it easier to maintain. Closes #16 2019-12-21 08:06:25 -08:00
Rhet Turnbull
cd51782ef2 test db update 2019-12-21 08:03:44 -08:00
Rhet Turnbull
18395933a5 removed old applescript code and files 2019-12-21 06:59:02 -08:00
Rhet Turnbull
591db8b5a6 Updated sidecar tests 2019-12-16 21:55:59 -08:00
Rhet Turnbull
eb7ec9b5c6 added alpha version of exiftool_json_sidecar to export() 2019-12-15 21:12:25 -08:00
Rhet Turnbull
1fe885962e changed interface for export, prepped for exiftool_json_sidecar 2019-12-15 19:21:04 -08:00
Rhet Turnbull
b35e9d73ab Updated TOC in README 2019-12-14 12:39:17 -08:00
Rhet Turnbull
c7b2b233e9 Added TOC to README; closes #24 2019-12-14 12:33:59 -08:00
Rhet Turnbull
bea1683b94 Updated exception handling in PhotosDB.__init__() 2019-12-14 10:55:11 -08:00
Rhet Turnbull
bf8aed69cf Updated export example 2019-12-14 10:35:39 -08:00
Rhet Turnbull
800daf3658 Added PhotoInfo.export(); closes #10 2019-12-14 10:29:06 -08:00
Rhet Turnbull
d5a5bd41b3 refactored private vars in PhotoInfo 2019-12-09 21:45:50 -08:00
75 changed files with 4261 additions and 4446 deletions

361
README.md
View File

@@ -3,17 +3,68 @@
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
- [OSXPhotos](#osxphotos)
* [What is osxphotos?](#what-is-osxphotos)
* [Supported operating systems](#supported-operating-systems)
* [Installation instructions](#installation-instructions)
* [Command Line Usage](#command-line-usage)
* [Example uses of the module](#example-uses-of-the-module)
* [Module Interface](#module-interface)
+ [PhotosDB](#photosdb)
- [Open the default Photos library](#open-the-default-photos-library)
- [Open System Photos library](#open-system-photos-library)
- [Open a specific Photos library](#open-a-specific-photos-library)
- [```keywords```](#keywords)
- [```albums```](#albums)
- [```persons```](#persons)
- [```keywords_as_dict```](#keywords_as_dict)
- [```persons_as_dict```](#persons_as_dict)
- [```albums_as_dict```](#albums_as_dict)
- [```library_path```](#library_path)
- [```db_path```](#db_path)
- [```db_version```](#db_version)
- [`photos(keywords=[], uuid=[], persons=[], albums=[])`](#photoskeywords-uuid-persons-albums)
+ [PhotoInfo](#photoinfo)
- [`uuid`](#uuid)
- [`filename`](#filename)
- [`original_filename`](#original_filename)
- [`date`](#date)
- [`description`](#description)
- [`title`](#title)
- [`keywords`](#keywords)
- [`albums`](#albums)
- [`persons`](#persons)
- [`path`](#path)
- [`path_edited`](#path_edited)
- [`ismissing`](#ismissing)
- [`hasadjustments`](#hasadjustments)
- [`external_edit`](#external_edit)
- [`favorite`](#favorite)
- [`hidden`](#hidden)
- [`location`](#location)
- [`json()`](#json)
- [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse)
+ [Utility Functions](#utility-functions)
- [```get_system_library_path()```](#get_system_library_path)
- [```get_last_library_path()```](#get_last_library_path)
- [```list_photo_libraries()```](#list_photo_libraries)
- [```dd_to_dms_str(lat, lon)```](#dd_to_dms_strlat-lon)
+ [Examples](#examples)
* [Related Projects](#related-projects)
* [Contributing](#contributing)
* [Implementation Notes](#implementation-notes)
* [Dependencies](#dependencies)
* [Acknowledgements](#acknowledgements)
## What is osxphotos?
OSXPhotos provides the ability to interact with and query Apple's Photos.app library database on MacOS. Using this module you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc.
OSXPhotos provides the ability to interact with and query Apple's Photos.app library database on MacOS. Using this module you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
**NOTE**: OSXPhotos currently only supports image files -- e.g. it does not handle movies.
## Supported operating systems
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0 and MacOS 10.14.5, 10.14.6 / Photos 4.0. Requires python >= 3.6
**NOTE**: Alpha support for Mac OS 10.15.0 / Photos 5.0. Photos 5.0 uses a new database format which required rewrite of much of the code for this module. If you find bugs, please open an [issue](https://github.com/RhetTbull/osxphotos/issues/).
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 / Photos 5.0. Requires python >= 3.6
This module will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12
@@ -26,14 +77,17 @@ osxmetadata uses setuptools, thus simply run:
## Command Line Usage
This module will install a command line utility called `osxphotos` that allows you to query the Photos database.
This module will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
If you only care about the command line tool, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
After installing pipx:
`pipx install osxphotos`
Then you should be able to run `osxphotos` on the command line:
```
> osxphotos
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
Options:
@@ -45,6 +99,7 @@ Options:
Commands:
albums Print out albums found in the Photos library.
dump Print list of all photos & associated info from the Photos...
export Export photos from the Photos database.
help Print help; for help on commands: help <command>.
info Print out descriptive info of the Photos library database.
keywords Print out keywords found in the Photos library.
@@ -69,11 +124,11 @@ Options:
--person TEXT Search for person(s).
--album TEXT Search for album(s).
--uuid TEXT Search for UUID(s).
--name TEXT Search for TEXT in name of photo.
--no-name Search for photos with no name.
--title TEXT Search for TEXT in title of photo.
--no-title Search for photos with no title.
--description TEXT Search for TEXT in description of photo.
--no-description Search for photos with no description.
-i, --ignore-case Case insensitive search for name or description. Does
-i, --ignore-case Case insensitive search for title or description. Does
not apply to keyword, person, or album.
--edited Search for photos that have been edited.
--external-edit Search for photos edited in external editor.
@@ -98,13 +153,13 @@ import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(photosdb.keywords())
print(photosdb.persons())
print(photosdb.albums())
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict())
print(photosdb.persons_as_dict())
print(photosdb.albums_as_dict())
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Foo and containing John Smith
photos = photosdb.photos(keywords=["Foo"],persons=["John Smith"])
@@ -115,37 +170,54 @@ def main():
for p in photos:
print(
p.uuid,
p.filename(),
p.original_filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.filename,
p.original_filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
)
if __name__ == "__main__":
main()
```
```python
""" Export all photos to ~/Desktop/export
If file has been edited, export the edited version,
otherwise, export the original version """
import os.path
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
export_path = os.path.expanduser("~/Desktop/export")
for p in photos:
if not p.ismissing:
if p.hasadjustments:
exported = p.export(export_path, edited=True)
else:
exported = p.export(export_path)
print(f"Exported {p.filename} to {exported}")
else:
print(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
main()
```
## Module Interface
### Utility Functions
#### ```get_system_library_path()```
**MacOS 10.15 Only** Returns path to System Photo Library as string. On MacOS version < 10.15, raises Exception.
#### ```get_last_library_path()```
Returns path to last opened Photo Library as string.
#### ```list_photo_libraries()```
Returns list of Photos libraries found on the system. **Note**: On MacOS 10.15, this appears to list all libraries. On older systems, it may not find some libraries if they are not located in ~/Pictures. Provided for convenience but do not rely on this to find all libraries on the system.
### PhotosDB
#### Open the default Photos library
@@ -212,7 +284,7 @@ Returns a PhotosDB object.
#### ```keywords```
```python
# assumes photosdb is a PhotosDB object (see above)
keywords = photosdb.keywords()
keywords = photosdb.keywords
```
Returns a list of the keywords found in the Photos library
@@ -220,7 +292,7 @@ Returns a list of the keywords found in the Photos library
#### ```albums```
```python
# assumes photosdb is a PhotosDB object (see above)
albums = photosdb.albums()
albums = photosdb.albums
```
Returns a list of the albums found in the Photos library.
@@ -230,7 +302,7 @@ Returns a list of the albums found in the Photos library.
#### ```persons```
```python
# assumes photosdb is a PhotosDB object (see above)
persons = photosdb.persons()
persons = photosdb.persons
```
Returns a list of the persons (faces) found in the Photos library
@@ -238,7 +310,7 @@ Returns a list of the persons (faces) found in the Photos library
#### ```keywords_as_dict```
```python
# assumes photosdb is a PhotosDB object (see above)
keyword_dict = photosdb.keywords_as_dict()
keyword_dict = photosdb.keywords_as_dict
```
Returns a dictionary of keywords found in the Photos library where key is the keyword and value is the count of how many times that keyword appears in the library (ie. how many photos are tagged with the keyword). Resulting dictionary is in reverse sorted order (e.g. keyword with the highest count is first).
@@ -246,7 +318,7 @@ Returns a dictionary of keywords found in the Photos library where key is the ke
#### ```persons_as_dict```
```python
# assumes photosdb is a PhotosDB object (see above)
persons_dict = photosdb.persons_as_dict()
persons_dict = photosdb.persons_as_dict
```
Returns a dictionary of persons (faces) found in the Photos library where key is the person name and value is the count of how many times that person appears in the library (ie. how many photos are tagged with the person). Resulting dictionary is in reverse sorted order (e.g. person who appears in the most photos is listed first).
@@ -254,39 +326,40 @@ Returns a dictionary of persons (faces) found in the Photos library where key is
#### ```albums_as_dict```
```python
# assumes photosdb is a PhotosDB object (see above)
albums_dict = photosdb.albums_as_dict()
albums_dict = photosdb.albums_as_dict
```
Returns a dictionary of albums found in the Photos library where key is the album name and value is the count of how many photos are in the album. Resulting dictionary is in reverse sorted order (e.g. album with the most photos is listed first).
**Note**: In Photos 5.0 (MacOS 10.15/Catalina), It is possible to have more than one album with the same name in Photos. Albums with duplicate names are treated as a single album and the photos in each are combined. For example, if you have two albums named "Wedding" and each has 2 photos, osxphotos will treat this as a single album named "Wedding" with 4 photos in it.
#### ```get_library_path```
#### ```library_path```
```python
# assumes photosdb is a PhotosDB object (see above)
photosdb.get_photos_library_path()
photosdb.library_path
```
Returns the path to the Photos library as a string
#### ```get_db_path```
#### ```db_path```
```python
# assumes photosdb is a PhotosDB object (see above)
photosdb.get_db_path()
photosdb.db_path
```
Returns the path to the Photos database PhotosDB was initialized with
#### ```get_db_version```
#### ```db_version```
```python
# assumes photosdb is a PhotosDB object (see above)
photosdb.get_db_version()
photosdb.db_version
```
Returns the version number for Photos library database. You likely won't need this but it's provided in case needed for debugging. PhotosDB will print a warning to `sys.stderr` if you open a database version that has not been tested.
#### ```photos```
#### `photos(keywords=[], uuid=[], persons=[], albums=[])`
```python
# assumes photosdb is a PhotosDB object (see above)
photos = photosdb.photos([keywords=['keyword',]], [uuid=['uuid',]], [persons=['person',]], [albums=['album',]])
@@ -355,92 +428,182 @@ photos3 = [p for p in photos2 if p not in photos1]
### PhotoInfo
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
#### `uuid()`
#### `uuid`
Returns the universally unique identifier (uuid) of the photo. This is how Photos keeps track of individual photos within the database.
#### `filename()`
Returns the current filename of the photo on disk. See also `original_filename()`
#### `filename`
Returns the current filename of the photo on disk. See also `original_filename`
#### `original_filename()`
Returns the original filename of the photo when it was imported to Photos. **Note**: Photos 5.0+ renames the photo when it adds the file to the library using UUID. See also `filename()`
#### `original_filename`
Returns the original filename of the photo when it was imported to Photos. **Note**: Photos 5.0+ renames the photo when it adds the file to the library using UUID. See also `filename`
#### `date()`
#### `date`
Returns the date of the photo as a datetime.datetime object
#### `description()`
#### `description`
Returns the description of the photo
#### `name()`
Returns the name (or the title as Photos calls it) of the photo
#### `title`
Returns the title of the photo
#### `keywords()`
#### `keywords`
Returns a list of keywords (e.g. tags) applied to the photo
#### `albums()`
#### `albums`
Returns a list of albums the photo is contained in
#### `persons()`
#### `persons`
Returns a list of the names of the persons in the photo
#### `path()`
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see `hasadjustments()`). If the file is missing on disk, path=`None` (see `ismissing()`)
#### `path`
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see `hasadjustments`). If the file is missing on disk, path=`None` (see `ismissing`)
#### `path_edited()`
Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also `path()` and `hasadjustments()`.
#### `path_edited`
Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also `path` and `hasadjustments`.
#### `ismissing()`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. **Note**: this status is set by Photos and osxphotos does not verify that the file path returned by `path()` actually exists. It merely reports what Photos has stored in the library database.
#### `ismissing`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. **Note**: this status is set by Photos and osxphotos does not verify that the file path returned by `path` actually exists. It merely reports what Photos has stored in the library database.
#### `hasadjustments()`
#### `hasadjustments`
Returns `True` if the picture has been edited, otherwise `False`
#### `external_edit()`
#### `external_edit`
Returns `True` if the picture was edited in an external editor (outside Photos.app), otherwise `False`
#### `favorite()`
#### `favorite`
Returns `True` if the picture has been marked as a favorite, otherwise `False`
#### `hidden()`
#### `hidden`
Returns `True` if the picture has been marked as hidden, otherwise `False`
#### `location()`
#### `location`
Returns latitude and longitude as a tuple of floats (latitude, longitude). If location is not set, latitude and longitude are returned as `None`
#### `to_json()`
#### `json()`
Returns a JSON representation of all photo info
Examples:
#### `export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
- *filename (optional): name of picture as str; if not provided, will use current filename
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
- increment: boolean; if True (default=True), will increment file name until a non-existant name is found
- sidecar: boolean; if True (default=False) will also write a json sidecar file with EXIF data in format readable by [exiftool](https://exiftool.org/); filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg)
The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example:
```python
# assumes photosdb is a PhotosDB object (see above)
photos=photosdb.photos()
for p in photos:
print(
p.uuid(),
p.filename(),
p.original_filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.ismissing(),
p.hasadjustments(),
)
import osxphotos
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
photos[0].export("/tmp","photo_name.jpg",sidecar=True)
```
## History
Then
This project started as a command line utility, `photosmeta`, available at [photosmeta](https://github.com/RhetTbull/photosmeta) This module converts the photosmeta Photos library query functionality into a module.
`exiftool -j=photo_name.jpg.json photo_name.jpg`
If overwrite=False and increment=False, export will fail if destination file already exists
Returns the full path to the exported file
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses /usr/bin/ditto to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
### Utility Functions
The following functions are located in osxphotos.utils
#### ```get_system_library_path()```
**MacOS 10.15 Only** Returns path to System Photo Library as string. On MacOS version < 10.15, raises Exception.
#### ```get_last_library_path()```
Returns path to last opened Photo Library as string.
#### ```list_photo_libraries()```
Returns list of Photos libraries found on the system. **Note**: On MacOS 10.15, this appears to list all libraries. On older systems, it may not find some libraries if they are not located in ~/Pictures. Provided for convenience but do not rely on this to find all libraries on the system.
#### ```dd_to_dms_str(lat, lon)```
Convert latitude, longitude in degrees to degrees, minutes, seconds as string.
lat: latitude in degrees
lon: longitude in degrees
returns: string tuple in format ("51 deg 30' 12.86\\" N", "0 deg 7' 54.50\\" W")
This is the same format used by exiftool's json format.
### Examples
```python
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(f"db file = {photosdb.db_path}")
print(f"db version = {photosdb.db_version}")
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Kids and containing person Katie
photos = photosdb.photos(keywords=["Kids"], persons=["Katie"])
print(f"found {len(photos)} photos")
# find all photos that include Katie but do not contain the keyword wedding
photos = [
p
for p in photosdb.photos(persons=["Katie"])
if p not in photosdb.photos(keywords=["wedding"])
]
# get all photos in the database
photos = photosdb.photos()
for p in photos:
print(
p.uuid,
p.filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
p.ismissing,
p.hasadjustments,
)
if __name__ == "__main__":
main()
```
## Related Projects
[photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files.
## Contributing
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/).
I'll gladly consider pull requests for bug fixes or feature implementations.
If you have an interesting example that shows usage of this module, submit an issue or pull request and i'll include it or link to it.
## Implementation Notes
This module works by creating a copy of the sqlite3 database that Photos uses to store data about the Photos library. The class PhotosDB then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc.
This module works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. the class photosdb then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc.
If Apple changes the database format this will likely break.
If apple changes the database format this will likely break.
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this module using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
@@ -450,9 +613,5 @@ Apple does provide a framework ([PhotoKit](https://developer.apple.com/documenta
- [Click](https://pypi.org/project/click/)
## Acknowledgements
This project was inspired by photo-export by Patrick Fältström see: (https://github.com/patrikhson/photo-export) Copyright (c) 2015 Patrik Fältström paf@frobbit.se
This project was originally inspired by photo-export by Patrick Fältström see: (https://github.com/patrikhson/photo-export) Copyright (c) 2015 Patrik Fältström paf@frobbit.se
To interact with the Photos app, I use [py-applescript]( https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee". Rather than import this module, I included the entire module
(which is published as public domain code) in a private module to prevent ambiguity with
other applescript modules on PyPi. py-applescript uses a native bridge via PyObjC and
is very fast compared to the other osascript based modules.

View File

@@ -3,16 +3,16 @@ import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(f"db file = {photosdb.get_db_path()}")
print(f"db version = {photosdb.get_db_version()}")
print(f"db file = {photosdb.db_path}")
print(f"db version = {photosdb.db_version}")
print(photosdb.keywords())
print(photosdb.persons())
print(photosdb.albums())
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict())
print(photosdb.persons_as_dict())
print(photosdb.albums_as_dict())
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Kids and containing person Katie
photos = photosdb.photos(keywords=["Kids"], persons=["Katie"])
@@ -29,17 +29,17 @@ def main():
photos = photosdb.photos()
for p in photos:
print(
p.uuid(),
p.filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.ismissing(),
p.hasadjustments(),
p.uuid,
p.filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
p.ismissing,
p.hasadjustments,
)

28
examples/export.py Normal file
View File

@@ -0,0 +1,28 @@
""" Export all photos to ~/Desktop/export
If file has been edited, export the edited version,
otherwise, export the original version """
import os.path
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
export_path = os.path.expanduser("~/Desktop/export")
for p in photos:
if not p.ismissing:
if p.hasadjustments:
exported = p.export(export_path, edited=True)
else:
exported = p.export(export_path)
print(f"Exported {p.filename} to {exported}")
else:
print(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

690
osxphotos/__main__.py Normal file
View File

@@ -0,0 +1,690 @@
import csv
import datetime
import json
import os
import os.path
import pathlib
import sys
import click
import yaml
import osxphotos
from ._constants import _EXIF_TOOL_URL
from ._version import __version__
# TODO: add "--any" to search any field (e.g. keyword, description, title contains "wedding") (add case insensitive option)
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
if debug:
osxphotos._debug(True)
self.db = db
self.json = json
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CTX_SETTINGS)
@click.option(
"--db",
required=False,
metavar="<Photos database path>",
default=None,
help="Specify database file.",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format.",
)
@click.option("--debug", required=False, is_flag=True, default=False, hidden=True)
@click.version_option(__version__, "--version", "-v")
@click.pass_context
def cli(ctx, db, json, debug):
ctx.obj = CLI_Obj(db=db, json=json, debug=debug)
@cli.command()
@click.pass_obj
def keywords(cli_obj):
""" Print out keywords found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
keywords = {"keywords": photosdb.keywords_as_dict}
if cli_obj.json:
click.echo(json.dumps(keywords))
else:
click.echo(yaml.dump(keywords, sort_keys=False))
@cli.command()
@click.pass_obj
def albums(cli_obj):
""" Print out albums found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
albums = {"albums": photosdb.albums_as_dict}
if cli_obj.json:
click.echo(json.dumps(albums))
else:
click.echo(yaml.dump(albums, sort_keys=False))
@cli.command()
@click.pass_obj
def persons(cli_obj):
""" Print out persons (faces) found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
persons = {"persons": photosdb.persons_as_dict}
if cli_obj.json:
click.echo(json.dumps(persons))
else:
click.echo(yaml.dump(persons, sort_keys=False))
@cli.command()
@click.pass_obj
def info(cli_obj):
""" Print out descriptive info of the Photos library database. """
pdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
info = {}
info["database_path"] = pdb.db_path
info["database_version"] = pdb.db_version
photos = pdb.photos()
info["photo_count"] = len(photos)
keywords = pdb.keywords_as_dict
info["keywords_count"] = len(keywords)
info["keywords"] = keywords
albums = pdb.albums_as_dict
info["albums_count"] = len(albums)
info["albums"] = albums
persons = pdb.persons_as_dict
# handle empty person names (added by Photos 5.0+ when face detected but not identified)
# TODO: remove this
# noperson = "UNKNOWN"
# if "" in persons:
# if noperson in persons:
# persons[noperson].append(persons[""])
# else:
# persons[noperson] = persons[""]
# persons.pop("", None)
info["persons_count"] = len(persons)
info["persons"] = persons
if cli_obj.json:
click.echo(json.dumps(info))
else:
click.echo(yaml.dump(info, sort_keys=False))
@cli.command()
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format.",
)
@click.pass_obj
def dump(cli_obj, json):
""" Print list of all photos & associated info from the Photos library. """
pdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
photos = pdb.photos()
print_photo_info(photos, cli_obj.json or json)
@cli.command(name="list")
@click.pass_obj
def list_libraries(cli_obj):
""" Print list of Photos libraries found on the system. """
photo_libs = osxphotos.utils.list_photo_libraries()
sys_lib = None
_, major, _ = osxphotos.utils._get_os_version()
if int(major) >= 15:
sys_lib = osxphotos.utils.get_system_library_path()
last_lib = osxphotos.utils.get_last_library_path()
last_lib_flag = sys_lib_flag = False
for lib in photo_libs:
if lib == sys_lib:
click.echo(f"(*)\t{lib}")
sys_lib_flag = True
elif lib == last_lib:
click.echo(f"(#)\t{lib}")
last_lib_flag = True
else:
click.echo(f"\t{lib}")
if sys_lib_flag or last_lib_flag:
click.echo("\n")
if sys_lib_flag:
click.echo("(*)\tSystem Photos Library")
if last_lib_flag:
click.echo("(#)\tLast opened Photos Library")
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="Search for keyword(s).")
@click.option("--person", default=None, multiple=True, help="Search for person(s).")
@click.option("--album", default=None, multiple=True, help="Search for album(s).")
@click.option("--uuid", default=None, multiple=True, help="Search for UUID(s).")
@click.option(
"--title", default=None, multiple=True, help="Search for TEXT in title of photo."
)
@click.option("--no-title", is_flag=True, help="Search for photos with no title.")
@click.option(
"--description",
default=None,
multiple=True,
help="Search for TEXT in description of photo.",
)
@click.option(
"--no-description", is_flag=True, help="Search for photos with no description."
)
@click.option(
"-i",
"--ignore-case",
is_flag=True,
help="Case insensitive search for title or description. Does not apply to keyword, person, or album.",
)
@click.option("--edited", is_flag=True, help="Search for photos that have been edited.")
@click.option(
"--external-edit", is_flag=True, help="Search for photos edited in external editor."
)
@click.option("--favorite", is_flag=True, help="Search for photos marked favorite.")
@click.option(
"--not-favorite", is_flag=True, help="Search for photos not marked favorite."
)
@click.option("--hidden", is_flag=True, help="Search for photos marked hidden.")
@click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.")
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
@click.option(
"--not-missing",
is_flag=True,
help="Search for photos present on disk (e.g. not missing).",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_obj
@click.pass_context
def query(
ctx,
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
):
""" 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
if not any(
[
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
]
):
click.echo(cli.commands["query"].get_help(ctx))
return
elif favorite and not_favorite:
# can't search for both favorite and notfavorite
click.echo(cli.commands["query"].get_help(ctx))
return
elif hidden and not_hidden:
# can't search for both hidden and nothidden
click.echo(cli.commands["query"].get_help(ctx))
return
elif missing and not_missing:
# can't search for both missing and notmissing
click.echo(cli.commands["query"].get_help(ctx))
return
elif title and no_title:
# can't search for both title and no_title
click.echo(cli.commands["query"].get_help(ctx))
return
elif description and no_description:
# can't search for both description and no_description
click.echo(cli.commands["query"].get_help(ctx))
return
else:
photos = _query(
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
)
print_photo_info(photos, cli_obj.json or json)
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="Search for keyword(s).")
@click.option("--person", default=None, multiple=True, help="Search for person(s).")
@click.option("--album", default=None, multiple=True, help="Search for album(s).")
@click.option("--uuid", default=None, multiple=True, help="Search for UUID(s).")
@click.option(
"--title", default=None, multiple=True, help="Search for TEXT in title of photo."
)
@click.option("--no-title", is_flag=True, help="Search for photos with no title.")
@click.option(
"--description",
default=None,
multiple=True,
help="Search for TEXT in description of photo.",
)
@click.option(
"--no-description", is_flag=True, help="Search for photos with no description."
)
@click.option(
"-i",
"--ignore-case",
is_flag=True,
help="Case insensitive search for title or description. Does not apply to keyword, person, or album.",
)
@click.option("--edited", is_flag=True, help="Search for photos that have been edited.")
@click.option(
"--external-edit", is_flag=True, help="Search for photos edited in external editor."
)
@click.option("--favorite", is_flag=True, help="Search for photos marked favorite.")
@click.option(
"--not-favorite", is_flag=True, help="Search for photos not marked favorite."
)
@click.option("--hidden", is_flag=True, help="Search for photos marked hidden.")
@click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.")
@click.option("--verbose", is_flag=True, help="Print verbose output.")
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files. "
"Default behavior is to add (1), (2), etc to filename if file already exists. "
"Use this with caution as it may create name collisions on export. "
"(e.g. if two files happen to have the same name)",
)
@click.option(
"--export-by-date",
is_flag=True,
help="Automatically create output folders to organize photos by date created "
"(e.g. DEST/2019/12/20/photoname.jpg).",
)
@click.option(
"--export-edited",
is_flag=True,
help="Also export edited version of photo "
'if an edited version exists. Edited photo will be named in form of "photoname_edited.ext"',
)
@click.option(
"--sidecar",
is_flag=True,
help="Create json sidecar for each photo exported "
f"in format 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 where ext is extension of the photo (e.g. jpg)",
)
@click.argument("dest", nargs=1)
@click.pass_obj
@click.pass_context
def export(
ctx,
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
verbose,
overwrite,
export_by_date,
export_edited,
sidecar,
dest,
):
""" 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.
"""
# TODO: --export-edited, --export-original
if not os.path.isdir(dest):
sys.exit("DEST must be valid path")
# if no query terms, show help and return
photos = _query(
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
None, # missing -- won't export these but will warn user
None, # not-missing
)
if photos:
num_photos = len(photos)
photo_str = "photos" if num_photos > 1 else "photo"
click.echo(f"Exporting {num_photos} {photo_str} to {dest}...")
if not verbose:
# show progress bar
with click.progressbar(photos) as bar:
for p in bar:
export_photo(
p,
dest,
verbose,
export_by_date,
sidecar,
overwrite,
export_edited,
)
else:
for p in photos:
export_path = export_photo(
p, dest, verbose, export_by_date, sidecar, overwrite, export_edited
)
click.echo(f"Exported {p.filename} to {export_path}")
else:
click.echo("Did not find any photos to export")
@cli.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" Print help; for help on commands: help <command>. """
if topic is None:
click.echo(ctx.parent.get_help())
else:
click.echo(cli.commands[topic].get_help(ctx))
def print_photo_info(photos, json=False):
if json:
dump = []
for p in photos:
dump.append(p.json())
click.echo(f"[{', '.join(dump)}]")
else:
# dump as CSV
csv_writer = csv.writer(
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
)
dump = []
# add headers
dump.append(
[
"uuid",
"filename",
"original_filename",
"date",
"description",
"title",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
"external_edit",
"favorite",
"hidden",
"latitude",
"longitude",
"path_edited",
]
)
for p in photos:
dump.append(
[
p.uuid,
p.filename,
p.original_filename,
str(p.date),
p.description,
p.title,
", ".join(p.keywords),
", ".join(p.albums),
", ".join(p.persons),
p.path,
p.ismissing,
p.hasadjustments,
p.external_edit,
p.favorite,
p.hidden,
p._latitude,
p._longitude,
p.path_edited,
]
)
for row in dump:
csv_writer.writerow(row)
def _query(
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
):
""" 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=cli_obj.db)
photos = photosdb.photos(keywords=keyword, persons=person, albums=album, uuid=uuid)
if title:
# search title field for text
# if more than one, find photos with all title values in title
if ignore_case:
# case-insensitive
for t in title:
t = t.lower()
photos = [p for p in photos if p.title and t in p.title.lower()]
else:
for t in title:
photos = [p for p in photos if p.title and t in p.title]
elif no_title:
photos = [p for p in photos if not p.title]
if description:
# search description field for text
# if more than one, find photos with all name values in in description
if ignore_case:
# case-insensitive
for d in description:
d = d.lower()
photos = [
p for p in photos if p.description and d in p.description.lower()
]
else:
for d in description:
photos = [p for p in photos if p.description and d in p.description]
elif no_description:
photos = [p for p in photos if not p.description]
if edited:
photos = [p for p in photos if p.hasadjustments]
if external_edit:
photos = [p for p in photos if p.external_edit]
if favorite:
photos = [p for p in photos if p.favorite]
elif not_favorite:
photos = [p for p in photos if not p.favorite]
if hidden:
photos = [p for p in photos if p.hidden]
elif not_hidden:
photos = [p for p in photos if not p.hidden]
if missing:
photos = [p for p in photos if p.ismissing]
elif not_missing:
photos = [p for p in photos if not p.ismissing]
return photos
def export_photo(
photo, dest, verbose, export_by_date, sidecar, overwrite, export_edited
):
""" Helper function for export that does the actual export
photo: PhotoInfo object
dest: destination path as string
verbose: boolean; print verbose output
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
sidecar: boolean; create json sidecar file with export
overwrite: boolean; overwrite dest file if it already exists
returns destination path of exported photo or None if photo was missing
"""
if photo.ismissing:
space = " " if not verbose else ""
click.echo(f"{space}Skipping missing photos {photo.filename}")
return None
if verbose:
click.echo(f"Exporting {photo.filename}")
if export_by_date:
date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created)
photo_path = photo.export(dest, sidecar=sidecar, overwrite=overwrite)
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments and photo.path_edited is not None:
edited_name = pathlib.Path(photo.filename)
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
if verbose:
click.echo(f"Exporting edited version of {photo.filename} as {edited_name}")
photo.export(
dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True
)
return photo_path
def create_path_by_date(dest, dt):
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str
dt: datetime.timetuple() object
Checks to see if path exists, if it does, do nothing and return path
If path does not exist, creates it and returns path"""
if not os.path.isdir(dest):
raise FileNotFoundError(f"dest {dest} must be valid path")
yyyy, mm, dd = dt[0:3]
yyyy = str(yyyy).zfill(4)
mm = str(mm).zfill(2)
dd = str(dd).zfill(2)
new_dest = os.path.join(dest, yyyy, mm, dd)
if not os.path.isdir(new_dest):
os.makedirs(new_dest)
return new_dest
if __name__ == "__main__":
cli()

View File

@@ -1,207 +0,0 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
"""
This code is from py-applescript, a public domain package available at:
https://github.com/rdhyee/py-applescript
I've included the whole thing here for simplicity as there is more than one
applescript packge on PyPi so there's ambiguity as to which one "import applescript"
would use if user had installed another library.
This package is used instead of the others because it uses a native PyObjC
bridge and is thus much faster than others which use osascript.
"""
import sys
from Foundation import (
NSAppleScript,
NSAppleEventDescriptor,
NSURL,
NSAppleScriptErrorMessage,
NSAppleScriptErrorBriefMessage,
NSAppleScriptErrorNumber,
NSAppleScriptErrorAppName,
NSAppleScriptErrorRange,
)
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ["AppleScript", "ScriptError", "AEType", "AEEnum", "kMissingValue", "kae"]
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(
url, None
)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return "AppleScript({})".format(
repr(s) if len(s) < 100 else "{}...{}".format(repr(s)[:80], repr(s)[-17:])
)
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite),
fourcharcode(code),
NSAppleEventDescriptor.nullDescriptor(),
0,
0,
)
evt.setDescriptor_forKeyword_(
self._codecs.pack(args), fourcharcode(kae.keyDirectObject)
)
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(
lambda self: str(self._script.source()), doc="str -- the script's source code"
)
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(
kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args
)
evt.setDescriptor_forKeyword_(
NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName),
)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return "ScriptError({})".format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, "Script Error")
return msg
number = property(
lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given",
)
appname = property(
lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant",
)
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [
(" ({})", self.number),
(" app={!r}", self.appname),
(" range={0[0]}-{0[1]}", self.range),
]:
if v is not None:
msg += s.format(v)
return (
msg.encode("ascii", "replace") if sys.version_info.major < 3 else msg
) # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

23
osxphotos/_constants.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Constants used by osxphotos
"""
# which Photos library database versions have been tested
# Photos 2.0 (10.12.6) == 2622
# Photos 3.0 (10.13.6) == 3301
# Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.14.6) == 4025
# Photos 5.0 (10.15.0) == 6000
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# versions later than this have a different database structure
_PHOTOS_5_VERSION = "6000"
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
# Photos 5 has persons who are empty string if unidentified face
_UNKNOWN_PERSON = "_UNKNOWN_"
_EXIF_TOOL_URL = "https://exiftool.org/"

View File

@@ -1,4 +1,3 @@
""" version info """
__version__ = "0.14.21"
__version__ = "0.17.02"

View File

@@ -1,294 +0,0 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ["Codecs", "AEType", "AEEnum"]
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack(">I", code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {
fourcharcode(k): v
for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()
}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for (
type,
encoder,
) in (
self.encoders.items()
): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError(
"Can't pack data into an AEDesc (unsupported type): {!r}".format(data)
)
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack(">I", desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data)
)
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2 ** 31) <= val < (2 ** 31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack("d", val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack("q", sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(
value, AEType
): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(
self.pack(value), fourcharcode(key.code)
)
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(
self.pack(desctype), fourcharcode(key.code)
)
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack("d", bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(
seconds=struct.unpack("q", bytes(desc.data()))[0]
)
def unpackaelist(self, desc):
return [
self.unpack(desc.descriptorAtIndex_(i + 1))
for i in range(desc.numberOfItems())
]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i + 1]
else:
dct[AEType(struct.pack(">I", key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack(">I", desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack(">I", desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(
desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()
).decode("utf8")
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError("invalid code (not a bytes object): {!r}".format(code))
elif len(code) != 4:
raise ValueError("invalid code (not four bytes long): {!r}".format(code))
self._code = code
code = property(
lambda self: self._code, doc="bytes -- four-char code, e.g. b'utxt'"
)
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

View File

@@ -1,422 +0,0 @@
import csv
import json
import sys
import click
import yaml
import osxphotos
from ._version import __version__
# TODO: add "--any" to search any field (e.g. keyword, description, name contains "wedding") (add case insensitive option)
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
if debug:
osxphotos._debug(True)
self.db = db
self.json = json
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CTX_SETTINGS)
@click.option(
"--db",
required=False,
metavar="<Photos database path>",
default=None,
help="Specify database file.",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format.",
)
@click.option("--debug", required=False, is_flag=True, default=False, hidden=True)
@click.version_option(__version__, "--version", "-v")
@click.pass_context
def cli(ctx, db, json, debug):
ctx.obj = CLI_Obj(db=db, json=json, debug=debug)
@cli.command()
@click.pass_obj
def keywords(cli_obj):
""" Print out keywords found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
keywords = {"keywords": photosdb.keywords_as_dict()}
if cli_obj.json:
click.echo(json.dumps(keywords))
else:
click.echo(yaml.dump(keywords, sort_keys=False))
@cli.command()
@click.pass_obj
def albums(cli_obj):
""" Print out albums found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
albums = {"albums": photosdb.albums_as_dict()}
if cli_obj.json:
click.echo(json.dumps(albums))
else:
click.echo(yaml.dump(albums, sort_keys=False))
@cli.command()
@click.pass_obj
def persons(cli_obj):
""" Print out persons (faces) found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
persons = {"persons": photosdb.persons_as_dict()}
if cli_obj.json:
click.echo(json.dumps(persons))
else:
click.echo(yaml.dump(persons, sort_keys=False))
@cli.command()
@click.pass_obj
def info(cli_obj):
""" Print out descriptive info of the Photos library database. """
pdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
info = {}
info["database_path"] = pdb.get_db_path()
info["database_version"] = pdb.get_db_version()
photos = pdb.photos()
info["photo_count"] = len(photos)
keywords = pdb.keywords_as_dict()
info["keywords_count"] = len(keywords)
info["keywords"] = keywords
albums = pdb.albums_as_dict()
info["albums_count"] = len(albums)
info["albums"] = albums
persons = pdb.persons_as_dict()
# handle empty person names (added by Photos 5.0+ when face detected but not identified)
# TODO: remove this
# noperson = "UNKNOWN"
# if "" in persons:
# if noperson in persons:
# persons[noperson].append(persons[""])
# else:
# persons[noperson] = persons[""]
# persons.pop("", None)
info["persons_count"] = len(persons)
info["persons"] = persons
if cli_obj.json:
click.echo(json.dumps(info))
else:
click.echo(yaml.dump(info, sort_keys=False))
@cli.command()
@click.pass_obj
def dump(cli_obj):
""" Print list of all photos & associated info from the Photos library. """
pdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
photos = pdb.photos()
print_photo_info(photos, cli_obj.json)
@cli.command(name="list")
@click.pass_obj
def list_libraries(cli_obj):
""" Print list of Photos libraries found on the system. """
photo_libs = osxphotos.list_photo_libraries()
sys_lib = None
_, major, _ = osxphotos._get_os_version()
if int(major) >= 15:
sys_lib = osxphotos.get_system_library_path()
last_lib = osxphotos.get_last_library_path()
last_lib_flag = sys_lib_flag = False
for lib in photo_libs:
if lib == sys_lib:
click.echo(f"(*)\t{lib}")
sys_lib_flag = True
elif lib == last_lib:
click.echo(f"(#)\t{lib}")
last_lib_flag = True
else:
click.echo(f"\t{lib}")
if sys_lib_flag or last_lib_flag:
click.echo("\n")
if sys_lib_flag:
click.echo("(*)\tSystem Photos Library")
if last_lib_flag:
click.echo("(#)\tLast opened Photos Library")
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="Search for keyword(s).")
@click.option("--person", default=None, multiple=True, help="Search for person(s).")
@click.option("--album", default=None, multiple=True, help="Search for album(s).")
@click.option("--uuid", default=None, multiple=True, help="Search for UUID(s).")
@click.option(
"--name", default=None, multiple=True, help="Search for TEXT in name of photo."
)
@click.option("--no-name", is_flag=True, help="Search for photos with no name.")
@click.option(
"--description",
default=None,
multiple=True,
help="Search for TEXT in description of photo.",
)
@click.option(
"--no-description", is_flag=True, help="Search for photos with no description."
)
@click.option(
"-i",
"--ignore-case",
is_flag=True,
help="Case insensitive search for name or description. Does not apply to keyword, person, or album.",
)
@click.option("--edited", is_flag=True, help="Search for photos that have been edited.")
@click.option(
"--external-edit", is_flag=True, help="Search for photos edited in external editor."
)
@click.option("--favorite", is_flag=True, help="Search for photos marked favorite.")
@click.option(
"--not-favorite", is_flag=True, help="Search for photos not marked favorite."
)
@click.option("--hidden", is_flag=True, help="Search for photos marked hidden.")
@click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.")
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
@click.option(
"--not-missing",
is_flag=True,
help="Search for photos present on disk (e.g. not missing).",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_obj
@click.pass_context
def query(
ctx,
cli_obj,
keyword,
person,
album,
uuid,
name,
no_name,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
):
""" 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
if not any(
[
keyword,
person,
album,
uuid,
name,
no_name,
description,
no_description,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
]
):
click.echo(cli.commands["query"].get_help(ctx))
return
elif favorite and not_favorite:
# can't search for both favorite and notfavorite
click.echo(cli.commands["query"].get_help(ctx))
return
elif hidden and not_hidden:
# can't search for both hidden and nothidden
click.echo(cli.commands["query"].get_help(ctx))
return
elif missing and not_missing:
# can't search for both missing and notmissing
click.echo(cli.commands["query"].get_help(ctx))
return
elif name and no_name:
# can't search for both name and no_name
click.echo(cli.commands["query"].get_help(ctx))
return
elif description and no_description:
# can't search for both description and no_description
click.echo(cli.commands["query"].get_help(ctx))
return
else:
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
photos = photosdb.photos(
keywords=keyword, persons=person, albums=album, uuid=uuid
)
if name:
# search name field for text
# if more than one, find photos with all name values in in name
if ignore_case:
# case-insensitive
for n in name:
n = n.lower()
photos = [p for p in photos if p.name() and n in p.name().lower()]
else:
for n in name:
photos = [p for p in photos if p.name() and n in p.name()]
elif no_name:
photos = [p for p in photos if not p.name()]
if description:
# search description field for text
# if more than one, find photos with all name values in in description
if ignore_case:
# case-insensitive
for d in description:
d = d.lower()
photos = [
p
for p in photos
if p.description() and d in p.description().lower()
]
else:
for d in description:
photos = [
p for p in photos if p.description() and d in p.description()
]
elif no_description:
photos = [p for p in photos if not p.description()]
if edited:
photos = [p for p in photos if p.hasadjustments()]
if external_edit:
photos = [p for p in photos if p.external_edit()]
if favorite:
photos = [p for p in photos if p.favorite()]
elif not_favorite:
photos = [p for p in photos if not p.favorite()]
if hidden:
photos = [p for p in photos if p.hidden()]
elif not_hidden:
photos = [p for p in photos if not p.hidden()]
if missing:
photos = [p for p in photos if p.ismissing()]
elif not_missing:
photos = [p for p in photos if not p.ismissing()]
print_photo_info(photos, cli_obj.json or json)
@cli.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" Print help; for help on commands: help <command>. """
if topic is None:
click.echo(ctx.parent.get_help())
else:
click.echo(cli.commands[topic].get_help(ctx))
def print_photo_info(photos, json=False):
if json:
dump = []
for p in photos:
dump.append(p.to_json())
click.echo(f"[{', '.join(dump)}]")
else:
# dump as CSV
csv_writer = csv.writer(
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
)
dump = []
# add headers
dump.append(
[
"uuid",
"filename",
"original_filename",
"date",
"description",
"name",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
"external_edit",
"favorite",
"hidden",
"latitude",
"longitude",
"path_edited",
]
)
for p in photos:
dump.append(
[
p.uuid(),
p.filename(),
p.original_filename(),
str(p.date()),
p.description(),
p.name(),
", ".join(p.keywords()),
", ".join(p.albums()),
", ".join(p.persons()),
p.path(),
p.ismissing(),
p.hasadjustments(),
p.external_edit(),
p.favorite(),
p.hidden(),
p._latitude(),
p._longitude(),
p.path_edited(),
]
)
for row in dump:
csv_writer.writerow(row)
if __name__ == "__main__":
cli()

File diff suppressed because it is too large Load Diff

511
osxphotos/photoinfo.py Normal file
View File

@@ -0,0 +1,511 @@
"""
PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects
"""
import json
import logging
import os.path
import pathlib
import re
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
import yaml
from ._constants import _PHOTOS_5_VERSION
from .utils import _get_resource_loc, dd_to_dms_str
class PhotoInfo:
"""
Info about a specific photo, contains all the details about the photo
including keywords, persons, albums, uuid, path, etc.
"""
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
self._info = info
self._db = db
@property
def filename(self):
""" filename of the picture """
return self._info["filename"]
@property
def original_filename(self):
""" original filename of the picture """
""" Photos 5 mangles filenames upon import """
return self._info["originalFilename"]
@property
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self._info["imageDate"]
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
@property
def tzoffset(self):
""" timezone offset from UTC in seconds """
return self._info["imageTimeZoneOffsetSeconds"]
@property
def path(self):
""" absolute path on disk of the original picture """
photopath = ""
if self._db._db_version < _PHOTOS_5_VERSION:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if self._info["isMissing"] == 1:
photopath = None # path would be meaningless until downloaded
# TODO: Is there a way to use applescript or PhotoKit to force the download in this
else:
if self._info["masterFingerprint"]:
# if masterFingerprint is not null, path appears to be valid
if self._info["directory"].startswith("/"):
photopath = os.path.join(
self._info["directory"], self._info["filename"]
)
else:
photopath = os.path.join(
self._db._masters_path,
self._info["directory"],
self._info["filename"],
)
else:
photopath = None
logging.debug(f"WARNING: masterFingerprint null {pformat(self._info)}")
# TODO: fix the logic for isMissing
if self._info["isMissing"] == 1:
photopath = None # path would be meaningless until downloaded
logging.debug(photopath)
return photopath
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
""" None if photo has not been edited """
photopath = ""
if self._db._db_version < _PHOTOS_5_VERSION:
if self._info["hasAdjustments"]:
edit_id = self._info["edit_resource_id"]
if edit_id is not None:
library = self._db._library_path
folder_id, file_id = _get_resource_loc(edit_id)
# todo: is this always true or do we need to search file file_id under folder_id
photopath = os.path.join(
library,
"resources",
"media",
"version",
folder_id,
"00",
f"fullsizeoutput_{file_id}.jpeg",
)
if not os.path.isfile(photopath):
logging.warning(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
logging.warning(
f"{self.uuid} hasAdjustments but edit_model_id is None"
)
else:
photopath = None
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
else:
# in Photos 5.0 / Catalina / MacOS 10.15:
# edited photos appear to always be converted to .jpeg and stored in
# library_name/resources/renders/X/UUID_1_201_a.jpeg
# where X = first letter of UUID
# and UUID = UUID of image
# this seems to be true even for photos not copied to Photos library and
# where original format was not jpg/jpeg
# if more than one edit, previous edit is stored as UUID_p.jpeg
if self._info["hasAdjustments"]:
library = self._db._library_path
directory = self._uuid[0] # first char of uuid
photopath = os.path.join(
library,
"resources",
"renders",
directory,
f"{self._uuid}_1_201_a.jpeg",
)
if not os.path.isfile(photopath):
logging.warning(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
# TODO: might be possible for original/master to be missing but edit to still be there
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
logging.debug(photopath)
return photopath
@property
def description(self):
""" long / extended description of picture """
return self._info["extendedDescription"]
@property
def persons(self):
""" list of persons in picture """
return self._info["persons"]
@property
def albums(self):
""" list of albums picture is contained in """
albums = []
for album in self._info["albums"]:
albums.append(self._db._dbalbum_details[album]["title"])
return albums
@property
def keywords(self):
""" list of keywords for picture """
return self._info["keywords"]
@property
def title(self):
""" name / title of picture """
# TODO: Update documentation and tests to use title
return self._info["name"]
@property
def uuid(self):
""" UUID of picture """
return self._uuid
@property
def ismissing(self):
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
do not immediately get written to disk. In particular, I've noticed that downloading
an image from the cloud does not force the database to be updated until something else
e.g. an edit, keyword, etc. occurs forcing a database synch
The exact process / timing is a mystery to be but be aware that if some photos were recently
downloaded from cloud to local storate their status in the database might still show
isMissing = 1
"""
return True if self._info["isMissing"] == 1 else False
@property
def hasadjustments(self):
""" True if picture has adjustments / edits """
return True if self._info["hasAdjustments"] == 1 else False
@property
def external_edit(self):
""" Returns True if picture was edited outside of Photos using external editor """
return (
True
if self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
else False
)
@property
def favorite(self):
""" True if picture is marked as favorite """
return True if self._info["favorite"] == 1 else False
@property
def hidden(self):
""" True if picture is hidden """
return True if self._info["hidden"] == 1 else False
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
return (self._latitude, self._longitude)
def export(
self,
dest,
*filename,
edited=False,
overwrite=False,
increment=True,
sidecar=False,
):
""" export photo """
""" first argument must be valid destination path (or exception raised) """
""" second argument (optional): name of picture; if not provided, will use current filename """
""" if edited=True (default=False), will export the edited version of the photo (or raise exception if no edited version) """
""" if overwrite=True (default=False), will overwrite files if they alreay exist """
""" if increment=True (default=True), will increment file name until a non-existant name is found """
""" if overwrite=False and increment=False, export will fail if destination file already exists """
""" if sidecar=True, will also write a json sidecar with EXIF data in format readable by exiftool """
""" sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) """
""" returns the full path to the exported file """
# TODO: add this docs:
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
else:
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# second arg is filename of picture
filename = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
edited_name = Path(self.path_edited).name
edited_suffix = Path(edited_name).suffix
filename = Path(self.filename).stem + "_edited" + edited_suffix
else:
filename = self.filename
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
dest = pathlib.Path(dest)
filename = pathlib.Path(filename)
dest = dest / filename
# check to see if file exists and if so, add (1), (2), etc until we find one that works
if increment and not overwrite:
count = 1
dest_new = dest
while dest_new.exists():
dest_new = dest.parent / f"{dest.stem} ({count}){dest.suffix}"
count += 1
dest = dest_new
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, incremetn={increment}, dest exists: {dest.exists()}"
)
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
# if error on copy, subprocess will raise CalledProcessError
try:
subprocess.run(
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
)
except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e
if sidecar:
logging.debug("writing exiftool_json_sidecar")
sidecar_filename = f"{dest}.json"
json_sidecar_str = self._exiftool_json_sidecar()
try:
self._write_sidecar_car(sidecar_filename, json_sidecar_str)
except Exception as e:
logging.critical(f"Error writing json sidecar to {sidecar_filename}")
raise e
return str(dest)
def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format """
exif = {}
exif["FileName"] = self.filename
if self.description:
exif["ImageDescription"] = self.description
exif["Description"] = self.description
if self.title:
exif["Title"] = self.title
if self.keywords:
exif["TagsList"] = exif["Keywords"] = self.keywords
if self.persons:
exif["PersonInImage"] = self.persons
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location
if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["GPSLatitude"] = lat_str
exif["GPSLongitude"] = lon_str
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["GPSLatitudeRef"] = lat_ref
exif["GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["DateTimeOriginal"] = datetimeoriginal
exif["OffsetTimeOriginal"] = offsettime
json_str = json.dumps([exif])
return json_str
def _write_sidecar_car(self, filename, json_str):
if not filename and not json_str:
raise (
ValueError(
f"filename {filename} and json_str {json_str} must not be None"
)
)
# TODO: catch exception?
f = open(filename, "w")
f.write(json_str)
f.close()
@property
def _longitude(self):
""" Returns longitude, in degrees """
return self._info["longitude"]
@property
def _latitude(self):
""" Returns latitude, in degrees """
return self._info["latitude"]
def __repr__(self):
# TODO: update to use __class__ and __name__
return f"osxphotos.PhotoInfo(db={self._db}, uuid='{self._uuid}', info={self._info})"
def __str__(self):
info = {
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": str(self.date),
"description": self.description,
"name": self.name,
"keywords": self.keywords,
"albums": self.albums,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
"external_edit": self.external_edit,
"favorite": self.favorite,
"hidden": self.hidden,
"latitude": self._latitude,
"longitude": self._longitude,
"path_edited": self.path_edited,
}
return yaml.dump(info, sort_keys=False)
def json(self):
""" return JSON representation """
# TODO: Add additional details here
pic = {
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": str(self.date),
"description": self.description,
"title": self.title,
"keywords": self.keywords,
"albums": self.albums,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
"external_edit": self.external_edit,
"favorite": self.favorite,
"hidden": self.hidden,
"latitude": self._latitude,
"longitude": self._longitude,
"path_edited": self.path_edited,
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
else:
return False
def __ne__(self, other):
return not self.__eq__(other)

1082
osxphotos/photosdb.py Normal file

File diff suppressed because it is too large Load Diff

207
osxphotos/utils.py Normal file
View File

@@ -0,0 +1,207 @@
import glob
import logging
import os.path
import platform
import subprocess
import urllib.parse
from pathlib import Path
from plistlib import load as plistload
import CoreFoundation
import objc
from Foundation import *
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
version = platform.mac_ver()[0].split(".")
if len(version) == 2:
(ver, major) = version
minor = "0"
elif len(version) == 3:
(ver, major, minor) = version
else:
raise (
ValueError(
f"Could not parse version string: {platform.mac_ver()} {version}"
)
)
return (ver, major, minor)
def _check_file_exists(filename):
""" returns true if file exists and is not a directory
otherwise returns false """
filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename)
def _get_resource_loc(model_id):
""" returns folder_id and file_id needed to find location of edited photo """
""" and live photos for version <= Photos 4.0 """
# determine folder where Photos stores edited version
# edited images are stored in:
# Photos Library.photoslibrary/resources/media/version/XX/00/fullsizeoutput_Y.jpeg
# where XX and Y are computed based on RKModelResources.modelId
# file_id (Y in above example) is hex representation of model_id without leading 0x
file_id = hex_id = hex(model_id)[2:]
# folder_id (XX) in above example if first two chars of model_id converted to hex
# and left padded with zeros if < 4 digits
folder_id = hex_id.zfill(4)[0:2]
return folder_id, file_id
def _dd_to_dms(dd):
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
""" return tuple of int(deg), int(min), float(sec) """
dd = float(dd)
negative = dd < 0
dd = abs(dd)
min_, sec_ = divmod(dd * 3600, 60)
deg_, min_ = divmod(min_, 60)
if negative:
if deg_ > 0:
deg_ = deg_ * -1
elif min_ > 0:
min_ = min_ * -1
else:
sec_ = sec_ * -1
return int(deg_), int(min_), sec_
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
""" lat: latitude in degrees """
""" lon: longitude in degrees """
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
""" this is the same format used by exiftool's json format """
# TODO: add this to readme
lat_deg, lat_min, lat_sec = _dd_to_dms(lat)
lon_deg, lon_min, lon_sec = _dd_to_dms(lon)
lat_hemisphere = "N"
if any([lat_deg < 0, lat_min < 0, lat_sec < 0]):
lat_hemisphere = "S"
lon_hemisphere = "E"
if any([lon_deg < 0, lon_min < 0, lon_sec < 0]):
lon_hemisphere = "W"
lat_str = (
f"{abs(lat_deg)} deg {abs(lat_min)}' {abs(lat_sec):.2f}\" {lat_hemisphere}"
)
lon_str = (
f"{abs(lon_deg)} deg {abs(lon_min)}' {abs(lon_sec):.2f}\" {lon_hemisphere}"
)
return lat_str, lon_str
def get_system_library_path():
""" return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """
""" on earlier versions, will raise exception """
_, major, _ = _get_os_version()
if int(major) < 15:
raise Exception(
"get_system_library_path not implemented for MacOS < 10.15", major
)
plist_file = Path(
str(Path.home())
+ "/Library/Containers/com.apple.photolibraryd/Data/Library/Preferences/com.apple.photolibraryd.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
return None
photospath = pl["SystemLibraryPath"]
if photospath is not None:
return photospath
else:
logging.warning("Could not get path to Photos database")
return None
def get_last_library_path():
""" return the path to the last opened Photos library """
# TODO: Need a module level method for this and another PhotosDB method to get current library path
plist_file = Path(
str(Path.home())
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
return None
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
if photosurlref is not None:
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
# the CFURLRef we got is a sruct that python treats as an array
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
# first element is the path string in form:
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
# now coerce the file URI back into an OS path
# surely there must be a better way
if photosurlstr is not None:
photospath = os.path.normpath(
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
)
else:
logging.warning(
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark"
)
return None
return photospath
else:
logging.warning("Could not get path to Photos database")
return None
def list_photo_libraries():
""" returns list of Photos libraries found on the system """
""" on MacOS < 10.15, this may omit some libraries """
# On 10.15, mdfind appears to find all libraries
# On older MacOS versions, mdfind appears to ignore some libraries
# glob to find libraries in ~/Pictures then mdfind to find all the others
# TODO: make this more robust
lib_list = glob.glob(f"{str(Path.home())}/Pictures/*.photoslibrary")
# On older OS, may not get all libraries so make sure we get the last one
last_lib = get_last_library_path()
if last_lib:
lib_list.append(last_lib)
output = subprocess.check_output(
["/usr/bin/mdfind", "-onlyin", "/", "-name", ".photoslibrary"]
).splitlines()
for lib in output:
lib_list.append(lib.decode("utf-8"))
lib_list = list(set(lib_list))
lib_list.sort()
return lib_list

View File

@@ -62,5 +62,5 @@ setup(
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=["pyobjc", "Click", "pyyaml"],
entry_points={"console_scripts": ["osxphotos=osxphotos.cmd_line:cli"]},
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
)

View File

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

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-12-07T16:40:40Z</date>
<date>2019-12-22T15:58:39Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-12-07T16:40:41Z</date>
<date>2019-12-22T15:58:39Z</date>
</dict>
</plist>

View File

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

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-11-18T01:38:02Z</date>
<date>2019-12-21T18:09:07Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-12-07T22:47:22Z</date>
<date>2019-12-22T07:53:27Z</date>
</dict>
</plist>

View File

@@ -2,7 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ProcessedInQuiescentState</key>
<true/>
<key>SuggestedMeIdentifier</key>
<string></string>
<key>Version</key>
<integer>3</integer>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-11-18T04:50:50Z</date>
<date>2019-12-16T06:19:55Z</date>
</dict>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-12-07T22:47:21Z</date>
<date>2019-12-22T07:53:26Z</date>
</dict>
</plist>

View File

@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key>
<date>2019-12-07T22:47:19Z</date>
<date>2019-12-22T07:56:25Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

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

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2019-12-08T05:40:32Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2019-12-08T05:40:32Z</date>
<date>2019-12-15T18:49:35Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2019-12-08T05:40:32Z</date>
<date>2019-12-15T20:55:19Z</date>
<key>BackgroundJobSearch</key>
<date>2019-12-08T05:40:32Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2019-12-08T05:40:31Z</date>
<date>2019-12-15T18:49:35Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2019-12-08T05:40:32Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2019-12-08T05:40:44Z</date>
<date>2019-12-15T20:55:19Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-12-07T19:48:13Z</date>
<date>2019-12-15T18:49:35Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-12-08T05:40:33Z</date>
<date>2019-12-15T20:55:19Z</date>
<key>SiriPortraitDonation</key>
<date>2019-12-08T05:40:32Z</date>
<date>2019-12-15T18:49:56Z</date>
</dict>
</plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>FaceIDModelLastGenerationKey</key>
<date>2019-12-08T05:40:33Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>LastContactClassificationKey</key>
<date>2019-12-08T05:40:34Z</date>
<date>2019-12-15T18:49:58Z</date>
</dict>
</plist>

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>coalesceDate</key>
<date>2019-12-08T18:06:37Z</date>
<key>coalescePayloadVersion</key>
<integer>1</integer>
<key>currentPayloadVersion</key>
<integer>1</integer>
<key>snapshotDate</key>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>coalesceDate</key>
<date>2019-10-27T15:36:05Z</date>
<date>2019-12-08T18:06:37Z</date>
<key>coalescePayloadVersion</key>
<integer>1</integer>
<key>currentPayloadVersion</key>

View File

@@ -43,15 +43,15 @@ def test_db_version():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# assert photosdb.get_db_version() in osxphotos._TESTED_DB_VERSIONS
assert photosdb.get_db_version() == "2622"
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
assert photosdb.db_version == "2622"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos._get_os_version()
assert major in osxphotos._TESTED_OS_VERSIONS
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons():
@@ -59,8 +59,8 @@ def test_persons():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Katie" in photosdb.persons()
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons())
assert "Katie" in photosdb.persons
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons)
def test_keywords():
@@ -68,8 +68,8 @@ def test_keywords():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "wedding" in photosdb.keywords()
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords())
assert "wedding" in photosdb.keywords
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords)
def test_albums():
@@ -77,15 +77,15 @@ def test_albums():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Pumpkin Farm" in photosdb.albums()
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums())
assert "Pumpkin Farm" in photosdb.albums
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums)
def test_keywords_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
keywords = photosdb.keywords_as_dict()
keywords = photosdb.keywords_as_dict
assert keywords["wedding"] == 2
assert keywords == KEYWORDS_DICT
@@ -94,7 +94,7 @@ def test_persons_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
persons = photosdb.persons_as_dict()
persons = photosdb.persons_as_dict
assert persons["Maria"] == 1
assert persons == PERSONS_DICT
@@ -103,7 +103,7 @@ def test_albums_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.albums_as_dict()
albums = photosdb.albums_as_dict
assert albums["Pumpkin Farm"] == 3
assert albums == ALBUM_DICT
@@ -116,20 +116,20 @@ def test_attributes():
photos = photosdb.photos(uuid=["sE5LlfekS8ykEE7o0cuMVA"])
assert len(photos) == 1
p = photos[0]
assert p.keywords() == ["Kids"]
assert p.original_filename() == "Pumkins2.jpg"
assert p.filename() == "Pumkins2.jpg"
assert p.date() == datetime.datetime(
assert p.keywords == ["Kids"]
assert p.original_filename == "Pumkins2.jpg"
assert p.filename == "Pumkins2.jpg"
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.name() == "I found one!"
assert p.albums() == ["Pumpkin Farm"]
assert p.persons() == ["Katie"]
assert p.path().endswith(
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.12.6.photoslibrary/Masters/2019/08/24/20190824-030824/Pumkins2.jpg"
)
assert p.ismissing() == False
assert p.ismissing == False
def test_missing():
@@ -139,8 +139,8 @@ def test_missing():
photos = photosdb.photos(uuid=["Pj99JmYjQkeezdY2OFuSaw"])
assert len(photos) == 1
p = photos[0]
assert p.path() == None
assert p.ismissing() == True
assert p.path == None
assert p.ismissing == True
def test_count():
@@ -169,5 +169,4 @@ def test_keyword_not_in_album():
photos2 = photosdb.photos(keywords=["Kids"])
photos3 = [p for p in photos2 if p not in photos1]
assert len(photos3) == 1
assert photos3[0].uuid() == "Pj99JmYjQkeezdY2OFuSaw"
assert photos3[0].uuid == "Pj99JmYjQkeezdY2OFuSaw"

View File

@@ -1,6 +1,6 @@
import pytest
from osxphotos import _UNKNOWN_PERSON
from osxphotos._constants import _UNKNOWN_PERSON
# TODO: put some of this code into a pre-function
@@ -42,8 +42,23 @@ ALBUM_DICT = {
"Test Album": 2,
} # 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": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
"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"
}
def test_init():
def test_init1():
# test named argument
import osxphotos
@@ -94,7 +109,8 @@ def test_init5():
def bad_library():
return None
osxphotos.get_last_library_path = bad_library
# force get_last_library to return a bad path for testing
osxphotos.photosdb.get_last_library_path = bad_library
with pytest.raises(Exception):
assert osxphotos.PhotosDB()
@@ -104,15 +120,15 @@ def test_db_version():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# assert photosdb.get_db_version() in osxphotos._TESTED_DB_VERSIONS
assert photosdb.get_db_version() == "6000"
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
assert photosdb.db_version == "6000"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos._get_os_version()
assert major in osxphotos._TESTED_OS_VERSIONS
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons():
@@ -120,8 +136,8 @@ def test_persons():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Katie" in photosdb.persons()
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons())
assert "Katie" in photosdb.persons
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons)
def test_keywords():
@@ -129,8 +145,8 @@ def test_keywords():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "wedding" in photosdb.keywords()
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords())
assert "wedding" in photosdb.keywords
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords)
def test_albums():
@@ -138,15 +154,15 @@ def test_albums():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Pumpkin Farm" in photosdb.albums()
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums())
assert "Pumpkin Farm" in photosdb.albums
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums)
def test_keywords_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
keywords = photosdb.keywords_as_dict()
keywords = photosdb.keywords_as_dict
assert keywords["wedding"] == 2
assert keywords == KEYWORDS_DICT
@@ -155,7 +171,7 @@ def test_persons_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
persons = photosdb.persons_as_dict()
persons = photosdb.persons_as_dict
assert persons["Maria"] == 1
assert persons == PERSONS_DICT
@@ -164,7 +180,7 @@ def test_albums_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.albums_as_dict()
albums = photosdb.albums_as_dict
assert albums["Pumpkin Farm"] == 3
assert albums == ALBUM_DICT
@@ -177,71 +193,71 @@ def test_attributes():
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(
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.name() == "I found one!"
assert p.albums() == ["Pumpkin Farm", "Test Album"]
assert p.persons() == ["Katie"]
assert p.path().endswith(
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm", "Test Album"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"tests/Test-10.15.1.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"
)
assert p.ismissing() == False
assert p.ismissing == False
def test_missing():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C"])
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
assert len(photos) == 1
p = photos[0]
assert p.path() == None
assert p.ismissing() == True
assert p.path == None
assert p.ismissing == True
def test_favorite():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"])
photos = photosdb.photos(uuid=[UUID_DICT["favorite"]])
assert len(photos) == 1
p = photos[0]
assert p.favorite() == True
assert p.favorite == True
def test_not_favorite():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C"])
photos = photosdb.photos(uuid=[UUID_DICT["not_favorite"]])
assert len(photos) == 1
p = photos[0]
assert p.favorite() == False
assert p.favorite == False
def test_hidden():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C"])
photos = photosdb.photos(uuid=[UUID_DICT["hidden"]])
assert len(photos) == 1
p = photos[0]
assert p.hidden() == True
assert p.hidden == True
def test_not_hidden():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"])
photos = photosdb.photos(uuid=[UUID_DICT["not_hidden"]])
assert len(photos) == 1
p = photos[0]
assert p.hidden() == False
assert p.hidden == False
def test_location_1():
@@ -249,10 +265,10 @@ def test_location_1():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["DC99FBDD-7A52-4100-A5BB-344131646C30"])
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
assert len(photos) == 1
p = photos[0]
lat, lon = p.location()
lat, lon = p.location
assert lat == pytest.approx(51.50357167)
assert lon == pytest.approx(-0.1318055)
@@ -262,10 +278,10 @@ def test_location_2():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"])
photos = photosdb.photos(uuid=[UUID_DICT["no_location"]])
assert len(photos) == 1
p = photos[0]
lat, lon = p.location()
lat, lon = p.location
assert lat is None
assert lon is None
@@ -275,10 +291,10 @@ def test_hasadjustments1():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"])
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
assert len(photos) == 1
p = photos[0]
assert p.hasadjustments() == True
assert p.hasadjustments == True
def test_hasadjustments2():
@@ -286,10 +302,10 @@ def test_hasadjustments2():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"])
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
assert len(photos) == 1
p = photos[0]
assert p.hasadjustments() == False
assert p.hasadjustments == False
def test_external_edit1():
@@ -297,11 +313,11 @@ def test_external_edit1():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["DC99FBDD-7A52-4100-A5BB-344131646C30"])
photos = photosdb.photos(uuid=[UUID_DICT["external_edit"]])
assert len(photos) == 1
p = photos[0]
assert p.external_edit() == True
assert p.external_edit == True
def test_external_edit2():
@@ -309,11 +325,11 @@ def test_external_edit2():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"])
photos = photosdb.photos(uuid=[UUID_DICT["no_external_edit"]])
assert len(photos) == 1
p = photos[0]
assert p.external_edit() == False
assert p.external_edit == False
def test_path_edited1():
@@ -324,7 +340,7 @@ def test_path_edited1():
photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"])
assert len(photos) == 1
p = photos[0]
path = p.path_edited()
path = p.path_edited
assert path.endswith(
"resources/renders/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_1_201_a.jpeg"
)
@@ -338,7 +354,7 @@ def test_path_edited2():
photos = photosdb.photos(uuid=["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"])
assert len(photos) == 1
p = photos[0]
path = p.path_edited()
path = p.path_edited
assert path is None
@@ -368,14 +384,14 @@ def test_keyword_not_in_album():
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"
assert photos3[0].uuid == "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C"
def test_get_db_path():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
db_path = photosdb.get_db_path()
db_path = photosdb.db_path
assert db_path.endswith(PHOTOS_DB_PATH)
@@ -383,6 +399,340 @@ def test_get_library_path():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
lib_path = photosdb.get_library_path()
lib_path = photosdb.library_path
assert lib_path.endswith(PHOTOS_LIBRARY_PATH)
def test_export_1():
# test basic export
# get an unedited image and export it using default filename
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
# remove the temporary file
os.remove(got_dest)
def test_export_2():
# test export with user provided filename
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
# remove the temporary file
os.remove(got_dest)
def test_export_3():
# test file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
filename2 = pathlib.Path(filename)
filename2 = f"{filename2.stem} (1){filename2.suffix}"
expected_dest = os.path.join(dest, filename)
expected_dest_2 = os.path.join(dest, filename2)
got_dest = photos[0].export(dest)
got_dest_2 = photos[0].export(dest)
assert got_dest_2 == expected_dest_2
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
os.remove(got_dest_2)
def test_export_4():
# test user supplied file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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 = os.path.join(dest, filename)
expected_dest_2 = os.path.join(dest, filename2)
got_dest = photos[0].export(dest, filename)
got_dest_2 = photos[0].export(dest, filename)
assert got_dest_2 == expected_dest_2
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
os.remove(got_dest_2)
def test_export_5():
# test file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
got_dest_2 = photos[0].export(dest, overwrite=True)
assert got_dest_2 == got_dest
assert got_dest_2 == expected_dest
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
def test_export_6():
# test user supplied file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import pathlib
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
got_dest_2 = photos[0].export(dest, filename, overwrite=True)
assert got_dest_2 == got_dest
assert got_dest_2 == expected_dest
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
def test_export_7():
# test file already exists and test increment=False (not default), overwrite=False (default)
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
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())
# remove the temporary file
os.remove(got_dest)
def test_export_8():
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
def test_export_9():
# try to export edited file that's not edited
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest, edited=True)
assert e.type == type(FileNotFoundError())
def test_export_10():
# try to export edited file that's not edited and name provided
# should raise exception
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
timestamp = time.time()
filename = f"osxphotos-export-test-{timestamp}.jpg"
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest, filename, edited=True)
assert e.type == type(FileNotFoundError())
def test_export_11():
# export edited file with name provided
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
# remove the temporary file
os.remove(got_dest)
def test_export_12():
# export edited file with default name
import os
import os.path
import pathlib
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
# remove the temporary file
os.remove(got_dest)
def test_export_13():
# export to invalid destination
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
# create a folder that doesn't exist
i = 0
while os.path.isdir(dest):
dest = os.path.join(dest, str(i))
i += 1
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())

View File

@@ -0,0 +1,471 @@
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.utils import dd_to_dms_str
# TODO: put some of this code into a pre-function
PHOTOS_DB = "./tests/Test-10.15.1.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.15.1.photoslibrary/database/Photos.sqlite"
PHOTOS_LIBRARY_PATH = "/Test-10.15.1.photoslibrary"
KEYWORDS = [
"Kids",
"wedding",
"flowers",
"England",
"London",
"London 2018",
"St. James's Park",
"UK",
"United Kingdom",
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
ALBUMS = [
"Pumpkin Farm",
"Test Album",
] # Note: there are 2 albums named "Test Album" for testing duplicate album names
KEYWORDS_DICT = {
"Kids": 4,
"wedding": 2,
"flowers": 1,
"England": 1,
"London": 1,
"London 2018": 1,
"St. James's Park": 1,
"UK": 1,
"United Kingdom": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
ALBUM_DICT = {
"Pumpkin Farm": 3,
"Test Album": 2,
} # 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": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
"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"
}
def test_export_1():
# test basic export
# get an unedited image and export it using default filename
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
# remove the temporary file
os.remove(got_dest)
def test_export_2():
# test export with user provided filename
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
# remove the temporary file
os.remove(got_dest)
def test_export_3():
# test file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
filename2 = pathlib.Path(filename)
filename2 = f"{filename2.stem} (1){filename2.suffix}"
expected_dest = os.path.join(dest, filename)
expected_dest_2 = os.path.join(dest, filename2)
got_dest = photos[0].export(dest)
got_dest_2 = photos[0].export(dest)
assert got_dest_2 == expected_dest_2
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
os.remove(got_dest_2)
def test_export_4():
# test user supplied file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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 = os.path.join(dest, filename)
expected_dest_2 = os.path.join(dest, filename2)
got_dest = photos[0].export(dest, filename)
got_dest_2 = photos[0].export(dest, filename)
assert got_dest_2 == expected_dest_2
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
os.remove(got_dest_2)
def test_export_5():
# test file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
got_dest_2 = photos[0].export(dest, overwrite=True)
assert got_dest_2 == got_dest
assert got_dest_2 == expected_dest
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
def test_export_6():
# test user supplied file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import pathlib
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
got_dest_2 = photos[0].export(dest, filename, overwrite=True)
assert got_dest_2 == got_dest
assert got_dest_2 == expected_dest
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
def test_export_7():
# test file already exists and test increment=False (not default), overwrite=False (default)
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
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())
# remove the temporary file
os.remove(got_dest)
def test_export_8():
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
def test_export_9():
# try to export edited file that's not edited
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest, edited=True)
assert e.type == type(FileNotFoundError())
def test_export_10():
# try to export edited file that's not edited and name provided
# should raise exception
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
timestamp = time.time()
filename = f"osxphotos-export-test-{timestamp}.jpg"
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest, filename, edited=True)
assert e.type == type(FileNotFoundError())
def test_export_11():
# export edited file with name provided
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
# remove the temporary file
os.remove(got_dest)
def test_export_12():
# export edited file with default name
import os
import os.path
import pathlib
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
# remove the temporary file
os.remove(got_dest)
def test_export_13():
# export to invalid destination
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
# create a folder that doesn't exist
i = 0
while os.path.isdir(dest):
dest = os.path.join(dest, str(i))
i += 1
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
def test_dd_to_dms_str_1():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
34.559331096, 69.206499174
) # Kabul, 34°33'33.59" N 69°12'23.40" E
assert lat_str == "34 deg 33' 33.59\" N"
assert lon_str == "69 deg 12' 23.40\" E"
def test_dd_to_dms_str_2():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
-34.601997592, -58.375665164
) # Buenos Aires, 34°36'7.19" S 58°22'32.39" W
assert lat_str == "34 deg 36' 7.19\" S"
assert lon_str == "58 deg 22' 32.39\" W"
def test_dd_to_dms_str_3():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
-1.2666656, 36.7999968
) # Nairobi, 1°15'60.00" S 36°47'59.99" E
assert lat_str == "1 deg 15' 60.00\" S"
assert lon_str == "36 deg 47' 59.99\" E"
def test_dd_to_dms_str_4():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
38.889248, -77.050636
) # DC: 38° 53' 21.2928" N, 77° 3' 2.2896" W
assert lat_str == "38 deg 53' 21.29\" N"
assert lon_str == "77 deg 3' 2.29\" W"
def test_exiftool_json_sidecar():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
json_expected = json.loads(
"""
[{"FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
"Title": "St. James\'s Park",
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"GPSLatitude": "51 deg 30\' 12.86\\" N",
"GPSLongitude": "0 deg 7\' 54.50\\" W",
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West",
"DateTimeOriginal": "2018:10:13 09:18:12", "OffsetTimeOriginal": "-04:00"}]
"""
)
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)
# some gymnastics to account for different sort order in different pythons
for item in zip(sorted(json_got[0].items()), sorted(json_expected[0].items())):
if type(item[0][1]) in (list, tuple):
assert sorted(item[0][1]) == sorted(item[1][1])
else:
assert item[0][1] == item[1][1]

View File

@@ -0,0 +1,412 @@
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
# TODO: put some of this code into a pre-function
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_LIBRARY_PATH = "/Test-10.14.6.photoslibrary"
KEYWORDS = [
"Kids",
"wedding",
"flowers",
"England",
"London",
"London 2018",
"St. James's Park",
"UK",
"United Kingdom",
]
PERSONS = ["Katie", "Suzy", "Maria"]
ALBUMS = ["Pumpkin Farm", "Test Album", "Test Album (1)"]
KEYWORDS_DICT = {
"Kids": 4,
"wedding": 2,
"flowers": 1,
"England": 1,
"London": 1,
"London 2018": 1,
"St. James's Park": 1,
"UK": 1,
"United Kingdom": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
ALBUM_DICT = {"Pumpkin Farm": 3, "Test Album": 1, "Test Album (1)": 1}
UUID_DICT = {
"missing": "od0fmC7NQx+ayVr+%i06XA",
"has_adjustments": "6bxcNnzRQKGnK4uPrCJ9UQ",
"no_adjustments": "15uNd7%8RguTEgNPKHfTWw",
"export": "15uNd7%8RguTEgNPKHfTWw",
"location": "3Jn73XpSQQCluzRBMWRsMA",
}
def test_export_1():
# test basic export
# get an unedited image and export it using default filename
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
# remove the temporary file
os.remove(got_dest)
def test_export_2():
# test export with user provided filename
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
# remove the temporary file
os.remove(got_dest)
def test_export_3():
# test file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
filename2 = pathlib.Path(filename)
filename2 = f"{filename2.stem} (1){filename2.suffix}"
expected_dest = os.path.join(dest, filename)
expected_dest_2 = os.path.join(dest, filename2)
got_dest = photos[0].export(dest)
got_dest_2 = photos[0].export(dest)
assert got_dest_2 == expected_dest_2
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
os.remove(got_dest_2)
def test_export_4():
# test user supplied file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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 = os.path.join(dest, filename)
expected_dest_2 = os.path.join(dest, filename2)
got_dest = photos[0].export(dest, filename)
got_dest_2 = photos[0].export(dest, filename)
assert got_dest_2 == expected_dest_2
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
os.remove(got_dest_2)
def test_export_5():
# test file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
got_dest_2 = photos[0].export(dest, overwrite=True)
assert got_dest_2 == got_dest
assert got_dest_2 == expected_dest
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
def test_export_6():
# test user supplied file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import pathlib
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
got_dest_2 = photos[0].export(dest, filename, overwrite=True)
assert got_dest_2 == got_dest
assert got_dest_2 == expected_dest
assert os.path.isfile(got_dest_2)
# remove the temporary file
os.remove(got_dest)
def test_export_7():
# test file already exists and test increment=False (not default), overwrite=False (default)
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)
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())
# remove the temporary file
os.remove(got_dest)
def test_export_8():
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
def test_export_9():
# try to export edited file that's not edited
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest, edited=True)
assert e.type == type(FileNotFoundError())
def test_export_10():
# try to export edited file that's not edited and name provided
# should raise exception
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
timestamp = time.time()
filename = f"osxphotos-export-test-{timestamp}.jpg"
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest, filename, edited=True)
assert e.type == type(FileNotFoundError())
def test_export_11():
# export edited file with name provided
import os
import os.path
import tempfile
import time
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
# remove the temporary file
os.remove(got_dest)
def test_export_12():
# export edited file with default name
import os
import os.path
import pathlib
import tempfile
import osxphotos
dest = tempfile.gettempdir()
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
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)
assert got_dest == expected_dest
# remove the temporary file
os.remove(got_dest)
def test_export_13():
# export to invalid destination
# should raise exception
import os
import os.path
import tempfile
import osxphotos
dest = tempfile.gettempdir()
# create a folder that doesn't exist
i = 0
while os.path.isdir(dest):
dest = os.path.join(dest, str(i))
i += 1
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
expected_dest = os.path.join(dest, filename)
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
def test_exiftool_json_sidecar():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
json_expected = json.loads(
"""
[{"FileName": "St James Park.jpg",
"Title": "St. James\'s Park",
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"GPSLatitude": "51 deg 30\' 12.86\\" N",
"GPSLongitude": "0 deg 7\' 54.50\\" W",
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West",
"DateTimeOriginal": "2018:10:13 09:18:12", "OffsetTimeOriginal": "-04:00"}]
"""
)
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)
# some gymnastics to account for different sort order in different pythons
for item in zip(sorted(json_got[0].items()), sorted(json_expected[0].items())):
if type(item[0][1]) in (list, tuple):
assert sorted(item[0][1]) == sorted(item[1][1])
else:
assert item[0][1] == item[1][1]

View File

@@ -42,15 +42,15 @@ def test_db_version():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert photosdb.get_db_version() == "3301"
# assert photosdb.get_db_version() in osxphotos._TESTED_DB_VERSIONS
assert photosdb.db_version == "3301"
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
def test_os_version():
import osxphotos
(_, major, _) = osxphotos._get_os_version()
assert major in osxphotos._TESTED_OS_VERSIONS
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons():
@@ -58,8 +58,8 @@ def test_persons():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Katie" in photosdb.persons()
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons())
assert "Katie" in photosdb.persons
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons)
def test_keywords():
@@ -67,8 +67,8 @@ def test_keywords():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "wedding" in photosdb.keywords()
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords())
assert "wedding" in photosdb.keywords
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords)
def test_albums():
@@ -76,15 +76,15 @@ def test_albums():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Pumpkin Farm" in photosdb.albums()
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums())
assert "Pumpkin Farm" in photosdb.albums
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums)
def test_keywords_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
keywords = photosdb.keywords_as_dict()
keywords = photosdb.keywords_as_dict
assert keywords["wedding"] == 2
assert keywords == KEYWORDS_DICT
@@ -93,7 +93,7 @@ def test_persons_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
persons = photosdb.persons_as_dict()
persons = photosdb.persons_as_dict
assert persons["Maria"] == 1
assert persons == PERSONS_DICT
@@ -102,7 +102,7 @@ def test_albums_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.albums_as_dict()
albums = photosdb.albums_as_dict
assert albums["Pumpkin Farm"] == 3
assert albums == ALBUM_DICT
@@ -115,20 +115,20 @@ def test_attributes():
photos = photosdb.photos(uuid=["RWmFYiDjSyKjeK8Pfna0Eg"])
assert len(photos) == 1
p = photos[0]
assert p.keywords() == ["Kids"]
assert p.original_filename() == "Pumkins2.jpg"
assert p.filename() == "Pumkins2.jpg"
assert p.date() == datetime.datetime(
assert p.keywords == ["Kids"]
assert p.original_filename == "Pumkins2.jpg"
assert p.filename == "Pumkins2.jpg"
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.name() == "I found one!"
assert p.albums() == ["Pumpkin Farm"]
assert p.persons() == ["Katie"]
assert p.path().endswith(
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.13.6.photoslibrary/Masters/2019/07/26/20190726-203227/Pumkins2.jpg"
)
assert p.ismissing() == False
assert p.ismissing == False
def test_missing():
@@ -138,8 +138,8 @@ def test_missing():
photos = photosdb.photos(uuid=["6iAZJP7ZQ5iXxapoJb3ytA"])
assert len(photos) == 1
p = photos[0]
assert p.path() == None
assert p.ismissing() == True
assert p.path == None
assert p.ismissing == True
def test_count():
@@ -168,5 +168,4 @@ def test_keyword_not_in_album():
photos2 = photosdb.photos(keywords=["Kids"])
photos3 = [p for p in photos2 if p not in photos1]
assert len(photos3) == 1
assert photos3[0].uuid() == "6iAZJP7ZQ5iXxapoJb3ytA"
assert photos3[0].uuid == "6iAZJP7ZQ5iXxapoJb3ytA"

View File

@@ -42,15 +42,15 @@ def test_db_version():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# assert photosdb.get_db_version() in osxphotos._TESTED_DB_VERSIONS
assert photosdb.get_db_version() == "4016"
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
assert photosdb.db_version == "4016"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos._get_os_version()
assert major in osxphotos._TESTED_OS_VERSIONS
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons():
@@ -58,8 +58,8 @@ def test_persons():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Katie" in photosdb.persons()
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons())
assert "Katie" in photosdb.persons
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons)
def test_keywords():
@@ -67,8 +67,8 @@ def test_keywords():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "wedding" in photosdb.keywords()
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords())
assert "wedding" in photosdb.keywords
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords)
def test_albums():
@@ -76,15 +76,15 @@ def test_albums():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Pumpkin Farm" in photosdb.albums()
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums())
assert "Pumpkin Farm" in photosdb.albums
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums)
def test_keywords_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
keywords = photosdb.keywords_as_dict()
keywords = photosdb.keywords_as_dict
assert keywords["wedding"] == 2
assert keywords == KEYWORDS_DICT
@@ -93,7 +93,7 @@ def test_persons_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
persons = photosdb.persons_as_dict()
persons = photosdb.persons_as_dict
assert persons["Maria"] == 1
assert persons == PERSONS_DICT
@@ -102,7 +102,7 @@ def test_albums_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.albums_as_dict()
albums = photosdb.albums_as_dict
assert albums["Pumpkin Farm"] == 3
assert albums == ALBUM_DICT
@@ -115,20 +115,20 @@ def test_attributes():
photos = photosdb.photos(uuid=["15uNd7%8RguTEgNPKHfTWw"])
assert len(photos) == 1
p = photos[0]
assert p.keywords() == ["Kids"]
assert p.original_filename() == "Pumkins2.jpg"
assert p.filename() == "Pumkins2.jpg"
assert p.date() == datetime.datetime(
assert p.keywords == ["Kids"]
assert p.original_filename == "Pumkins2.jpg"
assert p.filename == "Pumkins2.jpg"
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.name() == "I found one!"
assert p.albums() == ["Pumpkin Farm"]
assert p.persons() == ["Katie"]
assert p.path().endswith(
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.14.5.photoslibrary/Masters/2019/07/27/20190727-131650/Pumkins2.jpg"
)
assert p.ismissing() == False
assert p.ismissing == False
def test_missing():
@@ -138,8 +138,8 @@ def test_missing():
photos = photosdb.photos(uuid=["od0fmC7NQx+ayVr+%i06XA"])
assert len(photos) == 1
p = photos[0]
assert p.path() == None
assert p.ismissing() == True
assert p.path == None
assert p.ismissing == True
def test_count():
@@ -168,5 +168,4 @@ def test_keyword_not_in_album():
photos2 = photosdb.photos(keywords=["Kids"])
photos3 = [p for p in photos2 if p not in photos1]
assert len(photos3) == 1
assert photos3[0].uuid() == "od0fmC7NQx+ayVr+%i06XA"
assert photos3[0].uuid == "od0fmC7NQx+ayVr+%i06XA"

View File

@@ -45,15 +45,15 @@ def test_db_version():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert photosdb.get_db_version() in osxphotos._TESTED_DB_VERSIONS
assert photosdb.get_db_version() == "4025"
assert photosdb.db_version in osxphotos._constants._TESTED_DB_VERSIONS
assert photosdb.db_version == "4025"
def test_os_version():
import osxphotos
(_, major, _) = osxphotos._get_os_version()
assert major in osxphotos._TESTED_OS_VERSIONS
(_, major, _) = osxphotos.utils._get_os_version()
assert major in osxphotos._constants._TESTED_OS_VERSIONS
def test_persons():
@@ -61,8 +61,8 @@ def test_persons():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Katie" in photosdb.persons()
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons())
assert "Katie" in photosdb.persons
assert collections.Counter(PERSONS) == collections.Counter(photosdb.persons)
def test_keywords():
@@ -70,8 +70,8 @@ def test_keywords():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "wedding" in photosdb.keywords()
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords())
assert "wedding" in photosdb.keywords
assert collections.Counter(KEYWORDS) == collections.Counter(photosdb.keywords)
def test_albums():
@@ -79,15 +79,15 @@ def test_albums():
import collections
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
assert "Pumpkin Farm" in photosdb.albums()
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums())
assert "Pumpkin Farm" in photosdb.albums
assert collections.Counter(ALBUMS) == collections.Counter(photosdb.albums)
def test_keywords_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
keywords = photosdb.keywords_as_dict()
keywords = photosdb.keywords_as_dict
assert keywords["wedding"] == 2
assert keywords == KEYWORDS_DICT
@@ -96,7 +96,7 @@ def test_persons_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
persons = photosdb.persons_as_dict()
persons = photosdb.persons_as_dict
assert persons["Maria"] == 1
assert persons == PERSONS_DICT
@@ -105,7 +105,7 @@ def test_albums_as_dict():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.albums_as_dict()
albums = photosdb.albums_as_dict
assert albums["Pumpkin Farm"] == 3
assert albums == ALBUM_DICT
@@ -118,20 +118,20 @@ def test_attributes():
photos = photosdb.photos(uuid=["15uNd7%8RguTEgNPKHfTWw"])
assert len(photos) == 1
p = photos[0]
assert p.keywords() == ["Kids"]
assert p.original_filename() == "Pumkins2.jpg"
assert p.filename() == "Pumkins2.jpg"
assert p.date() == datetime.datetime(
assert p.keywords == ["Kids"]
assert p.original_filename == "Pumkins2.jpg"
assert p.filename == "Pumkins2.jpg"
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.name() == "I found one!"
assert p.albums() == ["Pumpkin Farm", "Test Album (1)"]
assert p.persons() == ["Katie"]
assert p.path().endswith(
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm", "Test Album (1)"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.14.6.photoslibrary/Masters/2019/07/27/20190727-131650/Pumkins2.jpg"
)
assert p.ismissing() == False
assert p.ismissing == False
def test_missing():
@@ -141,8 +141,8 @@ def test_missing():
photos = photosdb.photos(uuid=["od0fmC7NQx+ayVr+%i06XA"])
assert len(photos) == 1
p = photos[0]
assert p.path() == None
assert p.ismissing() == True
assert p.path == None
assert p.ismissing == True
def test_favorite():
@@ -152,7 +152,7 @@ def test_favorite():
photos = photosdb.photos(uuid=["6bxcNnzRQKGnK4uPrCJ9UQ"])
assert len(photos) == 1
p = photos[0]
assert p.favorite() == True
assert p.favorite == True
def test_not_favorite():
@@ -162,7 +162,7 @@ def test_not_favorite():
photos = photosdb.photos(uuid=["od0fmC7NQx+ayVr+%i06XA"])
assert len(photos) == 1
p = photos[0]
assert p.favorite() == False
assert p.favorite == False
def test_hidden():
@@ -172,7 +172,7 @@ def test_hidden():
photos = photosdb.photos(uuid=["od0fmC7NQx+ayVr+%i06XA"])
assert len(photos) == 1
p = photos[0]
assert p.hidden() == True
assert p.hidden == True
def test_not_hidden():
@@ -182,7 +182,7 @@ def test_not_hidden():
photos = photosdb.photos(uuid=["6bxcNnzRQKGnK4uPrCJ9UQ"])
assert len(photos) == 1
p = photos[0]
assert p.hidden() == False
assert p.hidden == False
def test_location_1():
@@ -193,7 +193,7 @@ def test_location_1():
photos = photosdb.photos(uuid=["3Jn73XpSQQCluzRBMWRsMA"])
assert len(photos) == 1
p = photos[0]
lat, lon = p.location()
lat, lon = p.location
assert lat == pytest.approx(51.50357167)
assert lon == pytest.approx(-0.1318055)
@@ -206,7 +206,7 @@ def test_location_2():
photos = photosdb.photos(uuid=["YZFCPY24TUySvpu7owiqxA"])
assert len(photos) == 1
p = photos[0]
lat, lon = p.location()
lat, lon = p.location
assert lat is None
assert lon is None
@@ -219,7 +219,7 @@ def test_hasadjustments1():
photos = photosdb.photos(uuid=["6bxcNnzRQKGnK4uPrCJ9UQ"])
assert len(photos) == 1
p = photos[0]
assert p.hasadjustments() == True
assert p.hasadjustments == True
def test_hasadjustments2():
@@ -230,7 +230,7 @@ def test_hasadjustments2():
photos = photosdb.photos(uuid=["15uNd7%8RguTEgNPKHfTWw"])
assert len(photos) == 1
p = photos[0]
assert p.hasadjustments() == False
assert p.hasadjustments == False
def test_external_edit1():
@@ -242,7 +242,7 @@ def test_external_edit1():
assert len(photos) == 1
p = photos[0]
assert p.external_edit() == True
assert p.external_edit == True
def test_external_edit2():
@@ -254,7 +254,7 @@ def test_external_edit2():
assert len(photos) == 1
p = photos[0]
assert p.external_edit() == False
assert p.external_edit == False
def test_path_edited1():
@@ -265,7 +265,7 @@ def test_path_edited1():
photos = photosdb.photos(uuid=["6bxcNnzRQKGnK4uPrCJ9UQ"])
assert len(photos) == 1
p = photos[0]
path = p.path_edited()
path = p.path_edited
assert path.endswith("resources/media/version/00/00/fullsizeoutput_9.jpeg")
@@ -277,7 +277,7 @@ def test_path_edited2():
photos = photosdb.photos(uuid=["15uNd7%8RguTEgNPKHfTWw"])
assert len(photos) == 1
p = photos[0]
path = p.path_edited()
path = p.path_edited
assert path is None
@@ -307,14 +307,14 @@ def test_keyword_not_in_album():
photos2 = photosdb.photos(keywords=["Kids"])
photos3 = [p for p in photos2 if p not in photos1]
assert len(photos3) == 1
assert photos3[0].uuid() == "od0fmC7NQx+ayVr+%i06XA"
assert photos3[0].uuid == "od0fmC7NQx+ayVr+%i06XA"
def test_get_db_path():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
db_path = photosdb.get_db_path()
db_path = photosdb.db_path
assert db_path.endswith(PHOTOS_DB_PATH)
@@ -322,6 +322,5 @@ def test_get_library_path():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
lib_path = photosdb.get_library_path()
lib_path = photosdb.library_path
assert lib_path.endswith(PHOTOS_LIBRARY_PATH)