diff --git a/README.md b/README.md index f7cf3f2e..85cc5e42 100644 --- a/README.md +++ b/README.md @@ -273,42 +273,38 @@ By default, osxphotos will use the original filename of the photo when exporting The above command will export photos using the title. Note that you don't need to specify the extension as part of the `--filename` template as osxphotos will automatically add the correct file extension. Some photos might not have a title so in this case, you could use the default value feature to specify a different name for these photos. For example, to use the title as the filename, but if no title is specified, use the original filename instead: -```txt -osxphotos export /path/to/export --filename "{title,{original_name}}" - │ ││ │ - │ ││ │ - Use photo's title as the filename <──────┘ ││ │ - ││ │ - Value after comma will be used <───────┘│ │ - if title is blank │ │ - │ │ - The default value can be <────┘ │ - another template field │ - │ - Use photo's original name if no title <──────┘ -``` + osxphotos export /path/to/export --filename "{title,{original_name}}" + │ ││ │ + │ ││ │ + Use photo's title as the filename <──────┘ ││ │ + ││ │ + Value after comma will be used <───────┘│ │ + if title is blank │ │ + │ │ + The default value can be <────┘ │ + another template field │ + │ + Use photo's original name if no title <──────┘ The osxphotos template system also allows for limited conditional logic of the type "If a condition is true then do one thing, otherwise, do a different thing". For example, you can use the `--filename` option to name files that are marked as "Favorites" in Photos differently than other files. For example, to add a "#" to the name of every photo that's a favorite: -```txt -osxphotos export /path/to/export --filename "{original_name}{favorite?#,}" - │ │ │││ - │ │ │││ - Use photo's original name as filename <──┘ │ │││ - │ │││ - 'favorite' is True if photo is a Favorite, <───────┘ │││ - otherwise, False │││ - │││ - '?' specifies a conditional <─────────────┘││ - ││ - Value immediately following ? will be used if <──────┘│ - preceding template field is True or non-blank │ - │ - Value immediately following comma will be used if <──────┘ - template field is False or blank (null); in this case - no value is specified so a blank string "" will be used -``` - + osxphotos export /path/to/export --filename "{original_name}{favorite?#,}" + │ │ │││ + │ │ │││ + Use photo's original name as filename <──┘ │ │││ + │ │││ + 'favorite' is True if photo is a Favorite, <───────┘ │││ + otherwise, False │││ + │││ + '?' specifies a conditional <─────────────┘││ + ││ + Value immediately following ? will be used if <──────┘│ + preceding template field is True or non-blank │ + │ + Value immediately following comma will be used if <──────┘ + template field is False or blank (null); in this case + no value is specified so a blank string "" will be used + Like with `--directory`, using a multi-valued template field such as `{keyword}` may result in more than one copy of a photo being exported. For example, if `IMG_1234.JPG` has keywords `Travel`, and `Vacation` and you run the following command: `osxphotos export /path/to/export --filename "{keyword}-{original_name}"` @@ -366,6 +362,10 @@ If you are exporting to an external network attached storage (NAS) device, you m In this example, osxphotos will attempt to export a photo up to 3 times if it encounters an error. +In addition to `--retry`, the `--exportdb` and `--ramdb` may improve performance when exporting to an external disk or a NAS. When osxphotos exports photos, it creates an export database file named `.osxphotos_export.db` in the export folder which osxphotos uses to keep track of which photos have been exported. This allows you to restart and export and to use `--update` to update an existing export. If the connection to the export location is slow or flaky, having the export database located on the export disk may decrease performance. In this case, you can use `--exportdb DBPATH` to instruct osxphotos to store the export database at DBPATH. If using this option, I recommend putting the export database on your Mac system disk (for example, in your home directory). If you intend to use `--update` to update the export in the future, you must remember where the export database is and use the `--exportdb` option every time you update the export. + +Another alternative to using `--exportdb` is to use `--ramdb`. This option instructs osxphotos to use a RAM database instead of a file on disk. The RAM database is much faster than the file on disk and doesn't require osxphotos to access the network drive to query or write to the database. When osxphotos completes the export it will write the RAM database to the export location. This can offer a significant performance boost but you will lose state information if osxphotos crashes or is interrupted during export. + #### Exporting metadata with exported photos Photos tracks a tremendous amount of metadata associated with photos in the library such as keywords, faces and persons, reverse geolocation data, and image classification labels. Photos' native export capability does not preserve most of this metadata. osxphotos can, however, access and preserve almost all the metadata associated with photos. Using the free [`exiftool`](https://exiftool.org/) app, osxphotos can write metadata to exported photos. Follow the instructions on the exiftool website to install exiftool then you can use the `--exiftool` option to write metadata to exported photos: @@ -374,20 +374,18 @@ Photos tracks a tremendous amount of metadata associated with photos in the libr This will write basic metadata such as keywords, persons, and GPS location to the exported files. osxphotos includes several additional options that can be used in conjunction with `--exiftool` to modify the metadata that is written by `exiftool`. For example, you can use the `--keyword-template` option to specify custom keywords (again, via the osxphotos template system). For example, to use the folder and album a photo is in to create hierarchal keywords in the format used by Lightroom Classic: -```txt -osxphotos export /path/to/export --exiftool --keyword-template "{folder_album(>)}" - │ │ - │ │ - folder_album results in the folder(s) <──┘ │ - and album a photo is contained in │ - │ - The value in () is used as the path separator <───────┘ - for joining the folders and albums. For example, - if photo is in Folder1/Folder2/Album, (>) produces - "Folder1>Folder2>Album" which some programs, such as - Lightroom Classic, treat as hierarchal keywords -``` - + osxphotos export /path/to/export --exiftool --keyword-template "{folder_album(>)}" + │ │ + │ │ + folder_album results in the folder(s) <──┘ │ + and album a photo is contained in │ + │ + The value in () is used as the path separator <───────┘ + for joining the folders and albums. For example, + if photo is in Folder1/Folder2/Album, (>) produces + "Folder1>Folder2>Album" which some programs, such as + Lightroom Classic, treat as hierarchal keywords + The above command will write all the regular metadata that `--exiftool` normally writes to the file upon export but will also add an additional keyword in the exported metadata in the form "Folder1>Folder2>Album". If you did not include the `(>)` in the template string (e.g. `{folder_album}`), folder_album would render in form "Folder1/Folder2/Album". A powerful feature of Photos is that it uses machine learning algorithms to automatically classify or label photos. These labels are used when you search for images in Photos but are not otherwise available to the user. osxphotos is able to read all the labels associated with a photo and makes those available through the template system via the `{label}`. Think of these as automatic keywords as opposed to the keywords you assign manually in Photos. One common use case is to use the automatic labels to create new keywords when exporting images so that these labels are embedded in the image's metadata: @@ -498,25 +496,23 @@ In the template string above, `{newline}` instructs osxphotos to insert a new li Explanation of the template string: -```txt -{title,}{title?{descr?{newline},},}{descr,} - │ │ │ │ │ │ │ - │ │ │ │ │ │ │ - └──> insert title (or nothing if no title) - │ │ │ │ │ │ - └───> is there a title? - │ │ │ │ │ - └───> if so, is there a description? - │ │ │ │ - └───> if so, insert new line - │ │ │ - └───> if descr is blank, insert nothing - │ │ - └───> if title is blank, insert nothing - │ - └───> finally, insert description - (or nothing if no description) -``` + {title,}{title?{descr?{newline},},}{descr,} + │ │ │ │ │ │ │ + │ │ │ │ │ │ │ + └──> insert title (or nothing if no title) + │ │ │ │ │ │ + └───> is there a title? + │ │ │ │ │ + └───> if so, is there a description? + │ │ │ │ + └───> if so, insert new line + │ │ │ + └───> if descr is blank, insert nothing + │ │ + └───> if title is blank, insert nothing + │ + └───> finally, insert description + (or nothing if no description) In this example, `title?` demonstrates use of the boolean (True/False) feature of the template system. `title?` is read as "Is the title True (or not blank/empty)? If so, then the value immediately following the `?` is used in place of `title`. If `title` is blank, then the value immediately following the comma is used instead. The format for boolean fields is `field?value if true,value if false`. Either `value if true` or `value if false` may be blank, in which case a blank string ("") is used for the value and both may also be an entirely new template string as seen in the above example. Using this format, template strings may be nested inside each other to form complex `if-then-else` statements. @@ -550,20 +546,18 @@ The special template field `{shell_quote}` ensures a string is properly quoted f Explanation of the template string: -```txt -{shell_quote,{filepath}{comma}{,+keyword,}} - │ │ │ │ │ - │ │ │ | │ - └──> quote everything after comma for proper execution in the shell - │ │ │ │ - └───> filepath of the exported file - │ │ │ - └───> insert a comma - │ │ - └───> join the list of keywords together with a "," - │ - └───> if no keywords, insert nothing (empty string: "") -``` + {shell_quote,{filepath}{comma}{,+keyword,}} + │ │ │ │ │ + │ │ │ | │ + └──> quote everything after comma for proper execution in the shell + │ │ │ │ + └───> filepath of the exported file + │ │ │ + └───> insert a comma + │ │ + └───> join the list of keywords together with a "," + │ + └───> if no keywords, insert nothing (empty string: "") Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following: @@ -1809,7 +1803,7 @@ Substitution Description {lf} A line feed: '\n', alias for {newline} {cr} A carriage return: '\r' {crlf} a carriage return + line feed: '\r\n' -{osxphotos_version} The osxphotos version, e.g. '0.47.9' +{osxphotos_version} The osxphotos version, e.g. '0.47.10' {osxphotos_cmd_line} The full command line used to run osxphotos The following substitutions may result in multiple values. Thus if specified @@ -3534,16 +3528,16 @@ e.g. if Photo keywords are `["foo","bar"]`: Valid filters are: -- lower: Convert value to lower case, e.g. 'Value' => 'value'. -- upper: Convert value to upper case, e.g. 'Value' => 'VALUE'. -- strip: Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'. -- titlecase: Convert value to title case, e.g. 'my value' => 'My Value'. -- capitalize: Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'. -- braces: Enclose value in curly braces, e.g. 'value => '{value}'. -- parens: Enclose value in parentheses, e.g. 'value' => '(value') -- brackets: Enclose value in brackets, e.g. 'value' => '[value]' -- shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. -- function: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py +- `lower`: Convert value to lower case, e.g. 'Value' => 'value'. +- `upper`: Convert value to upper case, e.g. 'Value' => 'VALUE'. +- `strip`: Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'. +- `titlecase`: Convert value to title case, e.g. 'my value' => 'My Value'. +- `capitalize`: Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'. +- `braces`: Enclose value in curly braces, e.g. 'value => '{value}'. +- `parens`: Enclose value in parentheses, e.g. 'value' => '(value') +- `brackets`: Enclose value in brackets, e.g. 'value' => '[value]' +- `shell_quote`: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. +- `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py e.g. if Photo keywords are `["FOO","bar"]`: @@ -3711,7 +3705,7 @@ The following template field substitutions are availabe for use the templating s |{comma}|A comma: ','| |{semicolon}|A semicolon: ';'| |{questionmark}|A question mark: '?'| -|{pipe}|A vertical pipe: '|'| +|{pipe}|A vertical pipe: '\|'| |{openbrace}|An open brace: '{'| |{closebrace}|A close brace: '}'| |{openparens}|An open parentheses: '('| @@ -3722,7 +3716,7 @@ The following template field substitutions are availabe for use the templating s |{lf}|A line feed: '\n', alias for {newline}| |{cr}|A carriage return: '\r'| |{crlf}|a carriage return + line feed: '\r\n'| -|{osxphotos_version}|The osxphotos version, e.g. '0.47.9'| +|{osxphotos_version}|The osxphotos version, e.g. '0.47.10'| |{osxphotos_cmd_line}|The full command line used to run osxphotos| |{album}|Album(s) photo is contained in| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder| diff --git a/README.rst b/README.rst index 00d6d275..993938f8 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ :format: html -OSXPhotos +osxphotos ========= What is osxphotos? @@ -16,9 +16,7 @@ You can also easily export both the original and edited photos. Supported operating systems --------------------------- -Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Big Sur (11.3). - -If you have access to macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please contact me via GitHub. +Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Monterey (12.3). This package will read Photos databases for any supported version on any supported macOS version. E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa. @@ -28,7 +26,7 @@ Requires python >= ``3.8``. Installation ------------ -If you are new to python and just want to use the command line application, I recommend you to install using pipx. See other advanced options below. +If you just want to use the command line application, I recommend you to install using pipx. See other advanced options below. Installation using pipx ^^^^^^^^^^^^^^^^^^^^^^^ @@ -55,7 +53,7 @@ You can also install directly from `pypi `_ Installation from git repository ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -OSXPhotos uses setuptools, thus simply run: +osxphotos uses setuptools, thus simply run: .. code-block:: diff --git a/build.sh b/build.sh index 88bbc184..bd3e11e3 100755 --- a/build.sh +++ b/build.sh @@ -3,10 +3,27 @@ # script to help build osxphotos release # this is unique to my own dev setup -# source venv/bin/activate rm -rf dist; rm -rf build python3 utils/update_readme.py + +# stage and convert markdown to rst +echo "Copying osxphotos/tutorial.md to docsrc/source/tutorial.md" +cp osxphotos/tutorial.md docsrc/source/tutorial.md +rm docsrc/source/tutorial.rst +m2r2 docsrc/source/tutorial.md +rm docsrc/source/tutorial.md + +echo "Generating template help docs" +rm docsrc/source/template_help.rst +python3 utils/generate_template_docs.py +m2r2 docsrc/source/template_help.md +rm docsrc/source/template_help.md + +# build docs (cd docsrc && make github && make docs && make pdf) -# python3 setup.py sdist bdist_wheel + +# build the package python3 -m build + +# build CLI executable ./make_cli_exe.sh diff --git a/dev_requirements.txt b/dev_requirements.txt index de4181c9..042aa837 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,13 +1,14 @@ -Sphinx build +furo m2r2 pdbpp pyinstaller==4.10 pytest-mock pytest==7.0.1 +Sphinx sphinx_click sphinx_rtd_theme -sphinxcontrib-programoutput +sphinx-copybutton sphinxcontrib-programoutput twine wheel \ No newline at end of file diff --git a/docs/.buildinfo b/docs/.buildinfo index 43aec638..38e24246 100644 --- a/docs/.buildinfo +++ b/docs/.buildinfo @@ -1,4 +1,4 @@ # Sphinx build info version 1 # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. -config: 16854688500bb6d125c4d28353ca6076 +config: 93e80185682565b83f06940002a2e690 tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/_modules/index.html b/docs/_modules/index.html index 2abce787..eae789d2 100644 --- a/docs/_modules/index.html +++ b/docs/_modules/index.html @@ -1,101 +1,257 @@ + + + + + - + + Overview: module code - osxphotos 0.47.10 documentation + + + + + + - - - - - Overview: module code — osxphotos 0.47.9 documentation - - - + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+ +
+ +
+ +
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+
+ +
+
+
+ +
+
+ +
+
- - - - - - - - - - - -
-
-
- - -
- -

All modules for which code is available

- - -
- -
-
- -
-
- - - - - - + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/_constants.html b/docs/_modules/osxphotos/_constants.html new file mode 100644 index 00000000..cf1ca26e --- /dev/null +++ b/docs/_modules/osxphotos/_constants.html @@ -0,0 +1,429 @@ + + + + + + + + osxphotos._constants — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos._constants

+"""
+Constants used by osxphotos 
+"""
+
+import os.path
+from datetime import datetime
+from enum import Enum
+
+APP_NAME = "osxphotos"
+
+OSXPHOTOS_URL = "https://github.com/RhetTbull/osxphotos"
+
+# Time delta: add this to Photos times to get unix time
+# Apple Epoch is Jan 1, 2001
+TIME_DELTA = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
+
+# Unicode format to use for comparing strings
+UNICODE_FORMAT = "NFC"
+
+# 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 or 5001
+_TESTED_DB_VERSIONS = ["6000", "5001", "4025", "4016", "3301", "2622"]
+
+# database model versions (applies to Photos 5, Photos 6)
+# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
+# Photos 5 (10.15.1) == 13537
+# Photos 5 (10.15.4, 10.15.5, 10.15.6) == 13703
+# Photos 6 (10.16.0 Beta) == 14104
+_TEST_MODEL_VERSIONS = ["13537", "13703", "14104"]
+
+_PHOTOS_2_VERSION = "2622"
+
+# only version 3 - 4 have RKVersion.selfPortrait
+_PHOTOS_3_VERSION = "3301"
+
+# versions 5.0 and later have a different database structure
+_PHOTOS_4_VERSION = "4025"  # latest Mojove version on 10.14.6
+_PHOTOS_5_VERSION = "5000"  # I've seen both 5001 and 6000.  6000 is most common on Catalina and up but there are some version 5001 database in the wild
+
+# Ranges for model version by Photos version
+_PHOTOS_5_MODEL_VERSION = [13000, 13999]
+_PHOTOS_6_MODEL_VERSION = [14000, 14999]
+_PHOTOS_7_MODEL_VERSION = [
+    15000,
+    15999,
+]  # Monterey developer preview is 15134, 12.1 is 15331
+
+# some table names differ between Photos 5 and Photos 6
+_DB_TABLE_NAMES = {
+    5: {
+        "ASSET": "ZGENERICASSET",
+        "KEYWORD_JOIN": "Z_1KEYWORDS.Z_37KEYWORDS",
+        "ALBUM_JOIN": "Z_26ASSETS.Z_34ASSETS",
+        "ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS",
+        "IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION",
+        "DEPTH_STATE": "ZGENERICASSET.ZDEPTHSTATES",
+        "UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
+        "ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
+        "ASSET_ALBUM_TABLE": "Z_26ASSETS",
+        "HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
+    },
+    6: {
+        "ASSET": "ZASSET",
+        "KEYWORD_JOIN": "Z_1KEYWORDS.Z_36KEYWORDS",
+        "ALBUM_JOIN": "Z_26ASSETS.Z_3ASSETS",
+        "ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS",
+        "IMPORT_FOK": "null",
+        "DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
+        "UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
+        "ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
+        "ASSET_ALBUM_TABLE": "Z_26ASSETS",
+        "HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
+    },
+    7: {
+        "ASSET": "ZASSET",
+        "KEYWORD_JOIN": "Z_1KEYWORDS.Z_38KEYWORDS",
+        "ALBUM_JOIN": "Z_27ASSETS.Z_3ASSETS",
+        "ALBUM_SORT_ORDER": "Z_27ASSETS.Z_FOK_3ASSETS",
+        "IMPORT_FOK": "null",
+        "DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
+        "UTI_ORIGINAL": "ZINTERNALRESOURCE.ZCOMPACTUTI",
+        "ASSET_ALBUM_JOIN": "Z_27ASSETS.Z_27ALBUMS",
+        "ASSET_ALBUM_TABLE": "Z_27ASSETS",
+        "HDR_TYPE": "ZHDRTYPE",
+    },
+}
+
+# which version operating systems have been tested
+_TESTED_OS_VERSIONS = [
+    ("10", "12"),
+    ("10", "13"),
+    ("10", "14"),
+    ("10", "15"),
+    ("10", "16"),
+    ("11", "0"),
+    ("11", "1"),
+    ("11", "2"),
+    ("11", "3"),
+    ("11", "4"),
+    ("11", "5"),
+    ("11", "6"),
+    ("12", "0"),
+    ("12", "1"),
+    ("12", "2"),
+    ("12", "3"),
+]
+
+# Photos 5 has persons who are empty string if unidentified face
+_UNKNOWN_PERSON = "_UNKNOWN_"
+
+# photos with no reverse geolocation info (place)
+_UNKNOWN_PLACE = "_UNKNOWN_"
+
+_EXIF_TOOL_URL = "https://exiftool.org/"
+
+# Where are shared iCloud photos located?
+_PHOTOS_5_SHARED_PHOTO_PATH = "resources/cloudsharing/data"
+
+# What type of file? Based on ZGENERICASSET.ZKIND in Photos 5 database
+_PHOTO_TYPE = 0
+_MOVIE_TYPE = 1
+
+# Name of XMP template file
+_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
+_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"
+_XMP_TEMPLATE_NAME_BETA = "xmp_sidecar_beta.mako"
+
+# Constants used for processing folders and albums
+_PHOTOS_5_ALBUM_KIND = 2  # normal user album
+_PHOTOS_5_SHARED_ALBUM_KIND = 1505  # shared album
+_PHOTOS_5_PROJECT_ALBUM_KIND = 1508  # My Projects (e.g. Calendar, Card, Slideshow)
+_PHOTOS_5_FOLDER_KIND = 4000  # user folder
+_PHOTOS_5_ROOT_FOLDER_KIND = 3999  # root folder
+_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506  # import session
+
+_PHOTOS_4_ALBUM_KIND = 3  # RKAlbum.albumSubclass
+_PHOTOS_4_ALBUM_TYPE_ALBUM = 1  # RKAlbum.albumType
+_PHOTOS_4_ALBUM_TYPE_PROJECT = 9  # RKAlbum.albumType
+_PHOTOS_4_ALBUM_TYPE_SLIDESHOW = 8  # RKAlbum.albumType
+_PHOTOS_4_TOP_LEVEL_ALBUMS = [
+    "TopLevelAlbums",
+    "TopLevelKeepsakes",
+    "TopLevelSlideshows",
+]
+_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
+
+# EXIF related constants
+# max keyword length for IPTC:Keyword, reference
+# https://www.iptc.org/std/photometadata/documentation/userguide/
+_MAX_IPTC_KEYWORD_LEN = 64
+
+# Sentinel value for detecting if a template in keyword_template doesn't match
+# If anyone has a keyword matching this, then too bad...
+_OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
+
+# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
+SEARCH_CATEGORY_LABEL = 2024
+SEARCH_CATEGORY_PLACE_NAME = 1
+SEARCH_CATEGORY_STREET = 2
+SEARCH_CATEGORY_NEIGHBORHOOD = 3
+SEARCH_CATEGORY_LOCALITY_4 = 4
+SEARCH_CATEGORY_SUB_LOCALITY_5 = 5
+SEARCH_CATEGORY_SUB_LOCALITY_6 = 6
+SEARCH_CATEGORY_CITY = 7
+SEARCH_CATEGORY_LOCALITY_8 = 8
+SEARCH_CATEGORY_NAMED_AREA = 9
+SEARCH_CATEGORY_ALL_LOCALITY = [
+    SEARCH_CATEGORY_LOCALITY_4,
+    SEARCH_CATEGORY_SUB_LOCALITY_5,
+    SEARCH_CATEGORY_SUB_LOCALITY_6,
+    SEARCH_CATEGORY_LOCALITY_8,
+    SEARCH_CATEGORY_NAMED_AREA,
+]
+SEARCH_CATEGORY_STATE = 10
+SEARCH_CATEGORY_STATE_ABBREVIATION = 11
+SEARCH_CATEGORY_COUNTRY = 12
+SEARCH_CATEGORY_BODY_OF_WATER = 14
+SEARCH_CATEGORY_MONTH = 1014
+SEARCH_CATEGORY_YEAR = 1015
+SEARCH_CATEGORY_KEYWORDS = 2016
+SEARCH_CATEGORY_TITLE = 2017
+SEARCH_CATEGORY_DESCRIPTION = 2018
+SEARCH_CATEGORY_HOME = 2020
+SEARCH_CATEGORY_PERSON = 2021
+SEARCH_CATEGORY_ACTIVITY = 2027
+SEARCH_CATEGORY_HOLIDAY = 2029
+SEARCH_CATEGORY_SEASON = 2030
+SEARCH_CATEGORY_WORK = 2036
+SEARCH_CATEGORY_VENUE = 2038
+SEARCH_CATEGORY_VENUE_TYPE = 2039
+SEARCH_CATEGORY_PHOTO_TYPE_VIDEO = 2044
+SEARCH_CATEGORY_PHOTO_TYPE_SLOMO = 2045
+SEARCH_CATEGORY_PHOTO_TYPE_LIVE = 2046
+SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT = 2047
+SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA = 2048
+SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE = 2049
+SEARCH_CATEGORY_PHOTO_TYPE_BURSTS = 2052
+SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT = 2053
+SEARCH_CATEGORY_PHOTO_TYPE_SELFIES = 2054
+SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES = 2055
+SEARCH_CATEGORY_MEDIA_TYPES = [
+    SEARCH_CATEGORY_PHOTO_TYPE_VIDEO,
+    SEARCH_CATEGORY_PHOTO_TYPE_SLOMO,
+    SEARCH_CATEGORY_PHOTO_TYPE_LIVE,
+    SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT,
+    SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA,
+    SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE,
+    SEARCH_CATEGORY_PHOTO_TYPE_BURSTS,
+    SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT,
+    SEARCH_CATEGORY_PHOTO_TYPE_SELFIES,
+    SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES,
+]
+SEARCH_CATEGORY_PHOTO_NAME = 2056
+
+
+# Max filename length on MacOS
+MAX_FILENAME_LEN = 255
+
+# Max directory name length on MacOS
+MAX_DIRNAME_LEN = 255
+
+# Default JPEG quality when converting to JPEG
+DEFAULT_JPEG_QUALITY = 1.0
+
+# Default suffix to add to edited images
+DEFAULT_EDITED_SUFFIX = "_edited"
+
+# Default suffix to add to original images
+DEFAULT_ORIGINAL_SUFFIX = ""
+
+# Default suffix to add to preview images
+DEFAULT_PREVIEW_SUFFIX = "_preview"
+
+# Bit masks for --sidecar
+SIDECAR_JSON = 0x1
+SIDECAR_EXIFTOOL = 0x2
+SIDECAR_XMP = 0x4
+
+# supported attributes for --xattr-template
+EXTENDED_ATTRIBUTE_NAMES = [
+    "authors",
+    "comment",
+    "copyright",
+    "creator",
+    "description",
+    "findercomment",
+    "headline",
+    "keywords",
+    "participants",
+    "projects",
+    "rating",
+    "subject",
+    "title",
+    "version",
+]
+EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
+
+
+# name of export DB
+OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
+
+# bit flags for burst images ("burstPickType")
+BURST_PICK_TYPE_NONE = 0b0  # 0: sometimes used for single images with a burst UUID
+BURST_NOT_SELECTED = 0b10  # 2: burst image is not selected
+BURST_DEFAULT_PICK = 0b100  # 4: burst image is the one Photos picked to be key image before any selections made
+BURST_SELECTED = 0b1000  # 8: burst image is selected
+BURST_KEY = 0b10000  # 16: burst image is the key photo (top of burst stack)
+BURST_UNKNOWN = 0b100000  # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set.  I think this has something to do with what algorithm Photos used to pick the default image
+
+LIVE_VIDEO_EXTENSIONS = [".mov"]
+
+# categories that --post-command can be used with; these map to ExportResults fields
+POST_COMMAND_CATEGORIES = {
+    "exported": "All exported files",
+    "new": "When used with '--update', all newly exported files",
+    "updated": "When used with '--update', all files which were previously exported but updated this time",
+    "skipped": "When used with '--update', all files which were skipped (because they were previously exported and didn't change)",
+    "missing": "All files which were not exported because they were missing from the Photos library",
+    "exif_updated": "When used with '--exiftool', all files on which exiftool updated the metadata",
+    "touched": "When used with '--touch-file', all files where the date was touched",
+    "converted_to_jpeg": "When used with '--convert-to-jpeg', all files which were converted to jpeg",
+    "sidecar_json_written": "When used with '--sidecar json', all JSON sidecar files which were written",
+    "sidecar_json_skipped": "When used with '--sidecar json' and '--update', all JSON sidecar files which were skipped",
+    "sidecar_exiftool_written": "When used with '--sidecar exiftool', all exiftool sidecar files which were written",
+    "sidecar_exiftool_skipped": "When used with '--sidecar exiftool' and '--update, all exiftool sidecar files which were skipped",
+    "sidecar_xmp_written": "When used with '--sidecar xmp', all XMP sidecar files which were written",
+    "sidecar_xmp_skipped": "When used with '--sidecar xmp' and '--update', all XMP sidecar files which were skipped",
+    "error": "All files which produced an error during export",
+    # "deleted_files": "When used with '--cleanup', all files deleted during the export",
+    # "deleted_directories": "When used with '--cleanup', all directories deleted during the export",
+}
+
+
+
[docs]class AlbumSortOrder(Enum): + """Album Sort Order""" + + UNKNOWN = 0 + MANUAL = 1 + NEWEST_FIRST = 2 + OLDEST_FIRST = 3 + TITLE = 5
+ + +TEXT_DETECTION_CONFIDENCE_THRESHOLD = 0.75 + +# stat sort order for cProfile: https://docs.python.org/3/library/profile.html#pstats.Stats.sort_stats +PROFILE_SORT_KEYS = [ + "calls", + "cumulative", + "cumtime", + "file", + "filename", + "module", + "ncalls", + "pcalls", + "line", + "name", + "nfl", + "stdname", + "time", + "tottime", +] +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/albuminfo.html b/docs/_modules/osxphotos/albuminfo.html new file mode 100644 index 00000000..30de2d59 --- /dev/null +++ b/docs/_modules/osxphotos/albuminfo.html @@ -0,0 +1,518 @@ + + + + + + + + osxphotos.albuminfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.albuminfo

+"""
+AlbumInfo and FolderInfo classes for dealing with albums and folders
+
+AlbumInfo class
+Represents a single Album in the Photos library and provides access to the album's attributes
+PhotosDB.albums() returns a list of AlbumInfo objects
+
+FolderInfo class
+Represents a single Folder in the Photos library and provides access to the folders attributes
+PhotosDB.folders() returns a list of FolderInfo objects
+"""
+
+from datetime import datetime, timedelta, timezone
+
+from ._constants import (
+    _PHOTOS_4_ALBUM_KIND,
+    _PHOTOS_4_TOP_LEVEL_ALBUMS,
+    _PHOTOS_4_VERSION,
+    _PHOTOS_5_ALBUM_KIND,
+    _PHOTOS_5_FOLDER_KIND,
+    TIME_DELTA,
+    AlbumSortOrder,
+)
+from .datetime_utils import get_local_tz
+from .query_builder import get_query
+
+__all__ = [
+    "sort_list_by_keys",
+    "AlbumInfoBaseClass",
+    "AlbumInfo",
+    "ImportInfo",
+    "ProjectInfo",
+    "FolderInfo",
+]
+
+
+def sort_list_by_keys(values, sort_keys):
+    """Sorts list values by a second list sort_keys
+        e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"]
+
+    Args:
+        values: a list of values to be sorted
+        sort_keys: a list of keys to sort values by
+
+    Returns:
+        list of values, sorted by sort_keys
+
+    Raises:
+        ValueError: raised if len(values) != len(sort_keys)
+    """
+    if len(values) != len(sort_keys):
+        return ValueError("values and sort_keys must have same length")
+
+    return list(zip(*sorted(zip(sort_keys, values))))[1]
+
+
+class AlbumInfoBaseClass:
+    """
+    Base class for AlbumInfo, ImportInfo
+    Info about a specific Album, contains all the details about the album
+    including folders, photos, etc.
+    """
+
+    def __init__(self, db=None, uuid=None):
+        self._uuid = uuid
+        self._db = db
+        self._title = self._db._dbalbum_details[uuid]["title"]
+        self._creation_date_timestamp = self._db._dbalbum_details[uuid]["creation_date"]
+        self._start_date_timestamp = self._db._dbalbum_details[uuid]["start_date"]
+        self._end_date_timestamp = self._db._dbalbum_details[uuid]["end_date"]
+        self._local_tz = get_local_tz(
+            datetime.fromtimestamp(self._creation_date_timestamp + TIME_DELTA)
+        )
+
+    @property
+    def uuid(self):
+        """return uuid of album"""
+        return self._uuid
+
+    @property
+    def creation_date(self):
+        """return creation date of album"""
+        try:
+            return self._creation_date
+        except AttributeError:
+            try:
+                self._creation_date = (
+                    datetime.fromtimestamp(
+                        self._creation_date_timestamp + TIME_DELTA
+                    ).astimezone(tz=self._local_tz)
+                    if self._creation_date_timestamp
+                    else datetime(1970, 1, 1, 0, 0, 0).astimezone(
+                        tz=timezone(timedelta(0))
+                    )
+                )
+            except ValueError:
+                self._creation_date = datetime(1970, 1, 1, 0, 0, 0).astimezone(
+                    tz=timezone(timedelta(0))
+                )
+            return self._creation_date
+
+    @property
+    def start_date(self):
+        """For Albums, return start date (earliest image) of album or None for albums with no images
+        For Import Sessions, return start date of import session (when import began)"""
+        try:
+            return self._start_date
+        except AttributeError:
+            try:
+                self._start_date = (
+                    datetime.fromtimestamp(
+                        self._start_date_timestamp + TIME_DELTA
+                    ).astimezone(tz=self._local_tz)
+                    if self._start_date_timestamp
+                    else None
+                )
+            except ValueError:
+                self._start_date = None
+            return self._start_date
+
+    @property
+    def end_date(self):
+        """For Albums, return end date (most recent image) of album or None for albums with no images
+        For Import Sessions, return end date of import sessions (when import was completed)"""
+        try:
+            return self._end_date
+        except AttributeError:
+            try:
+                self._end_date = (
+                    datetime.fromtimestamp(
+                        self._end_date_timestamp + TIME_DELTA
+                    ).astimezone(tz=self._local_tz)
+                    if self._end_date_timestamp
+                    else None
+                )
+            except ValueError:
+                self._end_date = None
+            return self._end_date
+
+    @property
+    def photos(self):
+        return []
+
+    @property
+    def owner(self):
+        """Return name of photo owner for shared album (Photos 5+ only), or None if not shared"""
+        if self._db._db_version <= _PHOTOS_4_VERSION:
+            return None
+
+        try:
+            return self._owner
+        except AttributeError:
+            try:
+                personid = self._db._dbalbum_details[self.uuid][
+                    "cloudownerhashedpersonid"
+                ]
+                self._owner = (
+                    self._db._db_hashed_person_id[personid]["full_name"]
+                    if personid
+                    else None
+                )
+            except KeyError:
+                self._owner = None
+            return self._owner
+
+    def __len__(self):
+        """return number of photos contained in album"""
+        return len(self.photos)
+
+
+
[docs]class AlbumInfo(AlbumInfoBaseClass): + """ + Info about a specific Album, contains all the details about the album + including folders, photos, etc. + """ + + @property + def title(self): + """return title / name of album""" + return self._title + + @property + def photos(self): + """return list of photos contained in album sorted in same sort order as Photos""" + try: + return self._photos + except AttributeError: + if self.uuid in self._db._dbalbums_album: + uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid]) + sorted_uuid = sort_list_by_keys(uuid, sort_order) + photos = self._db.photos_by_uuid(sorted_uuid) + sort_order = self.sort_order + if sort_order == AlbumSortOrder.NEWEST_FIRST: + self._photos = sorted(photos, key=lambda p: p.date, reverse=True) + elif sort_order == AlbumSortOrder.OLDEST_FIRST: + self._photos = sorted(photos, key=lambda p: p.date) + elif sort_order == AlbumSortOrder.TITLE: + self._photos = sorted(photos, key=lambda p: p.title or "") + else: + # assume AlbumSortOrder.MANUAL + self._photos = photos + else: + self._photos = [] + return self._photos + + @property + def folder_names(self): + """return hierarchical list of folders the album is contained in + the folder list is in form: + ["Top level folder", "sub folder 1", "sub folder 2", ...] + returns empty list if album is not in any folders""" + + try: + return self._folder_names + except AttributeError: + self._folder_names = self._db._album_folder_hierarchy_list(self._uuid) + return self._folder_names + + @property + def folder_list(self): + """return hierarchical list of folders the album is contained in + as list of FolderInfo objects in form + ["Top level folder", "sub folder 1", "sub folder 2", ...] + returns empty list if album is not in any folders""" + + try: + return self._folders + except AttributeError: + self._folders = self._db._album_folder_hierarchy_folderinfo(self._uuid) + return self._folders + + @property + def parent(self): + """returns FolderInfo object for parent folder or None if no parent (e.g. top-level album)""" + try: + return self._parent + except AttributeError: + if self._db._db_version <= _PHOTOS_4_VERSION: + parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"] + self._parent = ( + FolderInfo(db=self._db, uuid=parent_uuid) + if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS + else None + ) + else: + parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"] + self._parent = ( + FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk]) + if parent_pk != self._db._folder_root_pk + else None + ) + return self._parent + + @property + def sort_order(self): + """return sort order of album""" + if self._db._db_version <= _PHOTOS_4_VERSION: + return AlbumSortOrder.MANUAL + + details = self._db._dbalbum_details[self._uuid] + if details["customsortkey"] == 1: + if details["customsortascending"] == 0: + return AlbumSortOrder.NEWEST_FIRST + elif details["customsortascending"] == 1: + return AlbumSortOrder.OLDEST_FIRST + else: + return AlbumSortOrder.UNKNOWN + elif details["customsortkey"] == 5: + return AlbumSortOrder.TITLE + elif details["customsortkey"] == 0: + return AlbumSortOrder.MANUAL + else: + return AlbumSortOrder.UNKNOWN + +
[docs] def photo_index(self, photo): + """return index of photo in album (based on album sort order)""" + for index, p in enumerate(self.photos): + if p.uuid == photo.uuid: + return index + raise ValueError( + f"Photo with uuid {photo.uuid} does not appear to be in this album" + )
+ + +
[docs]class ImportInfo(AlbumInfoBaseClass): + """Information about import sessions""" + + @property + def photos(self): + """return list of photos contained in import session""" + try: + return self._photos + except AttributeError: + uuid_list, sort_order = zip( + *[ + (uuid, self._db._dbphotos[uuid]["fok_import_session"]) + for uuid in self._db._dbphotos + if self._db._dbphotos[uuid]["import_uuid"] == self.uuid + ] + ) + sorted_uuid = sort_list_by_keys(uuid_list, sort_order) + self._photos = self._db.photos_by_uuid(sorted_uuid) + return self._photos
+ + +
[docs]class ProjectInfo(AlbumInfo): + """ + ProjectInfo with info about projects + Projects are cards, calendars, slideshows, etc. + """ + + ...
+ + +class FolderInfo: + """ + Info about a specific folder, contains all the details about the folder + including folders, albums, etc + """ + + def __init__(self, db=None, uuid=None): + self._uuid = uuid + self._db = db + if self._db._db_version <= _PHOTOS_4_VERSION: + self._pk = None + self._title = self._db._dbfolder_details[uuid]["name"] + else: + self._pk = self._db._dbalbum_details[uuid]["pk"] + self._title = self._db._dbalbum_details[uuid]["title"] + + @property + def title(self): + """return title / name of folder""" + return self._title + + @property + def uuid(self): + """return uuid of folder""" + return self._uuid + + @property + def album_info(self): + """return list of albums (as AlbumInfo objects) contained in the folder""" + try: + return self._albums + except AttributeError: + if self._db._db_version <= _PHOTOS_4_VERSION: + albums = [ + AlbumInfo(db=self._db, uuid=album) + for album, detail in self._db._dbalbum_details.items() + if not detail["intrash"] + and detail["albumSubclass"] == _PHOTOS_4_ALBUM_KIND + and detail["folderUuid"] == self._uuid + ] + else: + albums = [ + AlbumInfo(db=self._db, uuid=album) + for album, detail in self._db._dbalbum_details.items() + if not detail["intrash"] + and detail["kind"] == _PHOTOS_5_ALBUM_KIND + and detail["parentfolder"] == self._pk + ] + self._albums = albums + return self._albums + + @property + def parent(self): + """returns FolderInfo object for parent or None if no parent (e.g. top-level folder)""" + try: + return self._parent + except AttributeError: + if self._db._db_version <= _PHOTOS_4_VERSION: + parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"] + self._parent = ( + FolderInfo(db=self._db, uuid=parent_uuid) + if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS + else None + ) + else: + parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"] + self._parent = ( + FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk]) + if parent_pk != self._db._folder_root_pk + else None + ) + return self._parent + + @property + def subfolders(self): + """return list of folders (as FolderInfo objects) contained in the folder""" + try: + return self._folders + except AttributeError: + if self._db._db_version <= _PHOTOS_4_VERSION: + folders = [ + FolderInfo(db=self._db, uuid=folder) + for folder, detail in self._db._dbfolder_details.items() + if not detail["intrash"] + and not detail["isMagic"] + and detail["parentFolderUuid"] == self._uuid + ] + else: + folders = [ + FolderInfo(db=self._db, uuid=album) + for album, detail in self._db._dbalbum_details.items() + if not detail["intrash"] + and detail["kind"] == _PHOTOS_5_FOLDER_KIND + and detail["parentfolder"] == self._pk + ] + self._folders = folders + return self._folders + + def __len__(self): + """returns count of folders + albums contained in the folder""" + return len(self.subfolders) + len(self.album_info) +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/debug.html b/docs/_modules/osxphotos/debug.html new file mode 100644 index 00000000..0051f1ae --- /dev/null +++ b/docs/_modules/osxphotos/debug.html @@ -0,0 +1,206 @@ + + + + + + + + osxphotos.debug — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.debug

+"""Utilities for debugging"""
+
+import logging
+import sys
+import time
+from datetime import datetime
+from typing import Dict, List
+
+import wrapt
+from rich import print
+
+# global variable to control debug output
+# set via --debug
+DEBUG = False
+
+
+
[docs]def set_debug(debug: bool): + """set debug flag""" + global DEBUG + DEBUG = debug + logging.disable(logging.NOTSET if debug else logging.DEBUG)
+ + +
[docs]def is_debug(): + """return debug flag""" + return DEBUG
+ + +def debug_watch(wrapped, instance, args, kwargs): + """For use with wrapt.wrap_function_wrapper to watch calls to a function""" + caller = sys._getframe().f_back.f_code.co_name + name = wrapped.__name__ + timestamp = datetime.now().isoformat() + print( + f"{timestamp} {name} called from {caller} with args: {args} and kwargs: {kwargs}" + ) + start_t = time.perf_counter() + rv = wrapped(*args, **kwargs) + stop_t = time.perf_counter() + print(f"{timestamp} {name} returned: {rv}, elapsed time: {stop_t - start_t} sec") + return rv + + +def debug_breakpoint(wrapped, instance, args, kwargs): + """For use with wrapt.wrap_function_wrapper to set breakpoint on a function""" + breakpoint() + return wrapped(*args, **kwargs) + + +def wrap_function(function_path, wrapper): + """Wrap a function with wrapper function""" + module, name = function_path.split(".", 1) + try: + return wrapt.wrap_function_wrapper(module, name, wrapper) + except AttributeError as e: + raise AttributeError(f"{module}.{name} does not exist") from e + + +def get_debug_options(arg_names: List, argv: List) -> Dict: + """Get the options for the debug options; + Some of the debug options like --watch and --breakpoint need to be processed before any other packages are loaded + so they can't be handled in the normal click argument processing, thus this function is called + from osxphotos/cli/__init__.py + + Assumes multi-valued options are OK and that all options take form of --option VALUE or --option=VALUE + """ + # argv[0] is the program name + # argv[1] is the command + # argv[2:] are the arguments + args = {} + for arg_name in arg_names: + for idx, arg in enumerate(argv[1:]): + if arg.startswith(f"{arg_name}="): + arg_value = arg.split("=")[1] + try: + args[arg].append(arg_value) + except KeyError: + args[arg] = [arg_value] + elif arg == arg_name: + try: + args[arg].append(argv[idx + 2]) + except KeyError: + try: + args[arg] = [argv[idx + 2]] + except IndexError as e: + raise ValueError(f"Missing value for {arg}") from e + except IndexError as e: + raise ValueError(f"Missing value for {arg}") from e + return args + + +def get_debug_flags(arg_names: List, argv: List) -> Dict: + """Get the flags for the debug options; + Processes flags like --debug that resolve to True or False + """ + # argv[0] is the program name + # argv[1] is the command + # argv[2:] are the arguments + args = {arg_name: False for arg_name in arg_names} + for arg_name in arg_names: + if arg_name in argv[1:]: + args[arg_name] = True + return args +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/exifinfo.html b/docs/_modules/osxphotos/exifinfo.html new file mode 100644 index 00000000..d09237a4 --- /dev/null +++ b/docs/_modules/osxphotos/exifinfo.html @@ -0,0 +1,133 @@ + + + + + + + + osxphotos.exifinfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.exifinfo

+""" ExifInfo class to expose EXIF info from the library """
+
+from dataclasses import dataclass
+
+__all__ = ["ExifInfo"]
+
+
+
[docs]@dataclass(frozen=True) +class ExifInfo: + """EXIF info associated with a photo from the Photos library""" + + flash_fired: bool + iso: int + metering_mode: int + sample_rate: int + track_format: int + white_balance: int + aperture: float + bit_rate: float + duration: float + exposure_bias: float + focal_length: float + fps: float + latitude: float + longitude: float + shutter_speed: float + camera_make: str + camera_model: str + codec: str + lens_model: str
+
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/exiftool.html b/docs/_modules/osxphotos/exiftool.html new file mode 100644 index 00000000..687115ab --- /dev/null +++ b/docs/_modules/osxphotos/exiftool.html @@ -0,0 +1,616 @@ + + + + + + + + osxphotos.exiftool — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.exiftool

+""" Yet another simple exiftool wrapper 
+    I rolled my own for following reasons: 
+    1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
+    2. I wanted singleton behavior so only a single exiftool process was ever running
+    3. When used as a context manager, I wanted the operations to batch until exiting the context (improved performance)
+    If these aren't important to you, I highly recommend you use Sven Marnach's excellent 
+    pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
+
+import atexit
+import html
+import json
+import logging
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+from abc import ABC, abstractmethod
+from functools import lru_cache  # pylint: disable=syntax-error
+
+__all__ = [
+    "escape_str",
+    "exiftool_can_write",
+    "ExifTool",
+    "ExifToolCaching",
+    "get_exiftool_path",
+    "terminate_exiftool",
+    "unescape_str",
+]
+
+# exiftool -stay_open commands outputs this EOF marker after command is run
+EXIFTOOL_STAYOPEN_EOF = "{ready}"
+EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
+
+# list of exiftool processes to cleanup when exiting or when terminate is called
+EXIFTOOL_PROCESSES = []
+
+# exiftool supported file types, created by utils/exiftool_supported_types.py
+EXIFTOOL_FILETYPES_JSON = "exiftool_filetypes.json"
+with (pathlib.Path(__file__).parent / EXIFTOOL_FILETYPES_JSON).open("r") as f:
+    EXIFTOOL_SUPPORTED_FILETYPES = json.load(f)
+
+
+def exiftool_can_write(suffix: str) -> bool:
+    """Return True if exiftool supports writing to a file with the given suffix, otherwise False"""
+    if not suffix:
+        return False
+    suffix = suffix.lower()
+    if suffix[0] == ".":
+        suffix = suffix[1:]
+    return (
+        suffix in EXIFTOOL_SUPPORTED_FILETYPES
+        and EXIFTOOL_SUPPORTED_FILETYPES[suffix]["write"]
+    )
+
+
+def escape_str(s):
+    """escape string for use with exiftool -E"""
+    if type(s) != str:
+        return s
+    s = html.escape(s)
+    s = s.replace("\n", "&#xa;")
+    s = s.replace("\t", "&#x9;")
+    s = s.replace("\r", "&#xd;")
+    return s
+
+
+def unescape_str(s):
+    """unescape an HTML string returned by exiftool -E"""
+    if type(s) != str:
+        return s
+    # avoid " in values which result in json.loads() throwing an exception, #636
+    s = s.replace("&quot;", '\\"')
+    return html.unescape(s)
+
+
+@atexit.register
+def terminate_exiftool():
+    """Terminate any running ExifTool subprocesses; call this to cleanup when done using ExifTool"""
+    for proc in EXIFTOOL_PROCESSES:
+        proc._stop_proc()
+
+
+@lru_cache(maxsize=1)
+def get_exiftool_path():
+    """return path of exiftool, cache result"""
+    exiftool_path = shutil.which("exiftool")
+    if exiftool_path:
+        return exiftool_path.rstrip()
+    else:
+        raise FileNotFoundError(
+            "Could not find exiftool. Please download and install from "
+            "https://exiftool.org/"
+        )
+
+
+class _ExifToolProc:
+    """Runs exiftool in a subprocess via Popen
+    Creates a singleton object"""
+
+    def __new__(cls, *args, **kwargs):
+        """create new object or return instance of already created singleton"""
+        if not hasattr(cls, "instance") or not cls.instance:
+            cls.instance = super().__new__(cls)
+
+        return cls.instance
+
+    def __init__(self, exiftool=None):
+        """construct _ExifToolProc singleton object or return instance of already created object
+        exiftool: optional path to exiftool binary (if not provided, will search path to find it)
+        """
+
+        if hasattr(self, "_process_running") and self._process_running:
+            # already running
+            if exiftool is not None and exiftool != self._exiftool:
+                logging.warning(
+                    f"exiftool subprocess already running, "
+                    f"ignoring exiftool={exiftool}"
+                )
+            return
+        self._process_running = False
+        self._exiftool = exiftool or get_exiftool_path()
+        self._start_proc()
+
+    @property
+    def process(self):
+        """return the exiftool subprocess"""
+        if self._process_running:
+            return self._process
+        else:
+            self._start_proc()
+            return self._process
+
+    @property
+    def pid(self):
+        """return process id (PID) of the exiftool process"""
+        return self._process.pid
+
+    @property
+    def exiftool(self):
+        """return path to exiftool process"""
+        return self._exiftool
+
+    def _start_proc(self):
+        """start exiftool in batch mode"""
+
+        if self._process_running:
+            logging.warning("exiftool already running: {self._process}")
+            return
+
+        # open exiftool process
+        # make sure /usr/bin at start of path so exiftool can find xattr (see #636)
+        env = os.environ.copy()
+        env["PATH"] = f'/usr/bin/:{env["PATH"]}'
+        self._process = subprocess.Popen(
+            [
+                self._exiftool,
+                "-stay_open",  # keep process open in batch mode
+                "True",  # -stay_open=True, keep process open in batch mode
+                "-@",  # read command-line arguments from file
+                "-",  # read from stdin
+                "-common_args",  # specifies args common to all commands subsequently run
+                "-n",  # no print conversion (e.g. print tag values in machine readable format)
+                "-P",  # Preserve file modification date/time
+                "-G",  # print group name for each tag
+                "-E",  # escape tag values for HTML (allows use of HTML &#xa; for newlines)
+            ],
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            env=env,
+        )
+        self._process_running = True
+
+        EXIFTOOL_PROCESSES.append(self)
+
+    def _stop_proc(self):
+        """stop the exiftool process if it's running, otherwise, do nothing"""
+
+        if not self._process_running:
+            return
+
+        try:
+            self._process.stdin.write(b"-stay_open\n")
+            self._process.stdin.write(b"False\n")
+            self._process.stdin.flush()
+        except Exception as e:
+            pass
+
+        try:
+            self._process.communicate(timeout=5)
+        except subprocess.TimeoutExpired:
+            self._process.kill()
+            self._process.communicate()
+
+        del self._process
+        self._process_running = False
+
+
+
[docs]class ExifTool: + """Basic exiftool interface for reading and writing EXIF tags""" + + def __init__(self, filepath, exiftool=None, overwrite=True, flags=None): + """Create ExifTool object + + Args: + file: path to image file + exiftool: path to exiftool, if not specified will look in path + overwrite: if True, will overwrite image file without creating backup, default=False + flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F) + + Returns: + ExifTool instance + """ + self.file = filepath + self.overwrite = overwrite + self.flags = flags or [] + self.data = {} + self.warning = None + self.error = None + # if running as a context manager, self._context_mgr will be True + self._context_mgr = False + self._exiftoolproc = _ExifToolProc(exiftool=exiftool) + self._read_exif() + + @property + def _process(self): + return self._exiftoolproc.process + +
[docs] def setvalue(self, tag, value): + """Set tag to value(s); if value is None, will delete tag + + Args: + tag: str; name of tag to set + value: str; value to set tag to + + Returns: + True if success otherwise False + + If error generated by exiftool, returns False and sets self.error to error string + If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string + If called in context manager, returns True (execution is delayed until exiting context manager) + """ + + if value is None: + value = "" + value = escape_str(value) + command = [f"-{tag}={value}"] + if self.overwrite and not self._context_mgr: + command.append("-overwrite_original") + + # avoid "Warning: Some character(s) could not be encoded in Latin" warning + command.append("-iptc:codedcharacterset=utf8") + + if self._context_mgr: + self._commands.extend(command) + return True + else: + _, _, error = self.run_commands(*command) + return error == ""
+ +
[docs] def addvalues(self, tag, *values): + """Add one or more value(s) to tag + If more than one value is passed, each value will be added to the tag + + Args: + tag: str; tag to set + *values: str; one or more values to set + + Returns: + True if success otherwise False + + If error generated by exiftool, returns False and sets self.error to error string + If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string + If called in context manager, returns True (execution is delayed until exiting context manager) + + Notes: exiftool may add duplicate values for some tags so the caller must ensure + the values being added are not already in the EXIF data + For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords, + but for others, such as EXIF:ISO, this will literally add a value to the existing value. + It's up to the caller to know what exiftool will do for each tag + If setvalue called before addvalues, exiftool does not appear to add duplicates, + but if addvalues called without first calling setvalue, exiftool will add duplicate values + """ + if not values: + raise ValueError("Must pass at least one value") + + command = [] + for value in values: + if value is None: + raise ValueError("Can't add None value to tag") + value = escape_str(value) + command.append(f"-{tag}+={value}") + + if self.overwrite and not self._context_mgr: + command.append("-overwrite_original") + + if self._context_mgr: + self._commands.extend(command) + return True + else: + _, _, error = self.run_commands(*command) + return error == ""
+ +
[docs] def run_commands(self, *commands, no_file=False): + """Run commands in the exiftool process and return result. + + Args: + *commands: exiftool commands to run + no_file: (bool) do not pass the filename to exiftool (default=False) + by default, all commands will be run against self.file + use no_file=True to run a command without passing the filename + Returns: + (output, warning, errror) + output: bytes is containing output of exiftool commands + warning: if exiftool generated warnings, string containing warning otherwise empty string + error: if exiftool generated errors, string containing otherwise empty string + + Note: Also sets self.warning and self.error if warning or error generated. + """ + if not (hasattr(self, "_process") and self._process): + raise ValueError("exiftool process is not running") + + if not commands: + raise TypeError("must provide one or more command to run") + + if self._context_mgr and self.overwrite: + commands = list(commands) + commands.append("-overwrite_original") + + filename = os.fsencode(self.file) if not no_file else b"" + + if self.flags: + # need to split flags, e.g. so "--ext AVI" becomes ["--ext", "AVI"] + flags = [] + for f in self.flags: + flags.extend(f.split()) + command_str = b"\n".join([f.encode("utf-8") for f in flags]) + command_str += b"\n" + else: + command_str = b"" + + command_str += ( + b"\n".join([c.encode("utf-8") for c in commands]) + + b"\n" + + filename + + b"\n" + + b"-execute\n" + ) + + # send the command + self._process.stdin.write(command_str) + self._process.stdin.flush() + + # read the output + output = b"" + warning = b"" + error = b"" + while EXIFTOOL_STAYOPEN_EOF not in str(output): + line = self._process.stdout.readline() + if line.startswith(b"Warning"): + warning += line.strip() + elif line.startswith(b"Error"): + error += line.strip() + else: + output += line.strip() + warning = "" if warning == b"" else warning.decode("utf-8") + error = "" if error == b"" else error.decode("utf-8") + self.warning = warning + self.error = error + + return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], warning, error
+ + @property + def pid(self): + """return process id (PID) of the exiftool process""" + return self._process.pid + + @property + def version(self): + """returns exiftool version""" + ver, _, _ = self.run_commands("-ver", no_file=True) + return ver.decode("utf-8") + +
[docs] def asdict(self, tag_groups=True, normalized=False): + """return dictionary of all EXIF tags and values from exiftool + returns empty dict if no tags + + Args: + tag_groups: if True (default), dict keys have tag groups, e.g. "IPTC:Keywords"; if False, drops groups from keys, e.g. "Keywords" + normalized: if True, dict keys are all normalized to lower case (default is False) + """ + json_str, _, _ = self.run_commands("-json") + if not json_str: + return dict() + json_str = unescape_str(json_str.decode("utf-8")) + + try: + exifdict = json.loads(json_str) + except Exception as e: + # will fail with some commands, e.g --ext AVI which produces + # 'No file with specified extension' instead of json + logging.warning(f"error loading json returned by exiftool: {e} {json_str}") + return dict() + exifdict = exifdict[0] + if not tag_groups: + # strip tag groups + exif_new = {} + for k, v in exifdict.items(): + k = re.sub(r".*:", "", k) + exif_new[k] = v + exifdict = exif_new + + if normalized: + exifdict = {k.lower(): v for (k, v) in exifdict.items()} + + return exifdict
+ +
[docs] def json(self): + """returns JSON string containing all EXIF tags and values from exiftool""" + json, _, _ = self.run_commands("-json") + json = unescape_str(json.decode("utf-8")) + return json
+ + def _read_exif(self): + """read exif data from file""" + data = self.asdict() + self.data = {k: v for k, v in data.items()} + + def __str__(self): + return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}" + + def __enter__(self): + self._context_mgr = True + self._commands = [] + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type: + return False + elif self._commands: + # run_commands sets self.warning and self.error as needed + self.run_commands(*self._commands)
+ + +class ExifToolCaching(ExifTool): + """Basic exiftool interface for reading and writing EXIF tags, with caching. + Use this only when you know the file's EXIF data will not be changed by any external process. + + Creates a singleton cached ExifTool instance""" + + _singletons = {} + + def __new__(cls, filepath, exiftool=None): + """create new object or return instance of already created singleton""" + if filepath not in cls._singletons: + cls._singletons[filepath] = _ExifToolCaching(filepath, exiftool=exiftool) + return cls._singletons[filepath] + + +class _ExifToolCaching(ExifTool): + def __init__(self, filepath, exiftool=None): + """Create read-only ExifTool object that caches values + + Args: + file: path to image file + exiftool: path to exiftool, if not specified will look in path + + Returns: + ExifTool instance + """ + self._json_cache = None + self._asdict_cache = {} + super().__init__(filepath, exiftool=exiftool, overwrite=False, flags=None) + + def run_commands(self, *commands, no_file=False): + if commands[0] not in ["-json", "-ver"]: + raise NotImplementedError(f"{self.__class__} is read-only") + return super().run_commands(*commands, no_file=no_file) + + def setvalue(self, tag, value): + raise NotImplementedError(f"{self.__class__} is read-only") + + def addvalues(self, tag, *values): + raise NotImplementedError(f"{self.__class__} is read-only") + + def json(self): + if not self._json_cache: + self._json_cache = super().json() + return self._json_cache + + def asdict(self, tag_groups=True, normalized=False): + """return dictionary of all EXIF tags and values from exiftool + returns empty dict if no tags + + Args: + tag_groups: if True (default), dict keys have tag groups, e.g. "IPTC:Keywords"; if False, drops groups from keys, e.g. "Keywords" + normalized: if True, dict keys are all normalized to lower case (default is False) + """ + try: + return self._asdict_cache[tag_groups][normalized] + except KeyError: + if tag_groups not in self._asdict_cache: + self._asdict_cache[tag_groups] = {} + self._asdict_cache[tag_groups][normalized] = super().asdict( + tag_groups=tag_groups, normalized=normalized + ) + return self._asdict_cache[tag_groups][normalized] + + def flush_cache(self): + """Clear cached data so that calls to json or asdict return fresh data""" + self._json_cache = None + self._asdict_cache = {} +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/export_db.html b/docs/_modules/osxphotos/export_db.html new file mode 100644 index 00000000..94d4475d --- /dev/null +++ b/docs/_modules/osxphotos/export_db.html @@ -0,0 +1,1052 @@ + + + + + + + + osxphotos.export_db — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.export_db

+""" Helper class for managing database used by PhotoExporter for tracking state of exports and updates """
+
+
+import datetime
+import json
+import logging
+import os
+import pathlib
+import sqlite3
+import sys
+from io import StringIO
+from sqlite3 import Error
+from tempfile import TemporaryDirectory
+from typing import Optional, Tuple, Union
+
+from ._constants import OSXPHOTOS_EXPORT_DB
+from ._version import __version__
+from .fileutil import FileUtil
+from .utils import normalize_fs_path
+
+__all__ = [
+    "ExportDB",
+    "ExportDBInMemory",
+    "ExportDBTemp",
+]
+
+OSXPHOTOS_EXPORTDB_VERSION = "6.0"
+OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
+
+
+
[docs]class ExportDB: + """Interface to sqlite3 database used to store state information for osxphotos export command""" + + def __init__(self, dbfile, export_dir): + """create a new ExportDB object + + Args: + dbfile: path to osxphotos export database file + export_dir: path to directory where exported files are stored + memory: if True, use in-memory database + """ + + self._dbfile = dbfile + # export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to + # relative paths to this path + # this allows the entire export tree to be moved to a new disk/location + # whilst preserving the UUID to filename mapping + self._path = export_dir + self._conn = self._open_export_db(dbfile) + self._perform_db_maintenace(self._conn) + self._insert_run_info() + + @property + def path(self): + """returns path to export database""" + return self._dbfile + + @property + def export_dir(self): + """returns path to export directory""" + return self._path + +
[docs] def get_file_record(self, filename: Union[pathlib.Path, str]) -> "ExportRecord": + """get info for filename and uuid + + Returns: an ExportRecord object + """ + filename = self._relative_filepath(filename) + filename_normalized = self._normalize_filepath(filename) + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT uuid FROM export_data WHERE filepath_normalized = ?;", + (filename_normalized,), + ).fetchone() + + if not row: + return None + return ExportRecord(conn, filename_normalized)
+ +
[docs] def create_file_record( + self, filename: Union[pathlib.Path, str], uuid: str + ) -> "ExportRecord": + """create a new record for filename and uuid + + Returns: an ExportRecord object + """ + filename = self._relative_filepath(filename) + filename_normalized = self._normalize_filepath(filename) + conn = self._conn + c = conn.cursor() + c.execute( + "INSERT INTO export_data (filepath, filepath_normalized, uuid) VALUES (?, ?, ?);", + (filename, filename_normalized, uuid), + ) + conn.commit() + return ExportRecord(conn, filename_normalized)
+ +
[docs] def create_or_get_file_record( + self, filename: Union[pathlib.Path, str], uuid: str + ) -> "ExportRecord": + """create a new record for filename and uuid or return existing record + + Returns: an ExportRecord object + """ + filename = self._relative_filepath(filename) + filename_normalized = self._normalize_filepath(filename) + conn = self._conn + c = conn.cursor() + c.execute( + "INSERT OR IGNORE INTO export_data (filepath, filepath_normalized, uuid) VALUES (?, ?, ?);", + (filename, filename_normalized, uuid), + ) + conn.commit() + return ExportRecord(conn, filename_normalized)
+ +
[docs] def get_uuid_for_file(self, filename): + """query database for filename and return UUID + returns None if filename not found in database + """ + filepath_normalized = self._normalize_filepath_relative(filename) + conn = self._conn + try: + c = conn.cursor() + c.execute( + "SELECT uuid FROM export_data WHERE filepath_normalized = ?", + (filepath_normalized,), + ) + results = c.fetchone() + uuid = results[0] if results else None + except Error as e: + logging.warning(e) + uuid = None + return uuid
+ +
[docs] def get_photoinfo_for_uuid(self, uuid): + """returns the photoinfo JSON struct for a UUID""" + conn = self._conn + try: + c = conn.cursor() + c.execute("SELECT photoinfo FROM photoinfo WHERE uuid = ?", (uuid,)) + results = c.fetchone() + info = results[0] if results else None + except Error as e: + logging.warning(e) + info = None + + return info
+ +
[docs] def set_photoinfo_for_uuid(self, uuid, info): + """sets the photoinfo JSON struct for a UUID""" + conn = self._conn + try: + c = conn.cursor() + c.execute( + "INSERT OR REPLACE INTO photoinfo(uuid, photoinfo) VALUES (?, ?);", + (uuid, info), + ) + conn.commit() + except Error as e: + logging.warning(e)
+ +
[docs] def get_previous_uuids(self): + """returns list of UUIDs of previously exported photos found in export database""" + conn = self._conn + previous_uuids = [] + try: + c = conn.cursor() + c.execute("SELECT DISTINCT uuid FROM export_data") + results = c.fetchall() + previous_uuids = [row[0] for row in results] + except Error as e: + logging.warning(e) + return previous_uuids
+ +
[docs] def set_config(self, config_data): + """set config in the database""" + conn = self._conn + try: + dt = datetime.datetime.now().isoformat() + c = conn.cursor() + c.execute( + "INSERT OR REPLACE INTO config(datetime, config) VALUES (?, ?);", + (dt, config_data), + ) + conn.commit() + except Error as e: + logging.warning(e)
+ +
[docs] def close(self): + """close the database connection""" + try: + self._conn.close() + except Error as e: + logging.warning(e)
+ + def _open_export_db(self, dbfile): + """open export database and return a db connection + if dbfile does not exist, will create and initialize the database + if dbfile needs to be upgraded, will perform needed migrations + returns: connection to the database + """ + + if not os.path.isfile(dbfile): + conn = self._get_db_connection(dbfile) + if not conn: + raise Exception("Error getting connection to database {dbfile}") + self._create_or_migrate_db_tables(conn) + self.was_created = True + self.was_upgraded = () + else: + conn = self._get_db_connection(dbfile) + self.was_created = False + version_info = self._get_database_version(conn) + if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION: + self._create_or_migrate_db_tables(conn) + self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION) + else: + self.was_upgraded = () + self.version = OSXPHOTOS_EXPORTDB_VERSION + + # turn on performance optimizations + c = conn.cursor() + c.execute("PRAGMA journal_mode=WAL;") + c.execute("PRAGMA synchronous=NORMAL;") + c.execute("PRAGMA cache_size=-100000;") + c.execute("PRAGMA temp_store=MEMORY;") + + return conn + + def _get_db_connection(self, dbfile): + """return db connection to dbname""" + try: + conn = sqlite3.connect(dbfile) + except Error as e: + logging.warning(e) + conn = None + + return conn + + def _get_database_version(self, conn): + """return tuple of (osxphotos, exportdb) versions for database connection conn""" + version_info = conn.execute( + "SELECT osxphotos, exportdb, max(id) FROM version" + ).fetchone() + return (version_info[0], version_info[1]) + + def _create_or_migrate_db_tables(self, conn): + """create (if not already created) the necessary db tables for the export database and apply any needed migrations + + Args: + conn: sqlite3 db connection + """ + try: + version = self._get_database_version(conn) + except Exception as e: + version = (__version__, "4.3") + + # Current for version 4.3, for anything greater, do a migration after creation + sql_commands = [ + """ CREATE TABLE IF NOT EXISTS version ( + id INTEGER PRIMARY KEY, + osxphotos TEXT, + exportdb TEXT + ); """, + """ CREATE TABLE IF NOT EXISTS about ( + id INTEGER PRIMARY KEY, + about TEXT + );""", + """ CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + filepath TEXT NOT NULL, + filepath_normalized TEXT NOT NULL, + uuid TEXT, + orig_mode INTEGER, + orig_size INTEGER, + orig_mtime REAL, + exif_mode INTEGER, + exif_size INTEGER, + exif_mtime REAL + ); """, + """ CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY, + datetime TEXT, + python_path TEXT, + script_name TEXT, + args TEXT, + cwd TEXT + ); """, + """ CREATE TABLE IF NOT EXISTS info ( + id INTEGER PRIMARY KEY, + uuid text NOT NULL, + json_info JSON + ); """, + """ CREATE TABLE IF NOT EXISTS exifdata ( + id INTEGER PRIMARY KEY, + filepath_normalized TEXT NOT NULL, + json_exifdata JSON + ); """, + """ CREATE TABLE IF NOT EXISTS edited ( + id INTEGER PRIMARY KEY, + filepath_normalized TEXT NOT NULL, + mode INTEGER, + size INTEGER, + mtime REAL + ); """, + """ CREATE TABLE IF NOT EXISTS converted ( + id INTEGER PRIMARY KEY, + filepath_normalized TEXT NOT NULL, + mode INTEGER, + size INTEGER, + mtime REAL + ); """, + """ CREATE TABLE IF NOT EXISTS sidecar ( + id INTEGER PRIMARY KEY, + filepath_normalized TEXT NOT NULL, + sidecar_data TEXT, + mode INTEGER, + size INTEGER, + mtime REAL + ); """, + """ CREATE TABLE IF NOT EXISTS detected_text ( + id INTEGER PRIMARY KEY, + uuid TEXT NOT NULL, + text_data JSON + ); """, + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """, + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """, + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """, + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""", + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""", + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""", + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_detected_text on detected_text (uuid);""", + ] + # create the tables if needed + try: + c = conn.cursor() + for cmd in sql_commands: + c.execute(cmd) + c.execute( + "INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);", + (__version__, OSXPHOTOS_EXPORTDB_VERSION), + ) + c.execute("INSERT INTO about(about) VALUES (?);", (OSXPHOTOS_ABOUT_STRING,)) + conn.commit() + except Error as e: + logging.warning(e) + + # perform needed migrations + if version[1] < "4.3": + self._migrate_normalized_filepath(conn) + + if version[1] < "5.0": + self._migrate_4_3_to_5_0(conn) + + if version[1] < "6.0": + # create export_data table + self._migrate_5_0_to_6_0(conn) + + conn.execute("VACUUM;") + conn.commit() + + def __del__(self): + """ensure the database connection is closed""" + try: + self._conn.close() + except: + pass + + def _insert_run_info(self): + dt = datetime.datetime.utcnow().isoformat() + python_path = sys.executable + cmd = sys.argv[0] + args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "" + cwd = os.getcwd() + conn = self._conn + try: + c = conn.cursor() + c.execute( + "INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)", + (dt, python_path, cmd, args, cwd), + ) + + conn.commit() + except Error as e: + logging.warning(e) + + def _relative_filepath(self, filepath: Union[str, pathlib.Path]) -> str: + """return filepath relative to self._path""" + return str(pathlib.Path(filepath).relative_to(self._path)) + + def _normalize_filepath(self, filepath: Union[str, pathlib.Path]) -> str: + """normalize filepath for unicode, lower case""" + return normalize_fs_path(str(filepath)).lower() + + def _normalize_filepath_relative(self, filepath: Union[str, pathlib.Path]) -> str: + """normalize filepath for unicode, relative path (to export dir), lower case""" + filepath = self._relative_filepath(filepath) + return normalize_fs_path(str(filepath)).lower() + + def _migrate_normalized_filepath(self, conn): + """Fix all filepath_normalized columns for unicode normalization""" + # Prior to database version 4.3, filepath_normalized was not normalized for unicode + c = conn.cursor() + migration_sql = [ + """ CREATE TABLE IF NOT EXISTS files_migrate ( + id INTEGER PRIMARY KEY, + filepath TEXT NOT NULL, + filepath_normalized TEXT NOT NULL, + uuid TEXT, + orig_mode INTEGER, + orig_size INTEGER, + orig_mtime REAL, + exif_mode INTEGER, + exif_size INTEGER, + exif_mtime REAL, + UNIQUE(filepath_normalized) + ); """, + """ INSERT INTO files_migrate SELECT * FROM files;""", + """ DROP TABLE files;""", + """ ALTER TABLE files_migrate RENAME TO files;""", + ] + for sql in migration_sql: + c.execute(sql) + conn.commit() + + for table in ["converted", "edited", "exifdata", "files", "sidecar"]: + old_values = c.execute( + f"SELECT filepath_normalized, id FROM {table}" + ).fetchall() + new_values = [ + (self._normalize_filepath(filepath_normalized), id_) + for filepath_normalized, id_ in old_values + ] + c.executemany( + f"UPDATE {table} SET filepath_normalized=? WHERE id=?", new_values + ) + conn.commit() + + def _migrate_4_3_to_5_0(self, conn): + """Migrate database from version 4.3 to 5.0""" + try: + c = conn.cursor() + # add metadata column to files to support --force-update + c.execute("ALTER TABLE files ADD COLUMN metadata TEXT;") + conn.commit() + except Error as e: + logging.warning(e) + + def _migrate_5_0_to_6_0(self, conn): + try: + c = conn.cursor() + + # add export_data table + c.execute( + """ CREATE TABLE IF NOT EXISTS export_data( + id INTEGER PRIMARY KEY, + filepath_normalized TEXT NOT NULL, + filepath TEXT NOT NULL, + uuid TEXT NOT NULL, + src_mode INTEGER, + src_size INTEGER, + src_mtime REAL, + dest_mode INTEGER, + dest_size INTEGER, + dest_mtime REAL, + digest TEXT, + exifdata JSON, + export_options INTEGER, + UNIQUE(filepath_normalized) + ); """, + ) + c.execute( + """ CREATE UNIQUE INDEX IF NOT EXISTS idx_export_data_filepath_normalized on export_data (filepath_normalized); """, + ) + + # migrate data + c.execute( + """ INSERT INTO export_data (filepath_normalized, filepath, uuid) SELECT filepath_normalized, filepath, uuid FROM files;""", + ) + c.execute( + """ UPDATE export_data + SET (src_mode, src_size, src_mtime) = + (SELECT mode, size, mtime + FROM edited + WHERE export_data.filepath_normalized = edited.filepath_normalized); + """, + ) + c.execute( + """ UPDATE export_data + SET (dest_mode, dest_size, dest_mtime) = + (SELECT orig_mode, orig_size, orig_mtime + FROM files + WHERE export_data.filepath_normalized = files.filepath_normalized); + """, + ) + c.execute( + """ UPDATE export_data SET digest = + (SELECT metadata FROM files + WHERE files.filepath_normalized = export_data.filepath_normalized + ); """ + ) + c.execute( + """ UPDATE export_data SET exifdata = + (SELECT json_exifdata FROM exifdata + WHERE exifdata.filepath_normalized = export_data.filepath_normalized + ); """ + ) + + # create config table + c.execute( + """ CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY, + datetime TEXT, + config TEXT + ); """ + ) + + # create photoinfo table + c.execute( + """ CREATE TABLE IF NOT EXISTS photoinfo ( + id INTEGER PRIMARY KEY, + uuid TEXT NOT NULL, + photoinfo JSON, + UNIQUE(uuid) + ); """ + ) + c.execute( + """CREATE UNIQUE INDEX IF NOT EXISTS idx_photoinfo_uuid on photoinfo (uuid);""" + ) + c.execute( + """ INSERT INTO photoinfo (uuid, photoinfo) SELECT uuid, json_info FROM info;""" + ) + + # drop indexes no longer needed + c.execute("DROP INDEX IF EXISTS idx_files_filepath_normalized;") + c.execute("DROP INDEX IF EXISTS idx_exifdata_filename;") + c.execute("DROP INDEX IF EXISTS idx_edited_filename;") + c.execute("DROP INDEX IF EXISTS idx_converted_filename;") + c.execute("DROP INDEX IF EXISTS idx_sidecar_filename;") + c.execute("DROP INDEX IF EXISTS idx_detected_text;") + + # drop tables no longer needed + c.execute("DROP TABLE IF EXISTS files;") + c.execute("DROP TABLE IF EXISTS info;") + c.execute("DROP TABLE IF EXISTS exifdata;") + c.execute("DROP TABLE IF EXISTS edited;") + c.execute("DROP TABLE IF EXISTS converted;") + c.execute("DROP TABLE IF EXISTS sidecar;") + c.execute("DROP TABLE IF EXISTS detected_text;") + + conn.commit() + except Error as e: + logging.warning(e) + + def _perform_db_maintenace(self, conn): + """Perform database maintenance""" + try: + c = conn.cursor() + c.execute( + """DELETE FROM config + WHERE id < ( + SELECT MIN(id) + FROM (SELECT id FROM config ORDER BY id DESC LIMIT 9) + ); + """ + ) + conn.commit() + except Error as e: + logging.warning(e)
+ + +class ExportDBInMemory(ExportDB): + """In memory version of ExportDB + Copies the on-disk database into memory so it may be operated on without + modifying the on-disk version + """ + + def __init__(self, dbfile: str, export_dir: str): + """ "Initialize ExportDBInMemory + + Args: + dbfile (str): path to database file + export_dir (str): path to export directory + write_back (bool): whether to write changes back to disk when closing; if False (default), changes are not written to disk + """ + self._dbfile = dbfile or f"./{OSXPHOTOS_EXPORT_DB}" + # export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to + # relative paths to this path + # this allows the entire export tree to be moved to a new disk/location + # whilst preserving the UUID to filename mapping + self._path = export_dir + self._conn = self._open_export_db(self._dbfile) + self._insert_run_info() + + def write_to_disk(self): + """Write changes from in-memory database back to disk""" + + # dump the database + conn = self._conn + conn.commit() + dbdump = self._dump_db(conn) + + # cleanup the old on-disk database + # also unlink the wal and shm files if needed + dbfile = pathlib.Path(self._dbfile) + if dbfile.exists(): + dbfile.unlink() + wal = dbfile.with_suffix(".db-wal") + if wal.exists(): + wal.unlink() + shm = dbfile.with_suffix(".db-shm") + if shm.exists(): + shm.unlink() + + conn_on_disk = sqlite3.connect(str(dbfile)) + conn_on_disk.cursor().executescript(dbdump.read()) + conn_on_disk.commit() + conn_on_disk.close() + + def close(self): + """close the database connection""" + try: + if self._conn: + self._conn.close() + except Error as e: + logging.warning(e) + + def _open_export_db(self, dbfile): + """open export database and return a db connection + returns: connection to the database + """ + if not os.path.isfile(dbfile): + conn = self._get_db_connection() + if not conn: + raise Exception("Error getting connection to in-memory database") + self._create_or_migrate_db_tables(conn) + self.was_created = True + self.was_upgraded = () + else: + conn = sqlite3.connect(dbfile) + dbdump = self._dump_db(conn) + conn.close() + + # Create a database in memory and import from the dump + conn = sqlite3.connect(":memory:") + conn.cursor().executescript(dbdump.read()) + conn.commit() + self.was_created = False + version_info = self._get_database_version(conn) + if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION: + self._create_or_migrate_db_tables(conn) + self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION) + else: + self.was_upgraded = () + self.version = OSXPHOTOS_EXPORTDB_VERSION + + return conn + + def _get_db_connection(self): + """return db connection to in memory database""" + try: + conn = sqlite3.connect(":memory:") + except Error as e: + logging.warning(e) + conn = None + + return conn + + def _dump_db(self, conn: sqlite3.Connection) -> StringIO: + """dump sqlite db to a string buffer""" + dbdump = StringIO() + for line in conn.iterdump(): + dbdump.write("%s\n" % line) + dbdump.seek(0) + return dbdump + + def __del__(self): + """close the database connection""" + try: + self.close() + except Error as e: + pass + + +class ExportDBTemp(ExportDBInMemory): + """Temporary in-memory version of ExportDB""" + + def __init__(self): + self._temp_dir = TemporaryDirectory() + self._dbfile = f"{self._temp_dir.name}/{OSXPHOTOS_EXPORT_DB}" + self._path = self._temp_dir.name + self._conn = self._open_export_db(self._dbfile) + self._insert_run_info() + + def _relative_filepath(self, filepath: Union[str, pathlib.Path]) -> str: + """Overrides _relative_filepath to return a path for use in the temp db""" + filepath = str(filepath) + if filepath[0] == "/": + return filepath[1:] + return filepath + + +class ExportRecord: + """ExportRecord class""" + + __slots__ = [ + "_conn", + "_context_manager", + "_filepath_normalized", + ] + + def __init__(self, conn, filepath_normalized): + self._conn = conn + self._filepath_normalized = filepath_normalized + self._context_manager = False + + @property + def filepath(self): + """return filepath""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT filepath FROM export_data WHERE filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + if row: + return row[0] + + raise ValueError( + f"No filepath found in database for {self._filepath_normalized}" + ) + + @property + def filepath_normalized(self): + """return filepath_normalized""" + return self._filepath_normalized + + @property + def uuid(self): + """return uuid""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT uuid FROM export_data WHERE filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + if row: + return row[0] + + raise ValueError(f"No uuid found in database for {self._filepath_normalized}") + + @property + def digest(self): + """returns the digest value""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT digest FROM export_data WHERE filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + if row: + return row[0] + + raise ValueError(f"No digest found in database for {self._filepath_normalized}") + + @digest.setter + def digest(self, value): + """set digest value""" + conn = self._conn + c = conn.cursor() + c.execute( + "UPDATE export_data SET digest = ? WHERE filepath_normalized = ?;", + (value, self._filepath_normalized), + ) + if not self._context_manager: + conn.commit() + + @property + def exifdata(self): + """returns exifdata value for record""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT exifdata FROM export_data WHERE filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + if row: + return row[0] + + raise ValueError( + f"No exifdata found in database for {self._filepath_normalized}" + ) + + @exifdata.setter + def exifdata(self, value): + """set exifdata value""" + conn = self._conn + c = conn.cursor() + c.execute( + "UPDATE export_data SET exifdata = ? WHERE filepath_normalized = ?;", + ( + value, + self._filepath_normalized, + ), + ) + if not self._context_manager: + conn.commit() + + @property + def src_sig(self): + """return source file signature value""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT src_mode, src_size, src_mtime FROM export_data WHERE filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + if row: + mtime = int(row[2]) if row[2] is not None else None + return (row[0], row[1], mtime) + + raise ValueError( + f"No src_sig found in database for {self._filepath_normalized}" + ) + + @src_sig.setter + def src_sig(self, value): + """set source file signature value""" + conn = self._conn + c = conn.cursor() + c.execute( + "UPDATE export_data SET src_mode = ?, src_size = ?, src_mtime = ? WHERE filepath_normalized = ?;", + ( + value[0], + value[1], + value[2], + self._filepath_normalized, + ), + ) + if not self._context_manager: + conn.commit() + + @property + def dest_sig(self): + """return destination file signature""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT dest_mode, dest_size, dest_mtime FROM export_data WHERE filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + if row: + mtime = int(row[2]) if row[2] is not None else None + return (row[0], row[1], mtime) + + raise ValueError( + f"No dest_sig found in database for {self._filepath_normalized}" + ) + + @dest_sig.setter + def dest_sig(self, value): + """set destination file signature""" + conn = self._conn + c = conn.cursor() + c.execute( + "UPDATE export_data SET dest_mode = ?, dest_size = ?, dest_mtime = ? WHERE filepath_normalized = ?;", + ( + value[0], + value[1], + value[2], + self._filepath_normalized, + ), + ) + if not self._context_manager: + conn.commit() + + @property + def photoinfo(self): + """Returns info value""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT photoinfo from photoinfo where uuid = ?;", + (self.uuid,), + ).fetchone() + return row[0] if row else None + + @photoinfo.setter + def photoinfo(self, value): + """Sets info value""" + conn = self._conn + c = conn.cursor() + c.execute( + "INSERT OR REPLACE INTO photoinfo (uuid, photoinfo) VALUES (?, ?);", + (self.uuid, value), + ) + if not self._context_manager: + conn.commit() + + @property + def export_options(self): + """Get export_options value""" + conn = self._conn + c = conn.cursor() + row = c.execute( + "SELECT export_options from export_data where filepath_normalized = ?;", + (self._filepath_normalized,), + ).fetchone() + return row[0] if row else None + + @export_options.setter + def export_options(self, value): + """Set export_options value""" + conn = self._conn + c = conn.cursor() + c.execute( + "UPDATE export_data SET export_options = ? WHERE filepath_normalized = ?;", + (value, self._filepath_normalized), + ) + if not self._context_manager: + conn.commit() + + def asdict(self): + """Return dict of self""" + exifdata = json.loads(self.exifdata) if self.exifdata else None + photoinfo = json.loads(self.photoinfo) if self.photoinfo else None + return { + "filepath": self.filepath, + "filepath_normalized": self.filepath_normalized, + "uuid": self.uuid, + "digest": self.digest, + "src_sig": self.src_sig, + "dest_sig": self.dest_sig, + "export_options": self.export_options, + "exifdata": exifdata, + "photoinfo": photoinfo, + } + + def __enter__(self): + self._context_manager = True + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type: + self._conn.rollback() + else: + self._conn.commit() + self._context_manager = False +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/fileutil.html b/docs/_modules/osxphotos/fileutil.html new file mode 100644 index 00000000..e7ad1ae7 --- /dev/null +++ b/docs/_modules/osxphotos/fileutil.html @@ -0,0 +1,422 @@ + + + + + + + + osxphotos.fileutil — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.fileutil

+""" FileUtil class with methods for copy, hardlink, unlink, etc. """
+
+import os
+import pathlib
+import stat
+import tempfile
+import typing as t
+from abc import ABC, abstractmethod
+from tempfile import TemporaryDirectory
+
+import Foundation
+
+from .imageconverter import ImageConverter
+
+__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtil", "FileUtilNoOp"]
+
+
+class FileUtilABC(ABC):
+    """Abstract base class for FileUtil"""
+
+    @classmethod
+    @abstractmethod
+    def hardlink(cls, src, dest):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def copy(cls, src, dest, norsrc=False):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def unlink(cls, dest):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def rmdir(cls, dest):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def utime(cls, path, times):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def cmp(cls, file1, file2, mtime1=None):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def cmp_file_sig(cls, file1, file2):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def file_sig(cls, file1):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def rename(cls, src, dest):
+        pass
+
+    @classmethod
+    @abstractmethod
+    def tmpdir(
+        cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
+    ) -> tempfile.TemporaryDirectory:
+        pass
+
+
+class FileUtilMacOS(FileUtilABC):
+    """Various file utilities"""
+
+    @classmethod
+    def hardlink(cls, src, dest):
+        """Hardlinks a file from src path to dest path
+        src: source path as string
+        dest: destination path as string
+        Raises exception if linking fails or either path is None"""
+
+        if src is None or dest is None:
+            raise ValueError("src and dest must not be None", src, dest)
+
+        if not os.path.isfile(src):
+            raise FileNotFoundError("src file does not appear to exist", src)
+
+        try:
+            os.link(src, dest)
+        except Exception as e:
+            raise e from e
+
+    @classmethod
+    def copy(cls, src, dest):
+        """Copies a file from src path to dest path
+
+        Args:
+            src: source path as string; must be a valid file path
+            dest: destination path as string
+                  dest may be either directory or file; in either case, src file must not exist in dest
+            Note: src and dest may be either a string or a pathlib.Path object
+
+        Returns:
+            True if copy succeeded
+
+        Raises:
+            OSError if copy fails
+            TypeError if either path is None
+        """
+        if not isinstance(src, pathlib.Path):
+            src = pathlib.Path(src)
+
+        if not isinstance(dest, pathlib.Path):
+            dest = pathlib.Path(dest)
+
+        if dest.is_dir():
+            dest /= src.name
+
+        filemgr = Foundation.NSFileManager.defaultManager()
+        error = filemgr.copyItemAtPath_toPath_error_(str(src), str(dest), None)
+        # error is a tuple of (bool, error_string)
+        # error[0] is True if copy succeeded
+        if not error[0]:
+            raise OSError(error[1])
+        return True
+
+    @classmethod
+    def unlink(cls, filepath):
+        """unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink"""
+        if isinstance(filepath, pathlib.Path):
+            filepath.unlink()
+        else:
+            os.unlink(filepath)
+
+    @classmethod
+    def rmdir(cls, dirpath):
+        """remove directory filepath; dirpath must be empty"""
+        if isinstance(dirpath, pathlib.Path):
+            dirpath.rmdir()
+        else:
+            os.rmdir(dirpath)
+
+    @classmethod
+    def utime(cls, path, times):
+        """Set the access and modified time of path."""
+        os.utime(path, times=times)
+
+    @classmethod
+    def cmp(cls, f1, f2, mtime1=None):
+        """Does shallow compare (file signatures) of f1 to file f2.
+        Arguments:
+        f1 --  File name
+        f2 -- File name
+        mtime1 -- optional, pass alternate file modification timestamp for f1; will be converted to int
+
+        Return value:
+        True if the file signatures as returned by stat are the same, False otherwise.
+        Does not do a byte-by-byte comparison.
+        """
+
+        s1 = cls._sig(os.stat(f1))
+        if mtime1 is not None:
+            s1 = (s1[0], s1[1], int(mtime1))
+        s2 = cls._sig(os.stat(f2))
+        if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
+            return False
+        return s1 == s2
+
+    @classmethod
+    def cmp_file_sig(cls, f1, s2):
+        """Compare file f1 to signature s2.
+        Arguments:
+        f1 --  File name
+        s2 -- stats as returned by _sig
+
+        Return value:
+        True if the files are the same, False otherwise.
+        """
+
+        if not s2:
+            return False
+
+        s1 = cls._sig(os.stat(f1))
+        if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
+            return False
+        return s1 == s2
+
+    @classmethod
+    def file_sig(cls, f1):
+        """return os.stat signature for file f1 as tuple of (mode, size, mtime)"""
+        return cls._sig(os.stat(f1))
+
+    @classmethod
+    def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
+        """converts image file src_file to jpeg format as dest_file
+
+        Args:
+            src_file: image file to convert
+            dest_file: destination path to write converted file to
+            compression quality: JPEG compression quality in range 0.0 <= compression_quality <= 1.0; default 1.0 (best quality)
+
+        Returns:
+            True if success, otherwise False
+        """
+        converter = ImageConverter()
+        return converter.write_jpeg(
+            src_file, dest_file, compression_quality=compression_quality
+        )
+
+    @classmethod
+    def rename(cls, src, dest):
+        """Copy src to dest
+
+        Args:
+            src: path to source file
+            dest: path to destination file
+
+        Returns:
+            Name of renamed file (dest)
+
+        """
+        os.rename(str(src), str(dest))
+        return dest
+
+    @classmethod
+    def tmpdir(
+        cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
+    ) -> tempfile.TemporaryDirectory:
+        """Securely creates a temporary directory using the same rules as mkdtemp().
+        The resulting object can be used as a context manager.
+        On completion of the context or destruction of the temporary directory object,
+        the newly created temporary directory and all its contents are removed from the filesystem.
+        """
+        return TemporaryDirectory(prefix=prefix, dir=dir)
+
+    @staticmethod
+    def _sig(st):
+        """return tuple of (mode, size, mtime) of file based on os.stat
+        Args:
+            st: os.stat signature
+        """
+        # use int(st.st_mtime) because ditto does not copy fractional portion of mtime
+        return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
+
+
+
[docs]class FileUtil(FileUtilMacOS): + """Various file utilities""" + + pass
+ + +
[docs]class FileUtilNoOp(FileUtil): + """No-Op implementation of FileUtil for testing / dry-run mode + all methods with exception of tmpdir, cmp, cmp_file_sig and file_cmp are no-op + cmp and cmp_file_sig functions as FileUtil methods do + file_cmp returns mock data + """ + + @staticmethod + def noop(*args): + pass + + def __new__(cls, verbose=None): + if verbose: + if callable(verbose): + cls.verbose = verbose + else: + raise ValueError(f"verbose {verbose} not callable") + return super(FileUtilNoOp, cls).__new__(cls) + + + +
[docs] @classmethod + def copy(cls, src, dest, norsrc=False): + pass
+ + + +
[docs] @classmethod + def rmdir(cls, dest): + pass
+ +
[docs] @classmethod + def utime(cls, path, times): + pass
+ +
[docs] @classmethod + def file_sig(cls, file1): + return (42, 42, 42)
+ +
[docs] @classmethod + def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0): + pass
+ +
[docs] @classmethod + def rename(cls, src, dest): + pass
+ +
[docs] @classmethod + def tmpdir( + cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None + ) -> tempfile.TemporaryDirectory: + """Securely creates a temporary directory using the same rules as mkdtemp(). + The resulting object can be used as a context manager. + On completion of the context or destruction of the temporary directory object, + the newly created temporary directory and all its contents are removed from the filesystem. + """ + return TemporaryDirectory(prefix=prefix, dir=dir)
+
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/momentinfo.html b/docs/_modules/osxphotos/momentinfo.html new file mode 100644 index 00000000..debf834d --- /dev/null +++ b/docs/_modules/osxphotos/momentinfo.html @@ -0,0 +1,173 @@ + + + + + + + + osxphotos.momentinfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.momentinfo

+__all__ = ["MomentInfo"]
+"""MomentInfo class with details about photo moments."""
+
+
+
[docs]class MomentInfo: + """Info about a photo moment""" + + def __init__(self, db, moment_pk): + """Initialize with a moment PK; returns None if PK not found.""" + self._db = db + self._pk = moment_pk + + self._moment = self._db._db_moment_pk.get(moment_pk) + if not self._moment: + raise ValueError(f"No moment with PK {moment_pk}") + + @property + def pk(self): + """Primary key of the moment.""" + return self._pk + + @property + def location(self): + """Location of the moment.""" + return (self._moment.get("latitude"), self._moment.get("longitude")) + + @property + def title(self): + """Title of the moment.""" + return self._moment.get("title") + + @property + def subtitle(self): + """Subtitle of the moment.""" + return self._moment.get("subtitle") + + @property + def start_date(self): + """Start date of the moment.""" + return self._moment.get("startDate") + + @property + def end_date(self): + """Stop date of the moment.""" + return self._moment.get("endDate") + + @property + def date(self): + """Date of the moment.""" + return self._moment.get("representativeDate") + + @property + def modification_date(self): + """Modification date of the moment.""" + return self._moment.get("modificationDate") + + @property + def photos(self): + """All photos in this moment""" + try: + return self._photos + except AttributeError: + photo_uuids = [ + uuid + for uuid, photo in self._db._dbphotos.items() + if photo["momentID"] == self._pk + ] + + self._photos = self._db.photos_by_uuid(photo_uuids) + return self._photos
+
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/personinfo.html b/docs/_modules/osxphotos/personinfo.html new file mode 100644 index 00000000..8b830b11 --- /dev/null +++ b/docs/_modules/osxphotos/personinfo.html @@ -0,0 +1,606 @@ + + + + + + + + osxphotos.personinfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.personinfo

+""" PhotoInfo and FaceInfo classes to expose info about persons and faces in the Photos library """
+
+import json
+import logging
+import math
+
+from collections import namedtuple
+
+__all__ = ["PersonInfo", "FaceInfo", "rotate_image_point"]
+
+MWG_RS_Area = namedtuple("MWG_RS_Area", ["x", "y", "h", "w"])
+MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"])
+
+
+
[docs]class PersonInfo: + """Info about a person in the Photos library""" + + def __init__(self, db=None, pk=None): + """Creates a new PersonInfo instance + + Arguments: + db: instance of PhotosDB object + pk: primary key value of person to initialize PersonInfo with + + Returns: + PersonInfo instance + """ + self._db = db + self._pk = pk + + person = self._db._dbpersons_pk[pk] + self.uuid = person["uuid"] + self.name = person["fullname"] + self.display_name = person["displayname"] + self.keyface = person["keyface"] + self.facecount = person["facecount"] + + @property + def keyphoto(self): + try: + return self._keyphoto + except AttributeError: + person = self._db._dbpersons_pk[self._pk] + if person["photo_uuid"]: + try: + key_photo = self._db.get_photo(person["photo_uuid"]) + except IndexError: + key_photo = None + else: + key_photo = None + self._keyphoto = key_photo + return self._keyphoto + + @property + def photos(self): + """Returns list of PhotoInfo objects associated with this person""" + return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk]) + + @property + def face_info(self): + """Returns a list of FaceInfo objects associated with this person sorted by quality score + Highest quality face is result[0] and lowest quality face is result[n] + """ + try: + faces = self._db._db_faceinfo_person[self._pk] + return sorted( + [FaceInfo(db=self._db, pk=face) for face in faces], + key=lambda face: face.quality, + reverse=True, + ) + except KeyError: + # no faces + return [] + +
[docs] def asdict(self): + """Returns dictionary representation of class instance""" + keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None + return { + "uuid": self.uuid, + "name": self.name, + "displayname": self.display_name, + "keyface": self.keyface, + "facecount": self.facecount, + "keyphoto": keyphoto, + }
+ +
[docs] def json(self): + """Returns JSON representation of class instance""" + return json.dumps(self.asdict())
+ + def __str__(self): + return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})" + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + + return all( + getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"] + ) + + def __ne__(self, other): + return not self.__eq__(other)
+ + +class FaceInfo: + """Info about a face in the Photos library""" + + def __init__(self, db=None, pk=None): + """Creates a new FaceInfo instance + + Arguments: + db: instance of PhotosDB object + pk: primary key value of face to init the object with + + Returns: + FaceInfo instance + """ + self._db = db + self._pk = pk + + face = self._db._db_faceinfo_pk[pk] + self._info = face + self.uuid = face["uuid"] + self.name = face["fullname"] + self.asset_uuid = face["asset_uuid"] + self._person_pk = face["person"] + self.center_x = face["centerx"] + self.center_y = face["centery"] + self.mouth_x = face["mouthx"] + self.mouth_y = face["mouthy"] + self.left_eye_x = face["lefteyex"] + self.left_eye_y = face["lefteyey"] + self.right_eye_x = face["righteyex"] + self.right_eye_y = face["righteyey"] + self.size = face["size"] + self.quality = face["quality"] + self.source_width = face["sourcewidth"] + self.source_height = face["sourceheight"] + self.has_smile = face["has_smile"] + self.left_eye_closed = face["left_eye_closed"] + self.right_eye_closed = face["right_eye_closed"] + self.manual = face["manual"] + self.face_type = face["facetype"] + self.age_type = face["agetype"] + # self.bald_type = face["baldtype"] + self.eye_makeup_type = face["eyemakeuptype"] + self.eye_state = face["eyestate"] + self.facial_hair_type = face["facialhairtype"] + self.gender_type = face["gendertype"] + self.glasses_type = face["glassestype"] + self.hair_color_type = face["haircolortype"] + self.intrash = face["intrash"] + self.lip_makeup_type = face["lipmakeuptype"] + self.smile_type = face["smiletype"] + + @property + def center(self): + """Coordinates, in PIL format, for center of face + + Returns: + tuple of coordinates in form (x, y) + """ + return self._make_point((self.center_x, self.center_y)) + + @property + def size_pixels(self): + """Size of face in pixels (centered around center_x, center_y) + + Returns: + size, in int pixels, of a circle drawn around the center of the face + """ + photo = self.photo + size_reference = photo.width if photo.width > photo.height else photo.height + return self.size * size_reference + + @property + def mouth(self): + """Coordinates, in PIL format, for mouth position + + Returns: + tuple of coordinates in form (x, y) + """ + return self._make_point_with_rotation((self.mouth_x, self.mouth_y)) + + @property + def left_eye(self): + """Coordinates, in PIL format, for left eye position + + Returns: + tuple of coordinates in form (x, y) + """ + return self._make_point_with_rotation((self.left_eye_x, self.left_eye_y)) + + @property + def right_eye(self): + """Coordinates, in PIL format, for right eye position + + Returns: + tuple of coordinates in form (x, y) + """ + return self._make_point_with_rotation((self.right_eye_x, self.right_eye_y)) + + @property + def person_info(self): + """PersonInfo instance for person associated with this face""" + try: + return self._person + except AttributeError: + self._person = PersonInfo(db=self._db, pk=self._person_pk) + return self._person + + @property + def photo(self): + """PhotoInfo instance associated with this face""" + try: + return self._photo + except AttributeError: + self._photo = self._db.get_photo(self.asset_uuid) + if self._photo is None: + logging.warning(f"Could not get photo for uuid: {self.asset_uuid}") + return self._photo + + @property + def mwg_rs_area(self): + """Get coordinates for Metadata Working Group Region Area. + + Returns: + MWG_RS_Area named tuple with x, y, h, w where: + x = stArea:x + y = stArea:y + h = stArea:h + w = stArea:w + + Reference: + https://photo.stackexchange.com/questions/106410/how-does-xmp-define-the-face-region + """ + x, y = self.center_x, self.center_y + x, y = self._fix_orientation((x, y)) + + if self.photo.orientation in [5, 6, 7, 8]: + w = self.size_pixels / self.photo.height + h = self.size_pixels / self.photo.width + else: + h = self.size_pixels / self.photo.height + w = self.size_pixels / self.photo.width + + return MWG_RS_Area(x, y, h, w) + + @property + def mpri_reg_rect(self): + """Get coordinates for Microsoft Photo Region Rectangle. + + Returns: + MPRI_Reg_Rect named tuple with x, y, h, w where: + x = x coordinate of top left corner of rectangle + y = y coordinate of top left corner of rectangle + h = height of rectangle + w = width of rectangle + + Reference: + https://docs.microsoft.com/en-us/windows/win32/wic/-wic-people-tagging + """ + x, y = self.center_x, self.center_y + x, y = self._fix_orientation((x, y)) + + if self.photo.orientation in [5, 6, 7, 8]: + w = self.size_pixels / self.photo.width + h = self.size_pixels / self.photo.height + x = x - self.size_pixels / self.photo.height / 2 + y = y - self.size_pixels / self.photo.width / 2 + else: + h = self.size_pixels / self.photo.width + w = self.size_pixels / self.photo.height + x = x - self.size_pixels / self.photo.width / 2 + y = y - self.size_pixels / self.photo.height / 2 + + return MPRI_Reg_Rect(x, y, h, w) + + def face_rect(self): + """Get face rectangle coordinates for current version of the associated image + If image has been edited, rectangle applies to edited version, otherwise original version + Coordinates in format and reference frame used by PIL + + Returns: + list [(x0, x1), (y0, y1)] of coordinates in reference frame used by PIL + """ + photo = self.photo + size_reference = photo.width if photo.width > photo.height else photo.height + radius = (self.size / 2) * size_reference + x, y = self._make_point((self.center_x, self.center_y)) + x0, y0 = x - radius, y - radius + x1, y1 = x + radius, y + radius + return [(x0, y0), (x1, y1)] + + def roll_pitch_yaw(self): + """Roll, pitch, yaw of face in radians as tuple""" + info = self._info + roll = 0 if info["roll"] is None else info["roll"] + pitch = 0 if info["pitch"] is None else info["pitch"] + yaw = 0 if info["yaw"] is None else info["yaw"] + + return (roll, pitch, yaw) + + @property + def roll(self): + """Return roll angle in radians of the face region""" + roll, _, _ = self.roll_pitch_yaw() + return roll + + @property + def pitch(self): + """Return pitch angle in radians of the face region""" + _, pitch, _ = self.roll_pitch_yaw() + return pitch + + @property + def yaw(self): + """Return yaw angle in radians of the face region""" + _, _, yaw = self.roll_pitch_yaw() + return yaw + + def _fix_orientation(self, xy): + """Translate an (x, y) tuple based on image orientation + + Arguments: + xy: tuple of (x, y) coordinates for point to translate + in format used by Photos (percent of height/width) + + Returns: + (x, y) tuple of translated coordinates + """ + # Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94 + + orientation = self.photo.orientation + x, y = xy + if orientation == 1: + y = 1.0 - y + elif orientation == 2: + y = 1.0 - y + x = 1.0 - x + elif orientation == 3: + x = 1.0 - x + elif orientation == 4: + pass + elif orientation == 5: + x, y = 1.0 - y, x + elif orientation == 6: + x, y = 1.0 - y, 1.0 - x + elif orientation == 7: + x, y = y, x + y = 1.0 - y + elif orientation == 8: + x, y = y, x + elif orientation == 0: + # set by osxphotos if adjusted orientation cannot be read, assume it's 1 + y = 1.0 - y + else: + logging.warning(f"Unhandled orientation: {orientation}") + + return (x, y) + + def _make_point(self, xy): + """Translate an (x, y) tuple based on image orientation + and convert to image coordinates + + Arguments: + xy: tuple of (x, y) coordinates for point to translate + in format used by Photos (percent of height/width) + + Returns: + (x, y) tuple of translated coordinates in pixels in PIL format/reference frame + """ + # Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94 + + orientation = self.photo.orientation + x, y = self._fix_orientation(xy) + dx = self.photo.width + dy = self.photo.height + if orientation in [5, 6, 7, 8]: + dx, dy = dy, dx + return (int(x * dx), int(y * dy)) + + def _make_point_with_rotation(self, xy): + """Translate an (x, y) tuple based on image orientation and rotation + and convert to image coordinates + + Arguments: + xy: tuple of (x, y) coordinates for point to translate + in format used by Photos (percent of height/width) + + Returns: + (x, y) tuple of translated coordinates in pixels in PIL format/reference frame + """ + + # convert to image coordinates + x, y = self._make_point(xy) + + # rotate about center + xmid, ymid = self.center + roll, _, _ = self.roll_pitch_yaw() + xr, yr = rotate_image_point(x, y, xmid, ymid, roll) + + return (int(xr), int(yr)) + + def asdict(self): + """Returns dict representation of class instance""" + roll, pitch, yaw = self.roll_pitch_yaw() + return { + "_pk": self._pk, + "uuid": self.uuid, + "name": self.name, + "asset_uuid": self.asset_uuid, + "_person_pk": self._person_pk, + "center_x": self.center_x, + "center_y": self.center_y, + "center": self.center, + "mouth_x": self.mouth_x, + "mouth_y": self.mouth_y, + "mouth": self.mouth, + "left_eye_x": self.left_eye_x, + "left_eye_y": self.left_eye_y, + "left_eye": self.left_eye, + "right_eye_x": self.right_eye_x, + "right_eye_y": self.right_eye_y, + "right_eye": self.right_eye, + "size": self.size, + "face_rect": self.face_rect(), + "mpri_reg_rect": self.mpri_reg_rect._asdict(), + "mwg_rs_area": self.mwg_rs_area._asdict(), + "roll": roll, + "pitch": pitch, + "yaw": yaw, + "quality": self.quality, + "source_width": self.source_width, + "source_height": self.source_height, + "has_smile": self.has_smile, + "left_eye_closed": self.left_eye_closed, + "right_eye_closed": self.right_eye_closed, + "manual": self.manual, + "face_type": self.face_type, + "age_type": self.age_type, + # "bald_type": self.bald_type, + "eye_makeup_type": self.eye_makeup_type, + "eye_state": self.eye_state, + "facial_hair_type": self.facial_hair_type, + "gender_type": self.gender_type, + "glasses_type": self.glasses_type, + "hair_color_type": self.hair_color_type, + "intrash": self.intrash, + "lip_makeup_type": self.lip_makeup_type, + "smile_type": self.smile_type, + } + + def json(self): + """Return JSON representation of FaceInfo instance""" + return json.dumps(self.asdict()) + + def __str__(self): + return f"FaceInfo(uuid={self.uuid}, center_x={self.center_x}, center_y = {self.center_y}, size={self.size}, person={self.name}, asset_uuid={self.asset_uuid})" + + def __repr__(self): + return f"FaceInfo(db={self._db}, pk={self._pk})" + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + + return all( + getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"] + ) + + def __ne__(self, other): + return not self.__eq__(other) + + +def rotate_image_point(x, y, xmid, ymid, angle): + """rotate image point about xm, ym by angle in radians + + Arguments: + x: x coordinate of point to rotate + y: y coordinate of point to rotate + xmid: x coordinate of center point to rotate about + ymid: y coordinate of center point to rotate about + angle: angle in radians about which to coordinate, + counter-clockwise is positive + + Returns: + tuple of rotated points (xr, yr) + """ + # translate point relative to the mid point + x = x - xmid + y = y - ymid + + # rotate by angle and translate back + # the photo coordinate system is downwards y is positive so + # need to adjust the rotation accordingly + cos_angle = math.cos(angle) + sin_angle = math.sin(angle) + xr = x * cos_angle + y * sin_angle + xmid + yr = -x * sin_angle + y * cos_angle + ymid + + return (xr, yr) +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/photoexporter.html b/docs/_modules/osxphotos/photoexporter.html new file mode 100644 index 00000000..d3d0ce56 --- /dev/null +++ b/docs/_modules/osxphotos/photoexporter.html @@ -0,0 +1,2322 @@ + + + + + + + + osxphotos.photoexporter - osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+ +
+ +
+
+

Source code for osxphotos.photoexporter

+""" PhotoExport class to export photos
+"""
+
+import dataclasses
+import hashlib
+import json
+import logging
+import os
+import pathlib
+import re
+import tempfile
+import typing as t
+from collections import namedtuple  # pylint: disable=syntax-error
+from dataclasses import asdict, dataclass
+from enum import Enum
+
+import photoscript
+from mako.template import Template
+
+from ._constants import (
+    _MAX_IPTC_KEYWORD_LEN,
+    _OSXPHOTOS_NONE_SENTINEL,
+    _TEMPLATE_DIR,
+    _UNKNOWN_PERSON,
+    _XMP_TEMPLATE_NAME,
+    _XMP_TEMPLATE_NAME_BETA,
+    DEFAULT_PREVIEW_SUFFIX,
+    LIVE_VIDEO_EXTENSIONS,
+    SIDECAR_EXIFTOOL,
+    SIDECAR_JSON,
+    SIDECAR_XMP,
+)
+from ._version import __version__
+from .datetime_utils import datetime_tz_to_utc
+from .exiftool import ExifTool, exiftool_can_write
+from .export_db import ExportDB, ExportDBTemp
+from .fileutil import FileUtil
+from .photokit import (
+    PHOTOS_VERSION_CURRENT,
+    PHOTOS_VERSION_ORIGINAL,
+    PHOTOS_VERSION_UNADJUSTED,
+    PhotoKitFetchFailed,
+    PhotoLibrary,
+)
+from .phototemplate import RenderOptions
+from .rich_utils import add_rich_markup_tag
+from .uti import get_preferred_uti_extension
+from .utils import increment_filename, lineno, list_directory
+
+__all__ = [
+    "ExportError",
+    "ExportOptions",
+    "ExportResults",
+    "PhotoExporter",
+    "hexdigest",
+    "rename_jpeg_files",
+]
+
+if t.TYPE_CHECKING:
+    from .photoinfo import PhotoInfo
+
+# retry if download_missing/use_photos_export fails the first time (which sometimes it does)
+MAX_PHOTOSCRIPT_RETRIES = 3
+
+# return values for _should_update_photo
+class ShouldUpdate(Enum):
+    NOT_IN_DATABASE = 1
+    HARDLINK_DIFFERENT_FILES = 2
+    NOT_HARDLINK_SAME_FILES = 3
+    DEST_SIG_DIFFERENT = 4
+    EXPORT_OPTIONS_DIFFERENT = 5
+    EXIFTOOL_DIFFERENT = 6
+    EDITED_SIG_DIFFERENT = 7
+    DIGEST_DIFFERENT = 8
+
+
+class ExportError(Exception):
+    """error during export"""
+
+    pass
+
+
+
[docs]@dataclass +class ExportOptions: + """Options class for exporting photos with export + + Attributes: + convert_to_jpeg (bool): if True, converts non-jpeg images to jpeg + description_template (str): t.Optional template string that will be rendered for use as photo description + download_missing: (bool, default=False): if True will attempt to export photo via applescript interaction with Photos if missing (see also use_photokit, use_photos_export) + dry_run: (bool, default=False): set to True to run in "dry run" mode + edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version + exiftool_flags (list of str): t.Optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"] + exiftool: (bool, default = False): if True, will use exiftool to write metadata to export file + export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them + export_db: (ExportDB): instance of a class that conforms to ExportDB with methods for getting/setting data related to exported files to compare update state + face_regions: (bool, default=True): if True, will export face regions + fileutil: (FileUtilABC): class that conforms to FileUtilABC with various file utilities + force_update: (bool, default=False): if True, will export photo if any metadata has changed but export otherwise would not be triggered (e.g. metadata changed but not using exiftool) + ignore_date_modified (bool): for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set + ignore_signature (bool, default=False): ignore file signature when used with update (look only at filename) + increment (bool, default=True): if True, will increment file name until a non-existant name is found if overwrite=False and increment=False, export will fail if destination file already exists + jpeg_ext (str): if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "." + jpeg_quality (float in range 0.0 <= jpeg_quality <= 1.0): a value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression. + keyword_template (list of str): list of template strings that will be rendered as used as keywords + live_photo (bool, default=False): if True, will also export the associated .mov for live photos + location (bool): if True, include location in exported metadata + merge_exif_keywords (bool): if True, merged keywords found in file's exif data (requires exiftool) + merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool) + overwrite (bool, default=False): if True will overwrite files if they already exist + persons (bool): if True, include persons in exported metadata + preview_suffix (str): t.Optional string to append to end of filename for preview images + preview (bool): if True, also exports preview image + raw_photo (bool, default=False): if True, will also export the associated RAW photo + render_options (RenderOptions): t.Optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates + replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive + rich (bool): if True, will use rich markup with verbose output + sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json') + sidecar: bit field (int): set to one or more of `SIDECAR_XMP`, `SIDECAR_JSON`, `SIDECAR_EXIFTOOL` + - SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; + includes exiftool tag group names (e.g. `exiftool -G -j`) + - SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; + does not include exiftool tag group names (e.g. `exiftool -j`) + - SIDECAR_XMP: if set will write an XMP sidecar with IPTC data sidecar filename will be dest/filename.xmp + strip (bool): if True, strip whitespace from rendered templates + timeout (int, default=120): timeout in seconds used with use_photos_export + touch_file (bool, default=False): if True, sets file's modification time upon photo date + update (bool, default=False): if True export will run in update mode, that is, it will not export the photo if the current version already exists in the destination + use_albums_as_keywords (bool, default = False): if True, will include album names in keywords when exporting metadata with exiftool or sidecar + use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar + use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing) + use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True + verbose (callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output. + tmpdir: (str, default=None): Optional directory to use for temporary files, if None (default) uses system tmp directory + + """ + + convert_to_jpeg: bool = False + description_template: t.Optional[str] = None + download_missing: bool = False + dry_run: bool = False + edited: bool = False + exiftool_flags: t.Optional[t.List] = None + exiftool: bool = False + export_as_hardlink: bool = False + export_db: t.Optional[ExportDB] = None + face_regions: bool = True + fileutil: t.Optional[FileUtil] = None + force_update: bool = False + ignore_date_modified: bool = False + ignore_signature: bool = False + increment: bool = True + jpeg_ext: t.Optional[str] = None + jpeg_quality: float = 1.0 + keyword_template: t.Optional[t.List[str]] = None + live_photo: bool = False + location: bool = True + merge_exif_keywords: bool = False + merge_exif_persons: bool = False + overwrite: bool = False + persons: bool = True + preview_suffix: str = DEFAULT_PREVIEW_SUFFIX + preview: bool = False + raw_photo: bool = False + render_options: t.Optional[RenderOptions] = None + replace_keywords: bool = False + rich: bool = False + sidecar_drop_ext: bool = False + sidecar: int = 0 + strip: bool = False + timeout: int = 120 + touch_file: bool = False + update: bool = False + use_albums_as_keywords: bool = False + use_persons_as_keywords: bool = False + use_photokit: bool = False + use_photos_export: bool = False + verbose: t.Optional[t.Callable] = None + tmpdir: t.Optional[str] = None + + def asdict(self): + return asdict(self) + + @property + def bit_flags(self): + """Return bit flags representing options that affect export""" + # currently only exiftool makes a difference + return self.exiftool << 1
+ + +class StagedFiles: + """Represents files staged for export""" + + def __init__( + self, + original: t.Optional[str] = None, + original_live: t.Optional[str] = None, + edited: t.Optional[str] = None, + edited_live: t.Optional[str] = None, + preview: t.Optional[str] = None, + raw: t.Optional[str] = None, + error: t.Optional[t.List[str]] = None, + ): + self.original = original + self.original_live = original_live + self.edited = edited + self.edited_live = edited_live + self.preview = preview + self.raw = raw + self.error = error or [] + + # TODO: bursts? + + def __ior__(self, other): + self.original = self.original or other.original + self.original_live = self.original_live or other.original_live + self.edited = self.edited or other.edited + self.edited_live = self.edited_live or other.edited_live + self.preview = self.preview or other.preview + self.raw = self.raw or other.raw + self.error += other.error + return self + + def __str__(self): + return str(self.asdict()) + + def asdict(self): + return { + "original": self.original, + "original_live": self.original_live, + "edited": self.edited, + "edited_live": self.edited_live, + "preview": self.preview, + "raw": self.raw, + "error": self.error, + } + + +
[docs]class ExportResults: + """Results class which holds export results for export""" + + def __init__( + self, + exported=None, + new=None, + updated=None, + skipped=None, + exif_updated=None, + touched=None, + to_touch=None, + converted_to_jpeg=None, + sidecar_json_written=None, + sidecar_json_skipped=None, + sidecar_exiftool_written=None, + sidecar_exiftool_skipped=None, + sidecar_xmp_written=None, + sidecar_xmp_skipped=None, + missing=None, + error=None, + exiftool_warning=None, + exiftool_error=None, + xattr_written=None, + xattr_skipped=None, + deleted_files=None, + deleted_directories=None, + exported_album=None, + skipped_album=None, + missing_album=None, + ): + self.exported = exported or [] + self.new = new or [] + self.updated = updated or [] + self.skipped = skipped or [] + self.exif_updated = exif_updated or [] + self.touched = touched or [] + self.to_touch = to_touch or [] + self.converted_to_jpeg = converted_to_jpeg or [] + self.sidecar_json_written = sidecar_json_written or [] + self.sidecar_json_skipped = sidecar_json_skipped or [] + self.sidecar_exiftool_written = sidecar_exiftool_written or [] + self.sidecar_exiftool_skipped = sidecar_exiftool_skipped or [] + self.sidecar_xmp_written = sidecar_xmp_written or [] + self.sidecar_xmp_skipped = sidecar_xmp_skipped or [] + self.missing = missing or [] + self.error = error or [] + self.exiftool_warning = exiftool_warning or [] + self.exiftool_error = exiftool_error or [] + self.xattr_written = xattr_written or [] + self.xattr_skipped = xattr_skipped or [] + self.deleted_files = deleted_files or [] + self.deleted_directories = deleted_directories or [] + self.exported_album = exported_album or [] + self.skipped_album = skipped_album or [] + self.missing_album = missing_album or [] + +
[docs] def all_files(self): + """return all filenames contained in results""" + files = ( + self.exported + + self.new + + self.updated + + self.skipped + + self.exif_updated + + self.touched + + self.converted_to_jpeg + + self.sidecar_json_written + + self.sidecar_json_skipped + + self.sidecar_exiftool_written + + self.sidecar_exiftool_skipped + + self.sidecar_xmp_written + + self.sidecar_xmp_skipped + + self.missing + ) + files += [x[0] for x in self.exiftool_warning] + files += [x[0] for x in self.exiftool_error] + files += [x[0] for x in self.error] + + files = list(set(files)) + return files
+ + def __iadd__(self, other): + self.exported += other.exported + self.new += other.new + self.updated += other.updated + self.skipped += other.skipped + self.exif_updated += other.exif_updated + self.touched += other.touched + self.to_touch += other.to_touch + self.converted_to_jpeg += other.converted_to_jpeg + self.sidecar_json_written += other.sidecar_json_written + self.sidecar_json_skipped += other.sidecar_json_skipped + self.sidecar_exiftool_written += other.sidecar_exiftool_written + self.sidecar_exiftool_skipped += other.sidecar_exiftool_skipped + self.sidecar_xmp_written += other.sidecar_xmp_written + self.sidecar_xmp_skipped += other.sidecar_xmp_skipped + self.missing += other.missing + self.error += other.error + self.exiftool_warning += other.exiftool_warning + self.exiftool_error += other.exiftool_error + self.deleted_files += other.deleted_files + self.deleted_directories += other.deleted_directories + self.exported_album += other.exported_album + self.skipped_album += other.skipped_album + self.missing_album += other.missing_album + + return self + + def __str__(self): + return ( + "ExportResults(" + + f"exported={self.exported}" + + f",new={self.new}" + + f",updated={self.updated}" + + f",skipped={self.skipped}" + + f",exif_updated={self.exif_updated}" + + f",touched={self.touched}" + + f",to_touch={self.to_touch}" + + f",converted_to_jpeg={self.converted_to_jpeg}" + + f",sidecar_json_written={self.sidecar_json_written}" + + f",sidecar_json_skipped={self.sidecar_json_skipped}" + + f",sidecar_exiftool_written={self.sidecar_exiftool_written}" + + f",sidecar_exiftool_skipped={self.sidecar_exiftool_skipped}" + + f",sidecar_xmp_written={self.sidecar_xmp_written}" + + f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}" + + f",missing={self.missing}" + + f",error={self.error}" + + f",exiftool_warning={self.exiftool_warning}" + + f",exiftool_error={self.exiftool_error}" + + f",deleted_files={self.deleted_files}" + + f",deleted_directories={self.deleted_directories}" + + f",exported_album={self.exported_album}" + + f",skipped_album={self.skipped_album}" + + f",missing_album={self.missing_album}" + + ")" + )
+ + +
[docs]class PhotoExporter: + """Export a photo""" + def __init__(self, photo: "PhotoInfo", tmpdir: t.Optional[str] = None): + self.photo = photo + self._render_options = RenderOptions() + self._verbose = self.photo._verbose + + # define functions for adding markup + self._filepath = add_rich_markup_tag("filepath", rich=False) + self._filename = add_rich_markup_tag("filename", rich=False) + self._uuid = add_rich_markup_tag("uuid", rich=False) + self._num = add_rich_markup_tag("num", rich=False) + + # temp directory for staging downloaded missing files + self._temp_dir = None + self._temp_dir_path = None + self.fileutil = FileUtil + +
[docs] def export( + self, + dest, + filename=None, + options: t.Optional[ExportOptions] = None, + ) -> ExportResults: + """Export photo + + Args: + dest: must be valid destination path or exception raised + filename: (optional): name of exported picture; if not provided, will use current filename + **NOTE**: if provided, user must ensure file extension (suffix) is correct. + For example, if photo is .CR2 file, edited image may be .jpeg. + If you provide an extension different than what the actual file is, + will export the photo using the incorrect file extension (unless use_photos_export is true, + in which case export will use the extension provided by Photos upon export. + e.g. to get the extension of the edited photo, + reference PhotoInfo.path_edited + options (`ExportOptions`): t.Optional ExportOptions instance + + Returns: + ExportResults instance + + Note: + To use dry run mode, you must set options.dry_run=True and also pass in memory version of export_db, + and no-op fileutil (e.g. `ExportDBInMemory` and `FileUtilNoOp`) in options.export_db and options.fileutil respectively + """ + + options = options or ExportOptions() + + # temp dir must be initialized before any of the methods called by export() are called + self._init_temp_dir(options) + + verbose = options.verbose or self._verbose + if verbose and not callable(verbose): + raise TypeError("verbose must be callable") + + # define functions for adding markup + self._filepath = add_rich_markup_tag("filepath", rich=options.rich) + self._filename = add_rich_markup_tag("filename", rich=options.rich) + self._uuid = add_rich_markup_tag("uuid", rich=options.rich) + self._num = add_rich_markup_tag("num", rich=options.rich) + + # can't use export_as_hardlink with download_missing, use_photos_export as can't hardlink the temporary files downloaded + if options.export_as_hardlink and options.download_missing: + raise ValueError( + "Cannot use export_as_hardlink with download_missing or use_photos_export" + ) + + # when called from export(), won't get an export_db, so use temp version + options.export_db = options.export_db or ExportDBTemp() + + # ensure there's a FileUtil class to use + options.fileutil = options.fileutil or FileUtil + self.fileutil = options.fileutil + + self._render_options = options.render_options or RenderOptions() + + # export_original, and export_edited are just used for clarity in the code + export_original = not options.edited + export_edited = options.edited + if export_edited and not self.photo.hasadjustments: + raise ValueError( + "Photo does not have adjustments, cannot export edited version" + ) + + # verify destination is a valid path + if dest is None: + raise ValueError("dest must not be None") + elif not options.dry_run and not os.path.isdir(dest): + raise FileNotFoundError("Invalid path passed to export") + + if export_edited: + filename = filename or self._get_edited_filename( + self.photo.original_filename + ) + else: + filename = filename or self.photo.original_filename + dest = pathlib.Path(dest) / filename + + # Is there something to convert with convert_to_jpeg? + dest, options = self._should_convert_to_jpeg(dest, options) + + # stage files for export by finding path in local library or downloading from iCloud as appropriate + staged_files = self._stage_photos_for_export(options) + src = staged_files.edited if options.edited else staged_files.original + + # get the right destination path depending on options.update, etc. + dest = self._get_dest_path(src, dest, options) + + self._render_options.filepath = str(dest) + all_results = ExportResults() + + if src: + # export the dest file + all_results += self._export_photo( + src, + dest, + options=options, + ) + else: + verbose( + f"Skipping missing {'edited' if options.edited else 'original'} photo {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})" + ) + all_results.missing.append(dest) + + # copy live photo associated .mov if requested + if export_original and options.live_photo and self.photo.live_photo: + live_name = dest.parent / f"{dest.stem}.mov" + if staged_files.original_live: + src_live = staged_files.original_live + all_results += self._export_photo( + src_live, + live_name, + # don't try to convert the live photo + options=dataclasses.replace(options, convert_to_jpeg=False), + ) + else: + verbose( + f"Skipping missing live photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})" + ) + all_results.missing.append(live_name) + + if export_edited and options.live_photo and self.photo.live_photo: + live_name = dest.parent / f"{dest.stem}.mov" + if staged_files.edited_live: + src_live = staged_files.edited_live + all_results += self._export_photo( + src_live, + live_name, + # don't try to convert the live photo + options=dataclasses.replace(options, convert_to_jpeg=False), + ) + else: + verbose( + f"Skipping missing edited live photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})" + ) + all_results.missing.append(live_name) + + # copy associated RAW image if requested + if options.raw_photo and self.photo.has_raw: + if staged_files.raw: + raw_path = pathlib.Path(staged_files.raw) + raw_ext = raw_path.suffix + raw_name = dest.parent / f"{dest.stem}{raw_ext}" + all_results += self._export_photo( + raw_path, + raw_name, + options=options, + ) + else: + # guess at most likely raw name + raw_ext = get_preferred_uti_extension(self.photo.uti_raw) or "raw" + raw_name = dest.parent / f"{dest.stem}.{raw_ext}" + all_results.missing.append(raw_name) + verbose( + f"Skipping missing raw photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})" + ) + + # copy preview image if requested + if options.preview: + if staged_files.preview: + # Photos keeps multiple different derivatives and path_derivatives returns list of them + # first derivative is the largest so export that one + preview_path = pathlib.Path(staged_files.preview) + preview_ext = preview_path.suffix + preview_name = ( + dest.parent / f"{dest.stem}{options.preview_suffix}{preview_ext}" + ) + # if original is missing, the filename won't have been incremented so + # need to check here to make sure there aren't duplicate preview files in + # the export directory + preview_name = ( + preview_name + if any([options.overwrite, options.update, options.force_update]) + else pathlib.Path(increment_filename(preview_name)) + ) + all_results += self._export_photo( + preview_path, + preview_name, + options=options, + ) + else: + # don't know what actual preview suffix would be but most likely jpeg + preview_name = dest.parent / f"{dest.stem}{options.preview_suffix}.jpeg" + all_results.missing.append(preview_name) + verbose( + f"Skipping missing preview photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})" + ) + + all_results += self._write_sidecar_files(dest=dest, options=options) + + return all_results
+ + def _init_temp_dir(self, options: ExportOptions): + """Initialize (if necessary) the object's temporary directory. + + Args: + options: ExportOptions object + """ + if self._temp_dir is not None: + return + + fileutil = options.fileutil or FileUtil + self._temp_dir = fileutil.tmpdir(prefix="osxphotos_export_", dir=options.tmpdir) + self._temp_dir_path = pathlib.Path(self._temp_dir.name) + return + + def _touch_files( + self, touch_files: t.List, options: ExportOptions + ) -> ExportResults: + """touch file date/time to match photo creation date/time; only touches files if needed""" + fileutil = options.fileutil + touch_results = [] + for touch_file in set(touch_files): + ts = int(self.photo.date.timestamp()) + try: + stat = os.stat(touch_file) + if stat.st_mtime != ts: + fileutil.utime(touch_file, (ts, ts)) + touch_results.append(touch_file) + except FileNotFoundError as e: + # ignore errors if in dry_run as file may not be present + if not options.dry_run: + raise e from e + return ExportResults(touched=touch_results) + + def _get_edited_filename(self, original_filename): + """Return the filename for the exported edited photo + (used when filename isn't provided in call to export)""" + # need to get the right extension for edited file + original_filename = pathlib.Path(original_filename) + if self.photo.path_edited: + ext = pathlib.Path(self.photo.path_edited).suffix + else: + uti = self.photo.uti_edited if self.photo.uti_edited else self.photo.uti + ext = get_preferred_uti_extension(uti) + ext = "." + ext + edited_filename = original_filename.stem + "_edited" + ext + return edited_filename + + def _get_dest_path( + self, src: str, dest: pathlib.Path, options: ExportOptions + ) -> pathlib.Path: + """If destination exists find match in ExportDB, on disk, or add (1), (2), and so on to filename to get a valid destination + + Args: + src (str): source file path + dest (str): destination path + options (ExportOptions): Export options + + Returns: + new dest path (pathlib.Path) + """ + + # if overwrite==False and #increment==False, export should fail if file exists + if dest.exists() and not any( + [options.increment, options.update, options.force_update, options.overwrite] + ): + raise FileExistsError( + f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}" + ) + + # if not update or overwrite, check to see if file exists and if so, add (1), (2), etc + # until we find one that works + # Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars + # e.g. exporting sidecar for file1.png and file1.jpeg + # if file1.png exists and exporting file1.jpeg, + # dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision + if options.increment and not any( + [options.update, options.force_update, options.overwrite] + ): + return pathlib.Path(increment_filename(dest)) + + # if update and file exists, need to check to see if it's the right file by checking export db + if (options.update or options.force_update) and dest.exists() and src: + export_db = options.export_db + # destination exists, check to see if destination is the right UUID + dest_uuid = export_db.get_uuid_for_file(dest) + if dest_uuid != self.photo.uuid: + # not the right file, find the right one + # find files that match "dest_name (*.ext" (e.g. "dest_name (1).jpg", "dest_name (2).jpg)", ...) + dest_files = list_directory( + dest.parent, + startswith=f"{dest.stem} (", + endswith=dest.suffix, + include_path=True, + ) + for file_ in dest_files: + dest_uuid = export_db.get_uuid_for_file(file_) + if dest_uuid == self.photo.uuid: + dest = pathlib.Path(file_) + break + else: + # increment the destination file + dest = pathlib.Path(increment_filename(dest)) + + # either dest was updated in the if clause above or not updated at all + return dest + + def _should_update_photo( + self, src: pathlib.Path, dest: pathlib.Path, options: ExportOptions + ) -> t.Literal[True, False]: + """Return True if photo should be updated, else False""" + export_db = options.export_db + fileutil = options.fileutil + + file_record = export_db.get_file_record(dest) + + if not file_record: + # photo doesn't exist in database, should update + return ShouldUpdate.NOT_IN_DATABASE + + if options.export_as_hardlink and not dest.samefile(src): + # different files, should update + return ShouldUpdate.HARDLINK_DIFFERENT_FILES + + if not options.export_as_hardlink and dest.samefile(src): + # same file but not exporting as hardlink, should update + return ShouldUpdate.NOT_HARDLINK_SAME_FILES + + if not options.ignore_signature and not fileutil.cmp_file_sig( + dest, file_record.dest_sig + ): + # destination file doesn't match what was last exported + return ShouldUpdate.DEST_SIG_DIFFERENT + + if file_record.export_options != options.bit_flags: + # exporting with different set of options (e.g. exiftool), should update + # need to check this before exiftool in case exiftool options are different + # and export database is missing; this will always be True if database is missing + # as it'll be None and bit_flags will be an int + return ShouldUpdate.EXPORT_OPTIONS_DIFFERENT + + if options.exiftool: + current_exifdata = self._exiftool_json_sidecar(options=options) + rv = current_exifdata != file_record.exifdata + # if using exiftool, don't need to continue checking edited below + # as exiftool will be used to update edited file + return ShouldUpdate.EXIFTOOL_DIFFERENT if rv else False + + if options.edited and not fileutil.cmp_file_sig(src, file_record.src_sig): + # edited file in Photos doesn't match what was last exported + return ShouldUpdate.EDITED_SIG_DIFFERENT + + if options.force_update: + current_digest = hexdigest(self.photo.json()) + if current_digest != file_record.digest: + # metadata in Photos changed, force update + return ShouldUpdate.DIGEST_DIFFERENT + + # photo should not be updated + return False + + def _stage_photos_for_export(self, options: ExportOptions) -> StagedFiles: + """Stages photos for export + + If photo is present on disk in the library, uses path to the photo on disk. + If photo is missing and download_missing is true, downloads the photo from iCloud to temporary location. + """ + + staged = StagedFiles() + + if options.use_photos_export: + # use Photos AppleScript or PhotoKit to do the export + return ( + self._stage_photo_for_export_with_photokit(options=options) + if options.use_photokit + else self._stage_photo_for_export_with_applescript(options=options) + ) + + if options.raw_photo and self.photo.has_raw: + staged.raw = self.photo.path_raw + + if options.preview and self.photo.path_derivatives: + staged.preview = self.photo.path_derivatives[0] + + if not options.edited: + # original file + if self.photo.path: + staged.original = self.photo.path + if options.live_photo and self.photo.live_photo: + staged.original_live = self.photo.path_live_photo + + if options.edited: + # edited file + staged.edited = self.photo.path_edited + if options.live_photo and self.photo.live_photo: + staged.edited_live = self.photo.path_edited_live_photo + + # download any missing files + if options.download_missing: + live_photo = staged.edited_live if options.edited else staged.original_live + missing_options = ExportOptions( + edited=options.edited, + preview=options.preview and not staged.preview, + raw_photo=options.raw_photo and not staged.raw, + live_photo=options.live_photo and not live_photo, + ) + if options.use_photokit: + missing_staged = self._stage_photo_for_export_with_photokit( + options=missing_options + ) + else: + missing_staged = self._stage_photo_for_export_with_applescript( + options=missing_options + ) + staged |= missing_staged + return staged + + def _stage_photo_for_export_with_photokit( + self, + options: ExportOptions, + ) -> StagedFiles: + """Stage a photo for export with photokit to a temporary directory""" + + if options.edited and not self.photo.hasadjustments: + raise ValueError("Edited version requested but photo has no adjustments") + + dest = self._temp_dir_path / self.photo.original_filename + + # export live_photo .mov file? + live_photo = bool(options.live_photo and self.photo.live_photo) + + overwrite = any([options.overwrite, options.update, options.force_update]) + + # figure out which photo version to request + if options.edited or self.photo.shared: + # shared photos (in shared albums) show up as not having adjustments (not edited) + # but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud + # so tell Photos to export the current version in this case + photos_version = PHOTOS_VERSION_CURRENT + elif self.photo.has_raw: + # PhotoKit always returns the raw photo of raw+jpeg pair for PHOTOS_VERSION_ORIGINAL even if JPEG is the original + photos_version = PHOTOS_VERSION_UNADJUSTED + else: + photos_version = PHOTOS_VERSION_ORIGINAL + + uti = ( + self.photo.uti_edited + if options.edited and self.photo.uti_edited + else self.photo.uti + ) + ext = get_preferred_uti_extension(uti) + dest = dest.parent / f"{dest.stem}.{ext}" + + photolib = PhotoLibrary() + results = StagedFiles() + photo = None + try: + photo = photolib.fetch_uuid(self.photo.uuid) + except PhotoKitFetchFailed as e: + # if failed to find UUID, might be a burst photo + if self.photo.burst and self.photo._info["burstUUID"]: + bursts = photolib.fetch_burst_uuid( + self.photo._info["burstUUID"], all=True + ) + # PhotoKit UUIDs may contain "/L0/001" so only look at beginning + photo = [p for p in bursts if p.uuid.startswith(self.photo.uuid)] + photo = photo[0] if photo else None + if not photo: + results.error.append( + ( + str(dest), + f"PhotoKitFetchFailed exception exporting photo {self.photo.uuid}: {e} ({lineno(__file__)})", + ) + ) + return results + + # now export the requested version of the photo + try: + exported = photo.export( + dest.parent, + dest.name, + version=photos_version, + overwrite=overwrite, + video=live_photo, + ) + if len(exported) == 1: + results_attr = "edited" if options.edited else "original" + setattr(results, results_attr, exported[0]) + elif len(exported) == 2: + for exported_file in exported: + if exported_file.lower().endswith(".mov"): + # live photo + results_attr = ( + "edited_live" if options.edited else "original_live" + ) + else: + results_attr = "edited" if options.edited else "original" + setattr(results, results_attr, exported_file) + except Exception as e: + results.error.append((str(dest), f"{e} ({lineno(__file__)})")) + + if options.raw_photo and self.photo.has_raw: + # also request the raw photo + try: + exported = photo.export( + dest.parent, + dest.name, + version=photos_version, + raw=True, + overwrite=overwrite, + video=live_photo, + ) + if exported: + results.raw = exported[0] + except Exception as e: + results.error.append((str(dest), f"{e} ({lineno(__file__)})")) + + if options.preview and self.photo.path_derivatives: + results.preview = self.photo.path_derivatives[0] + + return results + + def _stage_photo_for_export_with_applescript( + self, + options: ExportOptions, + ) -> StagedFiles: + """Stage a photo for export with AppleScript to a temporary directory + + Note: If exporting an edited live photo, the associated live video will not be exported. + This is a limitation of the Photos AppleScript interface and Photos behaves the same way.""" + + if options.edited and not self.photo.hasadjustments: + raise ValueError("Edited version requested but photo has no adjustments") + + dest = self._temp_dir_path / self.photo.original_filename + dest = pathlib.Path(increment_filename(dest)) + + # export live_photo .mov file? + live_photo = bool(options.live_photo and self.photo.live_photo) + overwrite = any([options.overwrite, options.update, options.force_update]) + edited_version = options.edited or self.photo.shared + # shared photos (in shared albums) show up as not having adjustments (not edited) + # but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud + # so tell Photos to export the current version in this case + uti = ( + self.photo.uti_edited + if options.edited and self.photo.uti_edited + else self.photo.uti + ) + ext = get_preferred_uti_extension(uti) + dest = dest.parent / f"{dest.stem}.{ext}" + + results = StagedFiles() + + try: + exported = self._export_photo_uuid_applescript( + self.photo.uuid, + dest.parent, + filestem=dest.stem, + original=not edited_version, + edited=edited_version, + live_photo=live_photo, + timeout=options.timeout, + burst=self.photo.burst, + overwrite=overwrite, + ) + except ExportError as e: + results.error.append((str(dest), f"{e} ({lineno(__file__)})")) + return results + + if len(exported) == 1: + results_attr = "edited" if options.edited else "original" + setattr(results, results_attr, exported[0]) + elif len(exported) == 2: + # could be live or raw+jpeg + for exported_file in exported: + if exported_file.lower().endswith(".mov"): + # live photo + results_attr = ( + "edited_live" + if live_photo and options.edited + else "original_live" + if live_photo + else None + ) + elif self.photo.has_raw and pathlib.Path( + exported_file.lower() + ).suffix not in [ + ".jpg", + ".jpeg", + ".heic", + ]: + # assume raw photo if not a common non-raw image format + results_attr = "raw" if options.raw_photo else None + else: + results_attr = "edited" if options.edited else "original" + if results_attr: + setattr(results, results_attr, exported_file) + + if options.preview and self.photo.path_derivatives: + results.preview = self.photo.path_derivatives[0] + + return results + + def _should_convert_to_jpeg( + self, dest: pathlib.Path, options: ExportOptions + ) -> t.Tuple[pathlib.Path, ExportOptions]: + """Determine if a file really should be converted to jpeg or not + and return the new destination and ExportOptions instance with the convert_to_jpeg flag set appropriately + """ + if not (options.convert_to_jpeg and self.photo.isphoto): + # nothing to convert + return dest, dataclasses.replace(options, convert_to_jpeg=False) + + convert_to_jpeg = False + ext = "." + options.jpeg_ext if options.jpeg_ext else ".jpeg" + if not options.edited and self.photo.uti_original != "public.jpeg": + # not a jpeg but will convert to jpeg upon export so fix file extension + convert_to_jpeg = True + dest = dest.parent / f"{dest.stem}{ext}" + elif options.edited and self.photo.uti != "public.jpeg": + # in Big Sur+, edited HEICs are HEIC + convert_to_jpeg = True + dest = dest.parent / f"{dest.stem}{ext}" + return dest, dataclasses.replace(options, convert_to_jpeg=convert_to_jpeg) + + def _is_temp_file(self, filepath: str) -> bool: + """Returns True if file is in the PhotosExporter temp directory otherwise False""" + filepath = pathlib.Path(filepath) + return filepath.parent == self._temp_dir_path + + def _copy_to_temp_file(self, filepath: str) -> str: + """Copies filepath to a temp file preserving access and modification times""" + filepath = pathlib.Path(filepath) + dest = self._temp_dir_path / filepath.name + dest = increment_filename(dest) + self.fileutil.copy(filepath, dest) + stat = os.stat(filepath) + self.fileutil.utime(dest, (stat.st_atime, stat.st_mtime)) + return str(dest) + + def _export_photo( + self, + src, + dest, + options, + ): + """Helper function for export() + Does the actual copy or hardlink taking the appropriate + action depending on update, overwrite, export_as_hardlink + Assumes destination is the right destination (e.g. UUID matches) + Sets UUID and JSON info for exported file using set_uuid_for_file, set_info_for_uuid + Expects that src is a temporary file (as set by _stage_photos_for_export) and + may modify the src (e.g. for convert_to_jpeg or exiftool) + + Args: + src (str): src path + dest (pathlib.Path): dest path + options (ExportOptions): options for export + + Returns: + ExportResults + + Raises: + ValueError if export_as_hardlink and convert_to_jpeg both True + """ + + if options.export_as_hardlink and options.convert_to_jpeg: + raise ValueError( + "export_as_hardlink and convert_to_jpeg cannot both be True" + ) + + if options.export_as_hardlink and self._is_temp_file(src): + raise ValueError("export_as_hardlink cannot be used with temp files") + + exported_files = [] + update_updated_files = [] + update_new_files = [] + update_skipped_files = [] # skip files that are already up to date + converted_to_jpeg_files = [] + exif_results = ExportResults() + + dest_str = str(dest) + dest_exists = dest.exists() + + fileutil = options.fileutil + export_db = options.export_db + + if options.update or options.force_update: # updating + if dest_exists: + if self._should_update_photo(src, dest, options): + update_updated_files.append(dest_str) + else: + update_skipped_files.append(dest_str) + else: + # update, destination doesn't exist (new file) + update_new_files.append(dest_str) + else: + # not update, export the file + exported_files.append(dest_str) + + export_files = update_new_files + update_updated_files + exported_files + for export_dest in export_files: + # set src_sig before any modifications by convert_to_jpeg or exiftool + export_record = export_db.create_or_get_file_record( + export_dest, self.photo.uuid + ) + export_record.src_sig = fileutil.file_sig(src) + if dest_exists and any( + [options.overwrite, options.update, options.force_update] + ): + # need to remove the destination first + try: + fileutil.unlink(dest) + except Exception as e: + raise ExportError( + f"Error removing file {dest}: {e} (({lineno(__file__)})" + ) from e + if options.export_as_hardlink: + try: + fileutil.hardlink(src, dest) + except Exception as e: + raise ExportError( + f"Error hardlinking {src} to {dest}: {e} ({lineno(__file__)})" + ) from e + else: + if options.convert_to_jpeg: + # use convert_to_jpeg to export the file + # convert to a temp file before copying + tmp_file = increment_filename( + self._temp_dir_path + / f"{pathlib.Path(src).stem}_converted_to_jpeg.jpeg" + ) + fileutil.convert_to_jpeg( + src, tmp_file, compression_quality=options.jpeg_quality + ) + src = tmp_file + converted_to_jpeg_files.append(dest_str) + + if options.exiftool: + # if exiftool, write the metadata + # need to copy the file to a temp file before writing metadata + src = pathlib.Path(src) + tmp_file = increment_filename( + self._temp_dir_path / f"{src.stem}_exiftool{src.suffix}" + ) + fileutil.copy(src, tmp_file) + # point src to the tmp_file so that the original source is not modified + # and the export grabs the new file + src = tmp_file + exif_results = self._write_exif_metadata_to_file( + src, dest, options=options + ) + + try: + fileutil.copy(src, dest_str) + except Exception as e: + raise ExportError( + f"Error copying file {src} to {dest_str}: {e} ({lineno(__file__)})" + ) from e + + results = ExportResults( + converted_to_jpeg=converted_to_jpeg_files, + error=exif_results.error, + exif_updated=exif_results.exif_updated, + exiftool_error=exif_results.exiftool_error, + exiftool_warning=exif_results.exiftool_warning, + exported=exported_files + update_new_files + update_updated_files, + new=update_new_files, + skipped=update_skipped_files, + updated=update_updated_files, + ) + + # touch files if needed + if options.touch_file: + results += self._touch_files( + exported_files + + update_new_files + + update_updated_files + + update_skipped_files, + options, + ) + + # set data in the database + with export_db.create_or_get_file_record(dest_str, self.photo.uuid) as rec: + photoinfo = self.photo.json() + rec.photoinfo = photoinfo + rec.export_options = options.bit_flags + # don't set src_sig as that is set above before any modifications by convert_to_jpeg or exiftool + if not options.ignore_signature: + rec.dest_sig = fileutil.file_sig(dest) + if options.exiftool: + rec.exifdata = self._exiftool_json_sidecar(options) + if options.force_update: + rec.digest = hexdigest(photoinfo) + + return results + + def _export_photo_uuid_applescript( + self, + uuid: str, + dest: str, + filestem=None, + original=True, + edited=False, + live_photo=False, + timeout=120, + burst=False, + dry_run=False, + overwrite=False, + ): + """Export photo to dest path using applescript to control Photos + If photo is a live photo, exports both the photo and associated .mov file + + Args: + uuid: UUID of photo to export + dest: destination path to export to + filestem: (string) if provided, exported filename will be named stem.ext + where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc) + If not provided, file will be named with whatever name Photos uses + If filestem.ext exists, it wil be overwritten + original: (boolean) if True, export original image; default = True + edited: (boolean) if True, export edited photo; default = False + If photo not edited and edited=True, will still export the original image + caller must verify image has been edited + *Note*: must be called with either edited or original but not both, + will raise error if called with both edited and original = True + live_photo: (boolean) if True, export associated .mov live photo; default = False + timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout + burst: (boolean) set to True if file is a burst image to avoid Photos export error + dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination + + Returns: list of paths to exported file(s) or None if export failed + + Raises: ExportError if error during export + + Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo + has not been edited. This is due to how Photos Applescript interface works. + """ + + dest = pathlib.Path(dest) + if not dest.is_dir(): + raise ValueError(f"dest {dest} must be a directory") + + if not original ^ edited: + raise ValueError("edited or original must be True but not both") + + # export to a subdirectory of tmpdir + tmpdir = self.fileutil.tmpdir( + "osxphotos_applescript_export_", dir=self._temp_dir_path + ) + + exported_files = [] + filename = None + try: + # I've seen intermittent failures with the PhotoScript export so retry if + # export doesn't return anything + retries = 0 + while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES: + photo = photoscript.Photo(uuid) + filename = photo.filename + exported_files = photo.export( + tmpdir.name, original=original, timeout=timeout + ) + retries += 1 + except Exception as e: + raise ExportError(e) + + if not exported_files or not filename: + # nothing got exported + raise ExportError(f"Could not export photo {uuid} ({lineno(__file__)})") + # need to find actual filename as sometimes Photos renames JPG to jpeg on export + # may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov) + # TemporaryDirectory will cleanup on return + filename_stem = pathlib.Path(filename).stem + exported_paths = [] + for fname in exported_files: + path = pathlib.Path(tmpdir.name) / fname + if ( + len(exported_files) > 1 + and not live_photo + and path.suffix.lower() == ".mov" + ): + # it's the .mov part of live photo but not requested, so don't export + continue + if len(exported_files) > 1 and burst and path.stem != filename_stem: + # skip any burst photo that's not the one we asked for + continue + if filestem: + # rename the file based on filestem, keeping original extension + dest_new = dest / f"{filestem}{path.suffix}" + else: + # use the name Photos provided + dest_new = dest / path.name + if not dry_run: + if overwrite and dest_new.exists(): + FileUtil.unlink(dest_new) + FileUtil.copy(str(path), str(dest_new)) + exported_paths.append(str(dest_new)) + return exported_paths + + def _write_sidecar_files( + self, + dest: pathlib.Path, + options: ExportOptions, + ) -> ExportResults: + """Write sidecar files for the photo.""" + + export_db = options.export_db + fileutil = options.fileutil + verbose = options.verbose or self._verbose + + # export metadata + sidecars = [] + sidecar_json_files_skipped = [] + sidecar_json_files_written = [] + sidecar_exiftool_files_skipped = [] + sidecar_exiftool_files_written = [] + sidecar_xmp_files_skipped = [] + sidecar_xmp_files_written = [] + + dest_suffix = "" if options.sidecar_drop_ext else dest.suffix + if options.sidecar & SIDECAR_JSON: + sidecar_filename = dest.parent / pathlib.Path( + f"{dest.stem}{dest_suffix}.json" + ) + sidecar_str = self._exiftool_json_sidecar( + filename=dest.name, options=options + ) + sidecars.append( + ( + sidecar_filename, + sidecar_str, + sidecar_json_files_written, + sidecar_json_files_skipped, + "JSON", + ) + ) + + if options.sidecar & SIDECAR_EXIFTOOL: + sidecar_filename = dest.parent / pathlib.Path( + f"{dest.stem}{dest_suffix}.json" + ) + sidecar_str = self._exiftool_json_sidecar( + tag_groups=False, filename=dest.name, options=options + ) + sidecars.append( + ( + sidecar_filename, + sidecar_str, + sidecar_exiftool_files_written, + sidecar_exiftool_files_skipped, + "exiftool", + ) + ) + + if options.sidecar & SIDECAR_XMP: + sidecar_filename = dest.parent / pathlib.Path( + f"{dest.stem}{dest_suffix}.xmp" + ) + sidecar_str = self._xmp_sidecar( + extension=dest.suffix[1:] if dest.suffix else None, options=options + ) + sidecars.append( + ( + sidecar_filename, + sidecar_str, + sidecar_xmp_files_written, + sidecar_xmp_files_skipped, + "XMP", + ) + ) + + for data in sidecars: + sidecar_filename = data[0] + sidecar_str = data[1] + files_written = data[2] + files_skipped = data[3] + sidecar_type = data[4] + + sidecar_digest = hexdigest(sidecar_str) + sidecar_record = export_db.create_or_get_file_record( + sidecar_filename, self.photo.uuid + ) + write_sidecar = ( + not (options.update or options.force_update) + or ( + (options.update or options.force_update) + and not sidecar_filename.exists() + ) + or ( + (options.update or options.force_update) + and (sidecar_digest != sidecar_record.digest) + or not fileutil.cmp_file_sig( + sidecar_filename, sidecar_record.dest_sig + ) + ) + ) + if write_sidecar: + verbose( + f"Writing {sidecar_type} sidecar {self._filepath(sidecar_filename)}" + ) + files_written.append(str(sidecar_filename)) + if not options.dry_run: + self._write_sidecar(sidecar_filename, sidecar_str) + sidecar_record.digest = sidecar_digest + sidecar_record.dest_sig = fileutil.file_sig(sidecar_filename) + else: + verbose( + f"Skipped up to date {sidecar_type} sidecar {self._filepath(sidecar_filename)}" + ) + files_skipped.append(str(sidecar_filename)) + + results = ExportResults( + sidecar_json_written=sidecar_json_files_written, + sidecar_json_skipped=sidecar_json_files_skipped, + sidecar_exiftool_written=sidecar_exiftool_files_written, + sidecar_exiftool_skipped=sidecar_exiftool_files_skipped, + sidecar_xmp_written=sidecar_xmp_files_written, + sidecar_xmp_skipped=sidecar_xmp_files_skipped, + ) + + if options.touch_file: + all_sidecars = ( + sidecar_json_files_written + + sidecar_exiftool_files_written + + sidecar_xmp_files_written + + sidecar_json_files_skipped + + sidecar_exiftool_files_skipped + + sidecar_xmp_files_skipped + ) + results += self._touch_files(all_sidecars, options) + + # update destination signatures in database + for sidecar_filename in all_sidecars: + sidecar_record = export_db.create_or_get_file_record( + sidecar_filename, self.photo.uuid + ) + sidecar_record.dest_sig = fileutil.file_sig(sidecar_filename) + + return results + + def _write_exif_metadata_to_file( + self, + src, + dest, + options: ExportOptions, + ) -> ExportResults: + """Write exif metadata to file using exiftool + + Note: this method modifies src so src must be a copy of the original file; + it also does not write to dest (dest is the intended destination for purposes of + referencing the export database. This allows the exiftool update to be done on the + local machine prior to being copied to the export destination which may be on a + network drive or other slower external storage.""" + + verbose = options.verbose or self._verbose + exiftool_results = ExportResults() + + # don't try to write if unsupported file type for exiftool + if not exiftool_can_write(os.path.splitext(src)[-1]): + exiftool_results.exiftool_warning.append( + ( + dest, + f"Unsupported file type for exiftool, skipping exiftool for {dest}", + ) + ) + # set file signature so the file doesn't get re-exported with --update + return exiftool_results + + # determine if we need to write the exif metadata + # if we are not updating, we always write + # else, need to check the database to determine if we need to write + verbose( + f"Writing metadata with exiftool for {self._filepath(pathlib.Path(dest).name)}" + ) + if not options.dry_run: + warning_, error_ = self._write_exif_data(src, options=options) + if warning_: + exiftool_results.exiftool_warning.append((dest, warning_)) + if error_: + exiftool_results.exiftool_error.append((dest, error_)) + exiftool_results.error.append((dest, error_)) + + exiftool_results.exif_updated.append(dest) + exiftool_results.to_touch.append(dest) + return exiftool_results + + def _should_run_exiftool(self, dest, options: ExportOptions) -> bool: + """Return True if exiftool should be run to update metadata""" + run_exiftool = not options.update and not options.force_update + if options.update or options.force_update: + files_are_different = False + exif_record = options.export_db.get_file_record(dest) + old_data = exif_record.exifdata if exif_record else None + if old_data is not None: + old_data = json.loads(old_data)[0] + current_data = json.loads(self._exiftool_json_sidecar(options=options)) + current_data = current_data[0] + if old_data != current_data: + files_are_different = True + + if old_data is None or files_are_different: + # didn't have old data, assume we need to write it + # or files were different + run_exiftool = True + return run_exiftool + + def _write_exif_data(self, filepath: str, options: ExportOptions): + """write exif data to image file at filepath + + Args: + filepath: full path to the image file + + Returns: + (warning, error) of warning and error strings if exiftool produces warnings or errors + """ + if not os.path.exists(filepath): + raise FileNotFoundError(f"Could not find file {filepath}") + exif_info = self._exiftool_dict(options=options) + + with ExifTool( + filepath, + flags=options.exiftool_flags, + exiftool=self.photo._db._exiftool_path, + ) as exiftool: + for exiftag, val in exif_info.items(): + if type(val) == list: + for v in val: + exiftool.setvalue(exiftag, v) + else: + exiftool.setvalue(exiftag, val) + return exiftool.warning, exiftool.error + + def _exiftool_dict( + self, + options: t.Optional[ExportOptions] = None, + filename: t.Optional[str] = None, + ): + """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. + Does not include all the EXIF fields as those are likely already in the image. + + Args: + options (ExportOptions): options for export + filename (str): name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included + + Returns: dict with exiftool tags / values + + Exports the following: + EXIF:ImageDescription (may include template) + XMP:Description (may include template) + XMP:Title + IPTC:ObjectName + XMP:TagsList (may include album name, person name, or template) + IPTC:Keywords (may include album name, person name, or template) + IPTC:Caption-Abstract + XMP:Subject (set to keywords + persons) + XMP:PersonInImage + EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef + EXIF:GPSLatitude, EXIF:GPSLongitude + EXIF:GPSPosition + EXIF:DateTimeOriginal + EXIF:OffsetTimeOriginal + EXIF:ModifyDate + IPTC:DateCreated + IPTC:TimeCreated + QuickTime:CreationDate + QuickTime:CreateDate (UTC) + QuickTime:ModifyDate (UTC) + QuickTime:GPSCoordinates + UserData:GPSCoordinates + + Reference: + https://iptc.org/std/photometadata/specification/IPTC-PhotoMetadata-201610_1.pdf + """ + + options = options or ExportOptions() + + exif = ( + { + "SourceFile": filename, + "ExifTool:ExifToolVersion": "12.00", + "File:FileName": filename, + } + if filename is not None + else {} + ) + + if options.description_template is not None: + render_options = dataclasses.replace( + self._render_options, expand_inplace=True, inplace_sep=", " + ) + rendered = self.photo.render_template( + options.description_template, render_options + )[0] + description = " ".join(rendered) if rendered else "" + if options.strip: + description = description.strip() + exif["EXIF:ImageDescription"] = description + exif["XMP:Description"] = description + exif["IPTC:Caption-Abstract"] = description + elif self.photo.description: + exif["EXIF:ImageDescription"] = self.photo.description + exif["XMP:Description"] = self.photo.description + exif["IPTC:Caption-Abstract"] = self.photo.description + + if self.photo.title: + exif["XMP:Title"] = self.photo.title + exif["IPTC:ObjectName"] = self.photo.title + + keyword_list = [] + if options.merge_exif_keywords: + keyword_list.extend(self._get_exif_keywords()) + + if self.photo.keywords and not options.replace_keywords: + keyword_list.extend(self.photo.keywords) + + person_list = [] + if options.persons: + if options.merge_exif_persons: + person_list.extend(self._get_exif_persons()) + + if self.photo.persons: + # filter out _UNKNOWN_PERSON + person_list.extend( + [p for p in self.photo.persons if p != _UNKNOWN_PERSON] + ) + + if options.use_persons_as_keywords and person_list: + keyword_list.extend(person_list) + + if options.use_albums_as_keywords and self.photo.albums: + keyword_list.extend(self.photo.albums) + + if options.keyword_template: + rendered_keywords = [] + render_options = dataclasses.replace( + self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/" + ) + for template_str in options.keyword_template: + rendered, unmatched = self.photo.render_template( + template_str, render_options + ) + if unmatched: + logging.warning( + f"Unmatched template substitution for template: {template_str} {unmatched}" + ) + rendered_keywords.extend(rendered) + + if options.strip: + rendered_keywords = [keyword.strip() for keyword in rendered_keywords] + + # filter out any template values that didn't match by looking for sentinel + rendered_keywords = [ + keyword + for keyword in sorted(rendered_keywords) + if _OSXPHOTOS_NONE_SENTINEL not in keyword + ] + + # check to see if any keywords too long + long_keywords = [ + long_str + for long_str in rendered_keywords + if len(long_str) > _MAX_IPTC_KEYWORD_LEN + ] + if long_keywords: + self._verbose( + f"Warning: some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN} (exiftool will truncate these): {long_keywords}" + ) + + keyword_list.extend(rendered_keywords) + + if keyword_list: + # remove duplicates + keyword_list = sorted(list(set(str(keyword) for keyword in keyword_list))) + exif["IPTC:Keywords"] = keyword_list.copy() + exif["XMP:Subject"] = keyword_list.copy() + exif["XMP:TagsList"] = keyword_list.copy() + + if options.persons and person_list: + person_list = sorted(list(set(person_list))) + exif["XMP:PersonInImage"] = person_list.copy() + + if options.face_regions and self.photo.face_info: + exif.update(self._get_mwg_face_regions_exiftool()) + + # if self.favorite(): + # exif["Rating"] = 5 + + if options.location: + (lat, lon) = self.photo.location + if lat is not None and lon is not None: + if self.photo.isphoto: + exif["EXIF:GPSLatitude"] = lat + exif["EXIF:GPSLongitude"] = lon + lat_ref = "N" if lat >= 0 else "S" + lon_ref = "E" if lon >= 0 else "W" + exif["EXIF:GPSLatitudeRef"] = lat_ref + exif["EXIF:GPSLongitudeRef"] = lon_ref + elif self.photo.ismovie: + exif["Keys:GPSCoordinates"] = f"{lat} {lon}" + exif["UserData:GPSCoordinates"] = f"{lat} {lon}" + + # process date/time and timezone offset + # Photos exports the following fields and sets modify date to creation date + # [EXIF] Modify Date : 2020:10:30 00:00:00 + # [EXIF] Date/Time Original : 2020:10:30 00:00:00 + # [EXIF] Create Date : 2020:10:30 00:00:00 + # [IPTC] Digital Creation Date : 2020:10:30 + # [IPTC] Date Created : 2020:10:30 + # + # for videos: + # [QuickTime] CreateDate : 2020:12:11 06:10:10 + # [QuickTime] ModifyDate : 2020:12:11 06:10:10 + # [Keys] CreationDate : 2020:12:10 22:10:10-08:00 + # This code deviates from Photos in one regard: + # if photo has modification date, use it otherwise use creation date + + date = self.photo.date + 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]}" + + # exiftool expects format to "2015:01:18 12:00:00" + datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S") + + if self.photo.isphoto: + exif["EXIF:DateTimeOriginal"] = datetimeoriginal + exif["EXIF:CreateDate"] = datetimeoriginal + exif["EXIF:OffsetTimeOriginal"] = offsettime + + dateoriginal = date.strftime("%Y:%m:%d") + exif["IPTC:DateCreated"] = dateoriginal + + timeoriginal = date.strftime(f"%H:%M:%S{offsettime}") + exif["IPTC:TimeCreated"] = timeoriginal + + if ( + self.photo.date_modified is not None + and not options.ignore_date_modified + ): + exif["EXIF:ModifyDate"] = self.photo.date_modified.strftime( + "%Y:%m:%d %H:%M:%S" + ) + else: + exif["EXIF:ModifyDate"] = self.photo.date.strftime("%Y:%m:%d %H:%M:%S") + elif self.photo.ismovie: + # QuickTime spec specifies times in UTC + # QuickTime:CreateDate and ModifyDate are in UTC w/ no timezone + # QuickTime:CreationDate must include time offset or Photos shows invalid values + # reference: https://exiftool.org/TagNames/QuickTime.html#Keys + # https://exiftool.org/forum/index.php?topic=11927.msg64369#msg64369 + exif["QuickTime:CreationDate"] = f"{datetimeoriginal}{offsettime}" + + date_utc = datetime_tz_to_utc(date) + creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S") + exif["QuickTime:CreateDate"] = creationdate + if self.photo.date_modified is None or options.ignore_date_modified: + exif["QuickTime:ModifyDate"] = creationdate + else: + exif["QuickTime:ModifyDate"] = datetime_tz_to_utc( + self.photo.date_modified + ).strftime("%Y:%m:%d %H:%M:%S") + + return exif + + def _get_mwg_face_regions_exiftool(self): + """Return a dict with MWG face regions for use by exiftool""" + if self.photo.orientation in [5, 6, 7, 8]: + w = self.photo.height + h = self.photo.width + else: + w = self.photo.width + h = self.photo.height + exif = {} + exif["XMP:RegionAppliedToDimensionsW"] = w + exif["XMP:RegionAppliedToDimensionsH"] = h + exif["XMP:RegionAppliedToDimensionsUnit"] = "pixel" + exif["XMP:RegionName"] = [] + exif["XMP:RegionType"] = [] + exif["XMP:RegionAreaX"] = [] + exif["XMP:RegionAreaY"] = [] + exif["XMP:RegionAreaW"] = [] + exif["XMP:RegionAreaH"] = [] + exif["XMP:RegionAreaUnit"] = [] + exif["XMP:RegionPersonDisplayName"] = [] + # exif["XMP:RegionRectangle"] = [] + for face in self.photo.face_info: + if not face.name: + continue + area = face.mwg_rs_area + exif["XMP:RegionName"].append(face.name) + exif["XMP:RegionType"].append("Face") + exif["XMP:RegionAreaX"].append(area.x) + exif["XMP:RegionAreaY"].append(area.y) + exif["XMP:RegionAreaW"].append(area.w) + exif["XMP:RegionAreaH"].append(area.h) + exif["XMP:RegionAreaUnit"].append("normalized") + exif["XMP:RegionPersonDisplayName"].append(face.name) + # exif["XMP:RegionRectangle"].append(f"{area.x},{area.y},{area.h},{area.w}") + return exif + + def _get_exif_keywords(self): + """returns list of keywords found in the file's exif metadata""" + keywords = [] + exif = self.photo.exiftool + if exif: + exifdict = exif.asdict() + for field in ["IPTC:Keywords", "XMP:TagsList", "XMP:Subject"]: + try: + kw = exifdict[field] + if kw and type(kw) != list: + kw = [kw] + kw = [str(k) for k in kw] + keywords.extend(kw) + except KeyError: + pass + return keywords + + def _get_exif_persons(self): + """returns list of persons found in the file's exif metadata""" + persons = [] + exif = self.photo.exiftool + if exif: + exifdict = exif.asdict() + try: + p = exifdict["XMP:PersonInImage"] + if p and type(p) != list: + p = [p] + p = [str(p_) for p_ in p] + persons.extend(p) + except KeyError: + pass + return persons + + def _exiftool_json_sidecar( + self, + options: t.Optional[ExportOptions] = None, + tag_groups: bool = True, + filename: t.Optional[str] = None, + ): + """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. + Does not include all the EXIF fields as those are likely already in the image. + + Args: + options (ExportOptions): options for export + tag_groups (bool, default=True): if True, include tag groups in the output + filename (str): name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included + + Returns: dict with exiftool tags / values + + Exports the following: + EXIF:ImageDescription + XMP:Description (may include template) + IPTC:CaptionAbstract + XMP:Title + IPTC:ObjectName + XMP:TagsList + IPTC:Keywords (may include album name, person name, or template) + XMP:Subject (set to keywords + person) + XMP:PersonInImage + EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef + EXIF:GPSLatitude, EXIF:GPSLongitude + EXIF:GPSPosition + EXIF:DateTimeOriginal + EXIF:OffsetTimeOriginal + EXIF:ModifyDate + IPTC:DigitalCreationDate + IPTC:DateCreated + QuickTime:CreationDate + QuickTime:CreateDate (UTC) + QuickTime:ModifyDate (UTC) + QuickTime:GPSCoordinates + UserData:GPSCoordinates + """ + + options = options or ExportOptions() + exif = self._exiftool_dict(filename=filename, options=options) + + if not tag_groups: + # strip tag groups + exif_new = {} + for k, v in exif.items(): + k = re.sub(r".*:", "", k) + exif_new[k] = v + exif = exif_new + + return json.dumps([exif]) + + def _xmp_sidecar( + self, + options: t.Optional[ExportOptions] = None, + extension: t.Optional[str] = None, + ): + """returns string for XMP sidecar + + Args: + options (ExportOptions): options for export + extension (t.Optional[str]): which extension to use for SidecarForExtension property + """ + + options = options or ExportOptions() + + xmp_template_file = ( + _XMP_TEMPLATE_NAME if not self.photo._db._beta else _XMP_TEMPLATE_NAME_BETA + ) + xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, xmp_template_file)) + + if extension is None: + extension = pathlib.Path(self.photo.original_filename) + extension = extension.suffix[1:] if extension.suffix else None + + if options.description_template is not None: + render_options = dataclasses.replace( + self._render_options, expand_inplace=True, inplace_sep=", " + ) + rendered = self.photo.render_template( + options.description_template, render_options + )[0] + description = " ".join(rendered) if rendered else "" + if options.strip: + description = description.strip() + else: + description = ( + self.photo.description if self.photo.description is not None else "" + ) + + keyword_list = [] + if options.merge_exif_keywords: + keyword_list.extend(self._get_exif_keywords()) + + if self.photo.keywords and not options.replace_keywords: + keyword_list.extend(self.photo.keywords) + + # TODO: keyword handling in this and _exiftool_json_sidecar is + # good candidate for pulling out in a function + + person_list = [] + if options.persons: + if options.merge_exif_persons: + person_list.extend(self._get_exif_persons()) + + if self.photo.persons: + # filter out _UNKNOWN_PERSON + person_list.extend( + [p for p in self.photo.persons if p != _UNKNOWN_PERSON] + ) + + if options.use_persons_as_keywords and person_list: + keyword_list.extend(person_list) + + if options.use_albums_as_keywords and self.photo.albums: + keyword_list.extend(self.photo.albums) + + if options.keyword_template: + rendered_keywords = [] + render_options = dataclasses.replace( + self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/" + ) + for template_str in options.keyword_template: + rendered, unmatched = self.photo.render_template( + template_str, render_options + ) + if unmatched: + logging.warning( + f"Unmatched template substitution for template: {template_str} {unmatched}" + ) + rendered_keywords.extend(rendered) + + if options.strip: + rendered_keywords = [keyword.strip() for keyword in rendered_keywords] + + # filter out any template values that didn't match by looking for sentinel + rendered_keywords = [ + keyword + for keyword in rendered_keywords + if _OSXPHOTOS_NONE_SENTINEL not in keyword + ] + + keyword_list.extend(rendered_keywords) + + # remove duplicates + # sorted mainly to make testing the XMP file easier + if keyword_list: + keyword_list = sorted(list(set(keyword_list))) + if options.persons and person_list: + person_list = sorted(list(set(person_list))) + + subject_list = keyword_list + + latlon = self.photo.location if options.location else (None, None) + + xmp_str = xmp_template.render( + photo=self.photo, + description=description, + keywords=keyword_list, + persons=person_list, + subjects=subject_list, + extension=extension, + location=latlon, + version=__version__, + ) + + # remove extra lines that mako inserts from template + xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "") + return xmp_str + + def _write_sidecar(self, filename, sidecar_str): + """write sidecar_str to filename + used for exporting sidecar info""" + if not (filename or sidecar_str): + raise ( + ValueError( + f"filename {filename} and sidecar_str {sidecar_str} must not be None" + ) + ) + + # TODO: catch exception? + f = open(filename, "w") + f.write(sidecar_str) + f.close()
+ + +def hexdigest(strval): + """hexdigest of a string, using blake2b""" + h = hashlib.blake2b(digest_size=20) + h.update(bytes(strval, "utf-8")) + return h.hexdigest() + + +def _check_export_suffix(src, dest, edited): + """Helper function for exporting photos to check file extensions of destination path. + + Checks that dst file extension is appropriate for the src. + If edited=True, will use src file extension of ".jpeg" if None provided for src. + + Args: + src: path to source file or None. + dest: path to destination file. + edited: set to True if exporting an edited photo. + + Returns: + True if src and dest extensions are OK, else False. + + Raises: + ValueError if edited is False and src is None + """ + + # check extension of destination + if src is not None: + # use suffix from edited file + actual_suffix = pathlib.Path(src).suffix + elif edited: + # use .jpeg as that's probably correct + actual_suffix = ".jpeg" + else: + raise ValueError("src must not be None if edited=False") + + # Photo's often converts .JPG to .jpeg or .tif to .tiff on import + dest_ext = dest.suffix.lower() + actual_ext = actual_suffix.lower() + suffixes = sorted([dest_ext, actual_ext]) + return ( + dest_ext == actual_ext + or suffixes == [".jpeg", ".jpg"] + or suffixes == [".tif", ".tiff"] + ) + + +def rename_jpeg_files(files, jpeg_ext, fileutil): + """rename any jpeg files in files so that extension matches jpeg_ext + + Args: + files: list of file paths + jpeg_ext: extension to use for jpeg files found in files, e.g. "jpg" + fileutil: a FileUtil object + + Returns: + list of files with updated names + + Note: If non-jpeg files found, they will be ignore and returned in the return list + """ + jpeg_ext = "." + jpeg_ext + jpegs = [".jpeg", ".jpg"] + new_files = [] + for file in files: + path = pathlib.Path(file) + if path.suffix.lower() in jpegs and path.suffix != jpeg_ext: + new_file = path.parent / (path.stem + jpeg_ext) + fileutil.rename(file, new_file) + new_files.append(new_file) + else: + new_files.append(file) + return new_files +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+
+ +
+
+
+ +
+
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/photoinfo.html b/docs/_modules/osxphotos/photoinfo.html index db5dd037..798c582e 100644 --- a/docs/_modules/osxphotos/photoinfo.html +++ b/docs/_modules/osxphotos/photoinfo.html @@ -1,41 +1,203 @@ + + + + + - + + osxphotos.photoinfo - osxphotos 0.47.9 documentation + + + + + + - - - - - osxphotos.photoinfo — osxphotos 0.47.5 documentation - - - - - - - - - - + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+ +
+ +
+
+

Source code for osxphotos.photoinfo

+"""
 PhotoInfo class
-Represents a single photo in the Photos library and provides access to the photo's attributes
+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 dataclasses
 import datetime
 import json
@@ -89,14 +251,14 @@
 from .uti import get_preferred_uti_extension, get_uti_for_extension
 from .utils import _get_resource_loc, list_directory
 
-__all__ = ["PhotoInfo", "PhotoInfoNone"]
+__all__ = ["PhotoInfo", "PhotoInfoNone"]
 
 
 
[docs]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 @@ -106,41 +268,41 @@ @property def filename(self): - """filename of the picture""" + """filename of the picture""" if ( self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original ): - # return the JPEG version as that's what Photos 5+ does - return self._info["raw_pair_info"]["filename"] + # return the JPEG version as that's what Photos 5+ does + return self._info["raw_pair_info"]["filename"] else: - return self._info["filename"] + return self._info["filename"] @property def original_filename(self): - """original filename of the picture - Photos 5 mangles filenames upon import""" + """original filename of the picture + Photos 5 mangles filenames upon import""" if ( self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original ): - # return the JPEG version as that's what Photos 5+ does - original_name = self._info["raw_pair_info"]["originalFilename"] + # return the JPEG version as that's what Photos 5+ does + original_name = self._info["raw_pair_info"]["originalFilename"] else: - original_name = self._info["originalFilename"] + original_name = self._info["originalFilename"] return original_name or self.filename @property def date(self): - """image creation date as timezone aware datetime object""" - return self._info["imageDate"] + """image creation date as timezone aware datetime object""" + return self._info["imageDate"] @property def date_modified(self): - """image modification date as timezone aware datetime object - or None if no modification date set""" + """image modification date as timezone aware datetime object + or None if no modification date set""" # Photos <= 4 provides no way to get date of adjustment and will update # lastmodifieddate anytime photo database record is updated (e.g. adding tags) @@ -149,9 +311,9 @@ if not self.hasadjustments and self._db._db_version <= _PHOTOS_4_VERSION: return None - imagedate = self._info["lastmodifieddate"] + imagedate = self._info["lastmodifieddate"] if imagedate: - seconds = self._info["imageTimeZoneOffsetSeconds"] or 0 + seconds = self._info["imageTimeZoneOffsetSeconds"] or 0 delta = timedelta(seconds=seconds) tz = timezone(delta) return imagedate.astimezone(tz=tz) @@ -160,45 +322,45 @@ @property def tzoffset(self): - """timezone offset from UTC in seconds""" - return self._info["imageTimeZoneOffsetSeconds"] + """timezone offset from UTC in seconds""" + return self._info["imageTimeZoneOffsetSeconds"] @property def path(self): - """absolute path on disk of the original picture""" + """absolute path on disk of the original picture""" try: return self._path except AttributeError: self._path = None photopath = None - if self._info["isMissing"] == 1: + if self._info["isMissing"] == 1: return photopath # path would be meaningless until downloaded if self._db._db_version <= _PHOTOS_4_VERSION: return self._path_4() - if self._info["shared"]: + if self._info["shared"]: # shared photo photopath = os.path.join( self._db._library_path, _PHOTOS_5_SHARED_PHOTO_PATH, - self._info["directory"], - self._info["filename"], + self._info["directory"], + self._info["filename"], ) if not os.path.isfile(photopath): photopath = None self._path = photopath return photopath - if self._info["directory"].startswith("/"): + if self._info["directory"].startswith("/"): photopath = os.path.join( - self._info["directory"], self._info["filename"] + self._info["directory"], self._info["filename"] ) else: photopath = os.path.join( self._db._masters_path, - self._info["directory"], - self._info["filename"], + self._info["directory"], + self._info["filename"], ) if not os.path.isfile(photopath): photopath = None @@ -206,30 +368,30 @@ return photopath def _path_4(self): - """return path for photo on Photos <= version 4""" - if self._info["has_raw"]: + """return path for photo on Photos <= version 4""" + if self._info["has_raw"]: # return the path to JPEG even if RAW is original vol = ( - self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]] - if self._info["raw_pair_info"]["volumeId"] is not None + self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]] + if self._info["raw_pair_info"]["volumeId"] is not None else None ) if vol is not None: photopath = os.path.join( - "/Volumes", vol, self._info["raw_pair_info"]["imagePath"] + "/Volumes", vol, self._info["raw_pair_info"]["imagePath"] ) else: photopath = os.path.join( self._db._masters_path, - self._info["raw_pair_info"]["imagePath"], + self._info["raw_pair_info"]["imagePath"], ) else: - vol = self._info["volume"] + vol = self._info["volume"] if vol is not None: - photopath = os.path.join("/Volumes", vol, self._info["imagePath"]) + photopath = os.path.join("/Volumes", vol, self._info["imagePath"]) else: photopath = os.path.join( - self._db._masters_path, self._info["imagePath"] + self._db._masters_path, self._info["imagePath"] ) if not os.path.isfile(photopath): photopath = None @@ -238,8 +400,8 @@ @property def path_edited(self): - """absolute path on disk of the edited picture""" - """ None if photo has not been edited """ + """absolute path on disk of the edited picture""" + """ None if photo has not been edited """ try: return self._path_edited @@ -252,7 +414,7 @@ return self._path_edited def _path_edited_5(self): - """return path_edited for Photos >= 5""" + """return path_edited for Photos >= 5""" # 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 @@ -262,85 +424,85 @@ # where original format was not jpg/jpeg # if more than one edit, previous edit is stored as UUID_p.jpeg # - # In Photos 6.0 / Big Sur, the edited image is a .heic if the photo isn't a jpeg, - # otherwise it's a jpeg. It could also be a jpeg if photo library upgraded from earlier + # In Photos 6.0 / Big Sur, the edited image is a .heic if the photo isn't a jpeg, + # otherwise it's a jpeg. It could also be a jpeg if photo library upgraded from earlier # version. if self._db._db_version < _PHOTOS_5_VERSION: - raise RuntimeError("Wrong database format!") + raise RuntimeError("Wrong database format!") - if self._info["hasAdjustments"]: + if self._info["hasAdjustments"]: library = self._db._library_path directory = self._uuid[0] # first char of uuid filename = None - if self._info["type"] == _PHOTO_TYPE: - # it's a photo - if self._db._photos_ver != 5 and self.uti == "public.heic": - filename = f"{self._uuid}_1_201_a.heic" + if self._info["type"] == _PHOTO_TYPE: + # it's a photo + if self._db._photos_ver != 5 and self.uti == "public.heic": + filename = f"{self._uuid}_1_201_a.heic" else: - filename = f"{self._uuid}_1_201_a.jpeg" - elif self._info["type"] == _MOVIE_TYPE: - # it's a movie - filename = f"{self._uuid}_2_0_a.mov" + filename = f"{self._uuid}_1_201_a.jpeg" + elif self._info["type"] == _MOVIE_TYPE: + # it's a movie + filename = f"{self._uuid}_2_0_a.mov" else: - # don't know what it is! - logging.debug(f"WARNING: unknown type {self._info['type']}") + # don't know what it is! + logging.debug(f"WARNING: unknown type {self._info['type']}") return None photopath = os.path.join( - library, "resources", "renders", directory, filename + library, "resources", "renders", directory, filename ) if not os.path.isfile(photopath): logging.debug( - f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" + 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: + # if self._info["isMissing"] == 1: # photopath = None # path would be meaningless until downloaded return photopath def _path_edited_4(self): - """return path_edited for Photos <= 4""" + """return path_edited for Photos <= 4""" if self._db._db_version > _PHOTOS_4_VERSION: - raise RuntimeError("Wrong database format!") + raise RuntimeError("Wrong database format!") photopath = None - if self._info["hasAdjustments"]: - edit_id = self._info["edit_resource_id"] + 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 # figure out what kind it is and build filename filename = None - if self._info["type"] == _PHOTO_TYPE: - # it's a photo - filename = f"fullsizeoutput_{file_id}.jpeg" - elif self._info["type"] == _MOVIE_TYPE: - # it's a movie - filename = f"fullsizeoutput_{file_id}.mov" + if self._info["type"] == _PHOTO_TYPE: + # it's a photo + filename = f"fullsizeoutput_{file_id}.jpeg" + elif self._info["type"] == _MOVIE_TYPE: + # it's a movie + filename = f"fullsizeoutput_{file_id}.mov" else: - # don't know what it is! - logging.debug(f"WARNING: unknown type {self._info['type']}") + # don't know what it is! + logging.debug(f"WARNING: unknown type {self._info['type']}") return None - # photopath appears to usually be in "00" subfolder but - # could be elsewhere--I haven't figured out this logic yet - # first see if it's in 00 + # photopath appears to usually be in "00" subfolder but + # could be elsewhere--I haven't figured out this logic yet + # first see if it's in 00 photopath = os.path.join( - library, "resources", "media", "version", folder_id, "00", filename + library, "resources", "media", "version", folder_id, "00", filename ) if not os.path.isfile(photopath): rootdir = os.path.join( - library, "resources", "media", "version", folder_id + library, "resources", "media", "version", folder_id ) for dirname, _, filelist in os.walk(rootdir): @@ -351,12 +513,12 @@ # check again to see if we found a valid file if not os.path.isfile(photopath): logging.debug( - f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" + f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" ) photopath = None else: logging.debug( - f"{self.uuid} hasAdjustments but edit_resource_id is None" + f"{self.uuid} hasAdjustments but edit_resource_id is None" ) photopath = None else: @@ -366,7 +528,7 @@ @property def path_edited_live_photo(self): - """return path to edited version of live photo movie; only valid for Photos 5+""" + """return path to edited version of live photo movie; only valid for Photos 5+""" if self._db._db_version < _PHOTOS_5_VERSION: return None @@ -377,16 +539,16 @@ return self._path_edited_live_photo def _path_edited_5_live_photo(self): - """return path_edited_live_photo for Photos >= 5""" + """return path_edited_live_photo for Photos >= 5""" if self._db._db_version < _PHOTOS_5_VERSION: - raise RuntimeError("Wrong database format!") + raise RuntimeError("Wrong database format!") - if self.live_photo and self._info["hasAdjustments"]: + if self.live_photo and self._info["hasAdjustments"]: library = self._db._library_path directory = self._uuid[0] # first char of uuid - filename = f"{self._uuid}_2_100_a.mov" + filename = f"{self._uuid}_2_100_a.mov" photopath = os.path.join( - library, "resources", "renders", directory, filename + library, "resources", "renders", directory, filename ) if not os.path.isfile(photopath): photopath = None @@ -397,29 +559,29 @@ @property def path_raw(self): - """absolute path of associated RAW image or None if there is not one""" + """absolute path of associated RAW image or None if there is not one""" # In Photos 5, raw is in same folder as original but with _4.ext - # Unless "Copy Items to the Photos Library" is not checked + # Unless "Copy Items to the Photos Library" is not checked # then RAW image is not renamed but has same name is jpeg buth with raw extension # Current implementation finds images with the correct raw UTI extension # in same folder as the original and with same stem as original in form: original_stem*.raw_ext - # TODO: I don't like this -- would prefer a more deterministic approach but until I have more + # TODO: I don't like this -- would prefer a more deterministic approach but until I have more # data on how Photos stores and retrieves RAW images, this seems to be working - if self._info["isMissing"] == 1: + if self._info["isMissing"] == 1: return None # path would be meaningless until downloaded if not self.has_raw: return None # no raw image to get path for - # if self._info["shared"]: + # if self._info["shared"]: # # shared photo # photopath = os.path.join( # self._db._library_path, # _PHOTOS_5_SHARED_PHOTO_PATH, - # self._info["directory"], - # self._info["filename"], + # self._info["directory"], + # self._info["filename"], # ) # return photopath @@ -427,18 +589,18 @@ return self._path_raw_4() if not self.isreference: - filestem = pathlib.Path(self._info["filename"]).stem - # raw_ext = get_preferred_uti_extension(self._info["UTI_raw"]) + filestem = pathlib.Path(self._info["filename"]).stem + # raw_ext = get_preferred_uti_extension(self._info["UTI_raw"]) - if self._info["directory"].startswith("/"): - filepath = self._info["directory"] + if self._info["directory"].startswith("/"): + filepath = self._info["directory"] else: - filepath = os.path.join(self._db._masters_path, self._info["directory"]) + filepath = os.path.join(self._db._masters_path, self._info["directory"]) # raw files have same name as original but with _4.raw_ext appended # I believe the _4 maps to PHAssetResourceTypeAlternatePhoto = 4 # see: https://developer.apple.com/documentation/photokit/phassetresourcetype/phassetresourcetypealternatephoto?language=objc - raw_file = list_directory(filepath, startswith=f"{filestem}_4") + raw_file = list_directory(filepath, startswith=f"{filestem}_4") if not raw_file: photopath = None else: @@ -448,58 +610,58 @@ # is a reference try: photopath = ( - pathlib.Path("/Volumes") - / self._info["raw_volume"] - / self._info["raw_relative_path"] + pathlib.Path("/Volumes") + / self._info["raw_volume"] + / self._info["raw_relative_path"] ) photopath = str(photopath) if photopath.is_file() else None except KeyError: - # don't have the path details + # don't have the path details photopath = None return photopath def _path_raw_4(self): - """Return path_raw for Photos <= version 4""" - vol = self._info["raw_info"]["volume"] + """Return path_raw for Photos <= version 4""" + vol = self._info["raw_info"]["volume"] if vol is not None: photopath = os.path.join( - "/Volumes", vol, self._info["raw_info"]["imagePath"] + "/Volumes", vol, self._info["raw_info"]["imagePath"] ) else: photopath = os.path.join( - self._db._masters_path, self._info["raw_info"]["imagePath"] + self._db._masters_path, self._info["raw_info"]["imagePath"] ) if not os.path.isfile(photopath): logging.debug( - f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist" + f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist" ) photopath = None @property def description(self): - """long / extended description of picture""" - return self._info["extendedDescription"] + """long / extended description of picture""" + return self._info["extendedDescription"] @property def persons(self): - """list of persons in picture""" - return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]] + """list of persons in picture""" + return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]] @property def person_info(self): - """list of PersonInfo objects for person in picture""" + """list of PersonInfo objects for person in picture""" try: return self._personinfo except AttributeError: self._personinfo = [ - PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"] + PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"] ] return self._personinfo @property def face_info(self): - """list of FaceInfo objects for faces in picture""" + """list of FaceInfo objects for faces in picture""" try: return self._faceinfo except AttributeError: @@ -513,31 +675,31 @@ @property def moment(self): - """Moment photo belongs to""" + """Moment photo belongs to""" try: return self._moment except AttributeError: try: - self._moment = MomentInfo(db=self._db, moment_pk=self._info["momentID"]) + self._moment = MomentInfo(db=self._db, moment_pk=self._info["momentID"]) except ValueError: self._moment = None return self._moment @property def albums(self): - """list of albums picture is contained in""" + """list of albums picture is contained in""" try: return self._albums except AttributeError: album_uuids = self._get_album_uuids() self._albums = list( - {self._db._dbalbum_details[album]["title"] for album in album_uuids} + {self._db._dbalbum_details[album]["title"] for album in album_uuids} ) return self._albums @property def burst_albums(self): - """If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums""" + """If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums""" try: return self._burst_albums except AttributeError: @@ -550,7 +712,7 @@ @property def album_info(self): - """list of AlbumInfo objects representing albums the photo is contained in""" + """list of AlbumInfo objects representing albums the photo is contained in""" try: return self._album_info except AttributeError: @@ -562,7 +724,7 @@ @property def burst_album_info(self): - """If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info.""" + """If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info.""" try: return self._burst_album_info except AttributeError: @@ -575,20 +737,20 @@ @property def import_info(self): - """ImportInfo object representing import session for the photo or None if no import session""" + """ImportInfo object representing import session for the photo or None if no import session""" try: return self._import_info except AttributeError: self._import_info = ( - ImportInfo(db=self._db, uuid=self._info["import_uuid"]) - if self._info["import_uuid"] is not None + ImportInfo(db=self._db, uuid=self._info["import_uuid"]) + if self._info["import_uuid"] is not None else None ) return self._import_info @property def project_info(self): - """list of AlbumInfo objects representing projects for the photo or None if no projects""" + """list of AlbumInfo objects representing projects for the photo or None if no projects""" try: return self._project_info except AttributeError: @@ -600,46 +762,46 @@ @property def keywords(self): - """list of keywords for picture""" - return self._info["keywords"] + """list of keywords for picture""" + return self._info["keywords"] @property def title(self): - """name / title of picture""" + """name / title of picture""" # if user sets then deletes title, Photos sets it to empty string in DB instead of NULL # in this case, return None so result is the same as if title had never been set (which returns NULL) # issue #512 - title = self._info["name"] - title = None if title == "" else title + title = self._info["name"] + title = None if title == "" else title return title @property def uuid(self): - """UUID of picture""" + """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) + """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 + 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 self._info["isMissing"] == 1 + """ + return self._info["isMissing"] == 1 @property def hasadjustments(self): - """True if picture has adjustments / edits""" - return self._info["hasAdjustments"] == 1 + """True if picture has adjustments / edits""" + return self._info["hasAdjustments"] == 1 @property def adjustments(self): - """Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only""" + """Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only""" if self._db._db_version <= _PHOTOS_4_VERSION: return None @@ -651,10 +813,10 @@ directory = self._uuid[0] # first char of uuid plist_file = ( pathlib.Path(library) - / "resources" - / "renders" + / "resources" + / "renders" / directory - / f"{self._uuid}.plist" + / f"{self._uuid}.plist" ) if not plist_file.is_file(): return None @@ -663,37 +825,37 @@ @property def external_edit(self): - """Returns True if picture was edited outside of Photos using external editor""" - return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit" + """Returns True if picture was edited outside of Photos using external editor""" + return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit" @property def favorite(self): - """True if picture is marked as favorite""" - return self._info["favorite"] == 1 + """True if picture is marked as favorite""" + return self._info["favorite"] == 1 @property def hidden(self): - """True if picture is hidden""" - return self._info["hidden"] == 1 + """True if picture is hidden""" + return self._info["hidden"] == 1 @property def visible(self): - """True if picture is visble""" - return self._info["visible"] + """True if picture is visble""" + return self._info["visible"] @property def intrash(self): - """True if picture is in trash ('Recently Deleted' folder)""" - return self._info["intrash"] + """True if picture is in trash ('Recently Deleted' folder)""" + return self._info["intrash"] @property def date_trashed(self): - """Date asset was placed in the trash or None""" + """Date asset was placed in the trash or None""" # TODO: add add_timezone(dt, offset_seconds) to datetime_utils # also update date_modified - trasheddate = self._info["trasheddate"] + trasheddate = self._info["trasheddate"] if trasheddate: - seconds = self._info["imageTimeZoneOffsetSeconds"] or 0 + seconds = self._info["imageTimeZoneOffsetSeconds"] or 0 delta = timedelta(seconds=seconds) tz = timezone(delta) return trasheddate.astimezone(tz=tz) @@ -702,13 +864,13 @@ @property def date_added(self): - """Date photo was added to the database""" + """Date photo was added to the database""" try: return self._date_added except AttributeError: - added_date = self._info["added_date"] + added_date = self._info["added_date"] if added_date: - seconds = self._info["imageTimeZoneOffsetSeconds"] or 0 + seconds = self._info["imageTimeZoneOffsetSeconds"] or 0 delta = timedelta(seconds=seconds) tz = timezone(delta) self._date_added = added_date.astimezone(tz=tz) @@ -719,79 +881,79 @@ @property def location(self): - """returns (latitude, longitude) as float in degrees or None""" + """returns (latitude, longitude) as float in degrees or None""" return (self._latitude, self._longitude) @property def shared(self): - """returns True if photos is in a shared iCloud album otherwise false - Only valid on Photos 5; returns None on older versions""" + """returns True if photos is in a shared iCloud album otherwise false + Only valid on Photos 5; returns None on older versions""" if self._db._db_version > _PHOTOS_4_VERSION: - return self._info["shared"] + return self._info["shared"] else: return None @property def uti(self): - """Returns Uniform Type Identifier (UTI) for the image + """Returns Uniform Type Identifier (UTI) for the image for example: public.jpeg or com.apple.quicktime-movie - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION and self.hasadjustments: - return self._info["UTI_edited"] + return self._info["UTI_edited"] elif ( self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original ): # return UTI of the non-raw image to match Photos 5+ behavior - return self._info["raw_pair_info"]["UTI"] + return self._info["raw_pair_info"]["UTI"] else: - return self._info["UTI"] + return self._info["UTI"] @property def uti_original(self): - """Returns Uniform Type Identifier (UTI) for the original image + """Returns Uniform Type Identifier (UTI) for the original image for example: public.jpeg or com.apple.quicktime-movie - """ + """ try: return self._uti_original except AttributeError: - if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]: - self._uti_original = self._info["raw_pair_info"]["UTI"] + if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]: + self._uti_original = self._info["raw_pair_info"]["UTI"] elif self.shared: # TODO: need reliable way to get original UTI for shared self._uti_original = self.uti elif self._db._photos_ver >= 7: # Monterey+ - # there are some cases with UTI_original is None (photo imported with no extension) so fallback to UTI and hope it's right + # there are some cases with UTI_original is None (photo imported with no extension) so fallback to UTI and hope it's right self._uti_original = ( get_uti_for_extension(pathlib.Path(self.original_filename).suffix) or self.uti ) else: - self._uti_original = self._info["UTI_original"] + self._uti_original = self._info["UTI_original"] return self._uti_original @property def uti_edited(self): - """Returns Uniform Type Identifier (UTI) for the edited image + """Returns Uniform Type Identifier (UTI) for the edited image if the photo has been edited, otherwise None; for example: public.jpeg - """ + """ if self._db._db_version >= _PHOTOS_5_VERSION: return self.uti if self.hasadjustments else None else: - return self._info["UTI_edited"] + return self._info["UTI_edited"] @property def uti_raw(self): - """Returns Uniform Type Identifier (UTI) for the RAW image if there is one + """Returns Uniform Type Identifier (UTI) for the RAW image if there is one for example: com.canon.cr2-raw-image Returns None if no associated RAW image - """ + """ if self._db._photos_ver < 7: - return self._info["UTI_raw"] + return self._info["UTI_raw"] rawpath = self.path_raw if rawpath: @@ -801,69 +963,69 @@ @property def ismovie(self): - """Returns True if file is a movie, otherwise False""" - return self._info["type"] == _MOVIE_TYPE + """Returns True if file is a movie, otherwise False""" + return self._info["type"] == _MOVIE_TYPE @property def isphoto(self): - """Returns True if file is an image, otherwise False""" - return self._info["type"] == _PHOTO_TYPE + """Returns True if file is an image, otherwise False""" + return self._info["type"] == _PHOTO_TYPE @property def incloud(self): - """Returns True if photo is cloud asset and is synched to cloud + """Returns True if photo is cloud asset and is synched to cloud False if photo is cloud asset and not yet synched to cloud None if photo is not cloud asset - """ - return self._info["incloud"] + """ + return self._info["incloud"] @property def iscloudasset(self): - """Returns True if photo is a cloud asset (in an iCloud library), + """Returns True if photo is a cloud asset (in an iCloud library), otherwise False - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: return ( True - if self._info["cloudLibraryState"] is not None - and self._info["cloudLibraryState"] != 0 + if self._info["cloudLibraryState"] is not None + and self._info["cloudLibraryState"] != 0 else False ) else: - return True if self._info["cloudAssetGUID"] is not None else False + return True if self._info["cloudAssetGUID"] is not None else False @property def isreference(self): - """Returns True if photo is a reference (not copied to the Photos library), otherwise False""" - return self._info["isreference"] + """Returns True if photo is a reference (not copied to the Photos library), otherwise False""" + return self._info["isreference"] @property def burst(self): - """Returns True if photo is part of a Burst photo set, otherwise False""" - return self._info["burst"] + """Returns True if photo is part of a Burst photo set, otherwise False""" + return self._info["burst"] @property def burst_selected(self): - """Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False""" - return bool(self._info["burstPickType"] & BURST_SELECTED) + """Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False""" + return bool(self._info["burstPickType"] & BURST_SELECTED) @property def burst_key(self): - """Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False""" - return bool(self._info["burstPickType"] & BURST_KEY) + """Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False""" + return bool(self._info["burstPickType"] & BURST_KEY) @property def burst_default_pick(self): - """Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False""" - return bool(self._info["burstPickType"] & BURST_DEFAULT_PICK) + """Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False""" + return bool(self._info["burstPickType"] & BURST_DEFAULT_PICK) @property def burst_photos(self): - """If photo is a burst photo, returns list of PhotoInfo objects + """If photo is a burst photo, returns list of PhotoInfo objects that are part of the same burst photo set; otherwise returns empty list. - self is not included in the returned list""" - if self._info["burst"]: - burst_uuid = self._info["burstUUID"] + self is not included in the returned list""" + if self._info["burst"]: + burst_uuid = self._info["burstUUID"] return [ PhotoInfo(db=self._db, uuid=u, info=self._db._dbphotos[u]) for u in self._db._dbphotos_burst[burst_uuid] @@ -874,49 +1036,49 @@ @property def live_photo(self): - """Returns True if photo is a live photo, otherwise False""" - return self._info["live_photo"] + """Returns True if photo is a live photo, otherwise False""" + return self._info["live_photo"] @property def path_live_photo(self): - """Returns path to the associated video file for a live photo + """Returns path to the associated video file for a live photo If photo is not a live photo, returns None - If photo is missing, returns None""" + If photo is missing, returns None""" photopath = None if self._db._db_version <= _PHOTOS_4_VERSION: if self.live_photo and not self.ismissing: - live_model_id = self._info["live_model_id"] + live_model_id = self._info["live_model_id"] if live_model_id is None: - logging.debug(f"missing live_model_id: {self._uuid}") + logging.debug(f"missing live_model_id: {self._uuid}") photopath = None else: folder_id, file_id = _get_resource_loc(live_model_id) library_path = self._db.library_path photopath = os.path.join( library_path, - "resources", - "media", - "master", + "resources", + "media", + "master", folder_id, - "00", - f"jpegvideocomplement_{file_id}.mov", + "00", + f"jpegvideocomplement_{file_id}.mov", ) if not os.path.isfile(photopath): - # In testing, I've seen occasional missing movie for live photo - # These appear to be valid -- e.g. live component hasn't been downloaded from iCloud - # photos 4 has "isOnDisk" column we could check - # or could do the actual check with "isfile" + # In testing, I've seen occasional missing movie for live photo + # These appear to be valid -- e.g. live component hasn't been downloaded from iCloud + # photos 4 has "isOnDisk" column we could check + # or could do the actual check with "isfile" # TODO: should this be a warning or debug? photopath = None else: photopath = None elif self.live_photo and self.path and not self.ismissing: filename = pathlib.Path(self.path) - photopath = filename.parent.joinpath(f"{filename.stem}_3.mov") + photopath = filename.parent.joinpath(f"{filename.stem}_3.mov") photopath = str(photopath) if not os.path.isfile(photopath): - # In testing, I've seen occasional missing movie for live photo + # In testing, I've seen occasional missing movie for live photo # these appear to be valid -- e.g. video component not yet downloaded from iCloud # TODO: should this be a warning or debug? photopath = None @@ -927,7 +1089,7 @@ @property def path_derivatives(self): - """Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)""" + """Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)""" try: return self._path_derivatives except AttributeError: @@ -938,20 +1100,20 @@ directory = self._uuid[0] # first char of uuid derivative_path = ( pathlib.Path(self._db._library_path) - / "resources" - / "derivatives" + / "resources" + / "derivatives" / directory ) - files = derivative_path.glob(f"{self.uuid}*.*") + files = derivative_path.glob(f"{self.uuid}*.*") files = sorted(files, reverse=True, key=lambda f: f.stat().st_size) # return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension) derivatives = [ - str(filename) for filename in files if filename.suffix != ".THM" + str(filename) for filename in files if filename.suffix != ".THM" ] if ( self.isphoto and len(derivatives) > 1 - and derivatives[0].endswith(".mov") + and derivatives[0].endswith(".mov") ): derivatives[1], derivatives[0] = derivatives[0], derivatives[1] @@ -959,90 +1121,90 @@ return self._path_derivatives def _path_derivatives_4(self): - """Return paths to all derivative (preview) files for Photos <= 4""" - modelid = self._info["modelID"] + """Return paths to all derivative (preview) files for Photos <= 4""" + modelid = self._info["modelID"] if modelid is None: return [] folder_id, file_id = _get_resource_loc(modelid) derivatives_root = ( pathlib.Path(self._db._library_path) - / "resources" - / "proxies" - / "derivatives" + / "resources" + / "proxies" + / "derivatives" / folder_id ) - # photos appears to usually be in "00" subfolder but - # could be elsewhere--I haven't figured out this logic yet - # first see if it's in 00 + # photos appears to usually be in "00" subfolder but + # could be elsewhere--I haven't figured out this logic yet + # first see if it's in 00 - derivatives_path = derivatives_root / "00" / file_id + derivatives_path = derivatives_root / "00" / file_id if derivatives_path.is_dir(): - files = derivatives_path.glob("*") + files = derivatives_path.glob("*") files = sorted(files, reverse=True, key=lambda f: f.stat().st_size) return [str(filename) for filename in files] - # didn't find derivatives path - for subdir in derivatives_root.glob("*"): + # didn't find derivatives path + for subdir in derivatives_root.glob("*"): if subdir.is_dir(): derivatives_path = derivatives_root / subdir / file_id if derivatives_path.is_dir(): - files = derivatives_path.glob("*") + files = derivatives_path.glob("*") files = sorted(files, reverse=True, key=lambda f: f.stat().st_size) return [str(filename) for filename in files] - # didn't find a derivatives path + # didn't find a derivatives path return [] @property def panorama(self): - """Returns True if photo is a panorama, otherwise False""" - return self._info["panorama"] + """Returns True if photo is a panorama, otherwise False""" + return self._info["panorama"] @property def slow_mo(self): - """Returns True if photo is a slow motion video, otherwise False""" - return self._info["slow_mo"] + """Returns True if photo is a slow motion video, otherwise False""" + return self._info["slow_mo"] @property def time_lapse(self): - """Returns True if photo is a time lapse video, otherwise False""" - return self._info["time_lapse"] + """Returns True if photo is a time lapse video, otherwise False""" + return self._info["time_lapse"] @property def hdr(self): - """Returns True if photo is an HDR photo, otherwise False""" - return self._info["hdr"] + """Returns True if photo is an HDR photo, otherwise False""" + return self._info["hdr"] @property def screenshot(self): - """Returns True if photo is an HDR photo, otherwise False""" - return self._info["screenshot"] + """Returns True if photo is an HDR photo, otherwise False""" + return self._info["screenshot"] @property def portrait(self): - """Returns True if photo is a portrait, otherwise False""" - return self._info["portrait"] + """Returns True if photo is a portrait, otherwise False""" + return self._info["portrait"] @property def selfie(self): - """Returns True if photo is a selfie (front facing camera), otherwise False""" - return self._info["selfie"] + """Returns True if photo is a selfie (front facing camera), otherwise False""" + return self._info["selfie"] @property def place(self): - """Returns PlaceInfo object containing reverse geolocation info""" + """Returns PlaceInfo object containing reverse geolocation info""" - # implementation note: doesn't create the PlaceInfo object until requested + # implementation note: doesn't create the PlaceInfo object until requested # then memoizes the object in self._place to avoid recreating the object if self._db._db_version <= _PHOTOS_4_VERSION: try: return self._place # pylint: disable=access-member-before-definition except AttributeError: - if self._info["placeNames"]: + if self._info["placeNames"]: self._place = PlaceInfo4( - self._info["placeNames"], self._info["countryCode"] + self._info["placeNames"], self._info["countryCode"] ) else: self._place = None @@ -1051,78 +1213,78 @@ try: return self._place # pylint: disable=access-member-before-definition except AttributeError: - if self._info["reverse_geolocation"]: - self._place = PlaceInfo5(self._info["reverse_geolocation"]) + if self._info["reverse_geolocation"]: + self._place = PlaceInfo5(self._info["reverse_geolocation"]) else: self._place = None return self._place @property def has_raw(self): - """returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False""" - return self._info["has_raw"] + """returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False""" + return self._info["has_raw"] @property def israw(self): - """returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw""" - return "raw-image" in self.uti_original if self.uti_original else False + """returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw""" + return "raw-image" in self.uti_original if self.uti_original else False @property def raw_original(self): - """returns True if associated raw image and the raw image is selected in Photos - via "Use RAW as Original " - otherwise returns False""" - return self._info["raw_is_original"] + """returns True if associated raw image and the raw image is selected in Photos + via "Use RAW as Original " + otherwise returns False""" + return self._info["raw_is_original"] @property def height(self): - """returns height of the current photo version in pixels""" - return self._info["height"] + """returns height of the current photo version in pixels""" + return self._info["height"] @property def width(self): - """returns width of the current photo version in pixels""" - return self._info["width"] + """returns width of the current photo version in pixels""" + return self._info["width"] @property def orientation(self): - """returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined""" + """returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined""" if self._db._db_version <= _PHOTOS_4_VERSION: - return self._info["orientation"] + return self._info["orientation"] # For Photos 5+, try to get the adjusted orientation if not self.hasadjustments: - return self._info["orientation"] + return self._info["orientation"] if self.adjustments: return self.adjustments.adj_orientation else: - # can't reliably determine orientation for edited photo if adjustmentinfo not available + # can't reliably determine orientation for edited photo if adjustmentinfo not available return 0 @property def original_height(self): - """returns height of the original photo version in pixels""" - return self._info["original_height"] + """returns height of the original photo version in pixels""" + return self._info["original_height"] @property def original_width(self): - """returns width of the original photo version in pixels""" - return self._info["original_width"] + """returns width of the original photo version in pixels""" + return self._info["original_width"] @property def original_orientation(self): - """returns EXIF orientation of the original photo version as int""" - return self._info["original_orientation"] + """returns EXIF orientation of the original photo version as int""" + return self._info["original_orientation"] @property def original_filesize(self): - """returns filesize of original photo in bytes as int""" - return self._info["original_filesize"] + """returns filesize of original photo in bytes as int""" + return self._info["original_filesize"] @property def duplicates(self): - """return list of PhotoInfo objects for possible duplicates (matching signature of original size, date, height, width) or empty list if no matching duplicates""" + """return list of PhotoInfo objects for possible duplicates (matching signature of original size, date, height, width) or empty list if no matching duplicates""" signature = self._db._duplicate_signature(self.uuid) duplicates = [] try: @@ -1131,13 +1293,13 @@ # found a possible duplicate duplicates.append(self._db.get_photo(uuid)) except KeyError: - # don't expect this to happen as the signature should be in db - logging.warning(f"Did not find signature for {self.uuid} in _db_signatures") + # don't expect this to happen as the signature should be in db + logging.warning(f"Did not find signature for {self.uuid} in _db_signatures") return duplicates @property def owner(self): - """Return name of photo owner for shared photos (Photos 5+ only), or None if not shared""" + """Return name of photo owner for shared photos (Photos 5+ only), or None if not shared""" if self._db._db_version <= _PHOTOS_4_VERSION: return None @@ -1145,9 +1307,9 @@ return self._owner except AttributeError: try: - personid = self._info["cloudownerhashedpersonid"] + personid = self._info["cloudownerhashedpersonid"] self._owner = ( - self._db._db_hashed_person_id[personid]["full_name"] + self._db._db_hashed_person_id[personid]["full_name"] if personid else None ) @@ -1157,14 +1319,14 @@ @property def score(self): - """Computed score information for a photo + """Computed score information for a photo Returns: ScoreInfo instance - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: - logging.debug(f"score not implemented for this database version") + logging.debug(f"score not implemented for this database version") return None try: @@ -1173,33 +1335,33 @@ try: scores = self._db._db_scoreinfo_uuid[self.uuid] self._scoreinfo = ScoreInfo( - overall=scores["overall_aesthetic"], - curation=scores["curation"], - promotion=scores["promotion"], - highlight_visibility=scores["highlight_visibility"], - behavioral=scores["behavioral"], - failure=scores["failure"], - harmonious_color=scores["harmonious_color"], - immersiveness=scores["immersiveness"], - interaction=scores["interaction"], - interesting_subject=scores["interesting_subject"], - intrusive_object_presence=scores["intrusive_object_presence"], - lively_color=scores["lively_color"], - low_light=scores["low_light"], - noise=scores["noise"], - pleasant_camera_tilt=scores["pleasant_camera_tilt"], - pleasant_composition=scores["pleasant_composition"], - pleasant_lighting=scores["pleasant_lighting"], - pleasant_pattern=scores["pleasant_pattern"], - pleasant_perspective=scores["pleasant_perspective"], - pleasant_post_processing=scores["pleasant_post_processing"], - pleasant_reflection=scores["pleasant_reflection"], - pleasant_symmetry=scores["pleasant_symmetry"], - sharply_focused_subject=scores["sharply_focused_subject"], - tastefully_blurred=scores["tastefully_blurred"], - well_chosen_subject=scores["well_chosen_subject"], - well_framed_subject=scores["well_framed_subject"], - well_timed_shot=scores["well_timed_shot"], + overall=scores["overall_aesthetic"], + curation=scores["curation"], + promotion=scores["promotion"], + highlight_visibility=scores["highlight_visibility"], + behavioral=scores["behavioral"], + failure=scores["failure"], + harmonious_color=scores["harmonious_color"], + immersiveness=scores["immersiveness"], + interaction=scores["interaction"], + interesting_subject=scores["interesting_subject"], + intrusive_object_presence=scores["intrusive_object_presence"], + lively_color=scores["lively_color"], + low_light=scores["low_light"], + noise=scores["noise"], + pleasant_camera_tilt=scores["pleasant_camera_tilt"], + pleasant_composition=scores["pleasant_composition"], + pleasant_lighting=scores["pleasant_lighting"], + pleasant_pattern=scores["pleasant_pattern"], + pleasant_perspective=scores["pleasant_perspective"], + pleasant_post_processing=scores["pleasant_post_processing"], + pleasant_reflection=scores["pleasant_reflection"], + pleasant_symmetry=scores["pleasant_symmetry"], + sharply_focused_subject=scores["sharply_focused_subject"], + tastefully_blurred=scores["tastefully_blurred"], + well_chosen_subject=scores["well_chosen_subject"], + well_framed_subject=scores["well_framed_subject"], + well_timed_shot=scores["well_timed_shot"], ) return self._scoreinfo except KeyError: @@ -1236,9 +1398,9 @@ @property def search_info(self): - """returns SearchInfo object for photo + """returns SearchInfo object for photo only valid on Photos 5, on older libraries, returns None - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: return None @@ -1251,9 +1413,9 @@ @property def search_info_normalized(self): - """returns SearchInfo object for photo that produces normalized results + """returns SearchInfo object for photo that produces normalized results only valid on Photos 5, on older libraries, returns None - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: return None @@ -1266,9 +1428,9 @@ @property def labels(self): - """returns list of labels applied to photo by Photos image categorization + """returns list of labels applied to photo by Photos image categorization only valid on Photos 5, on older libraries returns empty list - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: return [] @@ -1276,9 +1438,9 @@ @property def labels_normalized(self): - """returns normalized list of labels applied to photo by Photos image categorization + """returns normalized list of labels applied to photo by Photos image categorization only valid on Photos 5, on older libraries returns empty list - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: return [] @@ -1286,58 +1448,58 @@ @property def comments(self): - """Returns list of Comment objects for any comments on the photo (sorted by date)""" + """Returns list of Comment objects for any comments on the photo (sorted by date)""" try: - return self._db._db_comments_uuid[self.uuid]["comments"] + return self._db._db_comments_uuid[self.uuid]["comments"] except: return [] @property def likes(self): - """Returns list of Like objects for any likes on the photo (sorted by date)""" + """Returns list of Like objects for any likes on the photo (sorted by date)""" try: - return self._db._db_comments_uuid[self.uuid]["likes"] + return self._db._db_comments_uuid[self.uuid]["likes"] except: return [] @property def exif_info(self): - """Returns an ExifInfo object with the EXIF data for photo + """Returns an ExifInfo object with the EXIF data for photo Note: the returned EXIF data is the data Photos stores in the database on import; ExifInfo does not provide access to the EXIF info in the actual image file Some or all of the fields may be None Only valid for Photos 5; on earlier database returns None - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: - logging.debug(f"exif_info not implemented for this database version") + logging.debug(f"exif_info not implemented for this database version") return None try: exif = self._db._db_exifinfo_uuid[self.uuid] exif_info = ExifInfo( - iso=exif["ZISO"], - flash_fired=True if exif["ZFLASHFIRED"] == 1 else False, - metering_mode=exif["ZMETERINGMODE"], - sample_rate=exif["ZSAMPLERATE"], - track_format=exif["ZTRACKFORMAT"], - white_balance=exif["ZWHITEBALANCE"], - aperture=exif["ZAPERTURE"], - bit_rate=exif["ZBITRATE"], - duration=exif["ZDURATION"], - exposure_bias=exif["ZEXPOSUREBIAS"], - focal_length=exif["ZFOCALLENGTH"], - fps=exif["ZFPS"], - latitude=exif["ZLATITUDE"], - longitude=exif["ZLONGITUDE"], - shutter_speed=exif["ZSHUTTERSPEED"], - camera_make=exif["ZCAMERAMAKE"], - camera_model=exif["ZCAMERAMODEL"], - codec=exif["ZCODEC"], - lens_model=exif["ZLENSMODEL"], + iso=exif["ZISO"], + flash_fired=True if exif["ZFLASHFIRED"] == 1 else False, + metering_mode=exif["ZMETERINGMODE"], + sample_rate=exif["ZSAMPLERATE"], + track_format=exif["ZTRACKFORMAT"], + white_balance=exif["ZWHITEBALANCE"], + aperture=exif["ZAPERTURE"], + bit_rate=exif["ZBITRATE"], + duration=exif["ZDURATION"], + exposure_bias=exif["ZEXPOSUREBIAS"], + focal_length=exif["ZFOCALLENGTH"], + fps=exif["ZFPS"], + latitude=exif["ZLATITUDE"], + longitude=exif["ZLONGITUDE"], + shutter_speed=exif["ZSHUTTERSPEED"], + camera_make=exif["ZCAMERAMAKE"], + camera_model=exif["ZCAMERAMODEL"], + codec=exif["ZCODEC"], + lens_model=exif["ZLENSMODEL"], ) except KeyError: - logging.debug(f"Could not find exif record for uuid {self.uuid}") + logging.debug(f"Could not find exif record for uuid {self.uuid}") exif_info = ExifInfo( iso=None, flash_fired=None, @@ -1364,11 +1526,11 @@ @property def exiftool(self): - """Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo. + """Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo. Requires that exiftool (https://exiftool.org/) be installed If exiftool not installed, logs warning and returns None If photo path is missing, returns None - """ + """ try: # return the memoized instance if it exists return self._exiftool @@ -1383,14 +1545,14 @@ # get_exiftool_path raises FileNotFoundError if exiftool not found exiftool = None logging.warning( - "exiftool not in path; download and install from https://exiftool.org/" + "exiftool not in path; download and install from https://exiftool.org/" ) self._exiftool = exiftool return self._exiftool
[docs] def detected_text(self, confidence_threshold=TEXT_DETECTION_CONFIDENCE_THRESHOLD): - """Detects text in photo and returns lists of results as (detected text, confidence) + """Detects text in photo and returns lists of results as (detected text, confidence) confidence_threshold: float between 0.0 and 1.0. If text detection confidence is below this threshold, text will not be returned. Default is TEXT_DETECTION_CONFIDENCE_THRESHOLD @@ -1398,7 +1560,7 @@ If photo is edited, uses the edited photo, otherwise the original; falls back to the preview image if neither edited or original is available Returns: list of (detected text, confidence) tuples - """ + """ try: return self._detected_text_cache[confidence_threshold] @@ -1409,7 +1571,7 @@ try: detected_text = self._detected_text() except Exception as e: - logging.warning(f"Error detecting text in photo {self.uuid}: {e}") + logging.warning(f"Error detecting text in photo {self.uuid}: {e}") detected_text = [] self._detected_text_cache[confidence_threshold] = [ @@ -1420,7 +1582,7 @@ return self._detected_text_cache[confidence_threshold]
def _detected_text(self): - """detect text in photo, either from cached extended attribute or by attempting text detection""" + """detect text in photo, either from cached extended attribute or by attempting text detection""" path = ( self.path_edited if self.hasadjustments and self.path_edited else self.path ) @@ -1429,27 +1591,27 @@ return [] md = OSXMetaData(path) - detected_text = md.get_attribute("osxphotos_detected_text") + detected_text = md.get_attribute("osxphotos_detected_text") if detected_text is None: orientation = self.orientation or None detected_text = detect_text(path, orientation) - md.set_attribute("osxphotos_detected_text", detected_text) + md.set_attribute("osxphotos_detected_text", detected_text) return detected_text @property def _longitude(self): - """Returns longitude, in degrees""" - return self._info["longitude"] + """Returns longitude, in degrees""" + return self._info["longitude"] @property def _latitude(self): - """Returns latitude, in degrees""" - return self._info["latitude"] + """Returns latitude, in degrees""" + return self._info["latitude"]
[docs] def render_template( self, template_str: str, options: Optional[RenderOptions] = None ): - """Renders a template string for PhotoInfo instance using PhotoTemplate + """Renders a template string for PhotoInfo instance using PhotoTemplate Args: template_str: a template string with fields to render @@ -1457,7 +1619,7 @@ Returns: ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values - """ + """ options = options or RenderOptions() template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path) return template.render(template_str, options)
@@ -1484,47 +1646,49 @@ description_template=None, render_options: Optional[RenderOptions] = None, ): - """export photo - dest: must be valid destination path (or exception raised) - filename: (optional): name of exported picture; if not provided, will use current filename - **NOTE**: if provided, user must ensure file extension (suffix) is correct. - For example, if photo is .CR2 file, edited image may be .jpeg. - If you provide an extension different than what the actual file is, - export will print a warning but will export the photo using the - incorrect file extension (unless use_photos_export is true, in which case export will - use the extension provided by Photos upon export; in this case, an incorrect extension is - silently ignored). - e.g. to get the extension of the edited photo, - reference PhotoInfo.path_edited - edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version - (or raise exception if no edited version) - live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos - raw_photo: (boolean, default=False); if True, will also export the associated RAW photo - export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them - overwrite: (boolean, default=False); if True will overwrite files if they already exist - increment: (boolean, default=True); if True, will increment file name until a non-existant name is found - if overwrite=False and increment=False, export will fail if destination file already exists - sidecar_json: if set will write a json sidecar with data in format readable by exiftool - sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`) - sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool - sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`) - sidecar_xmp: if set will write an XMP sidecar with IPTC data - sidecar filename will be dest/filename.xmp - use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos - timeout: (int, default=120) timeout in seconds used with use_photos_export - exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file - returns list of full paths to the exported files - use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords - when exporting metadata with exiftool or sidecar - use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords - when exporting metadata with exiftool or sidecar - keyword_template: (list of strings); list of template strings that will be rendered as used as keywords - description_template: string; optional template string that will be rendered for use as photo description - render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer + """Export a photo + + Args: + dest: must be valid destination path (or exception raised) + filename: (optional): name of exported picture; if not provided, will use current filename + **NOTE**: if provided, user must ensure file extension (suffix) is correct. + For example, if photo is .CR2 file, edited image may be .jpeg. + If you provide an extension different than what the actual file is, + export will print a warning but will export the photo using the + incorrect file extension (unless use_photos_export is true, in which case export will + use the extension provided by Photos upon export; in this case, an incorrect extension is + silently ignored). + e.g. to get the extension of the edited photo, + reference PhotoInfo.path_edited + edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version + (or raise exception if no edited version) + live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos + raw_photo: (boolean, default=False); if True, will also export the associated RAW photo + export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them + overwrite: (boolean, default=False); if True will overwrite files if they already exist + increment: (boolean, default=True); if True, will increment file name until a non-existant name is found + if overwrite=False and increment=False, export will fail if destination file already exists + sidecar_json: if set will write a json sidecar with data in format readable by exiftool + sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`) + sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool + sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`) + sidecar_xmp: if set will write an XMP sidecar with IPTC data + sidecar filename will be dest/filename.xmp + use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos + timeout: (int, default=120) timeout in seconds used with use_photos_export + exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file + returns list of full paths to the exported files + use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords + when exporting metadata with exiftool or sidecar + use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords + when exporting metadata with exiftool or sidecar + keyword_template: (list of strings); list of template strings that will be rendered as used as keywords + description_template: string; optional template string that will be rendered for use as photo description + render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer Returns: list of photos exported - """ + """ exporter = PhotoExporter(self) sidecar = 0 @@ -1545,8 +1709,8 @@ else: uti = self.uti_edited if edited and self.uti_edited else self.uti ext = get_preferred_uti_extension(uti) - ext = "." + ext - filename = original_name.stem + "_edited" + ext + ext = f".{ext}" + filename = f"{original_name.stem}_edited{ext}" options = ExportOptions( description_template=description_template, @@ -1570,14 +1734,14 @@ return results.exported
def _get_album_uuids(self, project=False): - """Return list of album UUIDs this photo is found in + """Return list of album UUIDs this photo is found in Filters out albums in the trash and any special album types - if project is True, returns special "My Project" albums (e.g. cards, calendars, slideshows) + if project is True, returns special "My Project" albums (e.g. cards, calendars, slideshows) Returns: list of album UUIDs - """ + """ if self._db._db_version <= _PHOTOS_4_VERSION: album_kind = [_PHOTOS_4_ALBUM_KIND] album_type = ( @@ -1586,14 +1750,14 @@ else [_PHOTOS_4_ALBUM_TYPE_ALBUM] ) album_list = [] - for album in self._info["albums"]: + for album in self._info["albums"]: detail = self._db._dbalbum_details[album] if ( - detail["kind"] in album_kind - and detail["albumType"] in album_type - and not detail["intrash"] - and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER - # in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND + detail["kind"] in album_kind + and detail["albumType"] in album_type + and not detail["intrash"] + and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER + # in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND # but should not be listed here; they can be distinguished by looking # for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM ): @@ -1608,17 +1772,17 @@ ) album_list = [] - for album in self._info["albums"]: + for album in self._info["albums"]: detail = self._db._dbalbum_details[album] - if detail["kind"] in album_kind and not detail["intrash"]: + if detail["kind"] in album_kind and not detail["intrash"]: album_list.append(album) return album_list def __repr__(self): - return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})" + return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})" def __str__(self): - """string representation of PhotoInfo object""" + """string representation of PhotoInfo object""" date_iso = self.date.isoformat() date_modified_iso = ( @@ -1628,60 +1792,60 @@ score = str(self.score) if self.score else None info = { - "uuid": self.uuid, - "filename": self.filename, - "original_filename": self.original_filename, - "date": date_iso, - "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, - "shared": self.shared, - "isphoto": self.isphoto, - "ismovie": self.ismovie, - "uti": self.uti, - "burst": self.burst, - "live_photo": self.live_photo, - "path_live_photo": self.path_live_photo, - "iscloudasset": self.iscloudasset, - "incloud": self.incloud, - "date_modified": date_modified_iso, - "portrait": self.portrait, - "screenshot": self.screenshot, - "slow_mo": self.slow_mo, - "time_lapse": self.time_lapse, - "hdr": self.hdr, - "selfie": self.selfie, - "panorama": self.panorama, - "has_raw": self.has_raw, - "uti_raw": self.uti_raw, - "path_raw": self.path_raw, - "place": self.place, - "exif": exif, - "score": score, - "intrash": self.intrash, - "height": self.height, - "width": self.width, - "orientation": self.orientation, - "original_height": self.original_height, - "original_width": self.original_width, - "original_orientation": self.original_orientation, - "original_filesize": self.original_filesize, + "uuid": self.uuid, + "filename": self.filename, + "original_filename": self.original_filename, + "date": date_iso, + "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, + "shared": self.shared, + "isphoto": self.isphoto, + "ismovie": self.ismovie, + "uti": self.uti, + "burst": self.burst, + "live_photo": self.live_photo, + "path_live_photo": self.path_live_photo, + "iscloudasset": self.iscloudasset, + "incloud": self.incloud, + "date_modified": date_modified_iso, + "portrait": self.portrait, + "screenshot": self.screenshot, + "slow_mo": self.slow_mo, + "time_lapse": self.time_lapse, + "hdr": self.hdr, + "selfie": self.selfie, + "panorama": self.panorama, + "has_raw": self.has_raw, + "uti_raw": self.uti_raw, + "path_raw": self.path_raw, + "place": self.place, + "exif": exif, + "score": score, + "intrash": self.intrash, + "height": self.height, + "width": self.width, + "orientation": self.orientation, + "original_height": self.original_height, + "original_width": self.original_width, + "original_orientation": self.original_orientation, + "original_filesize": self.original_filesize, } return yaml.dump(info, sort_keys=False)
[docs] def asdict(self): - """return dict representation""" + """return dict representation""" folders = {album.title: album.folder_names for album in self.album_info} exif = dataclasses.asdict(self.exif_info) if self.exif_info else {} @@ -1693,71 +1857,71 @@ search_info = self.search_info.asdict() if self.search_info else {} return { - "library": self._db._library_path, - "uuid": self.uuid, - "filename": self.filename, - "original_filename": self.original_filename, - "date": self.date, - "description": self.description, - "title": self.title, - "keywords": self.keywords, - "labels": self.labels, - "keywords": self.keywords, - "albums": self.albums, - "folders": folders, - "persons": self.persons, - "faces": faces, - "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, - "shared": self.shared, - "isphoto": self.isphoto, - "ismovie": self.ismovie, - "uti": self.uti, - "uti_original": self.uti_original, - "burst": self.burst, - "live_photo": self.live_photo, - "path_live_photo": self.path_live_photo, - "iscloudasset": self.iscloudasset, - "incloud": self.incloud, - "isreference": self.isreference, - "date_modified": self.date_modified, - "portrait": self.portrait, - "screenshot": self.screenshot, - "slow_mo": self.slow_mo, - "time_lapse": self.time_lapse, - "hdr": self.hdr, - "selfie": self.selfie, - "panorama": self.panorama, - "has_raw": self.has_raw, - "israw": self.israw, - "raw_original": self.raw_original, - "uti_raw": self.uti_raw, - "path_raw": self.path_raw, - "place": place, - "exif": exif, - "score": score, - "intrash": self.intrash, - "height": self.height, - "width": self.width, - "orientation": self.orientation, - "original_height": self.original_height, - "original_width": self.original_width, - "original_orientation": self.original_orientation, - "original_filesize": self.original_filesize, - "comments": comments, - "likes": likes, - "search_info": search_info, + "library": self._db._library_path, + "uuid": self.uuid, + "filename": self.filename, + "original_filename": self.original_filename, + "date": self.date, + "description": self.description, + "title": self.title, + "keywords": self.keywords, + "labels": self.labels, + "keywords": self.keywords, + "albums": self.albums, + "folders": folders, + "persons": self.persons, + "faces": faces, + "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, + "shared": self.shared, + "isphoto": self.isphoto, + "ismovie": self.ismovie, + "uti": self.uti, + "uti_original": self.uti_original, + "burst": self.burst, + "live_photo": self.live_photo, + "path_live_photo": self.path_live_photo, + "iscloudasset": self.iscloudasset, + "incloud": self.incloud, + "isreference": self.isreference, + "date_modified": self.date_modified, + "portrait": self.portrait, + "screenshot": self.screenshot, + "slow_mo": self.slow_mo, + "time_lapse": self.time_lapse, + "hdr": self.hdr, + "selfie": self.selfie, + "panorama": self.panorama, + "has_raw": self.has_raw, + "israw": self.israw, + "raw_original": self.raw_original, + "uti_raw": self.uti_raw, + "path_raw": self.path_raw, + "place": place, + "exif": exif, + "score": score, + "intrash": self.intrash, + "height": self.height, + "width": self.width, + "orientation": self.orientation, + "original_height": self.original_height, + "original_width": self.original_width, + "original_orientation": self.original_orientation, + "original_filesize": self.original_filesize, + "comments": comments, + "likes": likes, + "search_info": search_info, }
[docs] def json(self): - """Return JSON representation""" + """Return JSON representation""" def default(o): if isinstance(o, (datetime.date, datetime.datetime)): @@ -1770,8 +1934,8 @@ return json.dumps(dict_data, sort_keys=True, default=default)
def __eq__(self, other): - """Compare two PhotoInfo objects for equality""" - # Can't just compare the two __dicts__ because some methods (like albums) + """Compare two PhotoInfo objects for equality""" + # Can't just compare the two __dicts__ because some methods (like albums) # memoize their value once called in an instance variable (e.g. self._albums) if isinstance(other, self.__class__): return ( @@ -1782,16 +1946,16 @@ return False def __ne__(self, other): - """Compare two PhotoInfo objects for inequality""" + """Compare two PhotoInfo objects for inequality""" return not self.__eq__(other) def __hash__(self): - """Make PhotoInfo hashable""" + """Make PhotoInfo hashable""" return hash(self.uuid)
class PhotoInfoNone: - """mock class that returns None for all attributes""" + """mock class that returns None for all attributes""" def __init__(self): pass @@ -1799,70 +1963,45 @@ def __getattribute__(self, name): return None
- -
+ +
+
+ + -
- - - - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/photosdb/_photosdb_process_comments.html b/docs/_modules/osxphotos/photosdb/_photosdb_process_comments.html new file mode 100644 index 00000000..d1da8d6c --- /dev/null +++ b/docs/_modules/osxphotos/photosdb/_photosdb_process_comments.html @@ -0,0 +1,272 @@ + + + + + + + + osxphotos.photosdb._photosdb_process_comments — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.photosdb._photosdb_process_comments

+""" PhotosDB method for processing comments and likes on shared photos.
+    Do not import this module directly """
+
+import dataclasses
+import datetime
+from dataclasses import dataclass
+
+from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION, TIME_DELTA
+from ..utils import _open_sql_file, normalize_unicode
+
+
+def _process_comments(self):
+    """load the comments and likes data from the database
+    this is a PhotosDB method that should be imported in
+    the PhotosDB class definition in photosdb.py
+    """
+    self._db_hashed_person_id = {}
+    self._db_comments_uuid = {}
+    if self._db_version <= _PHOTOS_4_VERSION:
+        _process_comments_4(self)
+    else:
+        _process_comments_5(self)
+
+
+
[docs]@dataclass +class CommentInfo: + """Class for shared photo comments""" + + datetime: datetime.datetime + user: str + ismine: bool + text: str + + def asdict(self): + return dataclasses.asdict(self)
+ + +
[docs]@dataclass +class LikeInfo: + """Class for shared photo likes""" + + datetime: datetime.datetime + user: str + ismine: bool + + def asdict(self): + return dataclasses.asdict(self)
+ + +# The following methods do not get imported into PhotosDB +# but will get called by _process_comments +def _process_comments_4(photosdb): + """process comments and likes info for Photos <= 4 + photosdb: PhotosDB instance""" + raise NotImplementedError( + f"Not implemented for database version {photosdb._db_version}." + ) + + +def _process_comments_5(photosdb): + """process comments and likes info for Photos >= 5 + photosdb: PhotosDB instance""" + + db = photosdb._tmp_db + + asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"] + + (conn, cursor) = _open_sql_file(db) + + results = conn.execute( + """ + SELECT DISTINCT + ZINVITEEHASHEDPERSONID AS HASHEDPERSONID, + ZINVITEEFIRSTNAME AS FIRSTNAME, + ZINVITEELASTNAME AS LASTNAME, + ZINVITEEFULLNAME AS FULLNAME + FROM ZCLOUDSHAREDALBUMINVITATIONRECORD + WHERE HASHEDPERSONID IS NOT NULL + AND HASHEDPERSONID != "" + AND NOT (FIRSTNAME IS NULL AND LASTNAME IS NULL) + UNION + SELECT DISTINCT + ZCLOUDOWNERHASHEDPERSONID AS HASHEDPERSONID, + ZCLOUDOWNERFIRSTNAME AS FIRSTNAME, + ZCLOUDOWNERLASTNAME AS LASTNAME, + ZCLOUDOWNERFULLNAME AS FULLNAME + FROM ZGENERICALBUM + WHERE HASHEDPERSONID IS NOT NULL + AND HASHEDPERSONID != "" + AND NOT (FIRSTNAME IS NULL AND LASTNAME IS NULL) + """ + ) + + # order of results + # 0: ZINVITEEHASHEDPERSONID, + # 1: ZINVITEEFIRSTNAME, + # 2: ZINVITEELASTNAME, + # 3: ZINVITEEFULLNAME + + photosdb._db_hashed_person_id = {} + for row in results.fetchall(): + person_id = row[0] + photosdb._db_hashed_person_id[person_id] = { + "first_name": normalize_unicode(row[1]), + "last_name": normalize_unicode(row[2]), + "full_name": normalize_unicode(row[3]), + } + + results = conn.execute( + f""" + SELECT + {asset_table}.ZUUID, -- UUID of the photo + ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like" + ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment + ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment + ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like + ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment + FROM ZCLOUDSHAREDCOMMENT + JOIN {asset_table} ON + {asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET + OR + {asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET + """ + ) + + # order of results + # 0: ZGENERICASSET.ZUUID, -- UUID of the photo + # 1: ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like" + # 2: ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment + # 3: ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment + # 4: ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like + # 5: ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment + + photosdb._db_comments_uuid = {} + for row in results: + uuid = row[0] + is_like = bool(row[1]) + text = normalize_unicode(row[3]) + try: + user_name = photosdb._db_hashed_person_id[row[4]]["full_name"] + except KeyError: + user_name = None + + try: + dt = datetime.datetime.fromtimestamp(row[2] + TIME_DELTA) + except: + dt = datetime.datetime(1970, 1, 1) + + ismine = bool(row[5]) + + try: + db_comments = photosdb._db_comments_uuid[uuid] + except KeyError: + photosdb._db_comments_uuid[uuid] = {"likes": [], "comments": []} + db_comments = photosdb._db_comments_uuid[uuid] + + if is_like: + db_comments["likes"].append(LikeInfo(dt, user_name, ismine)) + elif text: + db_comments["comments"].append(CommentInfo(dt, user_name, ismine, text)) + + # sort results + for uuid, value in photosdb._db_comments_uuid.items(): + if photosdb._db_comments_uuid[uuid]["likes"]: + photosdb._db_comments_uuid[uuid]["likes"].sort(key=lambda x: x.datetime) + if photosdb._db_comments_uuid[uuid]["comments"]: + value["comments"].sort(key=lambda x: x.datetime) + + conn.close() +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/phototemplate.html b/docs/_modules/osxphotos/phototemplate.html new file mode 100644 index 00000000..3b6f3ce5 --- /dev/null +++ b/docs/_modules/osxphotos/phototemplate.html @@ -0,0 +1,1709 @@ + + + + + + + + osxphotos.phototemplate - osxphotos 0.47.10 documentation + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+ +
+ +
+
+

Source code for osxphotos.phototemplate

+""" Custom template system for osxphotos, implements metadata template language (MTL) """
+
+import datetime
+import json
+import locale
+import logging
+import os
+import pathlib
+import shlex
+import sys
+from dataclasses import dataclass
+from typing import Optional
+
+from textx import TextXSyntaxError, metamodel_from_file
+
+from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD
+from ._version import __version__
+from .datetime_formatter import DateTimeFormatter
+from .exiftool import ExifToolCaching
+from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
+from .text_detection import detect_text
+from .utils import expand_and_validate_filepath, load_function
+
+__all__ = [
+    "RenderOptions",
+    "PhotoTemplateParser",
+    "PhotoTemplate",
+    "parse_default_kv",
+    "get_template_help",
+    "format_str_value",
+]
+
+# TODO: a lot of values are passed from function to function like path_sep--make these all class properties
+
+# ensure locale set to user's locale
+locale.setlocale(locale.LC_ALL, "")
+
+MTL_GRAMMAR_MODEL = str(pathlib.Path(__file__).parent / "phototemplate.tx")
+
+"""TextX metamodel for osxphotos template language """
+
+PHOTO_VIDEO_TYPE_DEFAULTS = {"photo": "photo", "video": "video"}
+
+MEDIA_TYPE_DEFAULTS = {
+    "selfie": "selfie",
+    "time_lapse": "time_lapse",
+    "panorama": "panorama",
+    "slow_mo": "slow_mo",
+    "screenshot": "screenshot",
+    "portrait": "portrait",
+    "live_photo": "live_photo",
+    "burst": "burst",
+    "photo": "photo",
+    "video": "video",
+}
+
+# Permitted substitutions (each of these returns a single value or None)
+TEMPLATE_SUBSTITUTIONS = {
+    "{name}": "Current filename of the photo",
+    "{original_name}": "Photo's original filename when imported to Photos",
+    "{title}": "Title of the photo",
+    "{descr}": "Description of the photo",
+    "{media_type}": (
+        f"Special media type resolved in this precedence: {', '.join(t for t in MEDIA_TYPE_DEFAULTS)}. "
+        "Defaults to 'photo' or 'video' if no special type. "
+        "Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'"
+    ),
+    "{photo_or_video}": "'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'",
+    "{hdr}": "Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
+    "{edited}": "True if photo has been edited (has adjustments), otherwise False; use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
+    "{edited_version}": "True if template is being rendered for the edited version of a photo, otherwise False. ",
+    "{favorite}": "Photo has been marked as favorite?; True/False value, use in format '{favorite?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
+    "{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
+    "{created.year}": "4-digit year of photo creation time",
+    "{created.yy}": "2-digit year of photo creation time",
+    "{created.mm}": "2-digit month of the photo creation time (zero padded)",
+    "{created.month}": "Month name in user's locale of the photo creation time",
+    "{created.mon}": "Month abbreviation in the user's locale of the photo creation time",
+    "{created.dd}": "2-digit day of the month (zero padded) of photo creation time",
+    "{created.dow}": "Day of week in user's locale of the photo creation time",
+    "{created.doy}": "3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded)",
+    "{created.hour}": "2-digit hour of the photo creation time",
+    "{created.min}": "2-digit minute of the photo creation time",
+    "{created.sec}": "2-digit second of the photo creation time",
+    "{created.strftime}": "Apply strftime template to file creation date/time. Should be used in form "
+    + "{created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+    + "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+    + "If used with no template will return null value. "
+    + "See https://strftime.org/ for help on strftime templates.",
+    "{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified",
+    "{modified.year}": "4-digit year of photo modification time; uses creation date if photo is not modified",
+    "{modified.yy}": "2-digit year of photo modification time; uses creation date if photo is not modified",
+    "{modified.mm}": "2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified",
+    "{modified.month}": "Month name in user's locale of the photo modification time; uses creation date if photo is not modified",
+    "{modified.mon}": "Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified",
+    "{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified",
+    "{modified.dow}": "Day of week in user's locale of the photo modification time; uses creation date if photo is not modified",
+    "{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified",
+    "{modified.hour}": "2-digit hour of the photo modification time; uses creation date if photo is not modified",
+    "{modified.min}": "2-digit minute of the photo modification time; uses creation date if photo is not modified",
+    "{modified.sec}": "2-digit second of the photo modification time; uses creation date if photo is not modified",
+    "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
+    + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+    + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+    + "If used with no template will return null value. Uses creation date if photo is not modified. "
+    + "See https://strftime.org/ for help on strftime templates.",
+    "{today.date}": "Current date in iso format, e.g. '2020-03-22'",
+    "{today.year}": "4-digit year of current date",
+    "{today.yy}": "2-digit year of current date",
+    "{today.mm}": "2-digit month of the current date (zero padded)",
+    "{today.month}": "Month name in user's locale of the current date",
+    "{today.mon}": "Month abbreviation in the user's locale of the current date",
+    "{today.dd}": "2-digit day of the month (zero padded) of current date",
+    "{today.dow}": "Day of week in user's locale of the current date",
+    "{today.doy}": "3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded)",
+    "{today.hour}": "2-digit hour of the current date",
+    "{today.min}": "2-digit minute of the current date",
+    "{today.sec}": "2-digit second of the current date",
+    "{today.strftime}": "Apply strftime template to current date/time. Should be used in form "
+    + "{today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+    + "{today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+    + "If used with no template will return null value. "
+    + "See https://strftime.org/ for help on strftime templates.",
+    "{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
+    "{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
+    "{place.name.country}": "Country name from the photo's reverse geolocation data",
+    "{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
+    "{place.name.city}": "City or locality name from the photo's reverse geolocation data",
+    "{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
+    "{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
+    "{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
+    "{place.address.city}": "City part of the postal address, e.g. 'Washington'",
+    "{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
+    "{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
+    "{place.address.country}": "Country name of the postal address, e.g. 'United States'",
+    "{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
+    "{searchinfo.season}": "Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
+    "{exif.camera_make}": "Camera make from original photo's EXIF information as imported by Photos, e.g. 'Apple'",
+    "{exif.camera_model}": "Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s'",
+    "{exif.lens_model}": "Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'",
+    "{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
+    "{id}": "A unique number for the photo based on its primary key in the Photos database. "
+    + "A sequential integer, e.g. 1, 2, 3...etc.  Each asset associated with a photo (e.g. an image and Live Photo preview) will share the same id. "
+    + "May be formatted using a python string format code. "
+    + "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in "
+    + "00001, 00002, 00003...etc. ",
+    "{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. "
+    + "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. "
+    + 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. '
+    + "To start counting at a value other than 0, append append a period and the starting value to the field name.  "
+    + "For example, to start counting at 1 instead of 0: '{album_seq.1}'. "
+    + "May be formatted using a python string format code. "
+    + "For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in "
+    + "00000, 00001, 00002...etc. "
+    + "This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.",
+    "{folder_album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. "
+    + "Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. "
+    + 'For example \'--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"\'. '
+    + "To start counting at a value other than 0, append append a period and the starting value to the field name.  "
+    + "For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' "
+    + "May be formatted using a python string format code. "
+    + "For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in "
+    + "00000, 00001, 00002...etc. "
+    + "This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'.",
+    "{comma}": "A comma: ','",
+    "{semicolon}": "A semicolon: ';'",
+    "{questionmark}": "A question mark: '?'",
+    "{pipe}": "A vertical pipe: '|'",
+    "{openbrace}": "An open brace: '{'",
+    "{closebrace}": "A close brace: '}'",
+    "{openparens}": "An open parentheses: '('",
+    "{closeparens}": "A close parentheses: ')'",
+    "{openbracket}": "An open bracket: '['",
+    "{closebracket}": "A close bracket: ']'",
+    "{newline}": r"A newline: '\n'",
+    "{lf}": r"A line feed: '\n', alias for {newline}",
+    "{cr}": r"A carriage return: '\r'",
+    "{crlf}": r"a carriage return + line feed: '\r\n'",
+    "{osxphotos_version}": f"The osxphotos version, e.g. '{__version__}'",
+    "{osxphotos_cmd_line}": "The full command line used to run osxphotos",
+}
+
+TEMPLATE_SUBSTITUTIONS_PATHLIB = {
+    "{export_dir}": "The full path to the export directory",
+    "{filepath}": "The full path to the exported file",
+}
+
+# Permitted multi-value substitutions (each of these returns None or 1 or more values)
+TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
+    "{album}": "Album(s) photo is contained in",
+    "{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
+    "{project}": "Project(s) photo is contained in (such as greeting cards, calendars, slideshows)",
+    "{album_project}": "Album(s) and project(s) photo is contained in; treats projects as regular albums",
+    "{folder_album_project}": "Folder path + album (includes projects as albums) photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
+    "{keyword}": "Keyword(s) assigned to photo",
+    "{person}": "Person(s) / face(s) in a photo",
+    "{label}": "Image categorization label associated with a photo (Photos 5+ only). "
+    "Labels are added automatically by Photos using machine learning algorithms to categorize images. "
+    "These are not the same as {keyword} which refers to the user-defined keywords/tags applied in Photos.",
+    "{label_normalized}": "All lower case version of 'label' (Photos 5+ only)",
+    "{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)",
+    "{exiftool}": "Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image.  "
+    "E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
+    "See https://exiftool.org/TagNames/ for list of valid tag names.  You must specify group (e.g. EXIF, IPTC, etc) "
+    "as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
+    "{searchinfo.holiday}": "Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
+    "{searchinfo.activity}": "Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
+    "{searchinfo.venue}": "Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
+    "{searchinfo.venue_type}": "Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
+    "{photo}": "Provides direct access to the PhotoInfo object for the photo. "
+    + "Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. "
+    + "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. "
+    + "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of "
+    + "the underlying PhotoInfo class.  See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
+    "{detected_text}": "List of text strings found in the image after performing text detection. "
+    + "Using '{detected_text}' will cause osxphotos to perform text detection on your photos using the built-in macOS text detection algorithms which will slow down your export. "
+    + "The results for each photo will be cached in the export database so that future exports with '--update' do not need to reprocess each photo. "
+    + "You may pass a confidence threshold value between 0.0 and 1.0 after a colon as in '{detected_text:0.5}'; "
+    + f"The default confidence threshold is {TEXT_DETECTION_CONFIDENCE_THRESHOLD}. "
+    + "'{detected_text}' works only on macOS Catalina (10.15) or later. "
+    + "Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support.",
+    "{shell_quote}": "Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
+    "{strip}": "Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s).",
+    "{function}": "Execute a python function from an external file and use return value as template substitution. "
+    + "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
+    + "The function will be passed the PhotoInfo object for the photo. "
+    + "See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.",
+}
+
+FILTER_VALUES = {
+    "lower": "Convert value to lower case, e.g. 'Value' => 'value'.",
+    "upper": "Convert value to upper case, e.g. 'Value' => 'VALUE'.",
+    "strip": "Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'.",
+    "titlecase": "Convert value to title case, e.g. 'my value' => 'My Value'.",
+    "capitalize": "Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'.",
+    "braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
+    "parens": "Enclose value in parentheses, e.g. 'value' => '(value')",
+    "brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
+    "shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
+    "function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py",
+}
+
+# Just the substitutions without the braces
+SINGLE_VALUE_SUBSTITUTIONS = [
+    field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS
+]
+
+PATHLIB_SUBSTITUTIONS = [
+    field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS_PATHLIB
+]
+
+MULTI_VALUE_SUBSTITUTIONS = [
+    field.replace("{", "").replace("}", "")
+    for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
+]
+
+FIELD_NAMES = (
+    SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS + PATHLIB_SUBSTITUTIONS
+)
+
+# default values for string manipulation template options
+INPLACE_DEFAULT = ","
+PATH_SEP_DEFAULT = os.path.sep
+
+PUNCTUATION = {
+    "comma": ",",
+    "semicolon": ";",
+    "pipe": "|",
+    "openbrace": "{",
+    "closebrace": "}",
+    "openparens": "(",
+    "closeparens": ")",
+    "openbracket": "[",
+    "closebracket": "]",
+    "questionmark": "?",
+    "newline": "\n",
+    "lf": "\n",
+    "cr": "\r",
+    "crlf": "\r\n",
+}
+
+
+@dataclass
+class RenderOptions:
+    """Options for PhotoTemplate.render
+
+    template: str template
+    none_str: str to use default for None values, default is '_'
+    path_sep: optional string to use as path separator, default is os.path.sep
+    expand_inplace: expand multi-valued substitutions in-place as a single string
+        instead of returning individual strings
+    inplace_sep: optional string to use as separator between multi-valued keywords
+    with expand_inplace; default is ','
+    filename: if True, template output will be sanitized to produce valid file name
+    dirname: if True, template output will be sanitized to produce valid directory name
+    strip: if True, strips leading/trailing whitespace from rendered templates
+    edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
+    export_dir: set to the export directory if you want to evalute {export_dir} template
+    dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename
+    filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
+    quote: quote path templates for execution in the shell
+    """
+
+    none_str: str = "_"
+    path_sep: Optional[str] = PATH_SEP_DEFAULT
+    expand_inplace: bool = False
+    inplace_sep: Optional[str] = INPLACE_DEFAULT
+    filename: bool = False
+    dirname: bool = False
+    strip: bool = False
+    edited_version: bool = False
+    export_dir: Optional[str] = None
+    dest_path: Optional[str] = None
+    filepath: Optional[str] = None
+    quote: bool = False
+
+
+class PhotoTemplateParser:
+    """Parser for PhotoTemplate"""
+
+    # implemented as Singleton
+
+    def __new__(cls, *args, **kwargs):
+        """create new object or return instance of already created singleton"""
+        if not hasattr(cls, "instance") or not cls.instance:
+            cls.instance = super().__new__(cls)
+
+        return cls.instance
+
+    def __init__(self):
+        """return existing singleton or create a new one"""
+
+        if hasattr(self, "metamodel"):
+            return
+
+        self.metamodel = metamodel_from_file(MTL_GRAMMAR_MODEL, skipws=False)
+
+    def parse(self, template_statement):
+        """Parse a template_statement string"""
+        return self.metamodel.model_from_str(template_statement)
+
+    def fields(self, template_statement):
+        """Return list of fields found in a template statement; does not verify that fields are valid"""
+        model = self.parse(template_statement)
+        return [ts.template.field for ts in model.template_strings if ts.template]
+
+
+
[docs]class PhotoTemplate: + """PhotoTemplate class to render a template string from a PhotoInfo object""" + + def __init__(self, photo, exiftool_path=None): + """Inits PhotoTemplate class with photo + + Args: + photo: a PhotoInfo instance. + exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH + """ + self.photo = photo + self.exiftool_path = exiftool_path + + # holds value of current date/time for {today.x} fields + # gets initialized in get_template_value + self.today = None + + # get parser singleton + self.parser = PhotoTemplateParser() + + # initialize render options + # this will be done in render() but for testing, some of the lookup functions are called directly + options = RenderOptions() + self.options = options + self.path_sep = options.path_sep + self.inplace_sep = options.inplace_sep + self.edited_version = options.edited_version + self.none_str = options.none_str + self.expand_inplace = options.expand_inplace + self.filename = options.filename + self.dirname = options.dirname + self.strip = options.strip + self.export_dir = options.export_dir + self.filepath = options.filepath + self.quote = options.quote + self.dest_path = options.dest_path + +
[docs] def render( + self, + template: str, + options: RenderOptions, + ): + """Render a filename or directory template + + Args: + template: str template + options: a RenderOptions instance + + Returns: + ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values + """ + + if type(template) is not str: + raise TypeError(f"template must be type str, not {type(template)}") + + self.options = options + self.path_sep = options.path_sep + self.inplace_sep = options.inplace_sep + self.edited_version = options.edited_version + self.none_str = options.none_str + self.expand_inplace = options.expand_inplace + self.filename = options.filename + self.dirname = options.dirname + self.strip = options.strip + self.export_dir = options.export_dir + self.dest_path = options.dest_path + self.filepath = options.filepath + self.quote = options.quote + self.dest_path = options.dest_path + + try: + model = self.parser.parse(template) + except TextXSyntaxError as e: + raise ValueError(f"SyntaxError: {e}") + + if not model: + # empty string + return [], [] + + return self._render_statement(model)
+ + def _render_statement( + self, + statement, + path_sep=None, + ): + path_sep = path_sep or self.path_sep + results = [] + unmatched = [] + for ts in statement.template_strings: + results, unmatched = self._render_template_string( + ts, results=results, unmatched=unmatched, path_sep=path_sep + ) + + rendered_strings = results + + if self.filename: + rendered_strings = [ + sanitize_filename(rendered_str) for rendered_str in rendered_strings + ] + + if self.strip: + rendered_strings = [ + rendered_str.strip() for rendered_str in rendered_strings + ] + + return rendered_strings, unmatched + + def _render_template_string( + self, + ts, + path_sep, + results=None, + unmatched=None, + ): + """Render a TemplateString object""" + + results = results or [""] + unmatched = unmatched or [] + + if ts.template: + # have a template field to process + field = ts.template.field + field_part = field.split(".")[0] + if field not in FIELD_NAMES and field_part not in FIELD_NAMES: + unmatched.append(field) + return [], unmatched + + subfield = ts.template.subfield + + # process filters + filters = [] + if ts.template.filter is not None: + filters = ts.template.filter.value + + # process path_sep + if ts.template.pathsep is not None: + path_sep = ts.template.pathsep.value + + # process delim + if ts.template.delim is not None: + # if value is None, means format was {+field} + delim = ts.template.delim.value or "" + else: + delim = None + + if ts.template.bool is not None: + is_bool = True + if ts.template.bool.value is not None: + bool_val, u = self._render_statement( + ts.template.bool.value, + path_sep=path_sep, + ) + unmatched.extend(u) + else: + # blank bool value + bool_val = [""] + else: + is_bool = False + bool_val = None + + # process default + if ts.template.default is not None: + # default is also a TemplateString + if ts.template.default.value is not None: + default, u = self._render_statement( + ts.template.default.value, + path_sep=path_sep, + ) + unmatched.extend(u) + else: + # blank default value + default = [""] + else: + default = [] + + # process conditional + if ts.template.conditional is not None: + operator = ts.template.conditional.operator + negation = ts.template.conditional.negation + if ts.template.conditional.value is not None: + # conditional value is also a TemplateString + conditional_value, u = self._render_statement( + ts.template.conditional.value, + path_sep=path_sep, + ) + unmatched.extend(u) + else: + # this shouldn't happen + conditional_value = [""] + else: + operator = None + negation = None + conditional_value = [] + + vals = [] + if ( + field in SINGLE_VALUE_SUBSTITUTIONS + or field.split(".")[0] in SINGLE_VALUE_SUBSTITUTIONS + ): + vals = self.get_template_value( + field, + default=default, + subfield=subfield, + # delim=delim or self.inplace_sep, + # path_sep=path_sep, + ) + elif field == "exiftool": + if subfield is None: + raise ValueError( + "SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'" + ) + vals = self.get_template_value_exiftool( + subfield, + ) + elif field == "function": + if subfield is None: + raise ValueError( + "SyntaxError: filename and function must not be null with {function::filename.py:function_name}" + ) + vals = self.get_template_value_function( + subfield, + ) + elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"): + vals = self.get_template_value_multi( + field, subfield, path_sep=path_sep, default=default + ) + elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS: + vals = self.get_template_value_pathlib(field) + else: + unmatched.append(field) + return [], unmatched + + vals = [val for val in vals if val is not None] + + if self.expand_inplace or delim is not None: + sep = delim if delim is not None else self.inplace_sep + vals = [sep.join(sorted(vals))] if vals else [] + + for filter_ in filters: + vals = self.get_template_value_filter(filter_, vals) + + # process find/replace + if ts.template.findreplace: + new_vals = [] + for val in vals: + for pair in ts.template.findreplace.pairs: + find = pair.find or "" + repl = pair.replace or "" + val = val.replace(find, repl) + new_vals.append(val) + vals = new_vals + + if operator: + # have a conditional operator + + def string_test(test_function): + """Perform string comparison using test_function; closure to capture conditional_value, vals, negation""" + match = False + for c in conditional_value: + for v in vals: + if test_function(v, c): + match = True + break + if match: + break + if (match and not negation) or (negation and not match): + return ["True"] + else: + return [] + + def comparison_test(test_function): + """Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation""" + if len(vals) != 1 or len(conditional_value) != 1: + raise ValueError( + f"comparison operators may only be used with a single value: {vals} {conditional_value}" + ) + try: + match = bool( + test_function(float(vals[0]), float(conditional_value[0])) + ) + if (match and not negation) or (negation and not match): + return ["True"] + else: + return [] + except ValueError as e: + raise ValueError( + f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}" + ) + + if operator in ["contains", "matches", "startswith", "endswith"]: + # process any "or" values separated by "|" + temp_values = [] + for c in conditional_value: + temp_values.extend(c.split("|")) + conditional_value = temp_values + + if operator == "contains": + vals = string_test(lambda v, c: c in v) + elif operator == "matches": + vals = string_test(lambda v, c: v == c) + elif operator == "startswith": + vals = string_test(lambda v, c: v.startswith(c)) + elif operator == "endswith": + vals = string_test(lambda v, c: v.endswith(c)) + elif operator == "==": + match = sorted(vals) == sorted(conditional_value) + if (match and not negation) or (negation and not match): + vals = ["True"] + else: + vals = [] + elif operator == "!=": + match = sorted(vals) != sorted(conditional_value) + if (match and not negation) or (negation and not match): + vals = ["True"] + else: + vals = [] + elif operator == "<": + vals = comparison_test(lambda v, c: v < c) + elif operator == "<=": + vals = comparison_test(lambda v, c: v <= c) + elif operator == ">": + vals = comparison_test(lambda v, c: v > c) + elif operator == ">=": + vals = comparison_test(lambda v, c: v >= c) + + if is_bool: + vals = default if not vals else bool_val + elif not vals: + vals = default or [self.none_str] + + pre = ts.pre or "" + post = ts.post or "" + + rendered = [pre + val + post for val in vals] + results_new = [] + for ren in rendered: + for res in results: + res_new = res + ren + results_new.append(res_new) + results = results_new + + else: + # no template + pre = ts.pre or "" + post = ts.post or "" + results = [r + pre + post for r in results] + + return results, unmatched + +
[docs] def get_template_value( + self, + field, + default, + subfield=None, + # bool_val=None, + # delim=None, + # path_sep=None, + ): + """lookup value for template field (single-value template substitutions) + + Args: + field: template field to find value for. + default: the default value provided by the user + bool_val: True value if expression is boolean + delim: delimiter for expand in place + path_sep: path separator for fields that are path-like + subfield: subfield (value after : in field) + + Returns: + The matching template value (which may be None). + + Raises: + ValueError if no rule exists for field. + """ + + if self.photo.uuid is None: + return [] + + # initialize today with current date/time if needed + if self.today is None: + self.today = datetime.datetime.now() + + value = None + + # wouldn't a switch/case statement be nice... + if field == "name": + value = pathlib.Path(self.photo.filename).stem + elif field == "original_name": + value = pathlib.Path(self.photo.original_filename).stem + elif field == "title": + value = self.photo.title + elif field == "descr": + value = self.photo.description + elif field == "media_type": + value = self.get_media_type(default) + elif field == "photo_or_video": + value = self.get_photo_video_type(default) + elif field == "hdr": + value = "hdr" if self.photo.hdr else None + elif field == "edited": + value = "edited" if self.photo.hasadjustments else None + elif field == "edited_version": + value = "edited_version" if self.edited_version else None + elif field == "favorite": + value = "favorite" if self.photo.favorite else None + elif field == "created.date": + value = DateTimeFormatter(self.photo.date).date + elif field == "created.year": + value = DateTimeFormatter(self.photo.date).year + elif field == "created.yy": + value = DateTimeFormatter(self.photo.date).yy + elif field == "created.mm": + value = DateTimeFormatter(self.photo.date).mm + elif field == "created.month": + value = DateTimeFormatter(self.photo.date).month + elif field == "created.mon": + value = DateTimeFormatter(self.photo.date).mon + elif field == "created.dd": + value = DateTimeFormatter(self.photo.date).dd + elif field == "created.dow": + value = DateTimeFormatter(self.photo.date).dow + elif field == "created.doy": + value = DateTimeFormatter(self.photo.date).doy + elif field == "created.hour": + value = DateTimeFormatter(self.photo.date).hour + elif field == "created.min": + value = DateTimeFormatter(self.photo.date).min + elif field == "created.sec": + value = DateTimeFormatter(self.photo.date).sec + elif field == "created.strftime": + if default: + try: + value = self.photo.date.strftime(default[0]) + except: + raise ValueError(f"Invalid strftime template: '{default}'") + else: + value = None + elif field == "modified.date": + value = ( + DateTimeFormatter(self.photo.date_modified).date + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).date + ) + elif field == "modified.year": + value = ( + DateTimeFormatter(self.photo.date_modified).year + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).year + ) + elif field == "modified.yy": + value = ( + DateTimeFormatter(self.photo.date_modified).yy + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).yy + ) + elif field == "modified.mm": + value = ( + DateTimeFormatter(self.photo.date_modified).mm + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).mm + ) + elif field == "modified.month": + value = ( + DateTimeFormatter(self.photo.date_modified).month + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).month + ) + elif field == "modified.mon": + value = ( + DateTimeFormatter(self.photo.date_modified).mon + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).mon + ) + elif field == "modified.dd": + value = ( + DateTimeFormatter(self.photo.date_modified).dd + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).dd + ) + elif field == "modified.dow": + value = ( + DateTimeFormatter(self.photo.date_modified).dow + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).dow + ) + elif field == "modified.doy": + value = ( + DateTimeFormatter(self.photo.date_modified).doy + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).doy + ) + elif field == "modified.hour": + value = ( + DateTimeFormatter(self.photo.date_modified).hour + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).hour + ) + elif field == "modified.min": + value = ( + DateTimeFormatter(self.photo.date_modified).min + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).min + ) + elif field == "modified.sec": + value = ( + DateTimeFormatter(self.photo.date_modified).sec + if self.photo.date_modified + else DateTimeFormatter(self.photo.date).sec + ) + elif field == "modified.strftime": + if default: + try: + date = self.photo.date_modified or self.photo.date + value = date.strftime(default[0]) + except: + raise ValueError(f"Invalid strftime template: '{default}'") + else: + value = None + elif field == "today.date": + value = DateTimeFormatter(self.today).date + elif field == "today.year": + value = DateTimeFormatter(self.today).year + elif field == "today.yy": + value = DateTimeFormatter(self.today).yy + elif field == "today.mm": + value = DateTimeFormatter(self.today).mm + elif field == "today.month": + value = DateTimeFormatter(self.today).month + elif field == "today.mon": + value = DateTimeFormatter(self.today).mon + elif field == "today.dd": + value = DateTimeFormatter(self.today).dd + elif field == "today.dow": + value = DateTimeFormatter(self.today).dow + elif field == "today.doy": + value = DateTimeFormatter(self.today).doy + elif field == "today.hour": + value = DateTimeFormatter(self.today).hour + elif field == "today.min": + value = DateTimeFormatter(self.today).min + elif field == "today.sec": + value = DateTimeFormatter(self.today).sec + elif field == "today.strftime": + if default: + try: + value = self.today.strftime(default[0]) + except: + raise ValueError(f"Invalid strftime template: '{default}'") + else: + value = None + elif field == "place.name": + value = self.photo.place.name if self.photo.place else None + elif field == "place.country_code": + value = self.photo.place.country_code if self.photo.place else None + elif field == "place.name.country": + value = ( + self.photo.place.names.country[0] + if self.photo.place and self.photo.place.names.country + else None + ) + elif field == "place.name.state_province": + value = ( + self.photo.place.names.state_province[0] + if self.photo.place and self.photo.place.names.state_province + else None + ) + elif field == "place.name.city": + value = ( + self.photo.place.names.city[0] + if self.photo.place and self.photo.place.names.city + else None + ) + elif field == "place.name.area_of_interest": + value = ( + self.photo.place.names.area_of_interest[0] + if self.photo.place and self.photo.place.names.area_of_interest + else None + ) + elif field == "place.address": + value = ( + self.photo.place.address_str + if self.photo.place and self.photo.place.address_str + else None + ) + elif field == "place.address.street": + value = ( + self.photo.place.address.street + if self.photo.place and self.photo.place.address.street + else None + ) + elif field == "place.address.city": + value = ( + self.photo.place.address.city + if self.photo.place and self.photo.place.address.city + else None + ) + elif field == "place.address.state_province": + value = ( + self.photo.place.address.state_province + if self.photo.place and self.photo.place.address.state_province + else None + ) + elif field == "place.address.postal_code": + value = ( + self.photo.place.address.postal_code + if self.photo.place and self.photo.place.address.postal_code + else None + ) + elif field == "place.address.country": + value = ( + self.photo.place.address.country + if self.photo.place and self.photo.place.address.country + else None + ) + elif field == "place.address.country_code": + value = ( + self.photo.place.address.iso_country_code + if self.photo.place and self.photo.place.address.iso_country_code + else None + ) + elif field == "searchinfo.season": + value = self.photo.search_info.season if self.photo.search_info else None + elif field == "exif.camera_make": + value = self.photo.exif_info.camera_make if self.photo.exif_info else None + elif field == "exif.camera_model": + value = self.photo.exif_info.camera_model if self.photo.exif_info else None + elif field == "exif.lens_model": + value = self.photo.exif_info.lens_model if self.photo.exif_info else None + elif field == "uuid": + value = self.photo.uuid + elif field == "id": + value = format_str_value(self.photo._info["pk"], subfield) + elif field.startswith("album_seq") or field.startswith("folder_album_seq"): + dest_path = self.dest_path + if not dest_path: + value = None + else: + if field.startswith("album_seq"): + album = pathlib.Path(dest_path).name + album_info = _get_album_by_name(self.photo, album) + else: + album_info = _get_album_by_path(self.photo, dest_path) + value = album_info.photo_index(self.photo) if album_info else None + if value is not None: + try: + start_id = field.split(".", 1) + value = int(value) + int(start_id[1]) + except IndexError: + pass + value = format_str_value(value, subfield) + elif field in PUNCTUATION: + value = PUNCTUATION[field] + elif field == "osxphotos_version": + value = __version__ + elif field == "osxphotos_cmd_line": + value = " ".join(sys.argv) + else: + # if here, didn't get a match + raise ValueError(f"Unhandled template value: {field}") + + if self.filename: + value = sanitize_pathpart(value) + elif self.dirname: + value = sanitize_dirname(value) + + # ensure no empty strings in value (see #512) + value = None if value == "" else value + + return [value]
+ +
[docs] def get_template_value_pathlib(self, field): + """lookup value for template pathlib template fields + + Args: + field: template field to find value for. + + Returns: + The matching template value (which may be None). + + Raises: + ValueError if no rule exists for field. + """ + field_stem = field.split(".")[0] + if field_stem not in PATHLIB_SUBSTITUTIONS: + raise ValueError(f"SyntaxError: Unknown field: {field}") + + field_value = None + try: + field_value = getattr(self, field_stem) + except AttributeError: + raise ValueError(f"Unknown path-like field: {field_stem}") + + value = _get_pathlib_value(field, field_value, self.quote) + + if self.filename: + value = sanitize_pathpart(value) + elif self.dirname: + value = sanitize_dirname(value) + + return [value]
+ + def get_template_value_filter(self, filter_, values): + if filter_ == "lower": + if values and type(values) == list: + value = [v.lower() for v in values] + else: + value = [values.lower()] if values else [] + elif filter_ == "upper": + if values and type(values) == list: + value = [v.upper() for v in values] + else: + value = [values.upper()] if values else [] + elif filter_ == "strip": + if values and type(values) == list: + value = [v.strip() for v in values] + else: + value = [values.strip()] if values else [] + elif filter_ == "capitalize": + if values and type(values) == list: + value = [v.capitalize() for v in values] + else: + value = [values.capitalize()] if values else [] + elif filter_ == "titlecase": + if values and type(values) == list: + value = [v.title() for v in values] + else: + value = [values.title()] if values else [] + elif filter_ == "braces": + if values and type(values) == list: + value = ["{" + v + "}" for v in values] + else: + value = ["{" + values + "}"] if values else [] + elif filter_ == "parens": + if values and type(values) == list: + value = ["(" + v + ")" for v in values] + else: + value = ["(" + values + ")"] if values else [] + elif filter_ == "brackets": + if values and type(values) == list: + value = ["[" + v + "]" for v in values] + else: + value = ["[" + values + "]"] if values else [] + elif filter_ == "shell_quote": + if values and type(values) == list: + value = [shlex.quote(v) for v in values] + else: + value = [shlex.quote(values)] if values else [] + elif filter_.startswith("function:"): + value = self.get_template_value_filter_function(filter_, values) + else: + value = [] + return value + +
[docs] def get_template_value_multi(self, field, subfield, path_sep, default): + """lookup value for template field (multi-value template substitutions) + + Args: + field: template field to find value for. + subfield: the template subfield value + path_sep: path separator to use for folder_album field + default: value of default field + + Returns: + List of the matching template values or []. + + Raises: + ValueError if no rule exists for field. + """ + + """ return list of values for a multi-valued template field """ + + if self.photo.uuid is None: + return [] + + values = [] + if field == "album": + values = self.photo.burst_albums if self.photo.burst else self.photo.albums + elif field == "project": + values = [p.title for p in self.photo.project_info] + elif field == "album_project": + values = self.photo.burst_albums if self.photo.burst else self.photo.albums + values += [p.title for p in self.photo.project_info] + elif field == "keyword": + values = self.photo.keywords + elif field == "person": + values = self.photo.persons + # remove any _UNKNOWN_PERSON values + values = [val for val in values if val != _UNKNOWN_PERSON] + elif field == "label": + values = self.photo.labels + elif field == "label_normalized": + values = self.photo.labels_normalized + elif field in ["folder_album", "folder_album_project"]: + values = [] + # photos must be in an album to be in a folder + if self.photo.burst: + album_info = self.photo.burst_album_info + else: + album_info = self.photo.album_info + if field == "folder_album_project": + album_info += self.photo.project_info + for album in album_info: + if album.folder_names: + # album in folder + if self.dirname: + # being used as a filepath so sanitize each part + folder = path_sep.join( + sanitize_dirname(f) for f in album.folder_names + ) + folder += path_sep + sanitize_dirname(album.title) + else: + folder = path_sep.join(album.folder_names) + folder += path_sep + album.title + values.append(folder) + elif self.dirname: + values.append(sanitize_dirname(album.title)) + else: + values.append(album.title) + elif field == "comment": + values = [ + f"{comment.user}: {comment.text}" for comment in self.photo.comments + ] + elif field == "searchinfo.holiday": + values = self.photo.search_info.holidays if self.photo.search_info else [] + elif field == "searchinfo.activity": + values = self.photo.search_info.activities if self.photo.search_info else [] + elif field == "searchinfo.venue": + values = self.photo.search_info.venues if self.photo.search_info else [] + elif field == "searchinfo.venue_type": + values = ( + self.photo.search_info.venue_types if self.photo.search_info else [] + ) + elif field == "shell_quote": + values = [shlex.quote(v) for v in default if v] + elif field == "strip": + values = [v.strip() for v in default] + elif field.startswith("photo"): + # provide access to PhotoInfo object + properties = field.split(".") + if len(properties) <= 1: + raise ValueError( + "Missing property in {photo} template. Use in form {photo.property}." + ) + obj = self.photo + for i in range(1, len(properties)): + property_ = properties[i] + try: + obj = getattr(obj, property_) + if obj is None: + break + except AttributeError: + raise ValueError( + "Invalid property for {photo} template: " + f"'{property_}'" + ) + if obj is None: + values = [] + elif isinstance(obj, bool): + values = [property_] if obj else [] + elif isinstance(obj, (str, int, float)): + values = [str(obj)] + else: + values = list(obj) + elif field == "detected_text": + values = _get_detected_text(self.photo, confidence=subfield) + else: + raise ValueError(f"Unhandled template value: {field}") + + # sanitize directory names if needed, folder_album handled differently above + if self.filename: + values = [sanitize_pathpart(value) for value in values] + elif self.dirname and field not in ["folder_album", "folder_album_project"]: + # skip folder_album because it would have been handled above + values = [sanitize_dirname(value) for value in values] + + # If no values, insert None so code below will substitute none_str for None + values = values or [] + return values
+ +
[docs] def get_template_value_exiftool( + self, + subfield, + ): + """Get template value for format "{exiftool:EXIF:Model}" """ + + if self.photo is None: + return [] + + if not self.photo.path: + return [] + + exif = ExifToolCaching(self.photo.path, exiftool=self.exiftool_path) + exifdict = exif.asdict(normalized=True) + subfield = subfield.lower() + if subfield in exifdict: + values = exifdict[subfield] + values = [values] if not isinstance(values, list) else values + values = [str(v) for v in values] + + # sanitize directory names if needed + if self.filename: + values = [sanitize_pathpart(value) for value in values] + elif self.dirname: + values = [sanitize_dirname(value) for value in values] + else: + values = [] + + return values
+ +
[docs] def get_template_value_function( + self, + subfield, + ): + """Get template value from external function""" + + if "::" not in subfield: + raise ValueError( + f"SyntaxError: could not parse function name from '{subfield}'" + ) + + filename, funcname = subfield.split("::") + + filename_validated = expand_and_validate_filepath(filename) + if not filename_validated: + raise ValueError(f"'{filename}' does not appear to be a file") + + template_func = load_function(filename_validated, funcname) + values = template_func(self.photo, options=self.options) + + if not isinstance(values, (str, list)): + raise TypeError( + f"Invalid return type for function {funcname}: expected str or list" + ) + if type(values) == str: + values = [values] + + # sanitize directory names if needed + if self.filename: + values = [sanitize_pathpart(value) for value in values] + elif self.dirname: + # sanitize but don't replace any "/" as user function may want to create sub directories + values = [sanitize_dirname(value, replacement=None) for value in values] + + return values
+ +
[docs] def get_template_value_filter_function(self, filter_, values): + """Filter template value from external function""" + + filter_ = filter_.replace("function:", "") + + if "::" not in filter_: + raise ValueError( + f"SyntaxError: could not parse function name from '{filter_}'" + ) + + filename, funcname = filter_.split("::") + + filename_validated = expand_and_validate_filepath(filename) + if not filename_validated: + raise ValueError(f"'{filename}' does not appear to be a file") + + template_func = load_function(filename_validated, funcname) + + if not isinstance(values, (list, tuple)): + values = [values] + values = template_func(values) + + if not isinstance(values, list): + raise TypeError( + f"Invalid return type for function {funcname}: expected list" + ) + + return values
+ +
[docs] def get_photo_video_type(self, default): + """return media type, e.g. photo or video""" + default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS) + if self.photo.isphoto: + return default_dict["photo"] + else: + return default_dict["video"]
+ +
[docs] def get_media_type(self, default): + """return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type""" + default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS) + p = self.photo + if p.selfie: + return default_dict["selfie"] + elif p.time_lapse: + return default_dict["time_lapse"] + elif p.panorama: + return default_dict["panorama"] + elif p.slow_mo: + return default_dict["slow_mo"] + elif p.screenshot: + return default_dict["screenshot"] + elif p.portrait: + return default_dict["portrait"] + elif p.live_photo: + return default_dict["live_photo"] + elif p.burst: + return default_dict["burst"] + elif p.ismovie: + return default_dict["video"] + else: + return default_dict["photo"]
+ + def get_photo_bool_attribute(self, attr, default, bool_val): + # get value for a PhotoInfo bool attribute + val = getattr(self.photo, attr) + if val: + return bool_val + else: + return default
+ + +def parse_default_kv(default, default_dict): + """parse a string in form key1=value1;key2=value2,... as used for some template fields + + Args: + default: str, in form 'photo=foto;video=vidéo' + default_dict: dict, in form {"photo": "fotos", "video": "vidéos"} with default values + + Returns: + dict in form {"photo": "fotos", "video": "vidéos"} + """ + + default_dict_ = default_dict.copy() + if default: + defaults = default[0].split(";") + for kv in defaults: + try: + k, v = kv.split("=") + k = k.strip() + v = v.strip() + default_dict_[k] = v + except ValueError: + pass + return default_dict_ + + +def get_template_help(): + """Return help for template system as markdown string""" + # TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this + help_file = pathlib.Path(__file__).parent / "phototemplate.md" + with open(help_file, "r") as fd: + md = fd.read() + return md + + +def _get_pathlib_value(field, value, quote): + """Get the value for a pathlib.Path type template + + Args: + field: the path field, e.g. "filename.stem" + value: the value for the path component + quote: bool; if true, quotes the returned path for safe execution in the shell + """ + parts = field.split(".") + + if len(parts) == 1: + return shlex.quote(value) if quote else value + + if len(parts) > 2: + raise ValueError(f"Illegal value for path template: {field}") + + path = parts[0] + attribute = parts[1] + path = pathlib.Path(value) + try: + val = getattr(path, attribute) + val_str = str(val) + if quote: + val_str = shlex.quote(val_str) + return val_str + except AttributeError: + raise ValueError("Illegal value for path template: {attribute}") + + +def format_str_value(value, format_str): + """Format value based on format code in field in format id:02d""" + if not format_str: + return str(value) + format_str = "{0:" + f"{format_str}" + "}" + return format_str.format(value) + + +def _get_album_by_name(photo, album): + """Finds first album named album that photo is in and returns the AlbumInfo object, otherwise returns None""" + for album_info in photo.album_info: + if album_info.title == album: + return album_info + return None + + +def _get_album_by_path(photo, folder_album_path): + """finds the first album whose folder_album path matches and folder_album_path and returns the AlbumInfo object, otherwise, returns None""" + + for album_info in photo.album_info: + # following code is how {folder_album} builds the folder path + folder = "/".join(sanitize_dirname(f) for f in album_info.folder_names) + folder += "/" + sanitize_dirname(album_info.title) + if folder_album_path.endswith(folder): + return album_info + return None + + +def _get_detected_text(photo, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD): + """Returns the detected text for a photo + {detected_text} uses this instead of PhotoInfo.detected_text() to cache the text for all confidence values + """ + if not photo.isphoto: + return [] + + confidence = ( + float(confidence) + if confidence is not None + else TEXT_DETECTION_CONFIDENCE_THRESHOLD + ) + + # _detected_text caches the text detection results in an extended attribute + # so the first time this gets called is slow but repeated accesses are fast + detected_text = photo._detected_text() + return [text for text, conf in detected_text if conf >= confidence] +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+
+ +
+
+
+ +
+
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/placeinfo.html b/docs/_modules/osxphotos/placeinfo.html new file mode 100644 index 00000000..2ac2be83 --- /dev/null +++ b/docs/_modules/osxphotos/placeinfo.html @@ -0,0 +1,758 @@ + + + + + + + + osxphotos.placeinfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.placeinfo

+""" 
+    PlaceInfo class
+    Provides reverse geolocation info for photos 
+    
+    See https://developer.apple.com/documentation/corelocation/clplacemark
+    for additional documentation on reverse geolocation data
+"""
+from abc import ABC, abstractmethod
+from collections import namedtuple  # pylint: disable=syntax-error
+
+import yaml
+from bpylist import archiver
+
+from ._constants import UNICODE_FORMAT
+from .utils import normalize_unicode
+
+__all__ = [
+    "PLRevGeoLocationInfo",
+    "PLRevGeoMapItem",
+    "PLRevGeoMapItemAdditionalPlaceInfo",
+    "CNPostalAddress",
+    "PlaceInfo",
+    "PlaceInfo4",
+    "PlaceInfo5",
+]
+
+# postal address information, returned by PlaceInfo.address
+PostalAddress = namedtuple(
+    "PostalAddress",
+    [
+        "street",
+        "sub_locality",
+        "city",
+        "sub_administrative_area",
+        "state_province",
+        "postal_code",
+        "country",
+        "iso_country_code",
+    ],
+)
+
+# PlaceNames tuple returned by PlaceInfo.names
+# order of fields 0 - 17 is mapped to placeType value in
+# PLRevGeoLocationInfo.mapInfo.sortedPlaceInfos
+# field 18 is combined bodies of water (ocean + inland_water)
+# and maps to Photos <= 4, RKPlace.type == 44
+# (Photos <= 4 doesn't have ocean or inland_water types)
+# The fields named "field0", etc. appear to be unused
+PlaceNames = namedtuple(
+    "PlaceNames",
+    [
+        "field0",
+        "country",  # The name of the country associated with the placemark.
+        "state_province",  # administrativeArea, The state or province associated with the placemark.
+        "sub_administrative_area",  # Additional administrative area information for the placemark.
+        "city",  # locality, The city associated with the placemark.
+        "field5",
+        "additional_city_info",  # subLocality, Additional city-level information for the placemark.
+        "ocean",  # The name of the ocean associated with the placemark.
+        "area_of_interest",  # areasOfInterest, The relevant areas of interest associated with the placemark.
+        "inland_water",  # The name of the inland water body associated with the placemark.
+        "field10",
+        "region",  # The geographic region associated with the placemark.
+        "sub_throughfare",  # Additional street-level information for the placemark.
+        "field13",
+        "postal_code",  # The postal code associated with the placemark.
+        "field15",
+        "field16",
+        "street_address",  # throughfare, The street address associated with the placemark.
+        "body_of_water",  # RKPlace.type == 44, appears to be any body of water (ocean or inland)
+    ],
+)
+
+# The following classes represent Photo Library Reverse Geolocation Info as stored
+# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
+# These classes are used by bpylist.archiver to unarchive the serialized objects
+class PLRevGeoLocationInfo:
+    """The top level reverse geolocation object"""
+
+    def __init__(
+        self,
+        addressString,
+        countryCode,
+        mapItem,
+        isHome,
+        compoundNames,
+        compoundSecondaryNames,
+        version,
+        geoServiceProvider,
+        postalAddress,
+    ):
+        self.addressString = normalize_unicode(addressString)
+        self.countryCode = countryCode
+        self.mapItem = mapItem
+        self.isHome = isHome
+        self.compoundNames = normalize_unicode(compoundNames)
+        self.compoundSecondaryNames = normalize_unicode(compoundSecondaryNames)
+        self.version = version
+        self.geoServiceProvider = geoServiceProvider
+        self.postalAddress = postalAddress
+
+    def __eq__(self, other):
+        return all(
+            getattr(self, field) == getattr(other, field)
+            for field in [
+                "addressString",
+                "countryCode",
+                "isHome",
+                "compoundNames",
+                "compoundSecondaryNames",
+                "version",
+                "geoServiceProvider",
+                "postalAddress",
+            ]
+        )
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __str__(self):
+        return f"addressString: {self.addressString}, countryCode: {self.countryCode}, isHome: {self.isHome}, mapItem: {self.mapItem}, postalAddress: {self.postalAddress}"
+
+    @staticmethod
+    def encode_archive(obj, archive):
+        archive.encode("addressString", obj.addressString)
+        archive.encode("countryCode", obj.countryCode)
+        archive.encode("mapItem", obj.mapItem)
+        archive.encode("isHome", obj.isHome)
+        archive.encode("compoundNames", obj.compoundNames)
+        archive.encode("compoundSecondaryNames", obj.compoundSecondaryNames)
+        archive.encode("version", obj.version)
+        archive.encode("geoServiceProvider", obj.geoServiceProvider)
+        archive.encode("postalAddress", obj.postalAddress)
+
+    @staticmethod
+    def decode_archive(archive):
+        addressString = archive.decode("addressString")
+        countryCode = archive.decode("countryCode")
+        mapItem = archive.decode("mapItem")
+        isHome = archive.decode("isHome")
+        compoundNames = archive.decode("compoundNames")
+        compoundSecondaryNames = archive.decode("compoundSecondaryNames")
+        version = archive.decode("version")
+        geoServiceProvider = archive.decode("geoServiceProvider")
+        postalAddress = archive.decode("postalAddress")
+        return PLRevGeoLocationInfo(
+            addressString,
+            countryCode,
+            mapItem,
+            isHome,
+            compoundNames,
+            compoundSecondaryNames,
+            version,
+            geoServiceProvider,
+            postalAddress,
+        )
+
+
+class PLRevGeoMapItem:
+    """Stores the list of place names, organized by area"""
+
+    def __init__(self, sortedPlaceInfos, finalPlaceInfos):
+        self.sortedPlaceInfos = sortedPlaceInfos
+        self.finalPlaceInfos = finalPlaceInfos
+
+    def __eq__(self, other):
+        return all(
+            getattr(self, field) == getattr(other, field)
+            for field in ["sortedPlaceInfos", "finalPlaceInfos"]
+        )
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __str__(self):
+        sortedPlaceInfos = [str(place) for place in self.sortedPlaceInfos]
+        finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
+        return (
+            f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
+        )
+
+    @staticmethod
+    def encode_archive(obj, archive):
+        archive.encode("sortedPlaceInfos", obj.sortedPlaceInfos)
+        archive.encode("finalPlaceInfos", obj.finalPlaceInfos)
+
+    @staticmethod
+    def decode_archive(archive):
+        sortedPlaceInfos = archive.decode("sortedPlaceInfos")
+        finalPlaceInfos = archive.decode("finalPlaceInfos")
+        return PLRevGeoMapItem(sortedPlaceInfos, finalPlaceInfos)
+
+
+class PLRevGeoMapItemAdditionalPlaceInfo:
+    """Additional info about individual places"""
+
+    def __init__(self, area, name, placeType, dominantOrderType):
+        self.area = area
+        self.name = normalize_unicode(name)
+        self.placeType = placeType
+        self.dominantOrderType = dominantOrderType
+
+    def __eq__(self, other):
+        return all(
+            getattr(self, field) == getattr(other, field)
+            for field in ["area", "name", "placeType", "dominantOrderType"]
+        )
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __str__(self):
+        return f"area: {self.area}, name: {self.name}, placeType: {self.placeType}"
+
+    @staticmethod
+    def encode_archive(obj, archive):
+        archive.encode("area", obj.area)
+        archive.encode("name", obj.name)
+        archive.encode("placeType", obj.placeType)
+        archive.encode("dominantOrderType", obj.dominantOrderType)
+
+    @staticmethod
+    def decode_archive(archive):
+        area = archive.decode("area")
+        name = archive.decode("name")
+        placeType = archive.decode("placeType")
+        dominantOrderType = archive.decode("dominantOrderType")
+        return PLRevGeoMapItemAdditionalPlaceInfo(
+            area, name, placeType, dominantOrderType
+        )
+
+
+class CNPostalAddress:
+    """postal address for the reverse geolocation info"""
+
+    def __init__(
+        self,
+        _ISOCountryCode,
+        _city,
+        _country,
+        _postalCode,
+        _state,
+        _street,
+        _subAdministrativeArea,
+        _subLocality,
+    ):
+        self._ISOCountryCode = _ISOCountryCode
+        self._city = normalize_unicode(_city)
+        self._country = normalize_unicode(_country)
+        self._postalCode = normalize_unicode(_postalCode)
+        self._state = normalize_unicode(_state)
+        self._street = normalize_unicode(_street)
+        self._subAdministrativeArea = normalize_unicode(_subAdministrativeArea)
+        self._subLocality = normalize_unicode(_subLocality)
+
+    def __eq__(self, other):
+        return all(
+            getattr(self, field) == getattr(other, field)
+            for field in [
+                "_ISOCountryCode",
+                "_city",
+                "_country",
+                "_postalCode",
+                "_state",
+                "_street",
+                "_subAdministrativeArea",
+                "_subLocality",
+            ]
+        )
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __str__(self):
+        return ", ".join(
+            map(
+                str,
+                [
+                    self._street,
+                    self._city,
+                    self._subLocality,
+                    self._subAdministrativeArea,
+                    self._state,
+                    self._postalCode,
+                    self._country,
+                    self._ISOCountryCode,
+                ],
+            )
+        )
+
+    @staticmethod
+    def encode_archive(obj, archive):
+        archive.encode("_ISOCountryCode", obj._ISOCountryCode)
+        archive.encode("_country", obj._country)
+        archive.encode("_city", obj._city)
+        archive.encode("_postalCode", obj._postalCode)
+        archive.encode("_state", obj._state)
+        archive.encode("_street", obj._street)
+        archive.encode("_subAdministrativeArea", obj._subAdministrativeArea)
+        archive.encode("_subLocality", obj._subLocality)
+
+    @staticmethod
+    def decode_archive(archive):
+        _ISOCountryCode = archive.decode("_ISOCountryCode")
+        _country = archive.decode("_country")
+        _city = archive.decode("_city")
+        _postalCode = archive.decode("_postalCode")
+        _state = archive.decode("_state")
+        _street = archive.decode("_street")
+        _subAdministrativeArea = archive.decode("_subAdministrativeArea")
+        _subLocality = archive.decode("_subLocality")
+
+        return CNPostalAddress(
+            _ISOCountryCode,
+            _city,
+            _country,
+            _postalCode,
+            _state,
+            _street,
+            _subAdministrativeArea,
+            _subLocality,
+        )
+
+
+# register the classes with bpylist.archiver
+archiver.update_class_map({"CNPostalAddress": CNPostalAddress})
+archiver.update_class_map(
+    {"PLRevGeoMapItemAdditionalPlaceInfo": PLRevGeoMapItemAdditionalPlaceInfo}
+)
+archiver.update_class_map({"PLRevGeoMapItem": PLRevGeoMapItem})
+archiver.update_class_map({"PLRevGeoLocationInfo": PLRevGeoLocationInfo})
+
+
+
[docs]class PlaceInfo(ABC): + @property + @abstractmethod + def address_str(self): + pass + + @property + @abstractmethod + def country_code(self): + pass + + @property + @abstractmethod + def ishome(self): + pass + + @property + @abstractmethod + def name(self): + pass + + @property + @abstractmethod + def names(self): + pass + + @property + @abstractmethod + def address(self): + pass
+ + +class PlaceInfo4(PlaceInfo): + """Reverse geolocation place info for a photo (Photos <= 4)""" + + def __init__(self, place_names, country_code): + """place_names: list of place name tuples in ascending order by area + tuple fields are: modelID, place name, place type, area, e.g. + [(5, "St James's Park", 45, 0), + (4, 'Westminster', 16, 22097376), + (3, 'London', 4, 1596146816), + (2, 'England', 2, 180406091776), + (1, 'United Kingdom', 1, 414681432064)] + country_code: two letter country code for the country + """ + self._place_names = place_names + self._country_code = country_code + self._process_place_info() + + @property + def address_str(self): + return None + + @property + def country_code(self): + return self._country_code + + @property + def ishome(self): + return None + + @property + def name(self): + return self._name + + @property + def names(self): + return self._names + + @property + def address(self): + return PostalAddress(None, None, None, None, None, None, None, None) + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + else: + return ( + self._place_names == other._place_names + and self._country_code == other._country_code + ) + + def _process_place_info(self): + """Process place_names to set self._name and self._names""" + places = self._place_names + + # build a dictionary where key is placetype + places_dict = {} + for p in places: + # places in format: + # [(5, "St James's Park", 45, 0), ] + # 0: modelID + # 1: name + # 2: type + # 3: area + try: + places_dict[p[2]].append((normalize_unicode(p[1]), p[3])) + except KeyError: + places_dict[p[2]] = [(normalize_unicode(p[1]), p[3])] + + # build list to populate PlaceNames tuple + # initialize with empty lists for each field in PlaceNames + place_info = [[]] * 19 + + # add the place names sorted by area (ascending) + # in Photos <=4, possible place type values are: + # 45: areasOfInterest (The relevant areas of interest associated with the placemark.) + # 44: body of water (includes both inlandWater and ocean) + # 43: subLocality (Additional city-level information for the placemark. + # 16: locality (The city associated with the placemark.) + # 4: subAdministrativeArea (Additional administrative area information for the placemark.) + # 2: administrativeArea (The state or province associated with the placemark.) + # 1: country + # mapping = mapping from PlaceNames to field in places_dict + # PlaceNames fields map to the placeType value in Photos5 (0..17) + # but place type in Photos <=4 has different values + # hence (3, 4) means PlaceNames[3] = places_dict[4] (sub_administrative_area) + mapping = [(1, 1), (2, 2), (3, 4), (4, 16), (18, 44), (8, 45)] + for field5, field4 in mapping: + try: + place_info[field5] = [ + p[0] + for p in sorted(places_dict[field4], key=lambda place: place[1]) + ] + except KeyError: + pass + + place_names = PlaceNames(*place_info) + self._names = place_names + + # build the name as it appears in Photos + # the length of the name is at most 3 fields and appears to be based on available + # reverse geolocation data in the following order (left to right, joined by ',') + # always has country if available then either area of interest and city OR + # city and state + # e.g. 4, 2, 1 OR 8, 4, 1 + # 8 (45): area_of_interest + # 4 (16): locality / city + # 2 (2): administrative area (state/province) + # 1 (1): country + name_list = [] + if place_names[8]: + name_list.append(place_names[8][0]) + if place_names[4]: + name_list.append(place_names[4][0]) + elif place_names[4]: + name_list.append(place_names[4][0]) + if place_names[2]: + name_list.append(place_names[2][0]) + elif place_names[2]: + name_list.append(place_names[2][0]) + + # add country + if place_names[1]: + name_list.append(place_names[1][0]) + + name = ", ".join(name_list) + self._name = name if name != "" else None + + def __ne__(self, other): + return not self.__eq__(other) + + def __str__(self): + info = { + "name": self.name, + "names": self.names, + "country_code": self.country_code, + } + return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")" + + def asdict(self): + return { + "name": self.name, + "names": self.names._asdict(), + "country_code": self.country_code, + } + + +class PlaceInfo5(PlaceInfo): + """Reverse geolocation place info for a photo (Photos >= 5)""" + + def __init__(self, revgeoloc_bplist): + """revgeoloc_bplist: a binary plist blob containing + a serialized PLRevGeoLocationInfo object""" + self._bplist = revgeoloc_bplist + self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist) + self._process_place_info() + + @property + def address_str(self): + """returns the postal address as a string""" + return self._plrevgeoloc.addressString + + @property + def country_code(self): + """returns the country code""" + return self._plrevgeoloc.countryCode + + @property + def ishome(self): + """returns True if place is user's home address""" + return self._plrevgeoloc.isHome + + @property + def name(self): + """returns local place name""" + return self._name + + @property + def names(self): + """returns PlaceNames tuple with detailed reverse geolocation place names""" + return self._names + + @property + def address(self): + addr = self._plrevgeoloc.postalAddress + if addr is not None: + postal_address = PostalAddress( + street=addr._street, + sub_locality=addr._subLocality, + city=addr._city, + sub_administrative_area=addr._subAdministrativeArea, + state_province=addr._state, + postal_code=addr._postalCode, + country=addr._country, + iso_country_code=addr._ISOCountryCode, + ) + else: + postal_address = PostalAddress( + None, None, None, None, None, None, None, None + ) + + return postal_address + + def _process_place_info(self): + """Process sortedPlaceInfos to set self._name and self._names""" + places = self._plrevgeoloc.mapItem.sortedPlaceInfos + + # build a dictionary where key is placetype + places_dict = {} + for p in places: + try: + places_dict[p.placeType].append((p.name, p.area)) + except KeyError: + places_dict[p.placeType] = [(p.name, p.area)] + + # build list to populate PlaceNames tuple + place_info = [] + for field in range(18): + try: + # add the place names sorted by area (ascending) + place_info.append( + [ + p[0] + for p in sorted(places_dict[field], key=lambda place: place[1]) + ] + ) + except: + place_info.append([]) + + # fill in body_of_water for compatibility with Photos <= 4 + place_info.append(place_info[7] + place_info[9]) + + place_names = PlaceNames(*place_info) + self._names = place_names + + # build the name as it appears in Photos + # the length of the name is variable and appears to be based on available + # reverse geolocation data in the following order (left to right, joined by ',') + # 8: area_of_interest + # 11: region (I've only seen this applied to islands) + # 4: locality / city + # 2: administrative area (state/province) + # 1: country + # 9: inland_water + # 7: ocean + name = ", ".join( + [ + p[0] + for p in [ + place_names[8], # area of interest + place_names[11], # region (I've only seen this applied to islands) + place_names[4], # locality / city + place_names[2], # administrative area (state/province) + place_names[1], # country + place_names[9], # inland_water + place_names[7], # ocean + ] + if p and p[0] + ] + ) + self._name = name if name != "" else None + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + else: + return self._plrevgeoloc == other._plrevgeoloc + + def __ne__(self, other): + return not self.__eq__(other) + + def __str__(self): + info = { + "name": self.name, + "names": self.names, + "country_code": self.country_code, + "ishome": self.ishome, + "address_str": self.address_str, + "address": str(self.address), + } + return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")" + + def asdict(self): + return { + "name": self.name, + "names": self.names._asdict(), + "country_code": self.country_code, + "ishome": self.ishome, + "address_str": self.address_str, + "address": self.address._asdict() if self.address is not None else None, + } +
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/queryoptions.html b/docs/_modules/osxphotos/queryoptions.html new file mode 100644 index 00000000..57df3263 --- /dev/null +++ b/docs/_modules/osxphotos/queryoptions.html @@ -0,0 +1,411 @@ + + + + + + + + osxphotos.queryoptions - osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+ +
+ +
+
+

Source code for osxphotos.queryoptions

+""" QueryOptions class for PhotosDB.query """
+
+import datetime
+from dataclasses import asdict, dataclass
+from typing import Iterable, List, Optional, Tuple
+
+import bitmath
+
+__all__ = ["QueryOptions"]
+
+
+
[docs]@dataclass +class QueryOptions: + + """QueryOptions class for PhotosDB.query + + Attributes: + keyword: list of keywords to search for + person: list of person names to search for + album: list of album names to search for + folder: list of folder names to search for + uuid: list of uuids to search for + title: list of titles to search for + no_title: search for photos with no title + description: list of descriptions to search for + no_description: search for photos with no description + ignore_case: ignore case when searching + edited: search for edited photos + external_edit: search for photos edited in external apps + favorite: search for favorite photos + not_favorite: search for non-favorite photos + hidden: search for hidden photos + not_hidden: search for non-hidden photos + missing: search for missing photos + not_missing: search for non-missing photos + shared: search for shared photos + not_shared: search for non-shared photos + photos: search for photos + movies: search for movies + uti: list of UTIs to search for + burst: search for burst photos + not_burst: search for non-burst photos + live: search for live photos + not_live: search for non-live photos + cloudasset: search for photos that are managed by iCloud + not_cloudasset: search for photos that are not managed by iCloud + incloud: search for cloud assets that are synched to iCloud + not_incloud: search for cloud asset photos that are not yet synched to iCloud + from_date: search for photos taken on or after this date + to_date: search for photos taken on or before this date + portrait: search for portrait photos + not_portrait: search for non-portrait photos + screenshot: search for screenshot photos + not_screenshot: search for non-screenshot photos + slow_mo: search for slow-mo photos + not_slow_mo: search for non-slow-mo photos + time_lapse: search for time-lapse photos + not_time_lapse: search for non-time-lapse photos + hdr: search for HDR photos + not_hdr: search for non-HDR photos + selfie: search for selfie photos + not_selfie: search for non-selfie photos + panorama: search for panorama photos + not_panorama: search for non-panorama photos + has_raw: search for photos with associated raw files + place: list of place names to search for + no_place: search for photos with no place + label: list of labels to search for + deleted: also include deleted photos + deleted_only: search only for deleted photos + has_comment: search for photos with comments + no_comment: search for photos with no comments + has_likes: search for shared photos with likes + no_likes: search for shared photos with no likes + is_reference: search for photos stored by reference (that is, they are not managed by Photos) + in_album: search for photos in an album + not_in_album: search for photos not in an album + burst_photos: search for burst photos + missing_bursts: for burst photos, also include burst photos that are missing + name: list of names to search for + min_size: minimum size of photos to search for + max_size: maximum size of photos to search for + regex: list of regular expressions to search for + query_eval: list of query expressions to evaluate + duplicate: search for duplicate photos + location: search for photos with a location + no_location: search for photos with no location + function: list of query functions to evaluate + selected: search for selected photos + exif: search for photos with EXIF tags that matches the given data + year: search for photos taken in a given year + + """ + + keyword: Optional[Iterable[str]] = None + person: Optional[Iterable[str]] = None + album: Optional[Iterable[str]] = None + folder: Optional[Iterable[str]] = None + uuid: Optional[Iterable[str]] = None + title: Optional[Iterable[str]] = None + no_title: Optional[bool] = None + description: Optional[Iterable[str]] = None + no_description: Optional[bool] = None + ignore_case: Optional[bool] = None + edited: Optional[bool] = None + external_edit: Optional[bool] = None + favorite: Optional[bool] = None + not_favorite: Optional[bool] = None + hidden: Optional[bool] = None + not_hidden: Optional[bool] = None + missing: Optional[bool] = None + not_missing: Optional[bool] = None + shared: Optional[bool] = None + not_shared: Optional[bool] = None + photos: Optional[bool] = True + movies: Optional[bool] = True + uti: Optional[Iterable[str]] = None + burst: Optional[bool] = None + not_burst: Optional[bool] = None + live: Optional[bool] = None + not_live: Optional[bool] = None + cloudasset: Optional[bool] = None + not_cloudasset: Optional[bool] = None + incloud: Optional[bool] = None + not_incloud: Optional[bool] = None + from_date: Optional[datetime.datetime] = None + to_date: Optional[datetime.datetime] = None + from_time: Optional[datetime.time] = None + to_time: Optional[datetime.time] = None + portrait: Optional[bool] = None + not_portrait: Optional[bool] = None + screenshot: Optional[bool] = None + not_screenshot: Optional[bool] = None + slow_mo: Optional[bool] = None + not_slow_mo: Optional[bool] = None + time_lapse: Optional[bool] = None + not_time_lapse: Optional[bool] = None + hdr: Optional[bool] = None + not_hdr: Optional[bool] = None + selfie: Optional[bool] = None + not_selfie: Optional[bool] = None + panorama: Optional[bool] = None + not_panorama: Optional[bool] = None + has_raw: Optional[bool] = None + place: Optional[Iterable[str]] = None + no_place: Optional[bool] = None + label: Optional[Iterable[str]] = None + deleted: Optional[bool] = None + deleted_only: Optional[bool] = None + has_comment: Optional[bool] = None + no_comment: Optional[bool] = None + has_likes: Optional[bool] = None + no_likes: Optional[bool] = None + is_reference: Optional[bool] = None + in_album: Optional[bool] = None + not_in_album: Optional[bool] = None + burst_photos: Optional[bool] = None + missing_bursts: Optional[bool] = None + name: Optional[Iterable[str]] = None + min_size: Optional[bitmath.Byte] = None + max_size: Optional[bitmath.Byte] = None + regex: Optional[Iterable[Tuple[str, str]]] = None + query_eval: Optional[Iterable[str]] = None + duplicate: Optional[bool] = None + location: Optional[bool] = None + no_location: Optional[bool] = None + function: Optional[List[Tuple[callable, str]]] = None + selected: Optional[bool] = None + exif: Optional[Iterable[Tuple[str, str]]] = None + year: Optional[Iterable[int]] = None + + def asdict(self): + return asdict(self)
+
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+
+ +
+
+
+ +
+
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/scoreinfo.html b/docs/_modules/osxphotos/scoreinfo.html new file mode 100644 index 00000000..241551d0 --- /dev/null +++ b/docs/_modules/osxphotos/scoreinfo.html @@ -0,0 +1,143 @@ + + + + + + + + osxphotos.scoreinfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.scoreinfo

+""" ScoreInfo class to expose computed score info from the library """
+
+from dataclasses import dataclass
+
+from ._constants import _PHOTOS_4_VERSION
+
+__all__ = ["ScoreInfo"]
+
+
+
[docs]@dataclass(frozen=True) +class ScoreInfo: + """Computed photo score info associated with a photo from the Photos library""" + + overall: float + curation: float + promotion: float + highlight_visibility: float + behavioral: float + failure: float + harmonious_color: float + immersiveness: float + interaction: float + interesting_subject: float + intrusive_object_presence: float + lively_color: float + low_light: float + noise: float + pleasant_camera_tilt: float + pleasant_composition: float + pleasant_lighting: float + pleasant_pattern: float + pleasant_perspective: float + pleasant_post_processing: float + pleasant_reflection: float + pleasant_symmetry: float + sharply_focused_subject: float + tastefully_blurred: float + well_chosen_subject: float + well_framed_subject: float + well_timed_shot: float
+
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_modules/osxphotos/searchinfo.html b/docs/_modules/osxphotos/searchinfo.html new file mode 100644 index 00000000..4b149c8c --- /dev/null +++ b/docs/_modules/osxphotos/searchinfo.html @@ -0,0 +1,325 @@ + + + + + + + + osxphotos.searchinfo — osxphotos 0.47.9 documentation + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +

Source code for osxphotos.searchinfo

+""" class for PhotoInfo exposing SearchInfo data such as labels 
+"""
+
+from ._constants import (
+    _PHOTOS_4_VERSION,
+    SEARCH_CATEGORY_ACTIVITY,
+    SEARCH_CATEGORY_ALL_LOCALITY,
+    SEARCH_CATEGORY_BODY_OF_WATER,
+    SEARCH_CATEGORY_CITY,
+    SEARCH_CATEGORY_COUNTRY,
+    SEARCH_CATEGORY_HOLIDAY,
+    SEARCH_CATEGORY_LABEL,
+    SEARCH_CATEGORY_MEDIA_TYPES,
+    SEARCH_CATEGORY_MONTH,
+    SEARCH_CATEGORY_NEIGHBORHOOD,
+    SEARCH_CATEGORY_PLACE_NAME,
+    SEARCH_CATEGORY_SEASON,
+    SEARCH_CATEGORY_STATE,
+    SEARCH_CATEGORY_STATE_ABBREVIATION,
+    SEARCH_CATEGORY_STREET,
+    SEARCH_CATEGORY_VENUE,
+    SEARCH_CATEGORY_VENUE_TYPE,
+    SEARCH_CATEGORY_YEAR,
+)
+
+__all__ = ["SearchInfo"]
+
+
+
[docs]class SearchInfo: + """Info about search terms such as machine learning labels that Photos knows about a photo""" + + def __init__(self, photo, normalized=False): + """photo: PhotoInfo object + normalized: if True, all properties return normalized (lower case) results""" + + if photo._db._db_version <= _PHOTOS_4_VERSION: + raise NotImplementedError( + "search info not implemented for this database version" + ) + + self._photo = photo + self._normalized = normalized + self.uuid = photo.uuid + try: + # get search info for this UUID + # there might not be any search info data (e.g. if Photo was missing or photoanalysisd not run yet) + self._db_searchinfo = photo._db._db_searchinfo_uuid[self.uuid] + except KeyError: + self._db_searchinfo = None + + @property + def labels(self): + """return list of labels associated with Photo""" + return self._get_text_for_category(SEARCH_CATEGORY_LABEL) + + @property + def place_names(self): + """returns list of place names""" + return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME) + + @property + def streets(self): + """returns list of street names""" + return self._get_text_for_category(SEARCH_CATEGORY_STREET) + + @property + def neighborhoods(self): + """returns list of neighborhoods""" + return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD) + + @property + def locality_names(self): + """returns list of other locality names""" + locality = [] + for category in SEARCH_CATEGORY_ALL_LOCALITY: + locality += self._get_text_for_category(category) + return locality + + @property + def city(self): + """returns city/town""" + city = self._get_text_for_category(SEARCH_CATEGORY_CITY) + return city[0] if city else "" + + @property + def state(self): + """returns state name""" + state = self._get_text_for_category(SEARCH_CATEGORY_STATE) + return state[0] if state else "" + + @property + def state_abbreviation(self): + """returns state abbreviation""" + abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION) + return abbrev[0] if abbrev else "" + + @property + def country(self): + """returns country name""" + country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY) + return country[0] if country else "" + + @property + def month(self): + """returns month name""" + month = self._get_text_for_category(SEARCH_CATEGORY_MONTH) + return month[0] if month else "" + + @property + def year(self): + """returns year""" + year = self._get_text_for_category(SEARCH_CATEGORY_YEAR) + return year[0] if year else "" + + @property + def bodies_of_water(self): + """returns list of body of water names""" + return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER) + + @property + def holidays(self): + """returns list of holiday names""" + return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY) + + @property + def activities(self): + """returns list of activity names""" + return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY) + + @property + def season(self): + """returns season name""" + season = self._get_text_for_category(SEARCH_CATEGORY_SEASON) + return season[0] if season else "" + + @property + def venues(self): + """returns list of venue names""" + return self._get_text_for_category(SEARCH_CATEGORY_VENUE) + + @property + def venue_types(self): + """returns list of venue types""" + return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE) + + @property + def media_types(self): + """returns list of media types (photo, video, panorama, etc)""" + types = [] + for category in SEARCH_CATEGORY_MEDIA_TYPES: + types += self._get_text_for_category(category) + return types + + @property + def all(self): + """return all search info properties in a single list""" + all = ( + self.labels + + self.place_names + + self.streets + + self.neighborhoods + + self.locality_names + + self.bodies_of_water + + self.holidays + + self.activities + + self.venues + + self.venue_types + + self.media_types + ) + if self.city: + all += [self.city] + if self.state: + all += [self.state] + if self.state_abbreviation: + all += [self.state_abbreviation] + if self.country: + all += [self.country] + if self.month: + all += [self.month] + if self.year: + all += [self.year] + if self.season: + all += [self.season] + + return all + +
[docs] def asdict(self): + """return dict of search info""" + return { + "labels": self.labels, + "place_names": self.place_names, + "streets": self.streets, + "neighborhoods": self.neighborhoods, + "city": self.city, + "locality_names": self.locality_names, + "state": self.state, + "state_abbreviation": self.state_abbreviation, + "country": self.country, + "bodies_of_water": self.bodies_of_water, + "month": self.month, + "year": self.year, + "holidays": self.holidays, + "activities": self.activities, + "season": self.season, + "venues": self.venues, + "venue_types": self.venue_types, + "media_types": self.media_types, + }
+ + def _get_text_for_category(self, category): + """return list of text for a specified category ID""" + if self._db_searchinfo: + content = "normalized_string" if self._normalized else "content_string" + return sorted( + [ + rec[content] + for rec in self._db_searchinfo + if rec["category"] == category + ] + ) + else: + return []
+
+ +
+ +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/_sources/cli.rst.txt b/docs/_sources/cli.rst.txt index ac832143..1b0be076 100644 --- a/docs/_sources/cli.rst.txt +++ b/docs/_sources/cli.rst.txt @@ -1,4 +1,4 @@ -osxphotos command line interface (CLI) +osxphotos Command Line Interface (CLI) ====================================== .. click:: osxphotos.cli:cli_main diff --git a/docs/_sources/cli_export.rst.txt b/docs/_sources/cli_export.rst.txt new file mode 100644 index 00000000..2796c2ee --- /dev/null +++ b/docs/_sources/cli_export.rst.txt @@ -0,0 +1,7 @@ +osxphotos export +====================================== + +.. click:: osxphotos.cli:cli_main + :prog: osxphotos + :commands: export + :nested: full diff --git a/docs/_sources/index.rst.txt b/docs/_sources/index.rst.txt index 5e14cd04..3fe98cac 100644 --- a/docs/_sources/index.rst.txt +++ b/docs/_sources/index.rst.txt @@ -6,12 +6,14 @@ Welcome to osxphotos's documentation! ===================================== -.. include:: ../../README.rst - .. toctree:: :maxdepth: 4 + overview + tutorial cli + template_help + package_overview reference diff --git a/docs/_sources/overview.rst.txt b/docs/_sources/overview.rst.txt new file mode 100644 index 00000000..67ed8079 --- /dev/null +++ b/docs/_sources/overview.rst.txt @@ -0,0 +1,81 @@ +osxphotos +========= + +What is osxphotos? +------------------ + +osxphotos provides both the ability to interact with and query Apple's Photos.app library on macOS directly from your python code +as well as a very flexible command line interface (CLI) app for exporting photos. +You can query the Photos library database -- 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. + +Supported operating systems +--------------------------- + +Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Monterey (12.3). + +This package will read Photos databases for any supported version on any supported macOS version. +E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa. + +Requires python >= ``3.8``. + +Installation +------------ + +The recommended way of installing ``osxphotos`` is with `pipx `_. The easiest way to do this on a Mac is to use `homebrew `_\ : + + +* Open ``Terminal`` (search for ``Terminal`` in Spotlight or look in ``Applications/Utilities``\ ) +* Install ``homebrew`` according to instructions at `https://brew.sh/ `_ +* Type the following into Terminal: ``brew install pipx`` +* Then type this: ``pipx install osxphotos`` +* Now you should be able to run ``osxphotos`` by typing: ``osxphotos`` + +Command Line Usage +------------------ + +This package will install a command line utility called ``osxphotos`` that allows you to query the Photos database and export photos. + +.. code-block:: + + > osxphotos + Usage: osxphotos [OPTIONS] COMMAND [ARGS]... + + Options: + --db Specify Photos database path. Path to Photos + library/database can be specified using either + --db or directly as PHOTOS_LIBRARY positional + argument. If neither --db or PHOTOS_LIBRARY + provided, will attempt to find the library to + use in the following order: 1. last opened + library, 2. system library, 3. + ~/Pictures/Photos Library.photoslibrary + --json Print output in JSON format. + -v, --version Show the version and exit. + -h, --help Show this message and exit. + + Commands: + about Print information about osxphotos including license. + albums Print out albums found in the Photos library. + diff Compare two Photos databases and print out differences + docs Open osxphotos documentation in your browser. + 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 . + info Print out descriptive info of the Photos library database. + install Install Python packages into the same environment as osxphotos + keywords Print out keywords found in the Photos library. + labels Print out image classification labels found in the Photos... + list Print list of Photos libraries found on the system. + persons Print out persons (faces) found in the Photos library. + places Print out places found in the Photos library. + query Query the Photos database using 1 or more search options; if... + repl Run interactive osxphotos REPL shell (useful for debugging,... + run Run a python file using same environment as osxphotos + snap Create snapshot of Photos database to use with diff command + theme Manage osxphotos color themes. + tutorial Display osxphotos tutorial. + uninstall Uninstall Python packages from the osxphotos environment + uuid Print out unique IDs (UUID) of photos selected in Photos + +To get help on a specific command, use ``osxphotos help `` diff --git a/docs/_sources/package_overview.rst.txt b/docs/_sources/package_overview.rst.txt new file mode 100644 index 00000000..b7d35ef9 --- /dev/null +++ b/docs/_sources/package_overview.rst.txt @@ -0,0 +1,119 @@ + +Example uses of the python package +---------------------------------- + +.. code-block:: python + + """ Simple usage of the package """ + import osxphotos + + def main(): + photosdb = osxphotos.PhotosDB() + print(photosdb.keywords) + print(photosdb.persons) + print(photosdb.album_names) + + 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"]) + + # find all photos that include Alice Smith but do not contain the keyword Bar + photos = [p for p in photosdb.photos(persons=["Alice Smith"]) + if p not in photosdb.photos(keywords=["Bar"]) ] + for p in photos: + print( + p.uuid, + p.filename, + p.original_filename, + p.date, + p.description, + p.title, + p.keywords, + p.albums, + p.persons, + p.path, + ) + + if __name__ == "__main__": + main() + +.. code-block:: python + + """ Export all photos to specified directory using album names as folders + If file has been edited, also export the edited version, + otherwise, export the original version + This will result in duplicate photos if photo is in more than album """ + + import os.path + import pathlib + import sys + + import click + from pathvalidate import is_valid_filepath, sanitize_filepath + + import osxphotos + + + @click.command() + @click.argument("export_path", type=click.Path(exists=True)) + @click.option( + "--default-album", + help="Default folder for photos with no album. Defaults to 'unfiled'", + default="unfiled", + ) + @click.option( + "--library-path", + help="Path to Photos library, default to last used library", + default=None, + ) + def export(export_path, default_album, library_path): + export_path = os.path.expanduser(export_path) + library_path = os.path.expanduser(library_path) if library_path else None + + if library_path is not None: + photosdb = osxphotos.PhotosDB(library_path) + else: + photosdb = osxphotos.PhotosDB() + + photos = photosdb.photos() + + for p in photos: + if not p.ismissing: + albums = p.albums + if not albums: + albums = [default_album] + for album in albums: + click.echo(f"exporting {p.filename} in album {album}") + + # make sure no invalid characters in destination path (could be in album name) + album_name = sanitize_filepath(album, platform="auto") + + # create destination folder, if necessary, based on album name + dest_dir = os.path.join(export_path, album_name) + + # verify path is a valid path + if not is_valid_filepath(dest_dir, platform="auto"): + sys.exit(f"Invalid filepath {dest_dir}") + + # create destination dir if needed + if not os.path.isdir(dest_dir): + os.makedirs(dest_dir) + + # export the photo + if p.hasadjustments: + # export edited version + exported = p.export(dest_dir, edited=True) + edited_name = pathlib.Path(p.path_edited).name + click.echo(f"Exported {edited_name} to {exported}") + # export unedited version + exported = p.export(dest_dir) + click.echo(f"Exported {p.filename} to {exported}") + else: + click.echo(f"Skipping missing photo: {p.filename}") + + + if __name__ == "__main__": + export() \ No newline at end of file diff --git a/docs/_sources/reference.rst.txt b/docs/_sources/reference.rst.txt index 0aa546ff..939cc6d0 100644 --- a/docs/_sources/reference.rst.txt +++ b/docs/_sources/reference.rst.txt @@ -1,13 +1,5 @@ -osxphotos package -=================== +osxphotos python API +==================== -osxphotos module ------------------------------- - -.. autoclass:: osxphotos.PhotosDB - :members: - :undoc-members: - -.. autoclass:: osxphotos.PhotoInfo - :members: - :undoc-members: +.. automodule:: osxphotos + :members: \ No newline at end of file diff --git a/docs/_sources/template_help.rst.txt b/docs/_sources/template_help.rst.txt new file mode 100644 index 00000000..fa1ddbb0 --- /dev/null +++ b/docs/_sources/template_help.rst.txt @@ -0,0 +1,364 @@ + +osxphotos Template System +========================= + +The templating system converts one or template statements, written in osxphotos metadata templating language, to one or more rendered values using information from the photo being processed. + +In its simplest form, a template statement has the form: ``"{template_field}"``\ , for example ``"{title}"`` which would resolve to the title of the photo. + +Template statements may contain one or more modifiers. The full syntax is: + +``"pretext{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?bool_value,default}posttext"`` + +Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement. + +``pretext`` and ``posttext`` are free form text. For example, if a photo has title "My Photo Title" the template statement ``"The title of the photo is {title}"``\ , resolves to ``"The title of the photo is My Photo Title"``. The ``pretext`` in this example is ``"The title if the photo is "`` and the template_field is ``{title}``. + +``delim``\ : optional delimiter string to use when expanding multi-valued template values in-place + +``+``\ : If present before template ``name``\ , expands the template in place. If ``delim`` not provided, values are joined with no delimiter. + +e.g. if Photo keywords are ``["foo","bar"]``\ : + + +* ``"{keyword}"`` renders to ``"foo", "bar"`` +* ``"{,+keyword}"`` renders to: ``"foo,bar"`` +* ``"{; +keyword}"`` renders to: ``"foo; bar"`` +* ``"{+keyword}"`` renders to ``"foobar"`` + +``template_field``\ : The template field to resolve. See `Template Substitutions <#template-substitutions>`_ for full list of template fields. + +`:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}\ ``; the template_field is``\ exiftool\ ``and the sub-field is``\ IPTC:Make`. + +`|filter`: You may optionally append one or more filter commands to the end of the template field using the vertical pipe ('|') symbol. Filters may be combined, separated by '|' as in: ``{keyword|capitalize|parens}``. + +Valid filters are: + + +* ``lower``\ : Convert value to lower case, e.g. 'Value' => 'value'. +* ``upper``\ : Convert value to upper case, e.g. 'Value' => 'VALUE'. +* ``strip``\ : Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'. +* ``titlecase``\ : Convert value to title case, e.g. 'my value' => 'My Value'. +* ``capitalize``\ : Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'. +* ``braces``\ : Enclose value in curly braces, e.g. 'value => '{value}'. +* ``parens``\ : Enclose value in parentheses, e.g. 'value' => '(value') +* ``brackets``\ : Enclose value in brackets, e.g. 'value' => '[value]' +* ``shell_quote``\ : Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. +* `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py + +e.g. if Photo keywords are ``["FOO","bar"]``\ : + + +* ``"{keyword|lower}"`` renders to ``"foo", "bar"`` +* ``"{keyword|upper}"`` renders to: ``"FOO", "BAR"`` +* ``"{keyword|capitalize}"`` renders to: ``"Foo", "Bar"`` +* ``"{keyword|lower|parens}"`` renders to: ``"(foo)", "(bar)"`` + +e.g. if Photo description is "my description": + + +* ``"{descr|titlecase}"`` renders to: ``"My Description"`` + +``(path_sep)``\ : optional path separator to use when joining path-like fields, for example ``{folder_album}``. Default is "/". + +e.g. If Photo is in ``Album1`` in ``Folder1``\ : + + +* ``"{folder_album}"`` renders to ``["Folder1/Album1"]`` +* ``"{folder_album(>)}"`` renders to ``["Folder1>Album1"]`` +* ``"{folder_album()}"`` renders to ``["Folder1Album1"]`` + +`[find,replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: ``"{album[/,-|:,-]}"``. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair. + +`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are: + + +* ``contains``\ : template field contains value, similar to python's ``in`` +* `matches`: template field contains exactly value, unlike `contains`: does not match partial matches +* ``startswith``\ : template field starts with value +* ``endswith``\ : template field ends with value +* ``<=``\ : template field is less than or equal to value +* ``>=``\ : template field is greater than or equal to value +* ``<``\ : template field is less than value +* ``>``\ : template field is greater than value +* ``==``\ : template field equals value +* ``!=``\ : template field does not equal value + +The ``value`` part of the conditional expression is treated as a bare (unquoted) word/phrase. Multiple values may be separated by '|' (the pipe symbol). ``value`` is itself a template statement so you can use one or more template fields in ``value`` which will be resolved before the comparison occurs. + +For example: + + +* ``{keyword matches Beach}`` resolves to True if 'Beach' is a keyword. It would not match keyword 'BeachDay'. +* ``{keyword contains Beach}`` resolves to True if any keyword contains the word 'Beach' so it would match both 'Beach' and 'BeachDay'. +* ``{photo.score.overall > 0.7}`` resolves to True if the photo's overall aesthetic score is greater than 0.7. +* ``{keyword|lower contains beach}`` uses the lower case filter to do case-insensitive matching to match any keyword that contains the word 'beach'. +* ``{keyword|lower not contains beach}`` uses the ``not`` modifier to negate the comparison so this resolves to True if there is no keyword that matches 'beach'. + +Examples: to export photos that contain certain keywords with the ``osxphotos export`` command's ``--directory`` option: + +``--directory "{keyword|lower matches travel|vacation?Travel-Photos,Not-Travel-Photos}"`` + +This exports any photo that has keywords 'travel' or 'vacation' into a directory 'Travel-Photos' and all other photos into directory 'Not-Travel-Photos'. + +This can be used to rename files as well, for example: +``--filename "{favorite?Favorite-{original_name},{original_name}}"`` + +This renames any photo that is a favorite as 'Favorite-ImageName.jpg' (where 'ImageName.jpg' is the original name of the photo) and all other photos with the unmodified original name. + +``?bool_value``\ : Template fields may be evaluated as boolean (True/False) by appending "?" after the field name (and following "(path_sep)" or "[find/replace]". If a field is True (e.g. photo is HDR and field is ``"{hdr}"``\ ) or has any value, the value following the "?" will be used to render the template instead of the actual field value. If the template field evaluates to False (e.g. in above example, photo is not HDR) or has no value (e.g. photo has no title and field is ``"{title}"``\ ) then the default value following a "," will be used. + +e.g. if photo is an HDR image, + + +* ``"{hdr?ISHDR,NOTHDR}"`` renders to ``"ISHDR"`` + +and if it is not an HDR image, + + +* ``"{hdr?ISHDR,NOTHDR}"`` renders to ``"NOTHDR"`` + +``,default``\ : optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like ``{created.strftime}``. If no default value provided, "_" is used. + +e.g., if photo has no title set, + + +* ``"{title}"`` renders to "_" +* ``"{title,I have no title}"`` renders to ``"I have no title"`` + +Template fields such as ``created.strftime`` use the default value to pass the template to use for ``strftime``. + +e.g., if photo date is 4 February 2020, 19:07:38, + + +* ``"{created.strftime,%Y-%m-%d-%H%M%S}"`` renders to ``"2020-02-04-190738"`` + +Some template fields such as ``"{media_type}"`` use the default value to allow customization of the output. For example, ``"{media_type}"`` resolves to the special media type of the photo such as ``panorama`` or ``selfie``. You may use the default value to override these in form: ``"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"``. In this example, if photo was a time_lapse photo, ``media_type`` would resolve to ``vidéo_accélérée`` instead of ``time_lapse``. + +Either or both bool_value or default (False value) may be empty which would result in empty string ``""`` when rendered. + +If you want to include "{" or "}" in the output, use "{openbrace}" or "{closebrace}" template substitution. + +e.g. ``"{created.year}/{openbrace}{title}{closebrace}"`` would result in ``"2020/{Photo Title}"``. + +Template Substitutions +---------------------- + +.. list-table:: + :header-rows: 1 + + * - Substitution + - Description + * - {name} + - Current filename of the photo + * - {original_name} + - Photo's original filename when imported to Photos + * - {title} + - Title of the photo + * - {descr} + - Description of the photo + * - {media_type} + - Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. Defaults to 'photo' or 'video' if no special type. Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}' + * - {photo_or_video} + - 'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}' + * - {hdr} + - Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}' + * - {edited} + - True if photo has been edited (has adjustments), otherwise False; use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}' + * - {edited_version} + - True if template is being rendered for the edited version of a photo, otherwise False. + * - {favorite} + - Photo has been marked as favorite?; True/False value, use in format '{favorite?VALUE_IF_TRUE,VALUE_IF_FALSE}' + * - {created.date} + - Photo's creation date in ISO format, e.g. '2020-03-22' + * - {created.year} + - 4-digit year of photo creation time + * - {created.yy} + - 2-digit year of photo creation time + * - {created.mm} + - 2-digit month of the photo creation time (zero padded) + * - {created.month} + - Month name in user's locale of the photo creation time + * - {created.mon} + - Month abbreviation in the user's locale of the photo creation time + * - {created.dd} + - 2-digit day of the month (zero padded) of photo creation time + * - {created.dow} + - Day of week in user's locale of the photo creation time + * - {created.doy} + - 3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded) + * - {created.hour} + - 2-digit hour of the photo creation time + * - {created.min} + - 2-digit minute of the photo creation time + * - {created.sec} + - 2-digit second of the photo creation time + * - {created.strftime} + - Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates. + * - {modified.date} + - Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified + * - {modified.year} + - 4-digit year of photo modification time; uses creation date if photo is not modified + * - {modified.yy} + - 2-digit year of photo modification time; uses creation date if photo is not modified + * - {modified.mm} + - 2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified + * - {modified.month} + - Month name in user's locale of the photo modification time; uses creation date if photo is not modified + * - {modified.mon} + - Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified + * - {modified.dd} + - 2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified + * - {modified.dow} + - Day of week in user's locale of the photo modification time; uses creation date if photo is not modified + * - {modified.doy} + - 3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified + * - {modified.hour} + - 2-digit hour of the photo modification time; uses creation date if photo is not modified + * - {modified.min} + - 2-digit minute of the photo modification time; uses creation date if photo is not modified + * - {modified.sec} + - 2-digit second of the photo modification time; uses creation date if photo is not modified + * - {modified.strftime} + - Apply strftime template to file modification date/time. Should be used in form {modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. Uses creation date if photo is not modified. See https://strftime.org/ for help on strftime templates. + * - {today.date} + - Current date in iso format, e.g. '2020-03-22' + * - {today.year} + - 4-digit year of current date + * - {today.yy} + - 2-digit year of current date + * - {today.mm} + - 2-digit month of the current date (zero padded) + * - {today.month} + - Month name in user's locale of the current date + * - {today.mon} + - Month abbreviation in the user's locale of the current date + * - {today.dd} + - 2-digit day of the month (zero padded) of current date + * - {today.dow} + - Day of week in user's locale of the current date + * - {today.doy} + - 3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded) + * - {today.hour} + - 2-digit hour of the current date + * - {today.min} + - 2-digit minute of the current date + * - {today.sec} + - 2-digit second of the current date + * - {today.strftime} + - Apply strftime template to current date/time. Should be used in form {today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates. + * - {place.name} + - Place name from the photo's reverse geolocation data, as displayed in Photos + * - {place.country_code} + - The ISO country code from the photo's reverse geolocation data + * - {place.name.country} + - Country name from the photo's reverse geolocation data + * - {place.name.state_province} + - State or province name from the photo's reverse geolocation data + * - {place.name.city} + - City or locality name from the photo's reverse geolocation data + * - {place.name.area_of_interest} + - Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data + * - {place.address} + - Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States' + * - {place.address.street} + - Street part of the postal address, e.g. '2007 18th St NW' + * - {place.address.city} + - City part of the postal address, e.g. 'Washington' + * - {place.address.state_province} + - State/province part of the postal address, e.g. 'DC' + * - {place.address.postal_code} + - Postal code part of the postal address, e.g. '20009' + * - {place.address.country} + - Country name of the postal address, e.g. 'United States' + * - {place.address.country_code} + - ISO country code of the postal address, e.g. 'US' + * - {searchinfo.season} + - Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms). + * - {exif.camera_make} + - Camera make from original photo's EXIF information as imported by Photos, e.g. 'Apple' + * - {exif.camera_model} + - Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s' + * - {exif.lens_model} + - Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2' + * - {uuid} + - Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546' + * - {id} + - A unique number for the photo based on its primary key in the Photos database. A sequential integer, e.g. 1, 2, 3...etc. Each asset associated with a photo (e.g. an image and Live Photo preview) will share the same id. May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in 00001, 00002, 00003...etc. + * - {album_seq} + - An integer, starting at 0, indicating the photo's index (sequence) in the containing album. Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. For example '--directory "{folder_album}" --filename "{album\ *seq}*\ {original_name}"'. To start counting at a value other than 0, append append a period and the starting value to the field name. For example, to start counting at 1 instead of 0: '{album_seq.1}'. May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in 00000, 00001, 00002...etc. This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'. + * - {folder_album_seq} + - An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. For example '--directory "{folder_album}" --filename "{folder_album\ *seq}*\ {original_name}"'. To start counting at a value other than 0, append append a period and the starting value to the field name. For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' May be formatted using a python string format code. For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in 00000, 00001, 00002...etc. This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'. + * - {comma} + - A comma: ',' + * - {semicolon} + - A semicolon: ';' + * - {questionmark} + - A question mark: '?' + * - {pipe} + - A vertical pipe: '|' + * - {openbrace} + - An open brace: '{' + * - {closebrace} + - A close brace: '}' + * - {openparens} + - An open parentheses: '(' + * - {closeparens} + - A close parentheses: ')' + * - {openbracket} + - An open bracket: '[' + * - {closebracket} + - A close bracket: ']' + * - {newline} + - A newline: '\n' + * - {lf} + - A line feed: '\n', alias for {newline} + * - {cr} + - A carriage return: '\r' + * - {crlf} + - a carriage return + line feed: '\r\n' + * - {osxphotos_version} + - The osxphotos version, e.g. '0.47.10' + * - {osxphotos_cmd_line} + - The full command line used to run osxphotos + * - {album} + - Album(s) photo is contained in + * - {folder_album} + - Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder + * - {project} + - Project(s) photo is contained in (such as greeting cards, calendars, slideshows) + * - {album_project} + - Album(s) and project(s) photo is contained in; treats projects as regular albums + * - {folder_album_project} + - Folder path + album (includes projects as albums) photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder + * - {keyword} + - Keyword(s) assigned to photo + * - {person} + - Person(s) / face(s) in a photo + * - {label} + - Image categorization label associated with a photo (Photos 5+ only). Labels are added automatically by Photos using machine learning algorithms to categorize images. These are not the same as {keyword} which refers to the user-defined keywords/tags applied in Photos. + * - {label_normalized} + - All lower case version of 'label' (Photos 5+ only) + * - {comment} + - Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only) + * - {exiftool} + - Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in ``exiftool -G``. exiftool must be installed in the path to use this template. + * - {searchinfo.holiday} + - Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms). + * - {searchinfo.activity} + - Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms). + * - {searchinfo.venue} + - Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms). + * - {searchinfo.venue_type} + - Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms). + * - {photo} + - Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. '{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class. + * - {detected_text} + - List of text strings found in the image after performing text detection. Using '{detected_text}' will cause osxphotos to perform text detection on your photos using the built-in macOS text detection algorithms which will slow down your export. The results for each photo will be cached in the export database so that future exports with '--update' do not need to reprocess each photo. You may pass a confidence threshold value between 0.0 and 1.0 after a colon as in '{detected_text:0.5}'; The default confidence threshold is 0.75. '{detected_text}' works only on macOS Catalina (10.15) or later. Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support. + * - {shell_quote} + - Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. + * - {strip} + - Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s). + * - {function} + - Execute a python function from an external file and use return value as template substitution. Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. The function will be passed the PhotoInfo object for the photo. See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function. + diff --git a/docs/_sources/tutorial.rst.txt b/docs/_sources/tutorial.rst.txt new file mode 100644 index 00000000..c4bb59e0 --- /dev/null +++ b/docs/_sources/tutorial.rst.txt @@ -0,0 +1,455 @@ +.. role:: raw-html-m2r(raw) + :format: html + + + +.. raw:: html + + + + + +osxphotos Tutorial +================== + +Overview +-------- + + +.. raw:: html + + + + + +The design philosophy for osxphotos is "make the easy things easy and make the hard things possible". To "make the hard things possible", osxphotos is very flexible and has many, many configuration options -- the ``export`` command for example, has over 100 command line options. Thus, osxphotos may seem daunting at first. The purpose of this tutorial is to explain a number of common use cases with examples and, hopefully, make osxphotos less daunting to use. osxphotos includes several commands for retrieving information from your Photos library but the one most users are interested in is the ``export`` command which exports photos from the library so that's the focus of this tutorial. + +Export your photos +------------------ + +``osxphotos export /path/to/export`` + +This command exports all your photos to the ``/path/to/export`` directory. + +**Note**\ : osxphotos uses the term 'photo' to refer to a generic media asset in your Photos Library. A photo may be an image, a video file, a combination of still image and video file (e.g. an Apple "Live Photo" which is an image and an associated "live preview" video file), a JPEG image with an associated RAW image, etc. + +Export by date +-------------- + +While the previous command will export all your photos (and videos--see note above), it probably doesn't do exactly what you want. In the previous example, all the photos will be exported to a single folder: ``/path/to/export``. If you have a large library with thousands of images and videos, this likely isn't very useful. You can use the ``--export-by-date`` option to export photos to a folder structure organized by year, month, day, e.g. ``2021/04/21``\ : + +``osxphotos export /path/to/export --export-by-date`` + +With this command, a photo that was created on 31 May 2015 would be exported to: ``/path/to/export/2015/05/31`` + +Specify directory structure +--------------------------- + +If you prefer a different directory structure for your exported images, osxphotos provides a very flexible :raw-html-m2r:``\ template system\ :raw-html-m2r:`` that allows you to specify the directory structure using the ``--directory`` option. For example, this command exported to a directory structure that looks like: ``2015/May`` (4-digit year / month name): + +``osxphotos export /path/to/export --directory "{created.year}/{created.month}"`` + +The string following ``--directory`` is an ``osxphotos template string``. Template strings are widely used throughout osxphotos and it's worth your time to learn more about them. In a template string, the values between the curly braces, e.g. ``{created.year}`` are replaced with metadata from the photo being exported. In this case, ``{created.year}`` is the 4-digit year of the photo's creation date and ``{created.month}`` is the full month name in the user's locale (e.g. ``May``\ , ``mai``\ , etc.). In the osxphotos template system these are referred to as template fields. The text not included between ``{}`` pairs is interpreted literally, in this case ``/``\ , is a directory separator. + +osxphotos provides access to almost all the metadata known to Photos about your images. For example, Photos performs reverse geolocation lookup on photos that contain GPS coordinates to assign place names to the photo. Using the ``--directory`` template, you could thus export photos organized by country name: + +``osxphotos export /path/to/export --directory "{created.year}/{place.name.country}"`` + +Of course, some photos might not have an associated place name so the template system allows you specify a default value to use if a template field is null (has no value). + +``osxphotos export /path/to/export --directory "{created.year}/{place.name.country,No-Country}"`` + +The value after the ',' in the template string is the default value, in this case 'No-Country'. **Note**\ : If you don't specify a default value and a template field is null, osxphotos will use "_" (underscore character) as the default. + +Some template fields, such as ``{keyword}``\ , may expand to more than one value. For example, if a photo has keywords of "Travel" and "Vacation", ``{keyword}`` would expand to "Travel", "Vacation". When used with ``--directory``\ , this would result in the photo being exported to more than one directory (thus more than one copy of the photo would be exported). For example, if ``IMG_1234.JPG`` has keywords ``Travel``\ , and ``Vacation`` and you run the following command: + +``osxphotos export /path/to/export --directory "{keyword}"`` + +the exported files would be: + +.. code-block:: + + /path/to/export/Travel/IMG_1234.JPG + /path/to/export/Vacation/IMG_1234.JPG + + +Specify exported filename +------------------------- + +By default, osxphotos will use the original filename of the photo when exporting. That is, the filename the photo had when it was taken or imported into Photos. This is often something like ``IMG_1234.JPG`` or ``DSC05678.dng``. osxphotos allows you to specify a custom filename template using the ``--filename`` option in the same way as ``--directory`` allows you to specify a custom directory name. For example, Photos allows you specify a title or caption for a photo and you can use this in place of the original filename: + +``osxphotos export /path/to/export --filename "{title}"`` + +The above command will export photos using the title. Note that you don't need to specify the extension as part of the ``--filename`` template as osxphotos will automatically add the correct file extension. Some photos might not have a title so in this case, you could use the default value feature to specify a different name for these photos. For example, to use the title as the filename, but if no title is specified, use the original filename instead: + +.. code-block:: + + osxphotos export /path/to/export --filename "{title,{original_name}}" + │ ││ │ + │ ││ │ + Use photo's title as the filename <──────┘ ││ │ + ││ │ + Value after comma will be used <───────┘│ │ + if title is blank │ │ + │ │ + The default value can be <────┘ │ + another template field │ + │ + Use photo's original name if no title <──────┘ + + +The osxphotos template system also allows for limited conditional logic of the type "If a condition is true then do one thing, otherwise, do a different thing". For example, you can use the ``--filename`` option to name files that are marked as "Favorites" in Photos differently than other files. For example, to add a "#" to the name of every photo that's a favorite: + +.. code-block:: + + osxphotos export /path/to/export --filename "{original_name}{favorite?#,}" + │ │ │││ + │ │ │││ + Use photo's original name as filename <──┘ │ │││ + │ │││ + 'favorite' is True if photo is a Favorite, <───────┘ │││ + otherwise, False │││ + │││ + '?' specifies a conditional <─────────────┘││ + ││ + Value immediately following ? will be used if <──────┘│ + preceding template field is True or non-blank │ + │ + Value immediately following comma will be used if <──────┘ + template field is False or blank (null); in this case + no value is specified so a blank string "" will be used + + +Like with ``--directory``\ , using a multi-valued template field such as ``{keyword}`` may result in more than one copy of a photo being exported. For example, if ``IMG_1234.JPG`` has keywords ``Travel``\ , and ``Vacation`` and you run the following command: + +``osxphotos export /path/to/export --filename "{keyword}-{original_name}"`` + +the exported files would be: + +.. code-block:: + + /path/to/export/Travel-IMG_1234.JPG + /path/to/export/Vacation-IMG_1234.JPG + + +Edited photos +------------- + +If a photo has been edited in Photos (e.g. cropped, adjusted, etc.) there will be both an original image and an edited image in the Photos Library. By default, osxphotos will export both the original and the edited image. To distinguish between them, osxphotos will append "_edited" to the edited image. For example, if the original image was named ``IMG_1234.JPG``\ , osxphotos will export the original as ``IMG_1234.JPG`` and the edited version as ``IMG_1234_edited.jpeg``. **Note:** Photos changes the extension of edited images to ".jpeg" even if the original was named ".JPG". You can change the suffix appended to edited images using the ``--edited-suffix`` option: + +``osxphotos export /path/to/export --edited-suffix "_EDIT"`` + +In this example, the edited image would be named ``IMG_1234_EDIT.jpeg``. Like many options in osxphotos, the ``--edited-suffix`` option can evaluate an osxphotos template string so you could append the modification date (the date the photo was edited) to all edited photos using this command: + +``osxphotos export /path/to/export --edited-suffix "_{modified.year}-{modified.mm}-{modified.dd}"`` + +In this example, if the photo was edited on 21 April 2021, the name of the exported file would be: ``IMG_1234_2021-04-21.jpeg``. + +You can tell osxphotos to not export edited photos (that is, only export the original unedited photos) using ``--skip-edited``\ : + +``osxphotos export /path/to/export --skip-edited`` + +You can also tell osxphotos to export either the original photo (if the photo has not been edited) or the edited photo (if it has been edited), but not both, using the ``--skip-original-if-edited`` option: + +``osxphotos export /path/to/export --skip-original-if-edited`` + +As mentioned above, Photos renames JPEG images that have been edited with the ".jpeg" extension. Some applications use ".JPG" and others use ".jpg" or ".JPEG". You can use the ``--jpeg-ext`` option to have osxphotos rename all JPEG files with the same extension. Valid values are jpeg, jpg, JPEG, JPG; e.g. ``--jpeg-ext jpg`` to use '.jpg' for all JPEGs. + +``osxphotos export /path/to/export --jpeg-ext jpg`` + +Specifying the Photos library +----------------------------- + +All the above commands operate on the default Photos library. Most users only use a single Photos library which is also known as the System Photo Library. It is possible to use Photos with more than one library. For example, if you hold down the "Option" key while opening Photos, you can select an alternate Photos library. If you don't specify which library to use, osxphotos will try find the last opened library. Occasionally it can't determine this and in that case, it will use the System Photos Library. If you use more than one Photos library and want to explicitly specify which library to use, you can do so with the ``--db`` option. (db is short for database and is so named because osxphotos operates on the database that Photos uses to manage your Photos library). + +``osxphotos export /path/to/export --db ~/Pictures/MyAlternateLibrary.photoslibrary`` + +Missing photos +-------------- + +osxphotos works by copying photos out of the Photos library folder to export them. You may see osxphotos report that one or more photos are missing and thus could not be exported. One possible reason for this is that you are using iCloud to synch your Photos library and Photos either hasn't yet synched the cloud library to the local Mac or you have Photos configured to "Optimize Mac Storage" in Photos Preferences. Another reason is that even if you have Photos configured to download originals to the Mac, Photos does not always download photos from shared albums or original screenshots to the Mac. + +If you encounter missing photos you can tell osxphotos to download the missing photos from iCloud using the ``--download-missing`` option. ``--download-missing`` uses AppleScript to communicate with Photos and tell it to download the missing photos. Photos' AppleScript interface is somewhat buggy and you may find that Photos crashes. In this case, osxphotos will attempt to restart Photos to resume the download process. There's also an experimental ``--use-photokit`` option that will communicate with Photos using a different "PhotoKit" interface. This option must be used together with ``--download-missing``\ : + +``osxphotos export /path/to/export --download-missing`` + +``osxphotos export /path/to/export --download-missing --use-photokit`` + +Exporting to external disks +--------------------------- + +If you are exporting to an external network attached storage (NAS) device, you may encounter errors if the network connection is unreliable. In this case, you can use the ``--retry`` option so that osxphotos will automatically retry the export. Use ``--retry`` with a number that specifies the number of times to retry the export: + +``osxphotos export /path/to/export --retry 3`` + +In this example, osxphotos will attempt to export a photo up to 3 times if it encounters an error. + +In addition to ``--retry``\ , the ``--exportdb`` and ``--ramdb`` may improve performance when exporting to an external disk or a NAS. When osxphotos exports photos, it creates an export database file named ``.osxphotos_export.db`` in the export folder which osxphotos uses to keep track of which photos have been exported. This allows you to restart and export and to use ``--update`` to update an existing export. If the connection to the export location is slow or flaky, having the export database located on the export disk may decrease performance. In this case, you can use ``--exportdb DBPATH`` to instruct osxphotos to store the export database at DBPATH. If using this option, I recommend putting the export database on your Mac system disk (for example, in your home directory). If you intend to use ``--update`` to update the export in the future, you must remember where the export database is and use the ``--exportdb`` option every time you update the export. + +Another alternative to using ``--exportdb`` is to use ``--ramdb``. This option instructs osxphotos to use a RAM database instead of a file on disk. The RAM database is much faster than the file on disk and doesn't require osxphotos to access the network drive to query or write to the database. When osxphotos completes the export it will write the RAM database to the export location. This can offer a significant performance boost but you will lose state information if osxphotos crashes or is interrupted during export. + +Exporting metadata with exported photos +--------------------------------------- + +Photos tracks a tremendous amount of metadata associated with photos in the library such as keywords, faces and persons, reverse geolocation data, and image classification labels. Photos' native export capability does not preserve most of this metadata. osxphotos can, however, access and preserve almost all the metadata associated with photos. Using the free `\ ``exiftool`` `_ app, osxphotos can write metadata to exported photos. Follow the instructions on the exiftool website to install exiftool then you can use the ``--exiftool`` option to write metadata to exported photos: + +``osxphotos export /path/to/export --exiftool`` + +This will write basic metadata such as keywords, persons, and GPS location to the exported files. osxphotos includes several additional options that can be used in conjunction with ``--exiftool`` to modify the metadata that is written by ``exiftool``. For example, you can use the ``--keyword-template`` option to specify custom keywords (again, via the osxphotos template system). For example, to use the folder and album a photo is in to create hierarchal keywords in the format used by Lightroom Classic: + +.. code-block:: + + osxphotos export /path/to/export --exiftool --keyword-template "{folder_album(>)}" + │ │ + │ │ + folder_album results in the folder(s) <──┘ │ + and album a photo is contained in │ + │ + The value in () is used as the path separator <───────┘ + for joining the folders and albums. For example, + if photo is in Folder1/Folder2/Album, (>) produces + "Folder1>Folder2>Album" which some programs, such as + Lightroom Classic, treat as hierarchal keywords + + +The above command will write all the regular metadata that ``--exiftool`` normally writes to the file upon export but will also add an additional keyword in the exported metadata in the form "Folder1>Folder2>Album". If you did not include the ``(>)`` in the template string (e.g. ``{folder_album}``\ ), folder_album would render in form "Folder1/Folder2/Album". + +A powerful feature of Photos is that it uses machine learning algorithms to automatically classify or label photos. These labels are used when you search for images in Photos but are not otherwise available to the user. osxphotos is able to read all the labels associated with a photo and makes those available through the template system via the ``{label}``. Think of these as automatic keywords as opposed to the keywords you assign manually in Photos. One common use case is to use the automatic labels to create new keywords when exporting images so that these labels are embedded in the image's metadata: + +``osxphotos export /path/to/export --exiftool --keyword-template "{label}"`` + +**Note**\ : When evaluating templates for ``--directory`` and ``--filename``\ , osxphotos inserts the automatic default value "_" for any template field which is null (empty or blank). This is to ensure that there's never a null directory or filename created. For metadata templates such as ``--keyword-template``\ , osxphotos does not provide an automatic default value thus if the template field is null, no keyword would be created. Of course, you can provide a default value if desired and osxphotos will use this. For example, to add "nolabel" as a keyword for any photo that doesn't have labels: + +``osxphotos export /path/to/export --exiftool --keyword-template "{label,nolabel}"`` + +Sidecar files +------------- + +Another way to export metadata about your photos is through the use of sidecar files. These are files that have the same name as your photo (but with a different extension) and carry the metadata. Many digital asset management applications (for example, PhotoPrism, Lightroom, Digikam, etc.) can read or write sidecar files. osxphotos can export metadata in exiftool compatible JSON and XMP formats using the ``--sidecar`` option. For example, to output metadata to XMP sidecars: + +``osxphotos export /path/to/export --sidecar XMP`` + +Unlike ``--exiftool``\ , you do not need to install exiftool to use the ``--sidecar`` feature. Many of the same configuration options that apply to ``--exiftool`` to modify metadata, for example, ``--keyword-template`` can also be used with ``--sidecar``. + +Sidecar files are named "photoname.ext.sidecar_ext". For example, if the photo is named ``IMG_1234.JPG`` and the sidecar format is XMP, the sidecar would be named ``IMG_1234.JPG.XMP``. Some applications expect the sidecar in this case to be named ``IMG_1234.XMP``. You can use the ``-sidecar-drop-ext`` option to force osxphotos to name the sidecar files in this manner: + +``osxphotos export /path/to/export --sidecar XMP -sidecar-drop-ext`` + +Updating a previous export +-------------------------- + +If you want to use osxphotos to perform periodic backups of your Photos library rather than a one-time export, use the ``--update`` option. When ``osxphotos export`` is run, it creates a database file named ``.osxphotos_export.db`` in the export folder. (\ **Note** Because the filename starts with a ".", you won't see it in Finder which treats "dot-files" like this as hidden. You will see the file in the Terminal.) . If you run osxphotos with the ``--update`` option, it will look for this database file and, if found, use it to retrieve state information from the last time it was run to only export new or changed files. For example: + +``osxphotos export /path/to/export --update`` + +will read the export database located in ``/path/to/export/.osxphotos_export.db`` and only export photos that have been added or changed since the last time osxphotos was run. You can run osxphotos with the ``--update`` option even if it's never been run before. If the database isn't found, osxphotos will create it. If you run ``osxphotos export`` without ``--update`` in a folder where you had previously exported photos, it will re-export all the photos. If your intent is to keep a periodic backup of your Photos Library up to date with osxphotos, you should always use ``--update``. + +If your workflow involves moving files out of the export directory (for example, you move them into a digital asset management app) but you want to use the features of ``--update``\ , you can use the ``--only-new`` with ``--update`` to force osxphotos to only export photos that are new (added to the library) since the last update. In this case, osxphotos will ignore the previously exported files that are now missing. Without ``--only-new``\ , osxphotos would see that previously exported files are missing and re-export them. + +``osxphotos export /path/to/export --update --only-new`` + +If your workflow involves editing the images you exported from Photos but you still want to maintain a backup with ``--update``\ , you should use the ``--ignore-signature`` option. ``--ignore-signature`` instructs osxphotos to ignore the file's signature (for example, size and date modified) when deciding which files should be updated with ``--update``. If you edit a file in the export directory and then run ``--update`` without ``--ignore-signature``\ , osxphotos will see that the file is different than the one in the Photos library and re-export it. + +``osxphotos export /path/to/export --update --ignore-signature`` + +Dry Run +------- + +You can use the ``--dry-run`` option to have osxphotos "dry run" or test an export without actually exporting any files. When combined with the ``--verbose`` option, which causes osxphotos to print out details of every file being exported, this can be a useful tool for testing your export options before actually running a full export. For example, if you are learning the template system and want to verify that your ``--directory`` and ``--filename`` templates are correct, ``--dry-run --verbose`` will print out the name of each file being exported. + +``osxphotos export /path/to/export --dry-run --verbose`` + +Creating a report of all exported files +--------------------------------------- + +You can use the ``--report`` option to create a report, in comma-separated values (CSV) format that will list the details of all files that were exported, skipped, missing, etc. This file format is compatible with programs such as Microsoft Excel. Provide the name of the report after the ``--report`` option: + +``osxphotos export /path/to/export --report export.csv`` + +Exporting only certain photos +----------------------------- + +By default, osxphotos will export your entire Photos library. If you want to export only certain photos, osxphotos provides a rich set of "query options" that allow you to query the Photos database to filter out only certain photos that match your query criteria. The tutorial does not cover all the query options as there are over 50 of them--read the help text (\ ``osxphotos help export``\ ) to better understand the available query options. No matter which subset of photos you would like to export, there is almost certainly a way for osxphotos to filter these. For example, you can filter for only images that contain certain keywords or images without a title, images from a specific time of day or specific date range, images contained in specific albums, etc. + +For example, to export only photos with keyword ``Travel``\ : + +``osxphotos export /path/to/export --keyword "Travel"`` + +Like many options in osxphotos, ``--keyword`` (and most other query options) can be repeated to search for more than one term. For example, to find photos with keyword ``Travel`` *or* keyword ``Vacation``\ : + +``osxphotos export /path/to/export --keyword "Travel" --keyword "Vacation"`` + +To export only photos contained in the album "Summer Vacation": + +``osxphotos export /path/to/export --album "Summer Vacation"`` + +In Photos, it's possible to have multiple albums with the same name. In this case, osxphotos would export photos from all albums matching the value passed to ``--album``. If you wanted to export only one of the albums and this album is in a folder, the ``--regex`` option (short for "regular expression"), which does pattern matching, could be used with the ``{folder_album}`` template to match the specific album. For example, if you had a "Summer Vacation" album inside the folder "2018" and also one with the same name inside the folder "2019", you could export just the album "2018/Summer Vacation" using this command: + +``osxphotos export /path/to/export --regex "2018/Summer Vacation" "{folder_album}"`` + +This command matches the pattern "2018/Summer Vacation" against the full folder/album path for every photo. + +There are also a number of query options to export only certain types of photos. For example, to export only photos taken with iPhone "Portrait Mode": + +``osxphotos export /path/to/export --portrait`` + +You can also export photos in a certain date range: + +``osxphotos export /path/to/export --from-date "2020-01-01" --to-date "2020-02-28"`` + +Converting images to JPEG on export +----------------------------------- + +Photos can store images in many different formats. osxphotos can convert non-JPEG images (for example, RAW photos) to JPEG on export using the ``--convert-to-jpeg`` option. You can specify the JPEG quality (0: worst, 1.0: best) using ``--jpeg-quality``. For example: + +``osxphotos export /path/to/export --convert-to-jpeg --jpeg-quality 0.9`` + +Finder attributes +----------------- + +In addition to using ``exiftool`` to write metadata directly to the image metadata, osxphotos can write certain metadata that is available to the Finder and Spotlight but does not modify the actual image file. This is done through something called extended attributes which are stored in the filesystem with a file but do not actually modify the file itself. Finder tags and Finder comments are common examples of these. + +osxphotos can, for example, write any keywords in the image to Finder tags so that you can search for images in Spotlight or the Finder using the ``tag:tagname`` syntax: + +``osxphotos export /path/to/export --finder-tag-keywords`` + +``--finder-tag-keywords`` also works with ``--keyword-template`` as described above in the section on ``exiftool``\ : + +``osxphotos export /path/to/export --finder-tag-keywords --keyword-template "{label}"`` + +The ``--xattr-template`` option allows you to set a variety of other extended attributes. It is used in the format ``--xattr-template ATTRIBUTE TEMPLATE`` where ATTRIBUTE is one of 'authors','comment', 'copyright', 'description', 'findercomment', 'headline', 'keywords'. + +For example, to set Finder comment to the photo's title and description: + +``osxphotos export /path/to/export --xattr-template findercomment "{title}{newline}{descr}"`` + +In the template string above, ``{newline}`` instructs osxphotos to insert a new line character ("\n") between the title and description. In this example, if ``{title}`` or ``{descr}`` is empty, you'll get "title\n" or "\ndescription" which may not be desired so you can use more advanced features of the template system to handle these cases: + +``osxphotos export /path/to/export --xattr-template findercomment "{title,}{title?{descr?{newline},},}{descr,}"`` + +Explanation of the template string: + +.. code-block:: + + {title,}{title?{descr?{newline},},}{descr,} + │ │ │ │ │ │ │ + │ │ │ │ │ │ │ + └──> insert title (or nothing if no title) + │ │ │ │ │ │ + └───> is there a title? + │ │ │ │ │ + └───> if so, is there a description? + │ │ │ │ + └───> if so, insert new line + │ │ │ + └───> if descr is blank, insert nothing + │ │ + └───> if title is blank, insert nothing + │ + └───> finally, insert description + (or nothing if no description) + + +In this example, ``title?`` demonstrates use of the boolean (True/False) feature of the template system. ``title?`` is read as "Is the title True (or not blank/empty)? If so, then the value immediately following the ``?`` is used in place of ``title``. If ``title`` is blank, then the value immediately following the comma is used instead. The format for boolean fields is ``field?value if true,value if false``. Either ``value if true`` or ``value if false`` may be blank, in which case a blank string ("") is used for the value and both may also be an entirely new template string as seen in the above example. Using this format, template strings may be nested inside each other to form complex ``if-then-else`` statements. + +The above example, while complex to read, shows how flexible the osxphotos template system is. If you invest a little time learning how to use the template system you can easily handle almost any use case you have. + +See Extended Attributes section in the help for ``osxphotos export`` for additional information about this feature. + +Saving and loading options +-------------------------- + +If you repeatedly run a complex osxphotos export command (for example, to regularly back-up your Photos library), you can save all the options to a configuration file for future use (\ ``--save-config FILE``\ ) and then load them (\ ``--load-config FILE``\ ) instead of repeating each option on the command line. + +To save the configuration: + +``osxphotos export /path/to/export --update --save-config osxphotos.toml`` + +Then the next to you run osxphotos, you can simply do this: + +``osxphotos export /path/to/export --load-config osxphotos.toml`` + +The configuration file is a plain text file in `TOML `_ format so the ``.toml`` extension is standard but you can name the file anything you like. + +Run commands on exported photos for post-processing +--------------------------------------------------- + +You can use the ``--post-command`` option to run one or more commands against exported files. The ``--post-command`` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: ``osxphotos help export``. For example, the ``exported`` category includes all exported photos and the ``skipped`` category includes all photos that were skipped when running export with ``--update``. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution. + +For example, the following command generates a log of all exported files and their associated keywords: + +``osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"`` + +The special template field ``{shell_quote}`` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template ``{filepath}`` is set to the full path of the exported file and ``{export_dir}`` is set to the full path of the base export directory. + +Explanation of the template string: + +.. code-block:: + + {shell_quote,{filepath}{comma}{,+keyword,}} + │ │ │ │ │ + │ │ │ | │ + └──> quote everything after comma for proper execution in the shell + │ │ │ │ + └───> filepath of the exported file + │ │ │ + └───> insert a comma + │ │ + └───> join the list of keywords together with a "," + │ + └───> if no keywords, insert nothing (empty string: "") + + +Another example: if you had ``exiftool`` installed and wanted to wipe all metadata from all exported files, you could use the following: + +``osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`` + +This command uses the ``|shell_quote`` template filter instead of the ``{shell_quote}`` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text. + +An example from an actual osxphotos user +---------------------------------------- + +Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!): + +.. code-block:: + + I usually import my iPhone’s photo roll on a more or less regular basis, and it + includes photos and videos. As a result, the size ot my Photos library may rise + very quickly. Nevertheless, I will tag and geolocate everything as Photos has a + quite good keyword management system. + + After a while, I want to take most of the videos out of the library and move them + to a separate "videos" folder on a different folder / volume. As I might want to + use them in Final Cut Pro, and since Final Cut is able to import Finder tags into + its internal library tagging system, I will use osxphotos to do just this. + + Picking the videos can be left to Photos, using a smart folder for instance. Then + just add a keyword to all videos to be processed. Here I chose "Quik" as I wanted + to spot all videos created on my iPhone using the Quik application (now part of + GoPro). + + I want to retrieve my keywords only and make sure they populate the Finder tags, as + well as export all the persons identified in the videos by Photos. I also want to + merge any keywords or persons already in the video metadata with the exported + metadata. + + Keeping Photo’s edited titles and descriptions and putting both in the Finder + comments field in a readable manner is also enabled. + + And I want to keep the file’s creation date (using `--touch-file`). + + Finally, use `--strip` to remove any leading or trailing whitespace from processed + template fields. + + +``osxphotos export ~/Desktop/folder for exported videos/ --keyword Quik --only-movies --db /path to my.photoslibrary --touch-file --finder-tag-keywords --person-keyword --xattr-template findercomment "{title}{title?{descr?{newline},},}{descr}" --exiftool-merge-keywords --exiftool-merge-persons --exiftool --strip`` + +Color Themes +------------ + +Some osxphotos commands such as export use color themes to colorize the output to make it more legible. The theme may be specified with the ``--theme`` option. For example: ``osxphotos export /path/to/export --verbose --theme dark`` uses a theme suited for dark terminals. If you don't specify the color theme, osxphotos will select a default theme based on the current terminal settings. You can also specify your own default theme. See ``osxphotos help theme`` for more information on themes and for commands to help manage themes. Themes are defined in ``.theme`` files in the ``~/.osxphotos/themes`` directory and use style specifications compatible with the `rich `_ library. + +Conclusion +---------- + +osxphotos is very flexible. If you merely want to backup your Photos library, then spending a few minutes to understand the ``--directory`` option is likely all you need and you can be up and running in minutes. However, if you have a more complex workflow, osxphotos likely provides options to implement your workflow. This tutorial does not attempt to cover every option offered by osxphotos but hopefully it provides a good understanding of what kinds of things are possible and where to explore if you want to learn more. diff --git a/docs/_static/check-solid.svg b/docs/_static/check-solid.svg new file mode 100644 index 00000000..92fad4b5 --- /dev/null +++ b/docs/_static/check-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/_static/clipboard.min.js b/docs/_static/clipboard.min.js new file mode 100644 index 00000000..54b3c463 --- /dev/null +++ b/docs/_static/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.8 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return o}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),c=n.n(e);function a(t){try{return document.execCommand(t)}catch(t){return}}var f=function(t){t=c()(t);return a("cut"),t};var l=function(t){var e,n,o,r=1 + + + + diff --git a/docs/_static/copybutton.css b/docs/_static/copybutton.css new file mode 100644 index 00000000..40eafe5f --- /dev/null +++ b/docs/_static/copybutton.css @@ -0,0 +1,93 @@ +/* Copy buttons */ +button.copybtn { + position: absolute; + display: flex; + top: .3em; + right: .3em; + width: 1.7em; + height: 1.7em; + opacity: 0; + transition: opacity 0.3s, border .3s, background-color .3s; + user-select: none; + padding: 0; + border: none; + outline: none; + border-radius: 0.4em; + /* The colors that GitHub uses */ + border: #1b1f2426 1px solid; + background-color: #f6f8fa; + color: #57606a; +} + +button.copybtn.success { + border-color: #22863a; + color: #22863a; +} + +button.copybtn svg { + stroke: currentColor; + width: 1.5em; + height: 1.5em; + padding: 0.1em; +} + +div.highlight { + position: relative; +} + +.highlight:hover button.copybtn { + opacity: 1; +} + +.highlight button.copybtn:hover { + background-color: rgb(235, 235, 235); +} + +.highlight button.copybtn:active { + background-color: rgb(187, 187, 187); +} + +/** + * A minimal CSS-only tooltip copied from: + * https://codepen.io/mildrenben/pen/rVBrpK + * + * To use, write HTML like the following: + * + *

Short

+ */ + .o-tooltip--left { + position: relative; + } + + .o-tooltip--left:after { + opacity: 0; + visibility: hidden; + position: absolute; + content: attr(data-tooltip); + padding: .2em; + font-size: .8em; + left: -.2em; + background: grey; + color: white; + white-space: nowrap; + z-index: 2; + border-radius: 2px; + transform: translateX(-102%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); +} + +.o-tooltip--left:hover:after { + display: block; + opacity: 1; + visibility: visible; + transform: translateX(-100%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); + transition-delay: .5s; +} + +/* By default the copy button shouldn't show up when printing a page */ +@media print { + button.copybtn { + display: none; + } +} diff --git a/docs/_static/copybutton.js b/docs/_static/copybutton.js new file mode 100644 index 00000000..40ac3310 --- /dev/null +++ b/docs/_static/copybutton.js @@ -0,0 +1,220 @@ +// Localization support +const messages = { + 'en': { + 'copy': 'Copy', + 'copy_to_clipboard': 'Copy to clipboard', + 'copy_success': 'Copied!', + 'copy_failure': 'Failed to copy', + }, + 'es' : { + 'copy': 'Copiar', + 'copy_to_clipboard': 'Copiar al portapapeles', + 'copy_success': '¡Copiado!', + 'copy_failure': 'Error al copiar', + }, + 'de' : { + 'copy': 'Kopieren', + 'copy_to_clipboard': 'In die Zwischenablage kopieren', + 'copy_success': 'Kopiert!', + 'copy_failure': 'Fehler beim Kopieren', + }, + 'fr' : { + 'copy': 'Copier', + 'copy_to_clipboard': 'Copié dans le presse-papier', + 'copy_success': 'Copié !', + 'copy_failure': 'Échec de la copie', + }, + 'ru': { + 'copy': 'Скопировать', + 'copy_to_clipboard': 'Скопировать в буфер', + 'copy_success': 'Скопировано!', + 'copy_failure': 'Не удалось скопировать', + }, + 'zh-CN': { + 'copy': '复制', + 'copy_to_clipboard': '复制到剪贴板', + 'copy_success': '复制成功!', + 'copy_failure': '复制失败', + }, + 'it' : { + 'copy': 'Copiare', + 'copy_to_clipboard': 'Copiato negli appunti', + 'copy_success': 'Copiato!', + 'copy_failure': 'Errore durante la copia', + } +} + +let locale = 'en' +if( document.documentElement.lang !== undefined + && messages[document.documentElement.lang] !== undefined ) { + locale = document.documentElement.lang +} + +let doc_url_root = DOCUMENTATION_OPTIONS.URL_ROOT; +if (doc_url_root == '#') { + doc_url_root = ''; +} + +/** + * SVG files for our copy buttons + */ +let iconCheck = ` + ${messages[locale]['copy_success']} + + +` + +// If the user specified their own SVG use that, otherwise use the default +let iconCopy = ``; +if (!iconCopy) { + iconCopy = ` + ${messages[locale]['copy_to_clipboard']} + + + +` +} + +/** + * Set up copy/paste for code blocks + */ + +const runWhenDOMLoaded = cb => { + if (document.readyState != 'loading') { + cb() + } else if (document.addEventListener) { + document.addEventListener('DOMContentLoaded', cb) + } else { + document.attachEvent('onreadystatechange', function() { + if (document.readyState == 'complete') cb() + }) + } +} + +const codeCellId = index => `codecell${index}` + +// Clears selected text since ClipboardJS will select the text when copying +const clearSelection = () => { + if (window.getSelection) { + window.getSelection().removeAllRanges() + } else if (document.selection) { + document.selection.empty() + } +} + +// Changes tooltip text for two seconds, then changes it back +const temporarilyChangeTooltip = (el, oldText, newText) => { + el.setAttribute('data-tooltip', newText) + el.classList.add('success') + setTimeout(() => el.setAttribute('data-tooltip', oldText), 2000) + setTimeout(() => el.classList.remove('success'), 2000) +} + +// Changes the copy button icon for two seconds, then changes it back +const temporarilyChangeIcon = (el) => { + el.innerHTML = iconCheck; + setTimeout(() => {el.innerHTML = iconCopy}, 2000) +} + +const addCopyButtonToCodeCells = () => { + // If ClipboardJS hasn't loaded, wait a bit and try again. This + // happens because we load ClipboardJS asynchronously. + if (window.ClipboardJS === undefined) { + setTimeout(addCopyButtonToCodeCells, 250) + return + } + + // Add copybuttons to all of our code cells + const codeCells = document.querySelectorAll('div.highlight pre') + codeCells.forEach((codeCell, index) => { + const id = codeCellId(index) + codeCell.setAttribute('id', id) + + const clipboardButton = id => + `` + codeCell.insertAdjacentHTML('afterend', clipboardButton(id)) + }) + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +} + + +var copyTargetText = (trigger) => { + var target = document.querySelector(trigger.attributes['data-clipboard-target'].value); + return formatCopyText(target.innerText, '', false, true, true, true, '', '') +} + + // Initialize with a callback so we can modify the text before copy + const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText}) + + // Update UI with error/success messages + clipboard.on('success', event => { + clearSelection() + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_success']) + temporarilyChangeIcon(event.trigger) + }) + + clipboard.on('error', event => { + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_failure']) + }) +} + +runWhenDOMLoaded(addCopyButtonToCodeCells) \ No newline at end of file diff --git a/docs/_static/copybutton_funcs.js b/docs/_static/copybutton_funcs.js new file mode 100644 index 00000000..b9168c55 --- /dev/null +++ b/docs/_static/copybutton_funcs.js @@ -0,0 +1,58 @@ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +export function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +} diff --git a/docs/_static/documentation_options.js b/docs/_static/documentation_options.js index dac63bdb..e2afb23b 100644 --- a/docs/_static/documentation_options.js +++ b/docs/_static/documentation_options.js @@ -1,6 +1,6 @@ var DOCUMENTATION_OPTIONS = { URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'), - VERSION: '0.47.9', + VERSION: '0.47.10', LANGUAGE: 'None', COLLAPSE_INDEX: false, BUILDER: 'html', diff --git a/docs/_static/pygments.css b/docs/_static/pygments.css index 87f8bd12..695b3334 100644 --- a/docs/_static/pygments.css +++ b/docs/_static/pygments.css @@ -1,22 +1,22 @@ -pre { line-height: 125%; } -td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight pre { line-height: 125%; } +.highlight td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +.highlight span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +.highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .highlight .hll { background-color: #ffffcc } .highlight { background: #f8f8f8; } .highlight .c { color: #8f5902; font-style: italic } /* Comment */ .highlight .err { color: #a40000; border: 1px solid #ef2929 } /* Error */ .highlight .g { color: #000000 } /* Generic */ -.highlight .k { color: #004461; font-weight: bold } /* Keyword */ +.highlight .k { color: #204a87; font-weight: bold } /* Keyword */ .highlight .l { color: #000000 } /* Literal */ .highlight .n { color: #000000 } /* Name */ -.highlight .o { color: #582800 } /* Operator */ +.highlight .o { color: #ce5c00; font-weight: bold } /* Operator */ .highlight .x { color: #000000 } /* Other */ .highlight .p { color: #000000; font-weight: bold } /* Punctuation */ .highlight .ch { color: #8f5902; font-style: italic } /* Comment.Hashbang */ .highlight .cm { color: #8f5902; font-style: italic } /* Comment.Multiline */ -.highlight .cp { color: #8f5902 } /* Comment.Preproc */ +.highlight .cp { color: #8f5902; font-style: italic } /* Comment.Preproc */ .highlight .cpf { color: #8f5902; font-style: italic } /* Comment.PreprocFile */ .highlight .c1 { color: #8f5902; font-style: italic } /* Comment.Single */ .highlight .cs { color: #8f5902; font-style: italic } /* Comment.Special */ @@ -25,25 +25,25 @@ span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: .highlight .gr { color: #ef2929 } /* Generic.Error */ .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ .highlight .gi { color: #00A000 } /* Generic.Inserted */ -.highlight .go { color: #888888 } /* Generic.Output */ -.highlight .gp { color: #745334 } /* Generic.Prompt */ +.highlight .go { color: #000000; font-style: italic } /* Generic.Output */ +.highlight .gp { color: #8f5902 } /* Generic.Prompt */ .highlight .gs { color: #000000; font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ .highlight .gt { color: #a40000; font-weight: bold } /* Generic.Traceback */ -.highlight .kc { color: #004461; font-weight: bold } /* Keyword.Constant */ -.highlight .kd { color: #004461; font-weight: bold } /* Keyword.Declaration */ -.highlight .kn { color: #004461; font-weight: bold } /* Keyword.Namespace */ -.highlight .kp { color: #004461; font-weight: bold } /* Keyword.Pseudo */ -.highlight .kr { color: #004461; font-weight: bold } /* Keyword.Reserved */ -.highlight .kt { color: #004461; font-weight: bold } /* Keyword.Type */ +.highlight .kc { color: #204a87; font-weight: bold } /* Keyword.Constant */ +.highlight .kd { color: #204a87; font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { color: #204a87; font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { color: #204a87; font-weight: bold } /* Keyword.Pseudo */ +.highlight .kr { color: #204a87; font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #204a87; font-weight: bold } /* Keyword.Type */ .highlight .ld { color: #000000 } /* Literal.Date */ -.highlight .m { color: #990000 } /* Literal.Number */ +.highlight .m { color: #0000cf; font-weight: bold } /* Literal.Number */ .highlight .s { color: #4e9a06 } /* Literal.String */ .highlight .na { color: #c4a000 } /* Name.Attribute */ -.highlight .nb { color: #004461 } /* Name.Builtin */ +.highlight .nb { color: #204a87 } /* Name.Builtin */ .highlight .nc { color: #000000 } /* Name.Class */ .highlight .no { color: #000000 } /* Name.Constant */ -.highlight .nd { color: #888888 } /* Name.Decorator */ +.highlight .nd { color: #5c35cc; font-weight: bold } /* Name.Decorator */ .highlight .ni { color: #ce5c00 } /* Name.Entity */ .highlight .ne { color: #cc0000; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #000000 } /* Name.Function */ @@ -51,15 +51,15 @@ span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: .highlight .nn { color: #000000 } /* Name.Namespace */ .highlight .nx { color: #000000 } /* Name.Other */ .highlight .py { color: #000000 } /* Name.Property */ -.highlight .nt { color: #004461; font-weight: bold } /* Name.Tag */ +.highlight .nt { color: #204a87; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #000000 } /* Name.Variable */ -.highlight .ow { color: #004461; font-weight: bold } /* Operator.Word */ -.highlight .w { color: #f8f8f8; text-decoration: underline } /* Text.Whitespace */ -.highlight .mb { color: #990000 } /* Literal.Number.Bin */ -.highlight .mf { color: #990000 } /* Literal.Number.Float */ -.highlight .mh { color: #990000 } /* Literal.Number.Hex */ -.highlight .mi { color: #990000 } /* Literal.Number.Integer */ -.highlight .mo { color: #990000 } /* Literal.Number.Oct */ +.highlight .ow { color: #204a87; font-weight: bold } /* Operator.Word */ +.highlight .w { color: #f8f8f8 } /* Text.Whitespace */ +.highlight .mb { color: #0000cf; font-weight: bold } /* Literal.Number.Bin */ +.highlight .mf { color: #0000cf; font-weight: bold } /* Literal.Number.Float */ +.highlight .mh { color: #0000cf; font-weight: bold } /* Literal.Number.Hex */ +.highlight .mi { color: #0000cf; font-weight: bold } /* Literal.Number.Integer */ +.highlight .mo { color: #0000cf; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #4e9a06 } /* Literal.String.Affix */ .highlight .sb { color: #4e9a06 } /* Literal.String.Backtick */ .highlight .sc { color: #4e9a06 } /* Literal.String.Char */ @@ -79,4 +79,174 @@ span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: .highlight .vg { color: #000000 } /* Name.Variable.Global */ .highlight .vi { color: #000000 } /* Name.Variable.Instance */ .highlight .vm { color: #000000 } /* Name.Variable.Magic */ -.highlight .il { color: #990000 } /* Literal.Number.Integer.Long */ \ No newline at end of file +.highlight .il { color: #0000cf; font-weight: bold } /* Literal.Number.Integer.Long */ +@media not print { +body[data-theme="dark"] .highlight pre { line-height: 125%; } +body[data-theme="dark"] .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight .hll { background-color: #404040 } +body[data-theme="dark"] .highlight { background: #202020; color: #d0d0d0 } +body[data-theme="dark"] .highlight .c { color: #999999; font-style: italic } /* Comment */ +body[data-theme="dark"] .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ +body[data-theme="dark"] .highlight .esc { color: #d0d0d0 } /* Escape */ +body[data-theme="dark"] .highlight .g { color: #d0d0d0 } /* Generic */ +body[data-theme="dark"] .highlight .k { color: #6ab825; font-weight: bold } /* Keyword */ +body[data-theme="dark"] .highlight .l { color: #d0d0d0 } /* Literal */ +body[data-theme="dark"] .highlight .n { color: #d0d0d0 } /* Name */ +body[data-theme="dark"] .highlight .o { color: #d0d0d0 } /* Operator */ +body[data-theme="dark"] .highlight .x { color: #d0d0d0 } /* Other */ +body[data-theme="dark"] .highlight .p { color: #d0d0d0 } /* Punctuation */ +body[data-theme="dark"] .highlight .ch { color: #999999; font-style: italic } /* Comment.Hashbang */ +body[data-theme="dark"] .highlight .cm { color: #999999; font-style: italic } /* Comment.Multiline */ +body[data-theme="dark"] .highlight .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */ +body[data-theme="dark"] .highlight .cpf { color: #999999; font-style: italic } /* Comment.PreprocFile */ +body[data-theme="dark"] .highlight .c1 { color: #999999; font-style: italic } /* Comment.Single */ +body[data-theme="dark"] .highlight .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ +body[data-theme="dark"] .highlight .gd { color: #d22323 } /* Generic.Deleted */ +body[data-theme="dark"] .highlight .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */ +body[data-theme="dark"] .highlight .gr { color: #d22323 } /* Generic.Error */ +body[data-theme="dark"] .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ +body[data-theme="dark"] .highlight .gi { color: #589819 } /* Generic.Inserted */ +body[data-theme="dark"] .highlight .go { color: #cccccc } /* Generic.Output */ +body[data-theme="dark"] .highlight .gp { color: #aaaaaa } /* Generic.Prompt */ +body[data-theme="dark"] .highlight .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */ +body[data-theme="dark"] .highlight .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */ +body[data-theme="dark"] .highlight .gt { color: #d22323 } /* Generic.Traceback */ +body[data-theme="dark"] .highlight .kc { color: #6ab825; font-weight: bold } /* Keyword.Constant */ +body[data-theme="dark"] .highlight .kd { color: #6ab825; font-weight: bold } /* Keyword.Declaration */ +body[data-theme="dark"] .highlight .kn { color: #6ab825; font-weight: bold } /* Keyword.Namespace */ +body[data-theme="dark"] .highlight .kp { color: #6ab825 } /* Keyword.Pseudo */ +body[data-theme="dark"] .highlight .kr { color: #6ab825; font-weight: bold } /* Keyword.Reserved */ +body[data-theme="dark"] .highlight .kt { color: #6ab825; font-weight: bold } /* Keyword.Type */ +body[data-theme="dark"] .highlight .ld { color: #d0d0d0 } /* Literal.Date */ +body[data-theme="dark"] .highlight .m { color: #3677a9 } /* Literal.Number */ +body[data-theme="dark"] .highlight .s { color: #ed9d13 } /* Literal.String */ +body[data-theme="dark"] .highlight .na { color: #bbbbbb } /* Name.Attribute */ +body[data-theme="dark"] .highlight .nb { color: #24909d } /* Name.Builtin */ +body[data-theme="dark"] .highlight .nc { color: #447fcf; text-decoration: underline } /* Name.Class */ +body[data-theme="dark"] .highlight .no { color: #40ffff } /* Name.Constant */ +body[data-theme="dark"] .highlight .nd { color: #ffa500 } /* Name.Decorator */ +body[data-theme="dark"] .highlight .ni { color: #d0d0d0 } /* Name.Entity */ +body[data-theme="dark"] .highlight .ne { color: #bbbbbb } /* Name.Exception */ +body[data-theme="dark"] .highlight .nf { color: #447fcf } /* Name.Function */ +body[data-theme="dark"] .highlight .nl { color: #d0d0d0 } /* Name.Label */ +body[data-theme="dark"] .highlight .nn { color: #447fcf; text-decoration: underline } /* Name.Namespace */ +body[data-theme="dark"] .highlight .nx { color: #d0d0d0 } /* Name.Other */ +body[data-theme="dark"] .highlight .py { color: #d0d0d0 } /* Name.Property */ +body[data-theme="dark"] .highlight .nt { color: #6ab825; font-weight: bold } /* Name.Tag */ +body[data-theme="dark"] .highlight .nv { color: #40ffff } /* Name.Variable */ +body[data-theme="dark"] .highlight .ow { color: #6ab825; font-weight: bold } /* Operator.Word */ +body[data-theme="dark"] .highlight .w { color: #666666 } /* Text.Whitespace */ +body[data-theme="dark"] .highlight .mb { color: #3677a9 } /* Literal.Number.Bin */ +body[data-theme="dark"] .highlight .mf { color: #3677a9 } /* Literal.Number.Float */ +body[data-theme="dark"] .highlight .mh { color: #3677a9 } /* Literal.Number.Hex */ +body[data-theme="dark"] .highlight .mi { color: #3677a9 } /* Literal.Number.Integer */ +body[data-theme="dark"] .highlight .mo { color: #3677a9 } /* Literal.Number.Oct */ +body[data-theme="dark"] .highlight .sa { color: #ed9d13 } /* Literal.String.Affix */ +body[data-theme="dark"] .highlight .sb { color: #ed9d13 } /* Literal.String.Backtick */ +body[data-theme="dark"] .highlight .sc { color: #ed9d13 } /* Literal.String.Char */ +body[data-theme="dark"] .highlight .dl { color: #ed9d13 } /* Literal.String.Delimiter */ +body[data-theme="dark"] .highlight .sd { color: #ed9d13 } /* Literal.String.Doc */ +body[data-theme="dark"] .highlight .s2 { color: #ed9d13 } /* Literal.String.Double */ +body[data-theme="dark"] .highlight .se { color: #ed9d13 } /* Literal.String.Escape */ +body[data-theme="dark"] .highlight .sh { color: #ed9d13 } /* Literal.String.Heredoc */ +body[data-theme="dark"] .highlight .si { color: #ed9d13 } /* Literal.String.Interpol */ +body[data-theme="dark"] .highlight .sx { color: #ffa500 } /* Literal.String.Other */ +body[data-theme="dark"] .highlight .sr { color: #ed9d13 } /* Literal.String.Regex */ +body[data-theme="dark"] .highlight .s1 { color: #ed9d13 } /* Literal.String.Single */ +body[data-theme="dark"] .highlight .ss { color: #ed9d13 } /* Literal.String.Symbol */ +body[data-theme="dark"] .highlight .bp { color: #24909d } /* Name.Builtin.Pseudo */ +body[data-theme="dark"] .highlight .fm { color: #447fcf } /* Name.Function.Magic */ +body[data-theme="dark"] .highlight .vc { color: #40ffff } /* Name.Variable.Class */ +body[data-theme="dark"] .highlight .vg { color: #40ffff } /* Name.Variable.Global */ +body[data-theme="dark"] .highlight .vi { color: #40ffff } /* Name.Variable.Instance */ +body[data-theme="dark"] .highlight .vm { color: #40ffff } /* Name.Variable.Magic */ +body[data-theme="dark"] .highlight .il { color: #3677a9 } /* Literal.Number.Integer.Long */ +@media (prefers-color-scheme: dark) { +body:not([data-theme="light"]) .highlight pre { line-height: 125%; } +body:not([data-theme="light"]) .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight .hll { background-color: #404040 } +body:not([data-theme="light"]) .highlight { background: #202020; color: #d0d0d0 } +body:not([data-theme="light"]) .highlight .c { color: #999999; font-style: italic } /* Comment */ +body:not([data-theme="light"]) .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ +body:not([data-theme="light"]) .highlight .esc { color: #d0d0d0 } /* Escape */ +body:not([data-theme="light"]) .highlight .g { color: #d0d0d0 } /* Generic */ +body:not([data-theme="light"]) .highlight .k { color: #6ab825; font-weight: bold } /* Keyword */ +body:not([data-theme="light"]) .highlight .l { color: #d0d0d0 } /* Literal */ +body:not([data-theme="light"]) .highlight .n { color: #d0d0d0 } /* Name */ +body:not([data-theme="light"]) .highlight .o { color: #d0d0d0 } /* Operator */ +body:not([data-theme="light"]) .highlight .x { color: #d0d0d0 } /* Other */ +body:not([data-theme="light"]) .highlight .p { color: #d0d0d0 } /* Punctuation */ +body:not([data-theme="light"]) .highlight .ch { color: #999999; font-style: italic } /* Comment.Hashbang */ +body:not([data-theme="light"]) .highlight .cm { color: #999999; font-style: italic } /* Comment.Multiline */ +body:not([data-theme="light"]) .highlight .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */ +body:not([data-theme="light"]) .highlight .cpf { color: #999999; font-style: italic } /* Comment.PreprocFile */ +body:not([data-theme="light"]) .highlight .c1 { color: #999999; font-style: italic } /* Comment.Single */ +body:not([data-theme="light"]) .highlight .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ +body:not([data-theme="light"]) .highlight .gd { color: #d22323 } /* Generic.Deleted */ +body:not([data-theme="light"]) .highlight .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */ +body:not([data-theme="light"]) .highlight .gr { color: #d22323 } /* Generic.Error */ +body:not([data-theme="light"]) .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ +body:not([data-theme="light"]) .highlight .gi { color: #589819 } /* Generic.Inserted */ +body:not([data-theme="light"]) .highlight .go { color: #cccccc } /* Generic.Output */ +body:not([data-theme="light"]) .highlight .gp { color: #aaaaaa } /* Generic.Prompt */ +body:not([data-theme="light"]) .highlight .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */ +body:not([data-theme="light"]) .highlight .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */ +body:not([data-theme="light"]) .highlight .gt { color: #d22323 } /* Generic.Traceback */ +body:not([data-theme="light"]) .highlight .kc { color: #6ab825; font-weight: bold } /* Keyword.Constant */ +body:not([data-theme="light"]) .highlight .kd { color: #6ab825; font-weight: bold } /* Keyword.Declaration */ +body:not([data-theme="light"]) .highlight .kn { color: #6ab825; font-weight: bold } /* Keyword.Namespace */ +body:not([data-theme="light"]) .highlight .kp { color: #6ab825 } /* Keyword.Pseudo */ +body:not([data-theme="light"]) .highlight .kr { color: #6ab825; font-weight: bold } /* Keyword.Reserved */ +body:not([data-theme="light"]) .highlight .kt { color: #6ab825; font-weight: bold } /* Keyword.Type */ +body:not([data-theme="light"]) .highlight .ld { color: #d0d0d0 } /* Literal.Date */ +body:not([data-theme="light"]) .highlight .m { color: #3677a9 } /* Literal.Number */ +body:not([data-theme="light"]) .highlight .s { color: #ed9d13 } /* Literal.String */ +body:not([data-theme="light"]) .highlight .na { color: #bbbbbb } /* Name.Attribute */ +body:not([data-theme="light"]) .highlight .nb { color: #24909d } /* Name.Builtin */ +body:not([data-theme="light"]) .highlight .nc { color: #447fcf; text-decoration: underline } /* Name.Class */ +body:not([data-theme="light"]) .highlight .no { color: #40ffff } /* Name.Constant */ +body:not([data-theme="light"]) .highlight .nd { color: #ffa500 } /* Name.Decorator */ +body:not([data-theme="light"]) .highlight .ni { color: #d0d0d0 } /* Name.Entity */ +body:not([data-theme="light"]) .highlight .ne { color: #bbbbbb } /* Name.Exception */ +body:not([data-theme="light"]) .highlight .nf { color: #447fcf } /* Name.Function */ +body:not([data-theme="light"]) .highlight .nl { color: #d0d0d0 } /* Name.Label */ +body:not([data-theme="light"]) .highlight .nn { color: #447fcf; text-decoration: underline } /* Name.Namespace */ +body:not([data-theme="light"]) .highlight .nx { color: #d0d0d0 } /* Name.Other */ +body:not([data-theme="light"]) .highlight .py { color: #d0d0d0 } /* Name.Property */ +body:not([data-theme="light"]) .highlight .nt { color: #6ab825; font-weight: bold } /* Name.Tag */ +body:not([data-theme="light"]) .highlight .nv { color: #40ffff } /* Name.Variable */ +body:not([data-theme="light"]) .highlight .ow { color: #6ab825; font-weight: bold } /* Operator.Word */ +body:not([data-theme="light"]) .highlight .w { color: #666666 } /* Text.Whitespace */ +body:not([data-theme="light"]) .highlight .mb { color: #3677a9 } /* Literal.Number.Bin */ +body:not([data-theme="light"]) .highlight .mf { color: #3677a9 } /* Literal.Number.Float */ +body:not([data-theme="light"]) .highlight .mh { color: #3677a9 } /* Literal.Number.Hex */ +body:not([data-theme="light"]) .highlight .mi { color: #3677a9 } /* Literal.Number.Integer */ +body:not([data-theme="light"]) .highlight .mo { color: #3677a9 } /* Literal.Number.Oct */ +body:not([data-theme="light"]) .highlight .sa { color: #ed9d13 } /* Literal.String.Affix */ +body:not([data-theme="light"]) .highlight .sb { color: #ed9d13 } /* Literal.String.Backtick */ +body:not([data-theme="light"]) .highlight .sc { color: #ed9d13 } /* Literal.String.Char */ +body:not([data-theme="light"]) .highlight .dl { color: #ed9d13 } /* Literal.String.Delimiter */ +body:not([data-theme="light"]) .highlight .sd { color: #ed9d13 } /* Literal.String.Doc */ +body:not([data-theme="light"]) .highlight .s2 { color: #ed9d13 } /* Literal.String.Double */ +body:not([data-theme="light"]) .highlight .se { color: #ed9d13 } /* Literal.String.Escape */ +body:not([data-theme="light"]) .highlight .sh { color: #ed9d13 } /* Literal.String.Heredoc */ +body:not([data-theme="light"]) .highlight .si { color: #ed9d13 } /* Literal.String.Interpol */ +body:not([data-theme="light"]) .highlight .sx { color: #ffa500 } /* Literal.String.Other */ +body:not([data-theme="light"]) .highlight .sr { color: #ed9d13 } /* Literal.String.Regex */ +body:not([data-theme="light"]) .highlight .s1 { color: #ed9d13 } /* Literal.String.Single */ +body:not([data-theme="light"]) .highlight .ss { color: #ed9d13 } /* Literal.String.Symbol */ +body:not([data-theme="light"]) .highlight .bp { color: #24909d } /* Name.Builtin.Pseudo */ +body:not([data-theme="light"]) .highlight .fm { color: #447fcf } /* Name.Function.Magic */ +body:not([data-theme="light"]) .highlight .vc { color: #40ffff } /* Name.Variable.Class */ +body:not([data-theme="light"]) .highlight .vg { color: #40ffff } /* Name.Variable.Global */ +body:not([data-theme="light"]) .highlight .vi { color: #40ffff } /* Name.Variable.Instance */ +body:not([data-theme="light"]) .highlight .vm { color: #40ffff } /* Name.Variable.Magic */ +body:not([data-theme="light"]) .highlight .il { color: #3677a9 } /* Literal.Number.Integer.Long */ +} +} \ No newline at end of file diff --git a/docs/_static/scripts/furo-extensions.js b/docs/_static/scripts/furo-extensions.js new file mode 100644 index 00000000..e69de29b diff --git a/docs/_static/scripts/furo.js b/docs/_static/scripts/furo.js new file mode 100644 index 00000000..cbf64878 --- /dev/null +++ b/docs/_static/scripts/furo.js @@ -0,0 +1,3 @@ +/*! For license information please see furo.js.LICENSE.txt */ +(()=>{var t={212:function(t,e,n){var o,r;r=void 0!==n.g?n.g:"undefined"!=typeof window?window:this,o=function(){return function(t){"use strict";var e={navClass:"active",contentClass:"active",nested:!1,nestedClass:"active",offset:0,reflow:!1,events:!0},n=function(t,e,n){if(n.settings.events){var o=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(o)}},o=function(t){var e=0;if(t.offsetParent)for(;t;)e+=t.offsetTop,t=t.offsetParent;return e>=0?e:0},r=function(t){t&&t.sort((function(t,e){return o(t.content)=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)},l=function(t,e){var n=t[t.length-1];if(function(t,e){return!(!s()||!c(t.content,e,!0))}(n,e))return n;for(var o=t.length-1;o>=0;o--)if(c(t[o].content,e))return t[o]},a=function(t,e){if(e.nested&&t.parentNode){var n=t.parentNode.closest("li");n&&(n.classList.remove(e.nestedClass),a(n,e))}},i=function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.remove(e.navClass),t.content.classList.remove(e.contentClass),a(o,e),n("gumshoeDeactivate",o,{link:t.nav,content:t.content,settings:e}))}},u=function(t,e){if(e.nested){var n=t.parentNode.closest("li");n&&(n.classList.add(e.nestedClass),u(n,e))}};return function(o,c){var s,a,d,f,m,v={setup:function(){s=document.querySelectorAll(o),a=[],Array.prototype.forEach.call(s,(function(t){var e=document.getElementById(decodeURIComponent(t.hash.substr(1)));e&&a.push({nav:t,content:e})})),r(a)},detect:function(){var t=l(a,m);t?d&&t.content===d.content||(i(d,m),function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.add(e.navClass),t.content.classList.add(e.contentClass),u(o,e),n("gumshoeActivate",o,{link:t.nav,content:t.content,settings:e}))}}(t,m),d=t):d&&(i(d,m),d=null)}},h=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(v.detect)},g=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame((function(){r(a),v.detect()}))};return v.destroy=function(){d&&i(d,m),t.removeEventListener("scroll",h,!1),m.reflow&&t.removeEventListener("resize",g,!1),a=null,s=null,d=null,f=null,m=null},m=function(){var t={};return Array.prototype.forEach.call(arguments,(function(e){for(var n in e){if(!e.hasOwnProperty(n))return;t[n]=e[n]}})),t}(e,c||{}),v.setup(),v.detect(),t.addEventListener("scroll",h,!1),m.reflow&&t.addEventListener("resize",g,!1),v}}(r)}.apply(e,[]),void 0===o||(t.exports=o)}},e={};function n(o){var r=e[o];if(void 0!==r)return r.exports;var c=e[o]={exports:{}};return t[o].call(c.exports,c,c.exports,n),c.exports}n.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return n.d(e,{a:e}),e},n.d=(t,e)=>{for(var o in e)n.o(e,o)&&!n.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),n.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=n(212),e=n.n(t),o=null,r=null,c=window.pageYOffset||document.documentElement.scrollTop;function s(){const t=localStorage.getItem("theme")||"auto";var e;"light"!==(e=window.matchMedia("(prefers-color-scheme: dark)").matches?"auto"===t?"light":"light"==t?"dark":"auto":"auto"===t?"dark":"dark"==t?"light":"auto")&&"dark"!==e&&"auto"!==e&&(console.error(`Got invalid theme mode: ${e}. Resetting to auto.`),e="auto"),document.body.dataset.theme=e,localStorage.setItem("theme",e),console.log(`Changed to ${e} mode.`)}function l(){!function(){const t=document.getElementsByClassName("theme-toggle");Array.from(t).forEach((t=>{t.addEventListener("click",s)}))}(),function(){let t=0,e=!1;window.addEventListener("scroll",(function(n){t=window.scrollY,e||(window.requestAnimationFrame((function(){var n;n=t,0==Math.floor(r.getBoundingClientRect().top)?r.classList.add("scrolled"):r.classList.remove("scrolled"),function(t){t<64?document.documentElement.classList.remove("show-back-to-top"):tc&&document.documentElement.classList.remove("show-back-to-top"),c=t}(n),function(t){null!==o&&(0==t?o.scrollTo(0,0):Math.ceil(t)>=Math.floor(document.documentElement.scrollHeight-window.innerHeight)?o.scrollTo(0,o.scrollHeight):document.querySelector(".scroll-current"))}(n),e=!1})),e=!0)})),window.scroll()}(),null!==o&&new(e())(".toc-tree a",{reflow:!0,recursive:!0,navClass:"scroll-current",offset:()=>{let t=parseFloat(getComputedStyle(document.documentElement).fontSize);return r.getBoundingClientRect().height+.5*t+1}})}document.addEventListener("DOMContentLoaded",(function(){document.body.parentNode.classList.remove("no-js"),r=document.querySelector("header"),o=document.querySelector(".toc-scroll"),l()}))})()})(); +//# sourceMappingURL=furo.js.map \ No newline at end of file diff --git a/docs/_static/scripts/furo.js.LICENSE.txt b/docs/_static/scripts/furo.js.LICENSE.txt new file mode 100644 index 00000000..1632189c --- /dev/null +++ b/docs/_static/scripts/furo.js.LICENSE.txt @@ -0,0 +1,7 @@ +/*! + * gumshoejs v5.1.2 (patched by @pradyunsg) + * A simple, framework-agnostic scrollspy script. + * (c) 2019 Chris Ferdinandi + * MIT License + * http://github.com/cferdinandi/gumshoe + */ diff --git a/docs/_static/scripts/furo.js.map b/docs/_static/scripts/furo.js.map new file mode 100644 index 00000000..c9e2d048 --- /dev/null +++ b/docs/_static/scripts/furo.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scripts/furo.js","mappings":";iCAAA,MAQWA,EAAAA,OAWS,IAAX,EAAAC,EACH,EAAAA,EACkB,oBAAXC,OACPA,OACAC,KAbS,EAAF,WACP,OAaJ,SAAUD,GACR,aAMA,IAAIE,EAAW,CAEbC,SAAU,SACVC,aAAc,SAGdC,QAAQ,EACRC,YAAa,SAGbC,OAAQ,EACRC,QAAQ,EAGRC,QAAQ,GA6BNC,EAAY,SAAUC,EAAMC,EAAMC,GAEpC,GAAKA,EAAOC,SAASL,OAArB,CAGA,IAAIM,EAAQ,IAAIC,YAAYL,EAAM,CAChCM,SAAS,EACTC,YAAY,EACZL,OAAQA,IAIVD,EAAKO,cAAcJ,KAQjBK,EAAe,SAAUR,GAC3B,IAAIS,EAAW,EACf,GAAIT,EAAKU,aACP,KAAOV,GACLS,GAAYT,EAAKW,UACjBX,EAAOA,EAAKU,aAGhB,OAAOD,GAAY,EAAIA,EAAW,GAOhCG,EAAe,SAAUC,GACvBA,GACFA,EAASC,MAAK,SAAUC,EAAOC,GAG7B,OAFcR,EAAaO,EAAME,SACnBT,EAAaQ,EAAMC,UACF,EACxB,MA2CTC,EAAW,SAAUlB,EAAME,EAAUiB,GACvC,IAAIC,EAASpB,EAAKqB,wBACd1B,EAnCU,SAAUO,GAExB,MAA+B,mBAApBA,EAASP,OACX2B,WAAWpB,EAASP,UAItB2B,WAAWpB,EAASP,QA4Bd4B,CAAUrB,GACvB,OAAIiB,EAEAK,SAASJ,EAAOD,OAAQ,KACvB/B,EAAOqC,aAAeC,SAASC,gBAAgBC,cAG7CJ,SAASJ,EAAOS,IAAK,KAAOlC,GAOjCmC,EAAa,WACf,OACEC,KAAKC,KAAK5C,EAAOqC,YAAcrC,EAAO6C,cAnCjCF,KAAKG,IACVR,SAASS,KAAKC,aACdV,SAASC,gBAAgBS,aACzBV,SAASS,KAAKE,aACdX,SAASC,gBAAgBU,aACzBX,SAASS,KAAKP,aACdF,SAASC,gBAAgBC,eAqDzBU,EAAY,SAAUzB,EAAUX,GAClC,IAAIqC,EAAO1B,EAASA,EAAS2B,OAAS,GACtC,GAbgB,SAAUC,EAAMvC,GAChC,SAAI4B,MAAgBZ,EAASuB,EAAKxB,QAASf,GAAU,IAYjDwC,CAAYH,EAAMrC,GAAW,OAAOqC,EACxC,IAAK,IAAII,EAAI9B,EAAS2B,OAAS,EAAGG,GAAK,EAAGA,IACxC,GAAIzB,EAASL,EAAS8B,GAAG1B,QAASf,GAAW,OAAOW,EAAS8B,IAS7DC,EAAmB,SAAUC,EAAK3C,GAEpC,GAAKA,EAAST,QAAWoD,EAAIC,WAA7B,CAGA,IAAIC,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASR,aAG7BkD,EAAiBG,EAAI7C,MAQnBiD,EAAa,SAAUC,EAAOlD,GAEhC,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASX,UAC7B6D,EAAMnC,QAAQgC,UAAUC,OAAOhD,EAASV,cAGxCoD,EAAiBG,EAAI7C,GAGrBJ,EAAU,oBAAqBiD,EAAI,CACjCM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,OASVoD,EAAiB,SAAUT,EAAK3C,GAElC,GAAKA,EAAST,OAAd,CAGA,IAAIsD,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASR,aAG1B4D,EAAeP,EAAI7C,MA8LrB,OA1JkB,SAAUsD,EAAUC,GAKpC,IACIC,EAAU7C,EAAU8C,EAASC,EAAS1D,EADtC2D,EAAa,CAUjBA,MAAmB,WAEjBH,EAAWhC,SAASoC,iBAAiBN,GAGrC3C,EAAW,GAGXkD,MAAMC,UAAUC,QAAQC,KAAKR,GAAU,SAAUjB,GAE/C,IAAIxB,EAAUS,SAASyC,eACrBC,mBAAmB3B,EAAK4B,KAAKC,OAAO,KAEjCrD,GAGLJ,EAAS0D,KAAK,CACZ1B,IAAKJ,EACLxB,QAASA,OAKbL,EAAaC,IAMfgD,OAAoB,WAElB,IAAIW,EAASlC,EAAUzB,EAAUX,GAG5BsE,EASDb,GAAWa,EAAOvD,UAAY0C,EAAQ1C,UAG1CkC,EAAWQ,EAASzD,GAzFT,SAAUkD,EAAOlD,GAE9B,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASX,UAC1B6D,EAAMnC,QAAQgC,UAAUM,IAAIrD,EAASV,cAGrC8D,EAAeP,EAAI7C,GAGnBJ,EAAU,kBAAmBiD,EAAI,CAC/BM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,MAuEVuE,CAASD,EAAQtE,GAGjByD,EAAUa,GAfJb,IACFR,EAAWQ,EAASzD,GACpByD,EAAU,QAoBZe,EAAgB,SAAUvE,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,sBAAsBf,EAAWgB,SAOhDC,EAAgB,SAAU3E,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,uBAAsB,WACrChE,EAAaC,GACbgD,EAAWgB,aAoDf,OA7CAhB,EAAWkB,QAAU,WAEfpB,GACFR,EAAWQ,EAASzD,GAItBd,EAAO4F,oBAAoB,SAAUN,GAAe,GAChDxE,EAASN,QACXR,EAAO4F,oBAAoB,SAAUF,GAAe,GAItDjE,EAAW,KACX6C,EAAW,KACXC,EAAU,KACVC,EAAU,KACV1D,EAAW,MAQXA,EA3XS,WACX,IAAI+E,EAAS,GAOb,OANAlB,MAAMC,UAAUC,QAAQC,KAAKgB,WAAW,SAAUC,GAChD,IAAK,IAAIC,KAAOD,EAAK,CACnB,IAAKA,EAAIE,eAAeD,GAAM,OAC9BH,EAAOG,GAAOD,EAAIC,OAGfH,EAmXMK,CAAOhG,EAAUmE,GAAW,IAGvCI,EAAW0B,QAGX1B,EAAWgB,SAGXzF,EAAOoG,iBAAiB,SAAUd,GAAe,GAC7CxE,EAASN,QACXR,EAAOoG,iBAAiB,SAAUV,GAAe,GAS9CjB,GA7bA4B,CAAQvG,IAChB,QAFM,SAEN,uBCXDwG,EAA2B,GAG/B,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBE,IAAjBD,EACH,OAAOA,EAAaE,QAGrB,IAAIC,EAASN,EAAyBE,GAAY,CAGjDG,QAAS,IAOV,OAHAE,EAAoBL,GAAU1B,KAAK8B,EAAOD,QAASC,EAAQA,EAAOD,QAASJ,GAGpEK,EAAOD,QCpBfJ,EAAoBO,EAAKF,IACxB,IAAIG,EAASH,GAAUA,EAAOI,WAC7B,IAAOJ,EAAiB,QACxB,IAAM,EAEP,OADAL,EAAoBU,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,GCLRR,EAAoBU,EAAI,CAACN,EAASQ,KACjC,IAAI,IAAInB,KAAOmB,EACXZ,EAAoBa,EAAED,EAAYnB,KAASO,EAAoBa,EAAET,EAASX,IAC5EqB,OAAOC,eAAeX,EAASX,EAAK,CAAEuB,YAAY,EAAMC,IAAKL,EAAWnB,MCJ3EO,EAAoBxG,EAAI,WACvB,GAA0B,iBAAf0H,WAAyB,OAAOA,WAC3C,IACC,OAAOxH,MAAQ,IAAIyH,SAAS,cAAb,GACd,MAAOC,GACR,GAAsB,iBAAX3H,OAAqB,OAAOA,QALjB,GCAxBuG,EAAoBa,EAAI,CAACrB,EAAK6B,IAAUP,OAAOzC,UAAUqB,eAAenB,KAAKiB,EAAK6B,4CCK9EC,EAAY,KACZC,EAAS,KACTC,EAAgB/H,OAAO6C,aAAeP,SAASC,gBAAgByF,UA4EnE,SAASC,IACP,MAAMC,EAAeC,aAAaC,QAAQ,UAAY,OAZxD,IAAkBC,EACH,WADGA,EAaIrI,OAAOsI,WAAW,gCAAgCC,QAI/C,SAAjBL,EACO,QACgB,SAAhBA,EACA,OAEA,OAIU,SAAjBA,EACO,OACgB,QAAhBA,EACA,QAEA,SA9BoB,SAATG,GAA4B,SAATA,IACzCG,QAAQC,MAAM,2BAA2BJ,yBACzCA,EAAO,QAGT/F,SAASS,KAAK2F,QAAQC,MAAQN,EAC9BF,aAAaS,QAAQ,QAASP,GAC9BG,QAAQK,IAAI,cAAcR,WA4E5B,SAASlC,KART,WAEE,MAAM2C,EAAUxG,SAASyG,uBAAuB,gBAChDpE,MAAMqE,KAAKF,GAASjE,SAASoE,IAC3BA,EAAI7C,iBAAiB,QAAS6B,MAKhCiB,GA9CF,WAEE,IAAIC,EAA6B,EAC7BC,GAAU,EAEdpJ,OAAOoG,iBAAiB,UAAU,SAAUuB,GAC1CwB,EAA6BnJ,OAAOqJ,QAE/BD,IACHpJ,OAAOwF,uBAAsB,WAzDnC,IAAuB8D,EAAAA,EA0DDH,EA9GkC,GAAlDxG,KAAK4G,MAAMzB,EAAO7F,wBAAwBQ,KAC5CqF,EAAOjE,UAAUM,IAAI,YAErB2D,EAAOjE,UAAUC,OAAO,YAI5B,SAAmCwF,GAC7BA,EAXmB,GAYrBhH,SAASC,gBAAgBsB,UAAUC,OAAO,oBAEtCwF,EAAYvB,EACdzF,SAASC,gBAAgBsB,UAAUM,IAAI,oBAC9BmF,EAAYvB,GACrBzF,SAASC,gBAAgBsB,UAAUC,OAAO,oBAG9CiE,EAAgBuB,EAqChBE,CAA0BF,GAlC5B,SAA6BA,GACT,OAAdzB,IAKa,GAAbyB,EACFzB,EAAU4B,SAAS,EAAG,GAGtB9G,KAAKC,KAAK0G,IACV3G,KAAK4G,MAAMjH,SAASC,gBAAgBS,aAAehD,OAAOqC,aAE1DwF,EAAU4B,SAAS,EAAG5B,EAAU7E,cAGhBV,SAASoH,cAAc,oBAmBzCC,CAAoBL,GAwDdF,GAAU,KAGZA,GAAU,MAGdpJ,OAAO4J,SA8BPC,GA1BkB,OAAdhC,GAKJ,IAAI,IAAJ,CAAY,cAAe,CACzBrH,QAAQ,EACRsJ,WAAW,EACX3J,SAAU,iBACVI,OAAQ,KACN,IAAIwJ,EAAM7H,WAAW8H,iBAAiB1H,SAASC,iBAAiB0H,UAChE,OAAOnC,EAAO7F,wBAAwBiI,OAAS,GAAMH,EAAM,KA+BjEzH,SAAS8D,iBAAiB,oBAT1B,WACE9D,SAASS,KAAKW,WAAWG,UAAUC,OAAO,SAE1CgE,EAASxF,SAASoH,cAAc,UAChC7B,EAAYvF,SAASoH,cAAc,eAEnCvD","sources":["webpack:///./src/furo/assets/scripts/gumshoe-patched.js","webpack:///webpack/bootstrap","webpack:///webpack/runtime/compat get default export","webpack:///webpack/runtime/define property getters","webpack:///webpack/runtime/global","webpack:///webpack/runtime/hasOwnProperty shorthand","webpack:///./src/furo/assets/scripts/furo.js"],"sourcesContent":["/*!\n * gumshoejs v5.1.2 (patched by @pradyunsg)\n * A simple, framework-agnostic scrollspy script.\n * (c) 2019 Chris Ferdinandi\n * MIT License\n * http://github.com/cferdinandi/gumshoe\n */\n\n(function (root, factory) {\n if (typeof define === \"function\" && define.amd) {\n define([], function () {\n return factory(root);\n });\n } else if (typeof exports === \"object\") {\n module.exports = factory(root);\n } else {\n root.Gumshoe = factory(root);\n }\n})(\n typeof global !== \"undefined\"\n ? global\n : typeof window !== \"undefined\"\n ? window\n : this,\n function (window) {\n \"use strict\";\n\n //\n // Defaults\n //\n\n var defaults = {\n // Active classes\n navClass: \"active\",\n contentClass: \"active\",\n\n // Nested navigation\n nested: false,\n nestedClass: \"active\",\n\n // Offset & reflow\n offset: 0,\n reflow: false,\n\n // Event support\n events: true,\n };\n\n //\n // Methods\n //\n\n /**\n * Merge two or more objects together.\n * @param {Object} objects The objects to merge together\n * @returns {Object} Merged values of defaults and options\n */\n var extend = function () {\n var merged = {};\n Array.prototype.forEach.call(arguments, function (obj) {\n for (var key in obj) {\n if (!obj.hasOwnProperty(key)) return;\n merged[key] = obj[key];\n }\n });\n return merged;\n };\n\n /**\n * Emit a custom event\n * @param {String} type The event type\n * @param {Node} elem The element to attach the event to\n * @param {Object} detail Any details to pass along with the event\n */\n var emitEvent = function (type, elem, detail) {\n // Make sure events are enabled\n if (!detail.settings.events) return;\n\n // Create a new event\n var event = new CustomEvent(type, {\n bubbles: true,\n cancelable: true,\n detail: detail,\n });\n\n // Dispatch the event\n elem.dispatchEvent(event);\n };\n\n /**\n * Get an element's distance from the top of the Document.\n * @param {Node} elem The element\n * @return {Number} Distance from the top in pixels\n */\n var getOffsetTop = function (elem) {\n var location = 0;\n if (elem.offsetParent) {\n while (elem) {\n location += elem.offsetTop;\n elem = elem.offsetParent;\n }\n }\n return location >= 0 ? location : 0;\n };\n\n /**\n * Sort content from first to last in the DOM\n * @param {Array} contents The content areas\n */\n var sortContents = function (contents) {\n if (contents) {\n contents.sort(function (item1, item2) {\n var offset1 = getOffsetTop(item1.content);\n var offset2 = getOffsetTop(item2.content);\n if (offset1 < offset2) return -1;\n return 1;\n });\n }\n };\n\n /**\n * Get the offset to use for calculating position\n * @param {Object} settings The settings for this instantiation\n * @return {Float} The number of pixels to offset the calculations\n */\n var getOffset = function (settings) {\n // if the offset is a function run it\n if (typeof settings.offset === \"function\") {\n return parseFloat(settings.offset());\n }\n\n // Otherwise, return it as-is\n return parseFloat(settings.offset);\n };\n\n /**\n * Get the document element's height\n * @private\n * @returns {Number}\n */\n var getDocumentHeight = function () {\n return Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight,\n document.body.offsetHeight,\n document.documentElement.offsetHeight,\n document.body.clientHeight,\n document.documentElement.clientHeight\n );\n };\n\n /**\n * Determine if an element is in view\n * @param {Node} elem The element\n * @param {Object} settings The settings for this instantiation\n * @param {Boolean} bottom If true, check if element is above bottom of viewport instead\n * @return {Boolean} Returns true if element is in the viewport\n */\n var isInView = function (elem, settings, bottom) {\n var bounds = elem.getBoundingClientRect();\n var offset = getOffset(settings);\n if (bottom) {\n return (\n parseInt(bounds.bottom, 10) <\n (window.innerHeight || document.documentElement.clientHeight)\n );\n }\n return parseInt(bounds.top, 10) <= offset;\n };\n\n /**\n * Check if at the bottom of the viewport\n * @return {Boolean} If true, page is at the bottom of the viewport\n */\n var isAtBottom = function () {\n if (\n Math.ceil(window.innerHeight + window.pageYOffset) >=\n getDocumentHeight()\n )\n return true;\n return false;\n };\n\n /**\n * Check if the last item should be used (even if not at the top of the page)\n * @param {Object} item The last item\n * @param {Object} settings The settings for this instantiation\n * @return {Boolean} If true, use the last item\n */\n var useLastItem = function (item, settings) {\n if (isAtBottom() && isInView(item.content, settings, true)) return true;\n return false;\n };\n\n /**\n * Get the active content\n * @param {Array} contents The content areas\n * @param {Object} settings The settings for this instantiation\n * @return {Object} The content area and matching navigation link\n */\n var getActive = function (contents, settings) {\n var last = contents[contents.length - 1];\n if (useLastItem(last, settings)) return last;\n for (var i = contents.length - 1; i >= 0; i--) {\n if (isInView(contents[i].content, settings)) return contents[i];\n }\n };\n\n /**\n * Deactivate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var deactivateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested || !nav.parentNode) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Remove the active class\n li.classList.remove(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n deactivateNested(li, settings);\n };\n\n /**\n * Deactivate a nav and content area\n * @param {Object} items The nav item and content to deactivate\n * @param {Object} settings The settings for this instantiation\n */\n var deactivate = function (items, settings) {\n // Make sure there are items to deactivate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Remove the active class from the nav and content\n li.classList.remove(settings.navClass);\n items.content.classList.remove(settings.contentClass);\n\n // Deactivate any parent navs in a nested navigation\n deactivateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeDeactivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Activate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var activateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Add the active class\n li.classList.add(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n activateNested(li, settings);\n };\n\n /**\n * Activate a nav and content area\n * @param {Object} items The nav item and content to activate\n * @param {Object} settings The settings for this instantiation\n */\n var activate = function (items, settings) {\n // Make sure there are items to activate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Add the active class to the nav and content\n li.classList.add(settings.navClass);\n items.content.classList.add(settings.contentClass);\n\n // Activate any parent navs in a nested navigation\n activateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeActivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Create the Constructor object\n * @param {String} selector The selector to use for navigation items\n * @param {Object} options User options and settings\n */\n var Constructor = function (selector, options) {\n //\n // Variables\n //\n\n var publicAPIs = {};\n var navItems, contents, current, timeout, settings;\n\n //\n // Methods\n //\n\n /**\n * Set variables from DOM elements\n */\n publicAPIs.setup = function () {\n // Get all nav items\n navItems = document.querySelectorAll(selector);\n\n // Create contents array\n contents = [];\n\n // Loop through each item, get it's matching content, and push to the array\n Array.prototype.forEach.call(navItems, function (item) {\n // Get the content for the nav item\n var content = document.getElementById(\n decodeURIComponent(item.hash.substr(1))\n );\n if (!content) return;\n\n // Push to the contents array\n contents.push({\n nav: item,\n content: content,\n });\n });\n\n // Sort contents by the order they appear in the DOM\n sortContents(contents);\n };\n\n /**\n * Detect which content is currently active\n */\n publicAPIs.detect = function () {\n // Get the active content\n var active = getActive(contents, settings);\n\n // if there's no active content, deactivate and bail\n if (!active) {\n if (current) {\n deactivate(current, settings);\n current = null;\n }\n return;\n }\n\n // If the active content is the one currently active, do nothing\n if (current && active.content === current.content) return;\n\n // Deactivate the current content and activate the new content\n deactivate(current, settings);\n activate(active, settings);\n\n // Update the currently active content\n current = active;\n };\n\n /**\n * Detect the active content on scroll\n * Debounced for performance\n */\n var scrollHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(publicAPIs.detect);\n };\n\n /**\n * Update content sorting on resize\n * Debounced for performance\n */\n var resizeHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(function () {\n sortContents(contents);\n publicAPIs.detect();\n });\n };\n\n /**\n * Destroy the current instantiation\n */\n publicAPIs.destroy = function () {\n // Undo DOM changes\n if (current) {\n deactivate(current, settings);\n }\n\n // Remove event listeners\n window.removeEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.removeEventListener(\"resize\", resizeHandler, false);\n }\n\n // Reset variables\n contents = null;\n navItems = null;\n current = null;\n timeout = null;\n settings = null;\n };\n\n /**\n * Initialize the current instantiation\n */\n var init = function () {\n // Merge user options into defaults\n settings = extend(defaults, options || {});\n\n // Setup variables based on the current DOM\n publicAPIs.setup();\n\n // Find the currently active content\n publicAPIs.detect();\n\n // Setup event listeners\n window.addEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.addEventListener(\"resize\", resizeHandler, false);\n }\n };\n\n //\n // Initialize and return the public APIs\n //\n\n init();\n return publicAPIs;\n };\n\n //\n // Return the Constructor\n //\n\n return Constructor;\n }\n);\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.g = (function() {\n\tif (typeof globalThis === 'object') return globalThis;\n\ttry {\n\t\treturn this || new Function('return this')();\n\t} catch (e) {\n\t\tif (typeof window === 'object') return window;\n\t}\n})();","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","import Gumshoe from \"./gumshoe-patched.js\";\n\n////////////////////////////////////////////////////////////////////////////////\n// Scroll Handling\n////////////////////////////////////////////////////////////////////////////////\nvar tocScroll = null;\nvar header = null;\nvar lastScrollTop = window.pageYOffset || document.documentElement.scrollTop;\nconst GO_TO_TOP_OFFSET = 64;\n\nfunction scrollHandlerForHeader() {\n if (Math.floor(header.getBoundingClientRect().top) == 0) {\n header.classList.add(\"scrolled\");\n } else {\n header.classList.remove(\"scrolled\");\n }\n}\n\nfunction scrollHandlerForBackToTop(positionY) {\n if (positionY < GO_TO_TOP_OFFSET) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n } else {\n if (positionY < lastScrollTop) {\n document.documentElement.classList.add(\"show-back-to-top\");\n } else if (positionY > lastScrollTop) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n }\n }\n lastScrollTop = positionY;\n}\n\nfunction scrollHandlerForTOC(positionY) {\n if (tocScroll === null) {\n return;\n }\n\n // top of page.\n if (positionY == 0) {\n tocScroll.scrollTo(0, 0);\n } else if (\n // bottom of page.\n Math.ceil(positionY) >=\n Math.floor(document.documentElement.scrollHeight - window.innerHeight)\n ) {\n tocScroll.scrollTo(0, tocScroll.scrollHeight);\n } else {\n // somewhere in the middle.\n const current = document.querySelector(\".scroll-current\");\n if (current == null) {\n return;\n }\n\n // https://github.com/pypa/pip/issues/9159 This breaks scroll behaviours.\n // // scroll the currently \"active\" heading in toc, into view.\n // const rect = current.getBoundingClientRect();\n // if (0 > rect.top) {\n // current.scrollIntoView(true); // the argument is \"alignTop\"\n // } else if (rect.bottom > window.innerHeight) {\n // current.scrollIntoView(false);\n // }\n }\n}\n\nfunction scrollHandler(positionY) {\n scrollHandlerForHeader();\n scrollHandlerForBackToTop(positionY);\n scrollHandlerForTOC(positionY);\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Theme Toggle\n////////////////////////////////////////////////////////////////////////////////\nfunction setTheme(mode) {\n if (mode !== \"light\" && mode !== \"dark\" && mode !== \"auto\") {\n console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);\n mode = \"auto\";\n }\n\n document.body.dataset.theme = mode;\n localStorage.setItem(\"theme\", mode);\n console.log(`Changed to ${mode} mode.`);\n}\n\nfunction cycleThemeOnce() {\n const currentTheme = localStorage.getItem(\"theme\") || \"auto\";\n const prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n\n if (prefersDark) {\n // Auto (dark) -> Light -> Dark\n if (currentTheme === \"auto\") {\n setTheme(\"light\");\n } else if (currentTheme == \"light\") {\n setTheme(\"dark\");\n } else {\n setTheme(\"auto\");\n }\n } else {\n // Auto (light) -> Dark -> Light\n if (currentTheme === \"auto\") {\n setTheme(\"dark\");\n } else if (currentTheme == \"dark\") {\n setTheme(\"light\");\n } else {\n setTheme(\"auto\");\n }\n }\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Setup\n////////////////////////////////////////////////////////////////////////////////\nfunction setupScrollHandler() {\n // Taken from https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event\n let last_known_scroll_position = 0;\n let ticking = false;\n\n window.addEventListener(\"scroll\", function (e) {\n last_known_scroll_position = window.scrollY;\n\n if (!ticking) {\n window.requestAnimationFrame(function () {\n scrollHandler(last_known_scroll_position);\n ticking = false;\n });\n\n ticking = true;\n }\n });\n window.scroll();\n}\n\nfunction setupScrollSpy() {\n if (tocScroll === null) {\n return;\n }\n\n // Scrollspy -- highlight table on contents, based on scroll\n new Gumshoe(\".toc-tree a\", {\n reflow: true,\n recursive: true,\n navClass: \"scroll-current\",\n offset: () => {\n let rem = parseFloat(getComputedStyle(document.documentElement).fontSize);\n return header.getBoundingClientRect().height + 0.5 * rem + 1;\n },\n });\n}\n\nfunction setupTheme() {\n // Attach event handlers for toggling themes\n const buttons = document.getElementsByClassName(\"theme-toggle\");\n Array.from(buttons).forEach((btn) => {\n btn.addEventListener(\"click\", cycleThemeOnce);\n });\n}\n\nfunction setup() {\n setupTheme();\n setupScrollHandler();\n setupScrollSpy();\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Main entrypoint\n////////////////////////////////////////////////////////////////////////////////\nfunction main() {\n document.body.parentNode.classList.remove(\"no-js\");\n\n header = document.querySelector(\"header\");\n tocScroll = document.querySelector(\".toc-scroll\");\n\n setup();\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", main);\n"],"names":["root","g","window","this","defaults","navClass","contentClass","nested","nestedClass","offset","reflow","events","emitEvent","type","elem","detail","settings","event","CustomEvent","bubbles","cancelable","dispatchEvent","getOffsetTop","location","offsetParent","offsetTop","sortContents","contents","sort","item1","item2","content","isInView","bottom","bounds","getBoundingClientRect","parseFloat","getOffset","parseInt","innerHeight","document","documentElement","clientHeight","top","isAtBottom","Math","ceil","pageYOffset","max","body","scrollHeight","offsetHeight","getActive","last","length","item","useLastItem","i","deactivateNested","nav","parentNode","li","closest","classList","remove","deactivate","items","link","activateNested","add","selector","options","navItems","current","timeout","publicAPIs","querySelectorAll","Array","prototype","forEach","call","getElementById","decodeURIComponent","hash","substr","push","active","activate","scrollHandler","cancelAnimationFrame","requestAnimationFrame","detect","resizeHandler","destroy","removeEventListener","merged","arguments","obj","key","hasOwnProperty","extend","setup","addEventListener","factory","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","exports","module","__webpack_modules__","n","getter","__esModule","d","a","definition","o","Object","defineProperty","enumerable","get","globalThis","Function","e","prop","tocScroll","header","lastScrollTop","scrollTop","cycleThemeOnce","currentTheme","localStorage","getItem","mode","matchMedia","matches","console","error","dataset","theme","setItem","log","buttons","getElementsByClassName","from","btn","setupTheme","last_known_scroll_position","ticking","scrollY","positionY","floor","scrollHandlerForBackToTop","scrollTo","querySelector","scrollHandlerForTOC","scroll","setupScrollHandler","recursive","rem","getComputedStyle","fontSize","height"],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_static/styles/furo-extensions.css b/docs/_static/styles/furo-extensions.css new file mode 100644 index 00000000..bc447f22 --- /dev/null +++ b/docs/_static/styles/furo-extensions.css @@ -0,0 +1,2 @@ +#furo-sidebar-ad-placement{padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)}#furo-sidebar-ad-placement .ethical-sidebar{background:var(--color-background-secondary);border:none;box-shadow:none}#furo-sidebar-ad-placement .ethical-sidebar:hover{background:var(--color-background-hover)}#furo-sidebar-ad-placement .ethical-sidebar a{color:var(--color-foreground-primary)}#furo-sidebar-ad-placement .ethical-callout a{color:var(--color-foreground-secondary)!important}#furo-readthedocs-versions{background:transparent;display:block;position:static;width:100%}#furo-readthedocs-versions .rst-versions{background:#1a1c1e}#furo-readthedocs-versions .rst-current-version{background:var(--color-sidebar-item-background);cursor:unset}#furo-readthedocs-versions .rst-current-version:hover{background:var(--color-sidebar-item-background)}#furo-readthedocs-versions .rst-current-version .fa-book{color:var(--color-foreground-primary)}#furo-readthedocs-versions>.rst-other-versions{padding:0}#furo-readthedocs-versions>.rst-other-versions small{opacity:1}#furo-readthedocs-versions .injected .rst-versions{position:unset}#furo-readthedocs-versions:focus-within,#furo-readthedocs-versions:hover{box-shadow:0 0 0 1px var(--color-sidebar-background-border)}#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:hover .rst-current-version{background:#1a1c1e;font-size:inherit;height:auto;line-height:inherit;padding:12px;text-align:right}#furo-readthedocs-versions:focus-within .rst-current-version .fa-book,#furo-readthedocs-versions:hover .rst-current-version .fa-book{color:#fff;float:left}#furo-readthedocs-versions:focus-within .fa-caret-down,#furo-readthedocs-versions:hover .fa-caret-down{display:none}#furo-readthedocs-versions:focus-within .injected,#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:focus-within .rst-other-versions,#furo-readthedocs-versions:hover .injected,#furo-readthedocs-versions:hover .rst-current-version,#furo-readthedocs-versions:hover .rst-other-versions{display:block}#furo-readthedocs-versions:focus-within>.rst-current-version,#furo-readthedocs-versions:hover>.rst-current-version{display:none}.highlight:hover button.copybtn{color:var(--color-code-foreground)}.highlight button.copybtn{align-items:center;background-color:var(--color-code-background);border:none;color:var(--color-background-item);cursor:pointer;height:1.25em;opacity:1;right:.5rem;top:.625rem;transition:color .3s,opacity .3s;width:1.25em}.highlight button.copybtn:hover{background-color:var(--color-code-background);color:var(--color-brand-content)}.highlight button.copybtn:after{background-color:transparent;color:var(--color-code-foreground);display:none}.highlight button.copybtn.success{color:#22863a;transition:color 0ms}.highlight button.copybtn.success:after{display:block}.highlight button.copybtn svg{padding:0}body{--sd-color-primary:var(--color-brand-primary);--sd-color-primary-highlight:var(--color-brand-content);--sd-color-primary-text:var(--color-background-primary);--sd-color-shadow:rgba(0,0,0,.05);--sd-color-card-border:var(--color-card-border);--sd-color-card-border-hover:var(--color-brand-content);--sd-color-card-background:var(--color-card-background);--sd-color-card-text:var(--color-foreground-primary);--sd-color-card-header:var(--color-card-marginals-background);--sd-color-card-footer:var(--color-card-marginals-background);--sd-color-tabs-label-active:var(--color-brand-content);--sd-color-tabs-label-hover:var(--color-foreground-muted);--sd-color-tabs-label-inactive:var(--color-foreground-muted);--sd-color-tabs-underline-active:var(--color-brand-content);--sd-color-tabs-underline-hover:var(--color-foreground-border);--sd-color-tabs-underline-inactive:var(--color-background-border);--sd-color-tabs-overline:var(--color-background-border);--sd-color-tabs-underline:var(--color-background-border)}.sd-tab-content{box-shadow:0 -2px var(--sd-color-tabs-overline),0 1px var(--sd-color-tabs-underline)}.sd-card{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)}.sd-shadow-sm{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-md{box-shadow:0 .3rem .75rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-lg{box-shadow:0 .6rem 1.5rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-card-hover:hover{transform:none}.sd-cards-carousel{gap:.25rem;padding:.25rem}body{--tabs--label-text:var(--color-foreground-muted);--tabs--label-text--hover:var(--color-foreground-muted);--tabs--label-text--active:var(--color-brand-content);--tabs--label-text--active--hover:var(--color-brand-content);--tabs--label-background:transparent;--tabs--label-background--hover:transparent;--tabs--label-background--active:transparent;--tabs--label-background--active--hover:transparent;--tabs--padding-x:0.25em;--tabs--margin-x:1em;--tabs--border:var(--color-background-border);--tabs--label-border:transparent;--tabs--label-border--hover:var(--color-foreground-muted);--tabs--label-border--active:var(--color-brand-content);--tabs--label-border--active--hover:var(--color-brand-content)}[role=main] .container{max-width:none;padding-left:0;padding-right:0}.shadow.docutils{border:none;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)!important}.sphinx-bs .card{background-color:var(--color-background-secondary);color:var(--color-foreground)} +/*# sourceMappingURL=furo-extensions.css.map*/ \ No newline at end of file diff --git a/docs/_static/styles/furo-extensions.css.map b/docs/_static/styles/furo-extensions.css.map new file mode 100644 index 00000000..9ba5637f --- /dev/null +++ b/docs/_static/styles/furo-extensions.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo-extensions.css","mappings":"AAGA,2BACE,oFACA,4CAKE,6CAHA,YACA,eAEA,CACA,kDACE,yCAEF,8CACE,sCAEJ,8CACE,kDAEJ,2BAGE,uBACA,cAHA,gBACA,UAEA,CAGA,yCACE,mBAEF,gDAEE,gDADA,YACA,CACA,sDACE,gDACF,yDACE,sCAEJ,+CACE,UACA,qDACE,UAGF,mDACE,eAEJ,yEAEE,4DAEA,mHASE,mBAPA,kBAEA,YADA,oBAGA,aADA,gBAIA,CAEA,qIAEE,WADA,UACA,CAEJ,uGACE,aAEF,iUAGE,cAEF,mHACE,aC1EJ,gCACE,mCAEF,0BAKE,mBAUA,8CACA,YAFA,mCAKA,eAZA,cALA,UASA,YADA,YAYA,iCAdA,YAcA,CAEA,gCAEE,8CADA,gCACA,CAEF,gCAGE,6BADA,mCADA,YAEA,CAEF,kCAEE,cADA,oBACA,CACA,wCACE,cAEJ,8BACE,UC5CN,KAEE,6CAA8C,CAC9C,uDAAwD,CACxD,uDAAwD,CAGxD,iCAAsC,CAGtC,+CAAgD,CAChD,uDAAwD,CACxD,uDAAwD,CACxD,oDAAqD,CACrD,6DAA8D,CAC9D,6DAA8D,CAG9D,uDAAwD,CACxD,yDAA0D,CAC1D,4DAA6D,CAC7D,2DAA4D,CAC5D,8DAA+D,CAC/D,iEAAkE,CAClE,uDAAwD,CACxD,wDAAyD,CAG3D,gBACE,qFAGF,SACE,6EAEF,cACE,uFAEF,cACE,uFAEF,cACE,uFAGF,qBACE,eAEF,mBACE,WACA,eChDF,KACE,gDAAiD,CACjD,uDAAwD,CACxD,qDAAsD,CACtD,4DAA6D,CAC7D,oCAAqC,CACrC,2CAA4C,CAC5C,4CAA6C,CAC7C,mDAAoD,CACpD,wBAAyB,CACzB,oBAAqB,CACrB,6CAA8C,CAC9C,gCAAiC,CACjC,yDAA0D,CAC1D,uDAAwD,CACxD,8DAA+D,CCbjE,uBACE,eACA,eACA,gBAGF,iBACE,YACA,+EAGF,iBACE,mDACA","sources":["webpack:///./src/furo/assets/styles/extensions/_readthedocs.sass","webpack:///./src/furo/assets/styles/extensions/_copybutton.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-design.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-inline-tabs.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-panels.sass"],"sourcesContent":["// This file contains the styles used for tweaking how ReadTheDoc's embedded\n// contents would show up inside the theme.\n\n#furo-sidebar-ad-placement\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n .ethical-sidebar\n // Remove the border and box-shadow.\n border: none\n box-shadow: none\n // Manage the background colors.\n background: var(--color-background-secondary)\n &:hover\n background: var(--color-background-hover)\n // Ensure the text is legible.\n a\n color: var(--color-foreground-primary)\n\n .ethical-callout a\n color: var(--color-foreground-secondary) !important\n\n#furo-readthedocs-versions\n position: static\n width: 100%\n background: transparent\n display: block\n\n // Make the background color fit with the theme's aesthetic.\n .rst-versions\n background: rgb(26, 28, 30)\n\n .rst-current-version\n cursor: unset\n background: var(--color-sidebar-item-background)\n &:hover\n background: var(--color-sidebar-item-background)\n .fa-book\n color: var(--color-foreground-primary)\n\n > .rst-other-versions\n padding: 0\n small\n opacity: 1\n\n .injected\n .rst-versions\n position: unset\n\n &:hover,\n &:focus-within\n box-shadow: 0 0 0 1px var(--color-sidebar-background-border)\n\n .rst-current-version\n // Undo the tweaks done in RTD's CSS\n font-size: inherit\n line-height: inherit\n height: auto\n text-align: right\n padding: 12px\n\n // Match the rest of the body\n background: #1a1c1e\n\n .fa-book\n float: left\n color: white\n\n .fa-caret-down\n display: none\n\n .rst-current-version,\n .rst-other-versions,\n .injected\n display: block\n\n > .rst-current-version\n display: none\n",".highlight\n &:hover button.copybtn\n color: var(--color-code-foreground)\n\n button.copybtn\n // Make it visible\n opacity: 1\n\n // Align things correctly\n align-items: center\n\n height: 1.25em\n width: 1.25em\n\n top: 0.625rem // $code-spacing-vertical\n right: 0.5rem\n\n // Make it look better\n color: var(--color-background-item)\n background-color: var(--color-code-background)\n border: none\n\n // Change to cursor to make it obvious that you can click on it\n cursor: pointer\n\n // Transition smoothly, for aesthetics\n transition: color 300ms, opacity 300ms\n\n &:hover\n color: var(--color-brand-content)\n background-color: var(--color-code-background)\n\n &::after\n display: none\n color: var(--color-code-foreground)\n background-color: transparent\n\n &.success\n transition: color 0ms\n color: #22863a\n &::after\n display: block\n\n svg\n padding: 0\n","body\n // Colors\n --sd-color-primary: var(--color-brand-primary)\n --sd-color-primary-highlight: var(--color-brand-content)\n --sd-color-primary-text: var(--color-background-primary)\n\n // Shadows\n --sd-color-shadow: rgba(0, 0, 0, 0.05)\n\n // Cards\n --sd-color-card-border: var(--color-card-border)\n --sd-color-card-border-hover: var(--color-brand-content)\n --sd-color-card-background: var(--color-card-background)\n --sd-color-card-text: var(--color-foreground-primary)\n --sd-color-card-header: var(--color-card-marginals-background)\n --sd-color-card-footer: var(--color-card-marginals-background)\n\n // Tabs\n --sd-color-tabs-label-active: var(--color-brand-content)\n --sd-color-tabs-label-hover: var(--color-foreground-muted)\n --sd-color-tabs-label-inactive: var(--color-foreground-muted)\n --sd-color-tabs-underline-active: var(--color-brand-content)\n --sd-color-tabs-underline-hover: var(--color-foreground-border)\n --sd-color-tabs-underline-inactive: var(--color-background-border)\n --sd-color-tabs-overline: var(--color-background-border)\n --sd-color-tabs-underline: var(--color-background-border)\n\n// Tabs\n.sd-tab-content\n box-shadow: 0 -2px var(--sd-color-tabs-overline), 0 1px var(--sd-color-tabs-underline)\n\n// Shadows\n.sd-card // Have a shadow by default\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n.sd-shadow-sm\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-md\n box-shadow: 0 0.3rem 0.75rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-lg\n box-shadow: 0 0.6rem 1.5rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Cards\n.sd-card-hover:hover // Don't change scale on hover\n transform: none\n\n.sd-cards-carousel // Have a bit of gap in the carousel by default\n gap: 0.25rem\n padding: 0.25rem\n","// This file contains styles to tweak sphinx-inline-tabs to work well with Furo.\n\nbody\n --tabs--label-text: var(--color-foreground-muted)\n --tabs--label-text--hover: var(--color-foreground-muted)\n --tabs--label-text--active: var(--color-brand-content)\n --tabs--label-text--active--hover: var(--color-brand-content)\n --tabs--label-background: transparent\n --tabs--label-background--hover: transparent\n --tabs--label-background--active: transparent\n --tabs--label-background--active--hover: transparent\n --tabs--padding-x: 0.25em\n --tabs--margin-x: 1em\n --tabs--border: var(--color-background-border)\n --tabs--label-border: transparent\n --tabs--label-border--hover: var(--color-foreground-muted)\n --tabs--label-border--active: var(--color-brand-content)\n --tabs--label-border--active--hover: var(--color-brand-content)\n","// This file contains styles to tweak sphinx-panels to work well with Furo.\n\n// sphinx-panels includes Bootstrap 4, which uses .container which can conflict\n// with docutils' `.. container::` directive.\n[role=\"main\"] .container\n max-width: initial\n padding-left: initial\n padding-right: initial\n\n// Make the panels look nicer!\n.shadow.docutils\n border: none\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Make panel colors respond to dark mode\n.sphinx-bs .card\n background-color: var(--color-background-secondary)\n color: var(--color-foreground)\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_static/styles/furo.css b/docs/_static/styles/furo.css new file mode 100644 index 00000000..887cc749 --- /dev/null +++ b/docs/_static/styles/furo.css @@ -0,0 +1,2 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{-webkit-text-size-adjust:100%;line-height:1.15}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}@media print{.content-icon-container,.headerlink,.mobile-header,.related-pages{display:none!important}.highlight{border:.1pt solid var(--color-foreground-border)}}.visually-hidden{clip:rect(0,0,0,0)!important;border:0!important;height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:1px!important}:-moz-focusring{outline:auto}body{--font-stack:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;--font-stack--monospace:"SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;--font-size--normal:100%;--font-size--small:87.5%;--font-size--small--2:81.25%;--font-size--small--3:75%;--font-size--small--4:62.5%;--sidebar-caption-font-size:var(--font-size--small--2);--sidebar-item-font-size:var(--font-size--small);--sidebar-search-input-font-size:var(--font-size--small);--toc-font-size:var(--font-size--small--3);--toc-font-size--mobile:var(--font-size--normal);--toc-title-font-size:var(--font-size--small--4);--admonition-font-size:0.8125rem;--admonition-title-font-size:0.8125rem;--code-font-size:var(--font-size--small--2);--api-font-size:var(--font-size--small);--header-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);--header-padding:0.5rem;--sidebar-tree-space-above:1.5rem;--sidebar-caption-space-above:1rem;--sidebar-item-line-height:1rem;--sidebar-item-spacing-vertical:0.5rem;--sidebar-item-spacing-horizontal:1rem;--sidebar-item-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);--sidebar-expander-width:var(--sidebar-item-height);--sidebar-search-space-above:0.5rem;--sidebar-search-input-spacing-vertical:0.5rem;--sidebar-search-input-spacing-horizontal:0.5rem;--sidebar-search-input-height:1rem;--sidebar-search-icon-size:var(--sidebar-search-input-height);--toc-title-padding:0.25rem 0;--toc-spacing-vertical:1.5rem;--toc-spacing-horizontal:1.5rem;--toc-item-spacing-vertical:0.4rem;--toc-item-spacing-horizontal:1rem;--icon-search:url('data:image/svg+xml;charset=utf-8,');--icon-pencil:url('data:image/svg+xml;charset=utf-8,');--icon-abstract:url('data:image/svg+xml;charset=utf-8,');--icon-info:url('data:image/svg+xml;charset=utf-8,');--icon-flame:url('data:image/svg+xml;charset=utf-8,');--icon-question:url('data:image/svg+xml;charset=utf-8,');--icon-warning:url('data:image/svg+xml;charset=utf-8,');--icon-failure:url('data:image/svg+xml;charset=utf-8,');--icon-spark:url('data:image/svg+xml;charset=utf-8,');--color-admonition-title--caution:#ff9100;--color-admonition-title-background--caution:rgba(255,145,0,.1);--color-admonition-title--warning:#ff9100;--color-admonition-title-background--warning:rgba(255,145,0,.1);--color-admonition-title--danger:#ff5252;--color-admonition-title-background--danger:rgba(255,82,82,.1);--color-admonition-title--attention:#ff5252;--color-admonition-title-background--attention:rgba(255,82,82,.1);--color-admonition-title--error:#ff5252;--color-admonition-title-background--error:rgba(255,82,82,.1);--color-admonition-title--hint:#00c852;--color-admonition-title-background--hint:rgba(0,200,82,.1);--color-admonition-title--tip:#00c852;--color-admonition-title-background--tip:rgba(0,200,82,.1);--color-admonition-title--important:#00bfa5;--color-admonition-title-background--important:rgba(0,191,165,.1);--color-admonition-title--note:#00b0ff;--color-admonition-title-background--note:rgba(0,176,255,.1);--color-admonition-title--seealso:#448aff;--color-admonition-title-background--seealso:rgba(68,138,255,.1);--color-admonition-title--admonition-todo:grey;--color-admonition-title-background--admonition-todo:hsla(0,0%,50%,.1);--color-admonition-title:#651fff;--color-admonition-title-background:rgba(101,31,255,.1);--icon-admonition-default:var(--icon-abstract);--color-topic-title:#14b8a6;--color-topic-title-background:rgba(20,184,166,.1);--icon-topic-default:var(--icon-pencil);--color-problematic:#b30000;--color-foreground-primary:#000;--color-foreground-secondary:#5a5c63;--color-foreground-muted:#646776;--color-foreground-border:#878787;--color-background-primary:#fff;--color-background-secondary:#f8f9fb;--color-background-hover:#efeff4;--color-background-hover--transparent:#efeff400;--color-background-border:#eeebee;--color-background-item:#ccc;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#2962ff;--color-brand-content:#2a5adf;--color-api-background:var(--color-background-secondary);--color-api-background-hover:var(--color-background-hover);--color-api-overall:var(--color-foreground-secondary);--color-api-name:var(--color-problematic);--color-api-pre-name:var(--color-problematic);--color-api-paren:var(--color-foreground-secondary);--color-api-keyword:var(--color-foreground-primary);--color-highlight-on-target:#ffc;--color-inline-code-background:var(--color-background-secondary);--color-highlighted-background:#def;--color-highlighted-text:var(--color-foreground-primary);--color-guilabel-background:#ddeeff80;--color-guilabel-border:#bedaf580;--color-guilabel-text:var(--color-foreground-primary);--color-admonition-background:transparent;--color-table-header-background:var(--color-background-secondary);--color-table-border:var(--color-background-border);--color-card-border:var(--color-background-secondary);--color-card-background:transparent;--color-card-marginals-background:var(--color-background-secondary);--color-header-background:var(--color-background-primary);--color-header-border:var(--color-background-border);--color-header-text:var(--color-foreground-primary);--color-sidebar-background:var(--color-background-secondary);--color-sidebar-background-border:var(--color-background-border);--color-sidebar-brand-text:var(--color-foreground-primary);--color-sidebar-caption-text:var(--color-foreground-muted);--color-sidebar-link-text:var(--color-foreground-secondary);--color-sidebar-link-text--top-level:var(--color-brand-primary);--color-sidebar-item-background:var(--color-sidebar-background);--color-sidebar-item-background--current:var( --color-sidebar-item-background );--color-sidebar-item-background--hover:linear-gradient(90deg,var(--color-background-hover--transparent) 0%,var(--color-background-hover) var(--sidebar-item-spacing-horizontal),var(--color-background-hover) 100%);--color-sidebar-item-expander-background:transparent;--color-sidebar-item-expander-background--hover:var( --color-background-hover );--color-sidebar-search-text:var(--color-foreground-primary);--color-sidebar-search-background:var(--color-background-secondary);--color-sidebar-search-background--focus:var(--color-background-primary);--color-sidebar-search-border:var(--color-background-border);--color-sidebar-search-icon:var(--color-foreground-muted);--color-toc-background:var(--color-background-primary);--color-toc-title-text:var(--color-foreground-muted);--color-toc-item-text:var(--color-foreground-secondary);--color-toc-item-text--hover:var(--color-foreground-primary);--color-toc-item-text--active:var(--color-brand-primary);--color-content-foreground:var(--color-foreground-primary);--color-content-background:transparent;--color-link:var(--color-brand-content);--color-link--hover:var(--color-brand-content);--color-link-underline:var(--color-background-border);--color-link-underline--hover:var(--color-foreground-border)}.only-light{display:block!important}html body .only-dark{display:none!important}@media not print{body[data-theme=dark]{--color-problematic:#ee5151;--color-foreground-primary:#ffffffcc;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#2b8cee;--color-brand-content:#368ce2;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body[data-theme=dark] .only-light{display:none!important}body[data-theme=dark] .only-dark{display:block!important}@media(prefers-color-scheme:dark){body:not([data-theme=light]){--color-problematic:#ee5151;--color-foreground-primary:#ffffffcc;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#2b8cee;--color-brand-content:#368ce2;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body:not([data-theme=light]) .only-light{display:none!important}body:not([data-theme=light]) .only-dark{display:block!important}}}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto,body[data-theme=dark] .theme-toggle svg.theme-icon-when-dark,body[data-theme=light] .theme-toggle svg.theme-icon-when-light{display:block}body{font-family:var(--font-stack)}code,kbd,pre,samp{font-family:var(--font-stack--monospace)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}article{line-height:1.5}h1,h2,h3,h4,h5,h6{border-radius:.5rem;font-weight:700;line-height:1.25;margin:.5rem -.5rem;padding-left:.5rem;padding-right:.5rem}h1+p,h2+p,h3+p,h4+p,h5+p,h6+p{margin-top:0}h1{font-size:2.5em;margin-bottom:1rem}h1,h2{margin-top:1.75rem}h2{font-size:2em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1.125em}h6{font-size:1em}small{font-size:80%;opacity:75%}p{margin-bottom:.75rem;margin-top:.5rem}hr.docutils{background-color:var(--color-background-border);border:0;height:1px;margin:2rem 0;padding:0}.centered{text-align:center}a{color:var(--color-link);text-decoration:underline;-webkit-text-decoration-color:var(--color-link-underline);text-decoration-color:var(--color-link-underline)}a:hover{color:var(--color-link--hover);-webkit-text-decoration-color:var(--color-link-underline--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link{color:inherit}a.muted-link:hover{color:var(--color-link);-webkit-text-decoration-color:var(--color-link-underline--hover);text-decoration-color:var(--color-link-underline--hover)}html{overflow-x:hidden;overflow-y:scroll;scroll-behavior:smooth}.sidebar-scroll,.toc-scroll,article[role=main] *{scrollbar-color:var(--color-foreground-border) transparent;scrollbar-width:thin}.sidebar-scroll::-webkit-scrollbar,.toc-scroll::-webkit-scrollbar,article[role=main] ::-webkit-scrollbar{height:.25rem;width:.25rem}.sidebar-scroll::-webkit-scrollbar-thumb,.toc-scroll::-webkit-scrollbar-thumb,article[role=main] ::-webkit-scrollbar-thumb{background-color:var(--color-foreground-border);border-radius:.125rem}body,html{background:var(--color-background-primary);color:var(--color-foreground-primary);height:100%}article{background:var(--color-content-background);color:var(--color-content-foreground)}.page{display:flex;min-height:100%}.mobile-header{background-color:var(--color-header-background);border-bottom:1px solid var(--color-header-border);color:var(--color-header-text);display:none;height:var(--header-height);width:100%;z-index:10}.mobile-header.scrolled{border-bottom:none;box-shadow:0 0 .2rem rgba(0,0,0,.1),0 .2rem .4rem rgba(0,0,0,.2)}.mobile-header .header-center a{color:var(--color-header-text);text-decoration:none}.main{display:flex;flex:1}.sidebar-drawer{background:var(--color-sidebar-background);border-right:1px solid var(--color-sidebar-background-border);box-sizing:border-box;display:flex;justify-content:flex-end;min-width:15em;width:calc(50% - 26em)}.sidebar-container,.toc-drawer{box-sizing:border-box;width:15em}.toc-drawer{background:var(--color-toc-background);padding-right:1rem}.sidebar-sticky,.toc-sticky{display:flex;flex-direction:column;height:min(100%,100vh);height:100vh;position:-webkit-sticky;position:sticky;top:0}.sidebar-scroll,.toc-scroll{flex-grow:1;flex-shrink:1;overflow:auto;scroll-behavior:smooth}.content{display:flex;flex-direction:column;justify-content:space-between;padding:0 3em;width:46em}.icon{display:inline-block;height:1rem;width:1rem}.icon svg{height:100%;width:100%}.announcement{align-items:center;background-color:var(--color-announcement-background);color:var(--color-announcement-text);display:flex;height:var(--header-height);overflow-x:auto}.announcement+.page{min-height:calc(100% - var(--header-height))}.announcement-content{box-sizing:border-box;min-width:100%;padding:.5rem;text-align:center;white-space:nowrap}.announcement-content a{color:var(--color-announcement-text);-webkit-text-decoration-color:var(--color-announcement-text);text-decoration-color:var(--color-announcement-text)}.announcement-content a:hover{color:var(--color-announcement-text);-webkit-text-decoration-color:var(--color-link--hover);text-decoration-color:var(--color-link--hover)}.no-js .theme-toggle-container{display:none}.theme-toggle-container{vertical-align:middle}.theme-toggle{background:transparent;border:none;cursor:pointer;padding:0}.theme-toggle svg{color:var(--color-foreground-primary);display:none;height:1rem;vertical-align:middle;width:1rem}.theme-toggle-header{float:left;padding:1rem .5rem}.nav-overlay-icon,.toc-overlay-icon{cursor:pointer;display:none}.nav-overlay-icon .icon,.toc-overlay-icon .icon{color:var(--color-foreground-secondary);height:1rem;width:1rem}.nav-overlay-icon,.toc-header-icon{align-items:center;justify-content:center}.toc-content-icon{height:1.5rem;width:1.5rem}.content-icon-container{display:flex;float:right;gap:.5rem;margin-bottom:1rem;margin-left:1rem;margin-top:1.5rem}.content-icon-container .edit-this-page svg{color:inherit;height:1rem;width:1rem}.sidebar-toggle{display:none;position:absolute}.sidebar-toggle[name=__toc]{left:20px}.sidebar-toggle:checked{left:40px}.overlay{background-color:rgba(0,0,0,.54);height:0;opacity:0;position:fixed;top:0;transition:width 0ms,height 0ms,opacity .25s ease-out;width:0}.sidebar-overlay{z-index:20}.toc-overlay{z-index:40}.sidebar-drawer{transition:left .25s ease-in-out;z-index:30}.toc-drawer{transition:right .25s ease-in-out;z-index:50}#__navigation:checked~.sidebar-overlay{height:100%;opacity:1;width:100%}#__navigation:checked~.page .sidebar-drawer{left:0;top:0}#__toc:checked~.toc-overlay{height:100%;opacity:1;width:100%}#__toc:checked~.page .toc-drawer{right:0;top:0}.back-to-top{background:var(--color-background-primary);border-radius:1rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 1px 0 #6b728080;display:none;font-size:.8125rem;left:0;margin-left:50%;padding:.5rem .75rem .5rem .5rem;position:fixed;text-decoration:none;top:1rem;transform:translateX(-50%);z-index:10}.back-to-top svg{fill:currentColor;display:inline-block;height:1rem;width:1rem}.back-to-top span{margin-left:.25rem}.show-back-to-top .back-to-top{align-items:center;display:flex}@media(min-width:97em){html{font-size:110%}}@media(max-width:82em){.toc-content-icon{display:flex}.toc-drawer{border-left:1px solid var(--color-background-muted);height:100vh;position:fixed;right:-15em;top:0}.toc-tree{border-left:none;font-size:var(--toc-font-size--mobile)}.sidebar-drawer{width:calc(50% - 18.5em)}}@media(max-width:67em){.nav-overlay-icon{display:flex}.sidebar-drawer{height:100vh;left:-15em;position:fixed;top:0;width:15em}.toc-header-icon{display:flex}.theme-toggle-content,.toc-content-icon{display:none}.theme-toggle-header{display:block}.mobile-header{align-items:center;display:flex;justify-content:space-between;position:-webkit-sticky;position:sticky;top:0}.mobile-header .header-left,.mobile-header .header-right{display:flex;height:var(--header-height);padding:0 var(--header-padding)}.mobile-header .header-left label,.mobile-header .header-right label{height:100%;width:100%}:target{scroll-margin-top:var(--header-height)}.back-to-top{top:calc(var(--header-height) + .5rem)}.page{flex-direction:column;justify-content:center}.content{margin-left:auto;margin-right:auto}}@media(max-width:52em){.content{overflow-x:auto;width:100%}}@media(max-width:46em){.content{padding:0 1em}article div.sidebar{float:none;margin:1rem 0;width:100%}}.admonition,.topic{background:var(--color-admonition-background);border-radius:.2rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1);font-size:var(--admonition-font-size);margin:1rem auto;overflow:hidden;padding:0 .5rem .5rem;page-break-inside:avoid}.admonition>:nth-child(2),.topic>:nth-child(2){margin-top:0}.admonition>:last-child,.topic>:last-child{margin-bottom:0}p.admonition-title,p.topic-title{font-size:var(--admonition-title-font-size);font-weight:500;line-height:1.3;margin:0 -.5rem .5rem;padding:.4rem .5rem .4rem 2rem;position:relative}p.admonition-title:before,p.topic-title:before{content:"";height:1rem;left:.5rem;position:absolute;width:1rem}p.admonition-title{background-color:var(--color-admonition-title-background)}p.admonition-title:before{background-color:var(--color-admonition-title);-webkit-mask-image:var(--icon-admonition-default);mask-image:var(--icon-admonition-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}p.topic-title{background-color:var(--color-topic-title-background)}p.topic-title:before{background-color:var(--color-topic-title);-webkit-mask-image:var(--icon-topic-default);mask-image:var(--icon-topic-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.admonition{border-left:.2rem solid var(--color-admonition-title)}.admonition.caution{border-left-color:var(--color-admonition-title--caution)}.admonition.caution>.admonition-title{background-color:var(--color-admonition-title-background--caution)}.admonition.caution>.admonition-title:before{background-color:var(--color-admonition-title--caution);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.warning{border-left-color:var(--color-admonition-title--warning)}.admonition.warning>.admonition-title{background-color:var(--color-admonition-title-background--warning)}.admonition.warning>.admonition-title:before{background-color:var(--color-admonition-title--warning);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.danger{border-left-color:var(--color-admonition-title--danger)}.admonition.danger>.admonition-title{background-color:var(--color-admonition-title-background--danger)}.admonition.danger>.admonition-title:before{background-color:var(--color-admonition-title--danger);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.attention{border-left-color:var(--color-admonition-title--attention)}.admonition.attention>.admonition-title{background-color:var(--color-admonition-title-background--attention)}.admonition.attention>.admonition-title:before{background-color:var(--color-admonition-title--attention);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.error{border-left-color:var(--color-admonition-title--error)}.admonition.error>.admonition-title{background-color:var(--color-admonition-title-background--error)}.admonition.error>.admonition-title:before{background-color:var(--color-admonition-title--error);-webkit-mask-image:var(--icon-failure);mask-image:var(--icon-failure)}.admonition.hint{border-left-color:var(--color-admonition-title--hint)}.admonition.hint>.admonition-title{background-color:var(--color-admonition-title-background--hint)}.admonition.hint>.admonition-title:before{background-color:var(--color-admonition-title--hint);-webkit-mask-image:var(--icon-question);mask-image:var(--icon-question)}.admonition.tip{border-left-color:var(--color-admonition-title--tip)}.admonition.tip>.admonition-title{background-color:var(--color-admonition-title-background--tip)}.admonition.tip>.admonition-title:before{background-color:var(--color-admonition-title--tip);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.important{border-left-color:var(--color-admonition-title--important)}.admonition.important>.admonition-title{background-color:var(--color-admonition-title-background--important)}.admonition.important>.admonition-title:before{background-color:var(--color-admonition-title--important);-webkit-mask-image:var(--icon-flame);mask-image:var(--icon-flame)}.admonition.note{border-left-color:var(--color-admonition-title--note)}.admonition.note>.admonition-title{background-color:var(--color-admonition-title-background--note)}.admonition.note>.admonition-title:before{background-color:var(--color-admonition-title--note);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition.seealso{border-left-color:var(--color-admonition-title--seealso)}.admonition.seealso>.admonition-title{background-color:var(--color-admonition-title-background--seealso)}.admonition.seealso>.admonition-title:before{background-color:var(--color-admonition-title--seealso);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.admonition-todo{border-left-color:var(--color-admonition-title--admonition-todo)}.admonition.admonition-todo>.admonition-title{background-color:var(--color-admonition-title-background--admonition-todo)}.admonition.admonition-todo>.admonition-title:before{background-color:var(--color-admonition-title--admonition-todo);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition-todo>.admonition-title{text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd{margin-left:2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:first-child{margin-top:.125rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list,dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:last-child{margin-bottom:.75rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt{font-size:var(--font-size--small);text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd:empty{margin-bottom:.5rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul{margin-left:-1.2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p:nth-child(2){margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p+p:last-child:empty{margin-bottom:0;margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt{color:var(--color-api-overall)}.sig{background:var(--color-api-background);border-radius:.25rem;font-family:var(--font-stack--monospace);font-size:var(--api-font-size);font-weight:700;padding:.25rem .5rem .25rem 3em;text-indent:-2.5em}.sig:hover{background:var(--color-api-background-hover)}.sig a.reference .viewcode-link{font-weight:400;width:3.5rem}.sig span.pre{overflow-wrap:anywhere}em.property{font-style:normal}em.property:first-child{color:var(--color-api-keyword)}.sig-name{color:var(--color-api-name)}.sig-prename{color:var(--color-api-pre-name);font-weight:400}.sig-paren{color:var(--color-api-paren)}.sig-param{font-style:normal}.versionmodified{font-style:italic}div.deprecated p,div.versionadded p,div.versionchanged p{margin-bottom:.125rem;margin-top:.125rem}.viewcode-back,.viewcode-link{float:right;text-align:right}.line-block{margin-bottom:.75rem;margin-top:.5rem}.line-block .line-block{margin-bottom:0;margin-top:0;padding-left:1rem}.code-block-caption,article p.caption,table>caption{font-size:var(--font-size--small);text-align:center}.toctree-wrapper.compound .caption,.toctree-wrapper.compound :not(.caption)>.caption-text{font-size:var(--font-size--small);margin-bottom:0;text-align:initial;text-transform:uppercase}.toctree-wrapper.compound>ul{margin-bottom:0;margin-top:0}code.literal{background:var(--color-inline-code-background);border-radius:.2em;font-size:var(--font-size--small--2);padding:.1em .2em}p code.literal{border:1px solid var(--color-background-border)}div[class*=" highlight-"],div[class^=highlight-]{display:flex;margin:1em 0}div[class*=" highlight-"] .table-wrapper,div[class^=highlight-] .table-wrapper,pre{margin:0;padding:0}article[role=main] .highlight pre{line-height:1.5}.highlight pre,pre.literal-block{font-size:var(--code-font-size);overflow:auto;padding:.625rem .875rem}pre.literal-block{background-color:var(--color-code-background);border-radius:.2rem;color:var(--color-code-foreground);margin-bottom:1rem;margin-top:1rem}.highlight{border-radius:.2rem;width:100%}.highlight .gp,.highlight span.linenos{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.highlight .hll{display:block;margin-left:-.875rem;margin-right:-.875rem;padding-left:.875rem;padding-right:.875rem}.code-block-caption{background-color:var(--color-code-background);border-bottom:1px solid;border-bottom-color:var(--color-background-border);border-radius:.25rem;border-bottom-left-radius:0;border-bottom-right-radius:0;border-left-color:var(--color-background-border);border-right-color:var(--color-background-border);border-top-color:var(--color-background-border);color:var(--color-code-foreground);display:flex;font-weight:300;padding:.625rem .875rem}.code-block-caption+div[class]{margin-top:0}.code-block-caption+div[class] pre{border-top-left-radius:0;border-top-right-radius:0}.highlighttable{display:block;width:100%}.highlighttable tbody{display:block}.highlighttable tr{display:flex}.highlighttable td.linenos{background-color:var(--color-code-background);border-bottom-left-radius:.2rem;border-top-left-radius:.2rem;color:var(--color-code-foreground);padding:.625rem 0 .625rem .875rem}.highlighttable .linenodiv{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;font-size:var(--code-font-size);padding-right:.875rem}.highlighttable td.code{display:block;flex:1;overflow:hidden;padding:0}.highlighttable td.code .highlight{border-bottom-left-radius:0;border-top-left-radius:0}.highlight span.linenos{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;display:inline-block;margin-right:.875rem;padding-left:0;padding-right:.875rem}.footnote-reference{font-size:var(--font-size--small--4);vertical-align:super}dl.footnote.brackets{color:var(--color-foreground-secondary);display:grid;font-size:var(--font-size--small);grid-template-columns:-webkit-max-content auto;grid-template-columns:max-content auto}dl.footnote.brackets dt{margin:0}dl.footnote.brackets dt>.fn-backref{margin-left:.25rem}dl.footnote.brackets dt:after{content:":"}dl.footnote.brackets dt .brackets:before{content:"["}dl.footnote.brackets dt .brackets:after{content:"]"}dl.footnote.brackets dd{margin:0;padding:0 1rem}img{box-sizing:border-box;height:auto;max-width:100%}article .figure,article figure{border-radius:.2rem;margin:0}article .figure :last-child,article figure :last-child{margin-bottom:0}article .align-left{clear:left;float:left;margin:0 1rem 1rem}article .align-right{clear:right;float:right;margin:0 1rem 1rem}article .align-center,article .align-default{display:block;margin-left:auto;margin-right:auto;text-align:center}article table.align-default{display:table;text-align:initial}.domainindex-jumpbox,.genindex-jumpbox{border-bottom:1px solid var(--color-background-border);border-top:1px solid var(--color-background-border);padding:.25rem}.domainindex-section h2,.genindex-section h2{margin-bottom:.5rem;margin-top:.75rem}.domainindex-section ul,.genindex-section ul{margin-bottom:0;margin-top:0}ol,ul{margin-bottom:1rem;margin-top:1rem;padding-left:1.2rem}ol li>p:first-child,ul li>p:first-child{margin-bottom:.25rem;margin-top:.25rem}ol li>p:last-child,ul li>p:last-child{margin-top:.25rem}ol li>ol,ol li>ul,ul li>ol,ul li>ul{margin-bottom:.5rem;margin-top:.5rem}ol.arabic{list-style:decimal}ol.loweralpha{list-style:lower-alpha}ol.upperalpha{list-style:upper-alpha}ol.lowerroman{list-style:lower-roman}ol.upperroman{list-style:upper-roman}.simple li>ol,.simple li>ul,.toctree-wrapper li>ol,.toctree-wrapper li>ul{margin-bottom:0;margin-top:0}.field-list dt,.option-list dt,dl.footnote dt,dl.glossary dt,dl.simple dt,dl:not([class]) dt{font-weight:500;margin-top:.25rem}.field-list dt+dt,.option-list dt+dt,dl.footnote dt+dt,dl.glossary dt+dt,dl.simple dt+dt,dl:not([class]) dt+dt{margin-top:0}.field-list dt .classifier:before,.option-list dt .classifier:before,dl.footnote dt .classifier:before,dl.glossary dt .classifier:before,dl.simple dt .classifier:before,dl:not([class]) dt .classifier:before{content:":";margin-left:.2rem;margin-right:.2rem}.field-list dd>p:first-child,.field-list dd ul,.option-list dd>p:first-child,.option-list dd ul,dl.footnote dd>p:first-child,dl.footnote dd ul,dl.glossary dd>p:first-child,dl.glossary dd ul,dl.simple dd>p:first-child,dl.simple dd ul,dl:not([class]) dd>p:first-child,dl:not([class]) dd ul{margin-top:.125rem}.field-list dd ul,.option-list dd ul,dl.footnote dd ul,dl.glossary dd ul,dl.simple dd ul,dl:not([class]) dd ul{margin-bottom:.125rem}.math-wrapper{overflow-x:auto;width:100%}div.math{position:relative;text-align:center}div.math .headerlink,div.math:focus .headerlink{display:none}div.math:hover .headerlink{display:inline-block}div.math span.eqno{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);z-index:1}abbr[title]{cursor:help}.problematic{color:var(--color-problematic)}kbd:not(.compound){background-color:var(--color-background-secondary);border:1px solid var(--color-foreground-border);border-radius:.2rem;box-shadow:0 .0625rem 0 rgba(0,0,0,.2),inset 0 0 0 .125rem var(--color-background-primary);color:var(--color-foreground-primary);display:inline-block;font-size:var(--font-size--small--3);margin:0 .2rem;padding:0 .2rem;vertical-align:text-bottom}blockquote{background:var(--color-background-secondary);border-left:4px solid var(--color-background-border);margin-left:0;margin-right:0;padding:.5rem 1rem}blockquote .attribution{font-weight:600;text-align:right}blockquote.highlights,blockquote.pull-quote{font-size:1.25em}blockquote.epigraph,blockquote.pull-quote{border-left-width:0;border-radius:.5rem}blockquote.highlights{background:transparent;border-left-width:0}p .reference img{vertical-align:middle}p.rubric{font-size:1.125em;font-weight:700;line-height:1.25}article .sidebar{background-color:var(--color-background-secondary);border:1px solid var(--color-background-border);border-radius:.2rem;clear:right;float:right;margin-left:1rem;margin-right:0;width:30%}article .sidebar>*{padding-left:1rem;padding-right:1rem}article .sidebar>ol,article .sidebar>ul{padding-left:2.2rem}article .sidebar .sidebar-title{border-bottom:1px solid var(--color-background-border);font-weight:500;margin:0;padding:.5rem 1rem}.table-wrapper{margin-bottom:.5rem;margin-top:1rem;overflow-x:auto;padding:.2rem .2rem .75rem;width:100%}table.docutils{border-collapse:collapse;border-radius:.2rem;border-spacing:0;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)}table.docutils th{background:var(--color-table-header-background)}table.docutils td,table.docutils th{border-bottom:1px solid var(--color-table-border);border-left:1px solid var(--color-table-border);border-right:1px solid var(--color-table-border);padding:0 .25rem}table.docutils td p,table.docutils th p{margin:.25rem}table.docutils td:first-child,table.docutils th:first-child{border-left:none}table.docutils td:last-child,table.docutils th:last-child{border-right:none}:target{scroll-margin-top:.5rem}@media(max-width:67em){:target{scroll-margin-top:calc(.5rem + var(--header-height))}section>span:target{scroll-margin-top:calc(.8rem + var(--header-height))}}.headerlink{font-weight:100;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.code-block-caption>.headerlink,dl dt>.headerlink,figcaption p>.headerlink,h1>.headerlink,h2>.headerlink,h3>.headerlink,h4>.headerlink,h5>.headerlink,h6>.headerlink,p.caption>.headerlink,table>caption>.headerlink{margin-left:.5rem;visibility:hidden}.code-block-caption:hover>.headerlink,dl dt:hover>.headerlink,figcaption p:hover>.headerlink,h1:hover>.headerlink,h2:hover>.headerlink,h3:hover>.headerlink,h4:hover>.headerlink,h5:hover>.headerlink,h6:hover>.headerlink,p.caption:hover>.headerlink,table>caption:hover>.headerlink{visibility:visible}.code-block-caption>.toc-backref,dl dt>.toc-backref,figcaption p>.toc-backref,h1>.toc-backref,h2>.toc-backref,h3>.toc-backref,h4>.toc-backref,h5>.toc-backref,h6>.toc-backref,p.caption>.toc-backref,table>caption>.toc-backref{color:inherit;-webkit-text-decoration-line:none;text-decoration-line:none}figure:hover>figcaption>p>.headerlink,table:hover>caption>.headerlink{visibility:visible}:target>h1:first-of-type,:target>h2:first-of-type,:target>h3:first-of-type,:target>h4:first-of-type,:target>h5:first-of-type,:target>h6:first-of-type,span:target~h1:first-of-type,span:target~h2:first-of-type,span:target~h3:first-of-type,span:target~h4:first-of-type,span:target~h5:first-of-type,span:target~h6:first-of-type{background-color:var(--color-highlight-on-target)}:target>h1:first-of-type code.literal,:target>h2:first-of-type code.literal,:target>h3:first-of-type code.literal,:target>h4:first-of-type code.literal,:target>h5:first-of-type code.literal,:target>h6:first-of-type code.literal,span:target~h1:first-of-type code.literal,span:target~h2:first-of-type code.literal,span:target~h3:first-of-type code.literal,span:target~h4:first-of-type code.literal,span:target~h5:first-of-type code.literal,span:target~h6:first-of-type code.literal{background-color:transparent}.literal-block-wrapper:target .code-block-caption,.this-will-duplicate-information-and-it-is-still-useful-here li :target,figure:target,table:target>caption{background-color:var(--color-highlight-on-target)}dt:target{background-color:var(--color-highlight-on-target)!important}.footnote-reference:target,.footnote>dt:target+dd{background-color:var(--color-highlight-on-target)}.guilabel{background-color:var(--color-guilabel-background);border:1px solid var(--color-guilabel-border);border-radius:.5em;color:var(--color-guilabel-text);font-size:.9em;padding:0 .3em}footer{display:flex;flex-direction:column;font-size:var(--font-size--small);margin-top:2rem}.bottom-of-page{align-items:center;border-top:1px solid var(--color-background-border);color:var(--color-foreground-secondary);display:flex;justify-content:space-between;line-height:1.5;margin-top:1rem;padding-bottom:1rem;padding-top:1rem}@media(max-width:46em){.bottom-of-page{flex-direction:column-reverse;gap:.25rem;text-align:center}}.bottom-of-page .left-details{font-size:var(--font-size--small)}.bottom-of-page .right-details{display:flex;flex-direction:column;gap:.25rem;text-align:right}.bottom-of-page .icons{display:flex;font-size:1rem;gap:.25rem;justify-content:flex-end}.bottom-of-page .icons a{text-decoration:none}.bottom-of-page .icons img,.bottom-of-page .icons svg{font-size:1.125rem;height:1em;width:1em}.related-pages a{align-items:center;display:flex;text-decoration:none}.related-pages a:hover .page-info .title{color:var(--color-link);text-decoration:underline;-webkit-text-decoration-color:var(--color-link-underline);text-decoration-color:var(--color-link-underline)}.related-pages a svg,.related-pages a svg>use{color:var(--color-foreground-border);flex-shrink:0;height:.75rem;margin:0 .5rem;width:.75rem}.related-pages a.next-page{clear:right;float:right;max-width:50%;text-align:right}.related-pages a.prev-page{clear:left;float:left;max-width:50%}.related-pages a.prev-page svg{transform:rotate(180deg)}.page-info{display:flex;flex-direction:column;overflow-wrap:anywhere}.next-page .page-info{align-items:flex-end}.page-info .context{align-items:center;color:var(--color-foreground-muted);display:flex;font-size:var(--font-size--small);padding-bottom:.1rem;text-decoration:none}ul.search{list-style:none;padding-left:0}ul.search li{border-bottom:1px solid var(--color-background-border);padding:1rem 0}[role=main] .highlighted{background-color:var(--color-highlighted-background);color:var(--color-highlighted-text)}.sidebar-brand{display:flex;flex-direction:column;flex-shrink:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none}.sidebar-brand-text{color:var(--color-sidebar-brand-text);font-size:1.5rem;overflow-wrap:break-word}.sidebar-brand-text,.sidebar-logo-container{margin:var(--sidebar-item-spacing-vertical) 0}.sidebar-logo{display:block;margin:0 auto;max-width:100%}.sidebar-search-container{align-items:center;background:var(--color-sidebar-search-background);display:flex;margin-top:var(--sidebar-search-space-above);position:relative}.sidebar-search-container:focus-within,.sidebar-search-container:hover{background:var(--color-sidebar-search-background--focus)}.sidebar-search-container:before{background-color:var(--color-sidebar-search-icon);content:"";height:var(--sidebar-search-icon-size);left:var(--sidebar-item-spacing-horizontal);-webkit-mask-image:var(--icon-search);mask-image:var(--icon-search);position:absolute;width:var(--sidebar-search-icon-size)}.sidebar-search{background:transparent;border:none;border-bottom:1px solid var(--color-sidebar-search-border);border-top:1px solid var(--color-sidebar-search-border);box-sizing:border-box;color:var(--color-sidebar-search-foreground);padding:var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal) var(--sidebar-search-input-spacing-vertical) calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size));width:100%;z-index:10}.sidebar-search:focus{outline:none}.sidebar-search::-moz-placeholder{font-size:var(--sidebar-search-input-font-size)}.sidebar-search:-ms-input-placeholder{font-size:var(--sidebar-search-input-font-size)}.sidebar-search::placeholder{font-size:var(--sidebar-search-input-font-size)}#searchbox .highlight-link{margin:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0;text-align:center}#searchbox .highlight-link a{color:var(--color-sidebar-search-icon);font-size:var(--font-size--small--2)}.sidebar-tree{font-size:var(--sidebar-item-font-size);margin-bottom:var(--sidebar-item-spacing-vertical);margin-top:var(--sidebar-tree-space-above)}.sidebar-tree ul{display:flex;flex-direction:column;list-style:none;margin-bottom:0;margin-top:0;padding:0}.sidebar-tree li{margin:0;position:relative}.sidebar-tree li>ul{margin-left:var(--sidebar-item-spacing-horizontal)}.sidebar-tree .icon,.sidebar-tree .reference{color:var(--color-sidebar-link-text)}.sidebar-tree .reference{box-sizing:border-box;display:inline-block;height:100%;line-height:var(--sidebar-item-line-height);overflow-wrap:anywhere;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none;width:100%}.sidebar-tree .reference:hover{background:var(--color-sidebar-item-background--hover)}.sidebar-tree .reference.external:after{color:var(--color-sidebar-link-text);content:url("data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='12' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23607D8B' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M0 0h24v24H0z' stroke='none'/%3E%3Cpath d='M11 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-5M10 14 20 4M15 4h5v5'/%3E%3C/svg%3E");margin:0 .25rem;vertical-align:middle}.sidebar-tree .current-page>.reference{font-weight:700}.sidebar-tree label{align-items:center;cursor:pointer;display:flex;height:var(--sidebar-item-height);justify-content:center;position:absolute;right:0;top:0;width:var(--sidebar-expander-width)}.sidebar-tree .caption,.sidebar-tree :not(.caption)>.caption-text{color:var(--color-sidebar-caption-text);font-size:var(--sidebar-caption-font-size);font-weight:700;margin:var(--sidebar-caption-space-above) 0 0 0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-transform:uppercase}.sidebar-tree li.has-children>.reference{padding-right:var(--sidebar-expander-width)}.sidebar-tree .toctree-l1>.reference,.sidebar-tree .toctree-l1>label .icon{color:var(--color-sidebar-link-text--top-level)}.sidebar-tree label{background:var(--color-sidebar-item-expander-background)}.sidebar-tree label:hover{background:var(--color-sidebar-item-expander-background--hover)}.sidebar-tree .current>.reference{background:var(--color-sidebar-item-background--current)}.sidebar-tree .current>.reference:hover{background:var(--color-sidebar-item-background--hover)}.toctree-checkbox{display:none;position:absolute}.toctree-checkbox~ul{display:none}.toctree-checkbox~label .icon svg{transform:rotate(90deg)}.toctree-checkbox:checked~ul{display:block}.toctree-checkbox:checked~label .icon svg{transform:rotate(-90deg)}.toc-title-container{padding:var(--toc-title-padding);padding-top:var(--toc-spacing-vertical)}.toc-title{color:var(--color-toc-title-text);font-size:var(--toc-title-font-size);padding-left:var(--toc-spacing-horizontal);text-transform:uppercase}.no-toc{display:none}.toc-tree-container{padding-bottom:var(--toc-spacing-vertical)}.toc-tree{border-left:1px solid var(--color-background-border);font-size:var(--toc-font-size);line-height:1.3;padding-left:calc(var(--toc-spacing-horizontal) - var(--toc-item-spacing-horizontal))}.toc-tree>ul>li:first-child{padding-top:0}.toc-tree>ul>li:first-child>ul{padding-left:0}.toc-tree>ul>li:first-child>a{display:none}.toc-tree ul{list-style-type:none;margin-bottom:0;margin-top:0;padding-left:var(--toc-item-spacing-horizontal)}.toc-tree li{padding-top:var(--toc-item-spacing-vertical)}.toc-tree li.scroll-current>.reference{color:var(--color-toc-item-text--active);font-weight:700}.toc-tree .reference{color:var(--color-toc-item-text);overflow-wrap:anywhere;text-decoration:none}.toc-scroll{max-height:100vh;overflow-y:scroll}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here){background:rgba(255,0,0,.25);color:var(--color-problematic)}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here):before{content:"ERROR: Adding a table of contents in Furo-based documentation is unnecessary, and does not work well with existing styling.Add a 'this-will-duplicate-information-and-it-is-still-useful-here' class, if you want an escape hatch."}.text-align\:left>p{text-align:left}.text-align\:center>p{text-align:center}.text-align\:right>p{text-align:right} +/*# sourceMappingURL=furo.css.map*/ \ No newline at end of file diff --git a/docs/_static/styles/furo.css.map b/docs/_static/styles/furo.css.map new file mode 100644 index 00000000..cfb53394 --- /dev/null +++ b/docs/_static/styles/furo.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo.css","mappings":"AAAA,2EAA2E,CAU3E,KAEE,6BAA8B,CAD9B,gBAEF,CASA,KACE,QACF,CAMA,KACE,aACF,CAOA,GACE,aAAc,CACd,cACF,CAUA,GACE,sBAAuB,CACvB,QAAS,CACT,gBACF,CAOA,IACE,+BAAiC,CACjC,aACF,CASA,EACE,4BACF,CAOA,YACE,kBAAmB,CACnB,yBAA0B,CAC1B,gCACF,CAMA,SAEE,kBACF,CAOA,cAGE,+BAAiC,CACjC,aACF,CAeA,QAEE,aAAc,CACd,aAAc,CACd,iBAAkB,CAClB,uBACF,CAEA,IACE,aACF,CAEA,IACE,SACF,CASA,IACE,iBACF,CAUA,sCAKE,mBAAoB,CACpB,cAAe,CACf,gBAAiB,CACjB,QACF,CAOA,aAEE,gBACF,CAOA,cAEE,mBACF,CAMA,gDAIE,yBACF,CAMA,wHAIE,iBAAkB,CAClB,SACF,CAMA,4GAIE,6BACF,CAMA,SACE,0BACF,CASA,OACE,qBAAsB,CACtB,aAAc,CACd,aAAc,CACd,cAAe,CACf,SAAU,CACV,kBACF,CAMA,SACE,uBACF,CAMA,SACE,aACF,CAOA,6BAEE,qBAAsB,CACtB,SACF,CAMA,kFAEE,WACF,CAOA,cACE,4BAA6B,CAC7B,mBACF,CAMA,yCACE,uBACF,CAOA,6BACE,yBAA0B,CAC1B,YACF,CASA,QACE,aACF,CAMA,QACE,iBACF,CAiBA,kBACE,YACF,CCvVA,aAcE,kEACE,uBAOF,WACE,iDARA,CCpBJ,iBAOE,6BAEA,mBANA,qBAEA,sBACA,0BAFA,oBAHA,4BAOA,6BANA,mBAOA,CAEF,gBACE,aCPF,KCGE,mHAEA,wGAGA,wBAAyB,CACzB,wBAAyB,CACzB,4BAA6B,CAC7B,yBAA0B,CAC1B,2BAA4B,CAG5B,sDAAuD,CACvD,gDAAiD,CACjD,wDAAyD,CAGzD,0CAA2C,CAC3C,gDAAiD,CACjD,gDAAiD,CAKjD,gCAAiC,CACjC,sCAAuC,CAGvC,2CAA4C,CAG5C,uCAAwC,CChCxC,+FAGA,uBAAwB,CAGxB,iCAAkC,CAClC,kCAAmC,CAEnC,+BAAgC,CAChC,sCAAuC,CACvC,sCAAuC,CACvC,qGAIA,mDAAoD,CAEpD,mCAAoC,CACpC,8CAA+C,CAC/C,gDAAiD,CACjD,kCAAmC,CACnC,6DAA8D,CAG9D,6BAA8B,CAC9B,6BAA8B,CAC9B,+BAAgC,CAChC,kCAAmC,CACnC,kCAAmC,CCPjC,ukBCYA,srCAZF,kaCVA,mLAOA,oTAWA,2UAaA,0CACA,gEACA,0CAGA,gEAUA,yCACA,+DAIA,4CACA,kEAGA,sGAEA,mGAGA,sCACA,2DAEA,4CACA,kEACA,uCACA,6DAEA,2GACA,+CAGA,+MAOA,4BACA,2FAIA,4DACA,sEACA,kEACA,sEACA,gDAGA,kCACA,uEACA,mCACA,4DACA,yDAGA,2DACA,qDAGA,0CACA,8CACA,oDACA,oDL7GF,iCAEA,iEAME,oCKyGA,yDAIA,sCACA,kCACA,sDAGA,0CACA,kEACA,oDAEA,sDAGA,oCACA,oEAIA,CAGA,yDAGA,qDACA,oDAGA,6DAIA,iEAGA,2DAEA,2DL9IE,4DAEA,gEAIF,gEKgGA,gFAIA,oNAOA,qDAEA,gFAIA,4DAIA,oEAMA,yEAIA,6DACA,0DAGA,uDAGA,qDAEA,wDLpII,6DAEA,yDACE,2DAMN,uCAIA,yCACE,8CAGF,sDMjDA,6DAKA,oCAIA,4CACA,kBAGF,sBAMA,2BAME,qCAGA,qCAEA,iCAEA,+BAEA,mCAEA,qCAIA,CACA,gCACA,gDAKA,kCAIA,6BAEA,0CAQA,kCAIF,8BAGE,8BACA,uCAGF,sCAKE,kCAEA,sDACA,uEAGE,sDACA,gGACF,wCAGI,sBACA,yHCzEJ,2BACA,qCAGF,sEAGE,kEAGA,sHAGA,2IACE,8BACA,8BAQF,uCACA,wEAGA,sDACA,CAEF,gCAKA,sCAEE,sDACA,gCACA,gEAKA,+CAOE,sBACA,gEAGA,GAYF,yLACA,gDAGA,mBAEA,wCACA,wCAKA,kCAGF,wBACE,mBAIF,mBAEE,CAJA,eAEF,CAJE,gBAEA,CAMA,mBACA,mBAGA,oBAEF,+BAEE,YACA,mBAEA,CACA,kBAIA,OALA,kBAQA,CAHA,gBAGA,IACA,mCACA,qBAGA,wBAEA,aACA,CAFA,WAEA,GAEE,oBAKJ,CAPE,gBAOF,aACE,+CAGA,UAHA,kCAGA,4BACA,GAEA,uBACA,CAFA,yBACA,CACA,yDAGF,kDAGE,uCAEA,iEAGE,yDACA,sEAEA,iEAEE,yHAKN,kDAMA,0DAIE,CANA,oBAMA,0GAOA,aAEF,CAHE,YAGF,4HAWE,+CACE,iCAIJ,0CAGE,CALE,qCAEJ,CAHI,WAMF,CAEF,QAIA,0CAEE,CANF,qCAME,OACA,4BACA,gBAIA,+CAEE,CAIF,kDAGF,CAPI,8BAIF,CAIA,YACF,CAbE,2BAEA,CAHA,UACA,CAWF,UAEA,yBACE,kBAIA,iEAKA,iCAGA,+BACF,oBACE,mBACF,OACE,iBAQA,0CAIA,CAPA,6DAGA,CALF,qBAEE,CAOA,qCAEE,CAGA,eAHA,sBAGA,gCAKF,qBACE,WACA,aACA,sCAGE,mBAMN,6BASE,kCACA,CAHA,sBACA,aACA,CANA,uBACA,gBAEA,MAIA,CAEA,qDACA,+CAIA,YACA,oDAEE,CAPF,aAEA,WAKE,OACA,oBACA,YACA,sBAGA,YADF,UACE,eAgBF,kBACE,CAfF,qDAEE,qCAQA,CAEJ,YAEE,CAJE,2BAEJ,CAGI,eACF,qBAEE,4CAGA,4CACF,CACE,cACA,CADA,cACA,iBAGF,CAHE,kBAGF,yBACE,oCAEJ,6DAKI,qDAQF,+BAEA,oCACE,uDAKF,+CACE,CACA,+BAEA,qCAGA,oCAGE,uBAHF,WAEE,CAFF,eAEE,SACA,mBAEA,kDACE,CADF,YADA,qBACA,WACE,sBAKJ,kEAIA,cAGF,CAHE,YAGF,iDAGA,uCAEE,YAEJ,+CAMA,kBAEI,CAJA,sBAIA,mBAEF,cACE,YACA,yBCzZJ,YACE,CADF,YAIE,UAFA,kBAEA,CAFA,iBADA,iBAGA,6CAGA,qDAEA,aAFA,iBAEA,6BAEA,SACA,yBAGA,mBAOF,gCACE,CAPE,SAIA,SAEJ,CAPE,qBACE,qDAIA,CALF,OAQA,kBACA,wBACA,2BAEA,gCACA,CAFA,UAEA,CAEA,YACA,kCADA,UACA,wCAIA,qBACE,CADF,UACE,6CAIA,OADA,KACA,6BAIF,kEACA,OACE,CADF,KACE,cAEA,0CAEJ,CAHI,mBAGJ,4DAEE,CANE,YACA,mBACA,CADA,OAKF,eACE,CANA,gDADA,qBACA,SAMA,2BADF,UACE,kBACA,gFACA,kDAMF,mBADF,YACE,6BAGE,gBACE,kEAEE,mDACA,CAFF,4BACE,iBACA,kEACE,yCACA,uDANN,iBACE,uBACA,CADA,+BACA,uEACE,kCACA,6BAEE,mBADA,0CACA,CAFF,uBACE,sBACA,0DALJ,wEACA,sEACE,WACA,0DACE,oDACA,6DANN,gBACE,oDACA,gBADA,UACA,yBACE,sDACA,cADA,UACA,qBACE,6CACA,yFALJ,sCACA,CAEE,gBACE,CAHJ,gBAGI,sBAHJ,uBACE,4DACA,4CACE,iDAJJ,2CACA,CADA,gBAEE,gBAGE,sBALJ,+BAII,iBAFF,gDACA,WACE,YADF,uCACE,6EACA,2BANN,8CACE,kDACA,0CACE,8BACA,yFACE,sBACA,sFALJ,mEACA,sBACE,kEACA,6EACE,uCACA,kEALJ,qGAEE,kEACA,6EACE,uCACA,kEALJ,8CACA,uDACE,sEACA,2EACE,sCACA,iEALJ,mGACA,qCACE,oDACA,0DACE,6GACA,gDAGR,yDCpEA,sEACE,CACA,6GACE,gEACF,iGAIF,wFACE,qDAGA,mGAEE,2CAEF,4FACE,gCACF,wGACE,8DAEE,6FAIA,iJAKN,6GACE,gDAKF,yDAGA,qCACA,6BAIA,kBACA,qDAIA,oCAGE,+DAIA,2CAKF,oDAIA,sCAEE,8BACJ,qBACE,wDAGA,uCAEA,kEAIF,8CAGE,uDAEE,oCAGJ,4BAEE,6BC9FA,gEAGE,+CCHJ,0EAGE,sDAKA,gEACE,qCACA,8BAEA,oCAGF,wBACE,4FCdF,gBAEA,yGAIE,kBAOJ,CAKE,2MAUE,oBAEF,wGASE,iCAEA,CAJF,wBAIE,8GAOF,mBAGA,2GACE,mBACA,6HAQA,YAGJ,mIAOE,eACA,CAFA,YAEA,4FAGA,8BACA,MAaE,sCAGA,CAJF,oBACE,CAVA,wCACA,CAFF,8BACE,CAHF,eAEA,CAOF,+BACE,mBAME,YAGF,4CACE,iCACA,4BACA,qCAEA,8BACA,uDAGA,sCACA,cACA,gCADA,eACA,wCAIA,YACA,iBAEA,kBAEA,2EAMF,qBACE,CALE,kBAKF,+BAEA,4BACA,aACA,qBApHsB,gBAoHtB,yBChIJ,eACE,CADF,aACE,sEAKA,iCACA,6GAGA,iCACA,CAEE,mCAFF,wBAEE,CACE,6BAEF,eACE,CADF,YACE,cAGA,8CAEA,wDAGJ,kBACG,CAAD,eACA,+CCvBF,CACA,iDAIE,YAGA,CAHA,YAGA,CAIA,mFACA,QACA,6CAGA,eACA,kCAGF,gCAGE,aACA,CAJF,uBAIE,mBAMA,6CCpCJ,CDkCE,mBACE,CCnCJ,kCACE,CDiCA,mBAHE,eC9BF,+BACA,CADA,UACA,wCAME,mBACF,CAPA,wBAIA,2CACE,iBAEF,mDACE,sBCVJ,qBAEE,CAGA,qBACA,qBAUE,6CAGE,CAKJ,uBAEA,CAFA,kDAEA,CAhBI,oBACA,4BAEF,6BACE,CAUJ,gDAEA,CAFA,iDAEA,CAFA,+CAEA,CAJA,kCAEA,CAfE,aAKE,eAEF,CAPA,uBAiBF,gCAEA,YACE,oCAEA,wBAMA,0FAGE,aASJ,yGAEE,CACA,+BACE,CADF,6BADA,kCACA,kCACE,4BAEF,4NAMA,4BAHE,wBAGF,uQAIA,qDAFE,iCAEF,+CACE,uCChEJ,yBAGA,6CAGA,iDAIA,qDAGA,YACE,wCAGA,oCACA,QCnBJ,eACE,KAGF,qBACE,YAGF,CAHE,cAGF,gCAEE,mBACA,iEAEA,oCACA,wCAEA,sBACA,WAEA,CAFA,YAEA,8EAEA,mCAFA,iBAEA,6BAIA,wEAKA,sDAIE,CARF,mDAIA,CAIE,cAEF,8CAIA,oBAFE,iBAEF,8CAGE,eAEF,CAFE,YAEF,OAEE,kBAGJ,CAJI,eACA,CAFF,mBAKF,yCCjDE,oBACA,CAFA,iBAEA,uCCFA,iBAEA,qCAKA,mBACA,CAHA,gBAGA,4CACA,2DAGE,qCAGF,gHAME,gBADA,YACA,8FCnBF,eACA,kIAQA,4NAWE,WACA,sUCvBF,kBAEF,gHAMI,oCAIF,gBADF,UAEE,8FAaA,8NAEE,oBAKF,mDAJA,8JAIA,CAJA,yJAIA,6JACE,yBACA,6EAGJ,2DAYI,+QACE,gDAGA,CAJF,qFAIE,kfACE,mBAER,+CAEE,qCAOF,iDACE,CALF,+CACE,iDAGF,CAJA,gBAKE,sDAOA,6DAGF,2EAEE,0BChFF,uBACE,oFAEA,yEAIA,cCHA,wCACA,sBACA,qBAEA,CAGF,gBACE,sNAWA,iBAEA,kBAdF,wRA8BI,kBACA,iOAkBA,aACA,kCACE,0BACA,uEACA,uVAyBA,iDAGN,ieC5EE,4BCVF,CASE,6JAEA,iDAGF,sEAGA,mDAGE,iDAOA,4DAGA,8CAEA,CACA,mBADA,gCACA,sCAEE,kCAEF,CAFE,kCAEF,gCAEE,kBACA,CAIA,mDACA,CAHA,uCAEA,CANA,YACA,CACA,8BAKA,gBALA,eACA,oBACA,CADA,gBAIA,wCAGF,6BAGA,YAJF,iBAIE,iEACA,4CAEA,kDACA,qCACA,0BADA,wBACA,8CACA,kIAIA,CAJA,aAIA,8DAIA,uBAGA,CANA,yBAGA,CAGA,yDACE,kDADF,mFACE,cADF,CACE,4BAKJ,CALI,YAKJ,4BACE,kFAEA,UAEA,CAFA,WADA,aAGA,gCACE,oCACA,kCAKJ,uBACE,2CACA,qBACA,sDAIE,CALF,YACA,CAIE,iCAGA,CAPF,qBAOE,oBACA,WAIF,eACE,CAHA,cAGA,cAGA,sDACE,CAHF,cAGE,0BAEJ,oDAGA,oCACE,CACA,iDAGA,cACA,oFAMA,qBACA,CAEA,0DAGE,iBAHF,wBAGE,6CAHF,6CAOE,yDACA,2BAEA,mBAMJ,iDAIE,CAVE,yDAIF,kBAMA,wEAGA,wDAIA,kCAOA,iDAEA,CAPF,WAEE,sCAGA,CALF,2CACE,CAMA,qCACA,+BARF,kBACE,qCAOA,iBAuBE,uBAlBF,YACE,0DAIF,CALA,uDACE,CANF,qBAKA,CAgBA,4CAEE,CALA,gRAGF,YAEE,iCAEN,+CAQE,+CAGA,uCACE,+CAMF,6EClNF,4BAKE,SAJA,qFAIA,kBACA,8BACA,uCACA,oCAIA,eAEF,uCACE,CAGA,kDAEA,CALA,0CAKA,kBAEA,mEAFA,YAEA,CAFA,SAEA,kBAGA,QACE,CADF,iBACE,qBACA,kDAEA,CAIA,6CAHE,oCAgBF,CAbA,yBAEA,qBACA,CACF,oBACE,CAGE,YAHF,2CAEA,uBACE,oFAKF,CANA,qBACE,UAKF,gCACA,sDAOJ,yCChDE,oCAGI,CD6CN,yXCnDE,gBAEF,sBAIE","sources":["webpack:///./node_modules/normalize.css/normalize.css","webpack:///./src/furo/assets/styles/base/_print.sass","webpack:///./src/furo/assets/styles/base/_screen-readers.sass","webpack:///./src/furo/assets/styles/base/_theme.sass","webpack:///./src/furo/assets/styles/variables/_fonts.scss","webpack:///./src/furo/assets/styles/variables/_spacing.scss","webpack:///./src/furo/assets/styles/variables/_icons.scss","webpack:///./src/furo/assets/styles/variables/_admonitions.scss","webpack:///./src/furo/assets/styles/variables/_colors.scss","webpack:///./src/furo/assets/styles/base/_typography.sass","webpack:///./src/furo/assets/styles/_scaffold.sass","webpack:///./src/furo/assets/styles/content/_admonitions.sass","webpack:///./src/furo/assets/styles/content/_api.sass","webpack:///./src/furo/assets/styles/content/_blocks.sass","webpack:///./src/furo/assets/styles/content/_captions.sass","webpack:///./src/furo/assets/styles/content/_code.sass","webpack:///./src/furo/assets/styles/content/_footnotes.sass","webpack:///./src/furo/assets/styles/content/_images.sass","webpack:///./src/furo/assets/styles/content/_indexes.sass","webpack:///./src/furo/assets/styles/content/_lists.sass","webpack:///./src/furo/assets/styles/content/_math.sass","webpack:///./src/furo/assets/styles/content/_misc.sass","webpack:///./src/furo/assets/styles/content/_rubrics.sass","webpack:///./src/furo/assets/styles/content/_sidebar.sass","webpack:///./src/furo/assets/styles/content/_tables.sass","webpack:///./src/furo/assets/styles/content/_target.sass","webpack:///./src/furo/assets/styles/content/_gui-labels.sass","webpack:///./src/furo/assets/styles/components/_footer.sass","webpack:///./src/furo/assets/styles/components/_search.sass","webpack:///./src/furo/assets/styles/components/_sidebar.sass","webpack:///./src/furo/assets/styles/components/_table_of_contents.sass","webpack:///./src/furo/assets/styles/_shame.sass"],"sourcesContent":["/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n line-height: 1.15; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n/* Grouping content\n ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n box-sizing: content-box; /* 1 */\n height: 0; /* 1 */\n overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n border-bottom: none; /* 1 */\n text-decoration: underline; /* 2 */\n text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/* Embedded content\n ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n border-style: none;\n}\n\n/* Forms\n ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-size: 100%; /* 1 */\n line-height: 1.15; /* 1 */\n margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n border-style: none;\n padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n * `fieldset` elements in all browsers.\n */\n\nlegend {\n box-sizing: border-box; /* 1 */\n color: inherit; /* 2 */\n display: table; /* 1 */\n max-width: 100%; /* 1 */\n padding: 0; /* 3 */\n white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n box-sizing: border-box; /* 1 */\n padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/* Interactive\n ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n display: list-item;\n}\n\n/* Misc\n ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n display: none;\n}\n","// This file contains styles for managing print media.\n\n////////////////////////////////////////////////////////////////////////////////\n// Hide elements not relevant to print media.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Hide icon container.\n .content-icon-container\n display: none !important\n\n // Hide showing header links if hovering over when printing.\n .headerlink\n display: none !important\n\n // Hide mobile header.\n .mobile-header\n display: none !important\n\n // Hide navigation links.\n .related-pages\n display: none !important\n\n////////////////////////////////////////////////////////////////////////////////\n// Tweaks related to decolorization.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Apply a border around code which no longer have a color background.\n .highlight\n border: 0.1pt solid var(--color-foreground-border)\n",".visually-hidden\n position: absolute !important\n width: 1px !important\n height: 1px !important\n padding: 0 !important\n margin: -1px !important\n overflow: hidden !important\n clip: rect(0,0,0,0) !important\n white-space: nowrap !important\n border: 0 !important\n\n:-moz-focusring\n outline: auto\n","// This file serves as the \"skeleton\" of the theming logic.\n//\n// This contains the bulk of the logic for handling dark mode, color scheme\n// toggling and the handling of color-scheme-specific hiding of elements.\n\nbody\n @include fonts\n @include spacing\n @include icons\n @include admonitions\n @include default-admonition(#651fff, \"abstract\")\n @include default-topic(#14B8A6, \"pencil\")\n\n @include colors\n\n.only-light\n display: block !important\nhtml body .only-dark\n display: none !important\n\n// Ignore dark-mode hints if print media.\n@media not print\n // Enable dark-mode, if requested.\n body[data-theme=\"dark\"]\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n // Enable dark mode, unless explicitly told to avoid.\n @media (prefers-color-scheme: dark)\n body:not([data-theme=\"light\"])\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n//\n// Theme toggle presentation\n//\nbody[data-theme=\"auto\"]\n .theme-toggle svg.theme-icon-when-auto\n display: block\n\nbody[data-theme=\"dark\"]\n .theme-toggle svg.theme-icon-when-dark\n display: block\n\nbody[data-theme=\"light\"]\n .theme-toggle svg.theme-icon-when-light\n display: block\n","// Fonts used by this theme.\n//\n// There are basically two things here -- using the system font stack and\n// defining sizes for various elements in %ages. We could have also used `em`\n// but %age is easier to reason about for me.\n\n@mixin fonts {\n // These are adapted from https://systemfontstack.com/\n --font-stack: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,\n sans-serif, Apple Color Emoji, Segoe UI Emoji;\n --font-stack--monospace: \"SFMono-Regular\", Menlo, Consolas, Monaco,\n Liberation Mono, Lucida Console, monospace;\n\n --font-size--normal: 100%;\n --font-size--small: 87.5%;\n --font-size--small--2: 81.25%;\n --font-size--small--3: 75%;\n --font-size--small--4: 62.5%;\n\n // Sidebar\n --sidebar-caption-font-size: var(--font-size--small--2);\n --sidebar-item-font-size: var(--font-size--small);\n --sidebar-search-input-font-size: var(--font-size--small);\n\n // Table of Contents\n --toc-font-size: var(--font-size--small--3);\n --toc-font-size--mobile: var(--font-size--normal);\n --toc-title-font-size: var(--font-size--small--4);\n\n // Admonitions\n //\n // These aren't defined in terms of %ages, since nesting these is permitted.\n --admonition-font-size: 0.8125rem;\n --admonition-title-font-size: 0.8125rem;\n\n // Code\n --code-font-size: var(--font-size--small--2);\n\n // API\n --api-font-size: var(--font-size--small);\n}\n","// Spacing for various elements on the page\n//\n// If the user wants to tweak things in a certain way, they are permitted to.\n// They also have to deal with the consequences though!\n\n@mixin spacing {\n // Header!\n --header-height: calc(\n var(--sidebar-item-line-height) + 4 * #{var(--sidebar-item-spacing-vertical)}\n );\n --header-padding: 0.5rem;\n\n // Sidebar\n --sidebar-tree-space-above: 1.5rem;\n --sidebar-caption-space-above: 1rem;\n\n --sidebar-item-line-height: 1rem;\n --sidebar-item-spacing-vertical: 0.5rem;\n --sidebar-item-spacing-horizontal: 1rem;\n --sidebar-item-height: calc(\n var(--sidebar-item-line-height) + 2 *#{var(--sidebar-item-spacing-vertical)}\n );\n\n --sidebar-expander-width: var(--sidebar-item-height); // be square\n\n --sidebar-search-space-above: 0.5rem;\n --sidebar-search-input-spacing-vertical: 0.5rem;\n --sidebar-search-input-spacing-horizontal: 0.5rem;\n --sidebar-search-input-height: 1rem;\n --sidebar-search-icon-size: var(--sidebar-search-input-height);\n\n // Table of Contents\n --toc-title-padding: 0.25rem 0;\n --toc-spacing-vertical: 1.5rem;\n --toc-spacing-horizontal: 1.5rem;\n --toc-item-spacing-vertical: 0.4rem;\n --toc-item-spacing-horizontal: 1rem;\n}\n","// Expose theme icons as CSS variables.\n\n$icons: (\n // Adapted from tabler-icons\n // url: https://tablericons.com/\n \"search\":\n url('data:image/svg+xml;charset=utf-8,'),\n // Factored out from mkdocs-material on 24-Aug-2020.\n // url: https://squidfunk.github.io/mkdocs-material/reference/admonitions/\n \"pencil\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"abstract\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"info\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"flame\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"question\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"warning\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"failure\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"spark\":\n url('data:image/svg+xml;charset=utf-8,')\n);\n\n@mixin icons {\n @each $name, $glyph in $icons {\n --icon-#{$name}: #{$glyph};\n }\n}\n","// Admonitions\n\n// Structure of these is:\n// admonition-class: color \"icon-name\";\n//\n// The colors are translated into CSS variables below. The icons are\n// used directly in the main declarations to set the `mask-image` in\n// the title.\n\n// prettier-ignore\n$admonitions: (\n // Each of these has an reST directives for it.\n \"caution\": #ff9100 \"spark\",\n \"warning\": #ff9100 \"warning\",\n \"danger\": #ff5252 \"spark\",\n \"attention\": #ff5252 \"warning\",\n \"error\": #ff5252 \"failure\",\n \"hint\": #00c852 \"question\",\n \"tip\": #00c852 \"info\",\n \"important\": #00bfa5 \"flame\",\n \"note\": #00b0ff \"pencil\",\n \"seealso\": #448aff \"info\",\n \"admonition-todo\": #808080 \"pencil\"\n);\n\n@mixin default-admonition($color, $icon-name) {\n --color-admonition-title: #{$color};\n --color-admonition-title-background: #{rgba($color, 0.1)};\n\n --icon-admonition-default: var(--icon-#{$icon-name});\n}\n\n@mixin default-topic($color, $icon-name) {\n --color-topic-title: #{$color};\n --color-topic-title-background: #{rgba($color, 0.1)};\n\n --icon-topic-default: var(--icon-#{$icon-name});\n}\n\n@mixin admonitions {\n @each $name, $values in $admonitions {\n --color-admonition-title--#{$name}: #{nth($values, 1)};\n --color-admonition-title-background--#{$name}: #{rgba(\n nth($values, 1),\n 0.1\n )};\n }\n}\n","// Colors used throughout this theme.\n//\n// The aim is to give the user more control. Thus, instead of hard-coding colors\n// in various parts of the stylesheet, the approach taken is to define all\n// colors as CSS variables and reusing them in all the places.\n//\n// `colors-dark` depends on `colors` being included at a lower specificity.\n\n@mixin colors {\n --color-problematic: #b30000;\n\n // Base Colors\n --color-foreground-primary: black; // for main text and headings\n --color-foreground-secondary: #5a5c63; // for secondary text\n --color-foreground-muted: #646776; // for muted text\n --color-foreground-border: #878787; // for content borders\n\n --color-background-primary: white; // for content\n --color-background-secondary: #f8f9fb; // for navigation + ToC\n --color-background-hover: #efeff4ff; // for navigation-item hover\n --color-background-hover--transparent: #efeff400;\n --color-background-border: #eeebee; // for UI borders\n --color-background-item: #ccc; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #2962ff;\n --color-brand-content: #2a5adf;\n\n // API documentation\n --color-api-background: var(--color-background-secondary);\n --color-api-background-hover: var(--color-background-hover);\n --color-api-overall: var(--color-foreground-secondary);\n --color-api-name: var(--color-problematic);\n --color-api-pre-name: var(--color-problematic);\n --color-api-paren: var(--color-foreground-secondary);\n --color-api-keyword: var(--color-foreground-primary);\n --color-highlight-on-target: #ffffcc;\n\n // Inline code background\n --color-inline-code-background: var(--color-background-secondary);\n\n // Highlighted text (search)\n --color-highlighted-background: #ddeeff;\n --color-highlighted-text: var(--color-foreground-primary);\n\n // GUI Labels\n --color-guilabel-background: #ddeeff80;\n --color-guilabel-border: #bedaf580;\n --color-guilabel-text: var(--color-foreground-primary);\n\n // Admonitions!\n --color-admonition-background: transparent;\n\n //////////////////////////////////////////////////////////////////////////////\n // Everything below this should be one of:\n // - var(...)\n // - *-gradient(...)\n // - special literal values (eg: transparent, none)\n //////////////////////////////////////////////////////////////////////////////\n\n // Tables\n --color-table-header-background: var(--color-background-secondary);\n --color-table-border: var(--color-background-border);\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: transparent;\n --color-card-marginals-background: var(--color-background-secondary);\n\n // Header\n --color-header-background: var(--color-background-primary);\n --color-header-border: var(--color-background-border);\n --color-header-text: var(--color-foreground-primary);\n\n // Sidebar (left)\n --color-sidebar-background: var(--color-background-secondary);\n --color-sidebar-background-border: var(--color-background-border);\n\n --color-sidebar-brand-text: var(--color-foreground-primary);\n --color-sidebar-caption-text: var(--color-foreground-muted);\n --color-sidebar-link-text: var(--color-foreground-secondary);\n --color-sidebar-link-text--top-level: var(--color-brand-primary);\n\n --color-sidebar-item-background: var(--color-sidebar-background);\n --color-sidebar-item-background--current: var(\n --color-sidebar-item-background\n );\n --color-sidebar-item-background--hover: linear-gradient(\n 90deg,\n var(--color-background-hover--transparent) 0%,\n var(--color-background-hover) var(--sidebar-item-spacing-horizontal),\n var(--color-background-hover) 100%\n );\n\n --color-sidebar-item-expander-background: transparent;\n --color-sidebar-item-expander-background--hover: var(\n --color-background-hover\n );\n\n --color-sidebar-search-text: var(--color-foreground-primary);\n --color-sidebar-search-background: var(--color-background-secondary);\n --color-sidebar-search-background--focus: var(--color-background-primary);\n --color-sidebar-search-border: var(--color-background-border);\n --color-sidebar-search-icon: var(--color-foreground-muted);\n\n // Table of Contents (right)\n --color-toc-background: var(--color-background-primary);\n --color-toc-title-text: var(--color-foreground-muted);\n --color-toc-item-text: var(--color-foreground-secondary);\n --color-toc-item-text--hover: var(--color-foreground-primary);\n --color-toc-item-text--active: var(--color-brand-primary);\n\n // Actual page contents\n --color-content-foreground: var(--color-foreground-primary);\n --color-content-background: transparent;\n\n // Links\n --color-link: var(--color-brand-content);\n --color-link--hover: var(--color-brand-content);\n --color-link-underline: var(--color-background-border);\n --color-link-underline--hover: var(--color-foreground-border);\n}\n\n@mixin colors-dark {\n --color-problematic: #ee5151;\n\n // Base Colors\n --color-foreground-primary: #ffffffcc; // for main text and headings\n --color-foreground-secondary: #9ca0a5; // for secondary text\n --color-foreground-muted: #81868d; // for muted text\n --color-foreground-border: #666666; // for content borders\n\n --color-background-primary: #131416; // for content\n --color-background-secondary: #1a1c1e; // for navigation + ToC\n --color-background-hover: #1e2124ff; // for navigation-item hover\n --color-background-hover--transparent: #1e212400;\n --color-background-border: #303335; // for UI borders\n --color-background-item: #444; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #2b8cee;\n --color-brand-content: #368ce2;\n\n // Highlighted text (search)\n --color-highlighted-background: #083563;\n\n // GUI Labels\n --color-guilabel-background: #08356380;\n --color-guilabel-border: #13395f80;\n\n // API documentation\n --color-api-keyword: var(--color-foreground-secondary);\n --color-highlight-on-target: #333300;\n\n // Admonitions\n --color-admonition-background: #18181a;\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: #18181a;\n --color-card-marginals-background: var(--color-background-hover);\n}\n","// This file contains the styling for making the content throughout the page,\n// including fonts, paragraphs, headings and spacing among these elements.\n\nbody\n font-family: var(--font-stack)\npre,\ncode,\nkbd,\nsamp\n font-family: var(--font-stack--monospace)\n\n// Make fonts look slightly nicer.\nbody\n -webkit-font-smoothing: antialiased\n -moz-osx-font-smoothing: grayscale\n\n// Line height from Bootstrap 4.1\narticle\n line-height: 1.5\n\n//\n// Headings\n//\nh1,\nh2,\nh3,\nh4,\nh5,\nh6\n line-height: 1.25\n font-weight: bold\n\n border-radius: 0.5rem\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n margin-left: -0.5rem\n margin-right: -0.5rem\n padding-left: 0.5rem\n padding-right: 0.5rem\n\n + p\n margin-top: 0\n\nh1\n font-size: 2.5em\n margin-top: 1.75rem\n margin-bottom: 1rem\nh2\n font-size: 2em\n margin-top: 1.75rem\nh3\n font-size: 1.5em\nh4\n font-size: 1.25em\nh5\n font-size: 1.125em\nh6\n font-size: 1em\n\nsmall\n opacity: 75%\n font-size: 80%\n\n// Paragraph\np\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n\n// Horizontal rules\nhr.docutils\n height: 1px\n padding: 0\n margin: 2rem 0\n background-color: var(--color-background-border)\n border: 0\n\n.centered\n text-align: center\n\n// Links\na\n text-decoration: underline\n\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n &:hover\n color: var(--color-link--hover)\n text-decoration-color: var(--color-link-underline--hover)\n &.muted-link\n color: inherit\n &:hover\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline--hover)\n","// This file contains the styles for the overall layouting of the documentation\n// skeleton, including the responsive changes as well as sidebar toggles.\n//\n// This is implemented as a mobile-last design, which isn't ideal, but it is\n// reasonably good-enough and I got pretty tired by the time I'd finished this\n// to move the rules around to fix this. Shouldn't take more than 3-4 hours,\n// if you know what you're doing tho.\n\n// HACK: Not all browsers account for the scrollbar width in media queries.\n// This results in horizontal scrollbars in the breakpoint where we go\n// from displaying everything to hiding the ToC. We accomodate for this by\n// adding a bit of padding to the TOC drawer, disabling the horizontal\n// scrollbar and allowing the scrollbars to cover the padding.\n// https://www.456bereastreet.com/archive/201301/media_query_width_and_vertical_scrollbars/\n\n// HACK: Always having the scrollbar visible, prevents certain browsers from\n// causing the content to stutter horizontally between taller-than-viewport and\n// not-taller-than-viewport pages.\n\nhtml\n overflow-x: hidden\n overflow-y: scroll\n scroll-behavior: smooth\n\n.sidebar-scroll, .toc-scroll, article[role=main] *\n // Override Firefox scrollbar style\n scrollbar-width: thin\n scrollbar-color: var(--color-foreground-border) transparent\n\n // Override Chrome scrollbar styles\n &::-webkit-scrollbar\n width: 0.25rem\n height: 0.25rem\n &::-webkit-scrollbar-thumb\n background-color: var(--color-foreground-border)\n border-radius: 0.125rem\n\n//\n// Overalls\n//\nhtml,\nbody\n height: 100%\n color: var(--color-foreground-primary)\n background: var(--color-background-primary)\n\narticle\n color: var(--color-content-foreground)\n background: var(--color-content-background)\n\n.page\n display: flex\n // fill the viewport for pages with little content.\n min-height: 100%\n\n.mobile-header\n width: 100%\n height: var(--header-height)\n background-color: var(--color-header-background)\n color: var(--color-header-text)\n border-bottom: 1px solid var(--color-header-border)\n\n // Looks like sub-script/super-script have this, and we need this to\n // be \"on top\" of those.\n z-index: 10\n\n // We don't show the header on large screens.\n display: none\n\n // Add shadow when scrolled\n &.scrolled\n border-bottom: none\n box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.1), 0 0.2rem 0.4rem rgba(0, 0, 0, 0.2)\n\n .header-center\n a\n color: var(--color-header-text)\n text-decoration: none\n\n.main\n display: flex\n flex: 1\n\n// Sidebar (left) also covers the entire left portion of screen.\n.sidebar-drawer\n box-sizing: border-box\n\n border-right: 1px solid var(--color-sidebar-background-border)\n background: var(--color-sidebar-background)\n\n display: flex\n justify-content: flex-end\n // These next two lines took me two days to figure out.\n width: calc((100% - #{$full-width}) / 2 + #{$sidebar-width})\n min-width: $sidebar-width\n\n// Scroll-along sidebars\n.sidebar-container,\n.toc-drawer\n box-sizing: border-box\n width: $sidebar-width\n\n.toc-drawer\n background: var(--color-toc-background)\n // See HACK described on top of this document\n padding-right: 1rem\n\n.sidebar-sticky,\n.toc-sticky\n position: sticky\n top: 0\n height: min(100%, 100vh)\n height: 100vh\n\n display: flex\n flex-direction: column\n\n.sidebar-scroll,\n.toc-scroll\n flex-grow: 1\n flex-shrink: 1\n\n overflow: auto\n scroll-behavior: smooth\n\n// Central items.\n.content\n padding: 0 $content-padding\n width: $content-width\n\n display: flex\n flex-direction: column\n justify-content: space-between\n\n.icon\n display: inline-block\n height: 1rem\n width: 1rem\n svg\n width: 100%\n height: 100%\n\n//\n// Accommodate announcement banner\n//\n.announcement\n background-color: var(--color-announcement-background)\n color: var(--color-announcement-text)\n\n height: var(--header-height)\n display: flex\n align-items: center\n overflow-x: auto\n & + .page\n min-height: calc(100% - var(--header-height))\n\n.announcement-content\n box-sizing: border-box\n padding: 0.5rem\n min-width: 100%\n white-space: nowrap\n text-align: center\n\n a\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-announcement-text)\n\n &:hover\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-link--hover)\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for theme\n////////////////////////////////////////////////////////////////////////////////\n.no-js .theme-toggle-container // don't show theme toggle if there's no JS\n display: none\n\n.theme-toggle-container\n vertical-align: middle\n\n.theme-toggle\n cursor: pointer\n border: none\n padding: 0\n background: transparent\n\n.theme-toggle svg\n vertical-align: middle\n height: 1rem\n width: 1rem\n color: var(--color-foreground-primary)\n display: none\n\n.theme-toggle-header\n float: left\n padding: 1rem 0.5rem\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for elements\n////////////////////////////////////////////////////////////////////////////////\n.toc-overlay-icon, .nav-overlay-icon\n display: none\n cursor: pointer\n\n .icon\n color: var(--color-foreground-secondary)\n height: 1rem\n width: 1rem\n\n.toc-header-icon, .nav-overlay-icon\n // for when we set display: flex\n justify-content: center\n align-items: center\n\n.toc-content-icon\n height: 1.5rem\n width: 1.5rem\n\n.content-icon-container\n float: right\n display: flex\n margin-top: 1.5rem\n margin-left: 1rem\n margin-bottom: 1rem\n gap: 0.5rem\n\n .edit-this-page svg\n color: inherit\n height: 1rem\n width: 1rem\n\n.sidebar-toggle\n position: absolute\n display: none\n// \n.sidebar-toggle[name=\"__toc\"]\n left: 20px\n.sidebar-toggle:checked\n left: 40px\n// \n\n.overlay\n position: fixed\n top: 0\n width: 0\n height: 0\n\n transition: width 0ms, height 0ms, opacity 250ms ease-out\n\n opacity: 0\n background-color: rgba(0, 0, 0, 0.54)\n.sidebar-overlay\n z-index: 20\n.toc-overlay\n z-index: 40\n\n// Keep things on top and smooth.\n.sidebar-drawer\n z-index: 30\n transition: left 250ms ease-in-out\n.toc-drawer\n z-index: 50\n transition: right 250ms ease-in-out\n\n// Show the Sidebar\n#__navigation:checked\n & ~ .sidebar-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .sidebar-drawer\n top: 0\n left: 0\n // Show the toc sidebar\n#__toc:checked\n & ~ .toc-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .toc-drawer\n top: 0\n right: 0\n\n////////////////////////////////////////////////////////////////////////////////\n// Back to top\n////////////////////////////////////////////////////////////////////////////////\n.back-to-top\n text-decoration: none\n\n display: none\n position: fixed\n left: 0\n top: 1rem\n padding: 0.5rem\n padding-right: 0.75rem\n border-radius: 1rem\n font-size: 0.8125rem\n\n background: var(--color-background-primary)\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), #6b728080 0px 0px 1px 0px\n\n z-index: 10\n\n margin-left: 50%\n transform: translateX(-50%)\n svg\n height: 1rem\n width: 1rem\n fill: currentColor\n display: inline-block\n\n span\n margin-left: 0.25rem\n\n .show-back-to-top &\n display: flex\n align-items: center\n\n////////////////////////////////////////////////////////////////////////////////\n// Responsive layouting\n////////////////////////////////////////////////////////////////////////////////\n// Make things a bit bigger on bigger screens.\n@media (min-width: $full-width + $sidebar-width)\n html\n font-size: 110%\n\n@media (max-width: $full-width)\n // Collapse \"toc\" into the icon.\n .toc-content-icon\n display: flex\n .toc-drawer\n position: fixed\n height: 100vh\n top: 0\n right: -$sidebar-width\n border-left: 1px solid var(--color-background-muted)\n .toc-tree\n border-left: none\n font-size: var(--toc-font-size--mobile)\n\n // Accomodate for a changed content width.\n .sidebar-drawer\n width: calc((100% - #{$full-width - $sidebar-width}) / 2 + #{$sidebar-width})\n\n@media (max-width: $full-width - $sidebar-width)\n // Collapse \"navigation\".\n .nav-overlay-icon\n display: flex\n .sidebar-drawer\n position: fixed\n height: 100vh\n width: $sidebar-width\n\n top: 0\n left: -$sidebar-width\n\n // Swap which icon is visible.\n .toc-header-icon\n display: flex\n .toc-content-icon, .theme-toggle-content\n display: none\n .theme-toggle-header\n display: block\n\n // Show the header.\n .mobile-header\n position: sticky\n top: 0\n display: flex\n justify-content: space-between\n align-items: center\n\n .header-left,\n .header-right\n display: flex\n height: var(--header-height)\n padding: 0 var(--header-padding)\n label\n height: 100%\n width: 100%\n\n // Add a scroll margin for the content\n :target\n scroll-margin-top: var(--header-height)\n\n // Show back-to-top below the header\n .back-to-top\n top: calc(var(--header-height) + 0.5rem)\n\n // Center the page, and accommodate for the header.\n .page\n flex-direction: column\n justify-content: center\n .content\n margin-left: auto\n margin-right: auto\n\n@media (max-width: $content-width + 2* $content-padding)\n // Content should respect window limits.\n .content\n width: 100%\n overflow-x: auto\n\n@media (max-width: $content-width)\n .content\n padding: 0 $content-padding--small\n // Don't float sidebars to the right.\n article div.sidebar\n float: none\n width: 100%\n margin: 1rem 0\n","//\n// The design here is strongly inspired by mkdocs-material.\n.admonition, .topic\n margin: 1rem auto\n padding: 0 0.5rem 0.5rem 0.5rem\n\n background: var(--color-admonition-background)\n\n border-radius: 0.2rem\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n font-size: var(--admonition-font-size)\n\n overflow: hidden\n page-break-inside: avoid\n\n // First element should have no margin, since the title has it.\n > :nth-child(2)\n margin-top: 0\n\n // Last item should have no margin, since we'll control that w/ padding\n > :last-child\n margin-bottom: 0\n\np.admonition-title, p.topic-title\n position: relative\n margin: 0 -0.5rem 0.5rem\n padding-left: 2rem\n padding-right: .5rem\n padding-top: .4rem\n padding-bottom: .4rem\n\n font-weight: 500\n font-size: var(--admonition-title-font-size)\n line-height: 1.3\n\n // Our fancy icon\n &::before\n content: \"\"\n position: absolute\n left: 0.5rem\n width: 1rem\n height: 1rem\n\n// Default styles\np.admonition-title\n background-color: var(--color-admonition-title-background)\n &::before\n background-color: var(--color-admonition-title)\n mask-image: var(--icon-admonition-default)\n mask-repeat: no-repeat\n\np.topic-title\n background-color: var(--color-topic-title-background)\n &::before\n background-color: var(--color-topic-title)\n mask-image: var(--icon-topic-default)\n mask-repeat: no-repeat\n\n//\n// Variants\n//\n.admonition\n border-left: 0.2rem solid var(--color-admonition-title)\n\n @each $type, $value in $admonitions\n &.#{$type}\n border-left-color: var(--color-admonition-title--#{$type})\n > .admonition-title\n background-color: var(--color-admonition-title-background--#{$type})\n &::before\n background-color: var(--color-admonition-title--#{$type})\n mask-image: var(--icon-#{nth($value, 2)})\n\n.admonition-todo > .admonition-title\n text-transform: uppercase\n","// This file stylizes the API documentation (stuff generated by autodoc). It's\n// deeply nested due to how autodoc structures the HTML without enough classes\n// to select the relevant items.\n\n// API docs!\ndl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)\n // Tweak the spacing of all the things!\n dd\n margin-left: 2rem\n > :first-child\n margin-top: 0.125rem\n > :last-child\n margin-bottom: 0.75rem\n\n // This is used for the arguments\n .field-list\n margin-bottom: 0.75rem\n\n // \"Headings\" (like \"Parameters\" and \"Return\")\n > dt\n text-transform: uppercase\n font-size: var(--font-size--small)\n\n dd:empty\n margin-bottom: 0.5rem\n dd > ul\n margin-left: -1.2rem\n > li\n > p:nth-child(2)\n margin-top: 0\n // When the last-empty-paragraph follows a paragraph, it doesn't need\n // to augument the existing spacing.\n > p + p:last-child:empty\n margin-top: 0\n margin-bottom: 0\n\n // Colorize the elements\n > dt\n color: var(--color-api-overall)\n\n.sig\n font-weight: bold\n\n font-size: var(--api-font-size)\n font-family: var(--font-stack--monospace)\n\n padding-top: 0.25rem\n padding-bottom: 0.25rem\n padding-right: 0.5rem\n\n // These are intentionally em, to properly match the font size.\n padding-left: 3em\n text-indent: -2.5em\n\n border-radius: 0.25rem\n\n background: var(--color-api-background)\n\n &:hover\n background: var(--color-api-background-hover)\n\n // adjust the size of the [source] link on the right.\n a.reference\n .viewcode-link\n font-weight: normal\n width: 3.5rem\n\n // Break words when they're too long\n span.pre\n overflow-wrap: anywhere\n\nem.property\n font-style: normal\n &:first-child\n color: var(--color-api-keyword)\n.sig-name\n color: var(--color-api-name)\n.sig-prename\n font-weight: normal\n color: var(--color-api-pre-name)\n.sig-paren\n color: var(--color-api-paren)\n.sig-param\n font-style: normal\n\n.versionmodified\n font-style: italic\ndiv.versionadded, div.versionchanged, div.deprecated\n p\n margin-top: 0.125rem\n margin-bottom: 0.125rem\n\n// Align the [docs] and [source] to the right.\n.viewcode-link, .viewcode-back\n float: right\n text-align: right\n",".line-block\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n .line-block\n margin-top: 0rem\n margin-bottom: 0rem\n padding-left: 1rem\n","// Captions\narticle p.caption,\ntable > caption,\n.code-block-caption\n font-size: var(--font-size--small)\n text-align: center\n\n// Caption above a TOCTree\n.toctree-wrapper.compound\n .caption, :not(.caption) > .caption-text\n font-size: var(--font-size--small)\n text-transform: uppercase\n\n text-align: initial\n margin-bottom: 0\n\n > ul\n margin-top: 0\n margin-bottom: 0\n","// Inline code\ncode.literal\n background: var(--color-inline-code-background)\n border-radius: 0.2em\n // Make the font smaller, and use padding to recover.\n font-size: var(--font-size--small--2)\n padding: 0.1em 0.2em\n\n p &\n border: 1px solid var(--color-background-border)\n\n// Code and Literal Blocks\n$code-spacing-vertical: 0.625rem\n$code-spacing-horizontal: 0.875rem\n\n// Wraps every literal block + line numbers.\ndiv[class*=\" highlight-\"],\ndiv[class^=\"highlight-\"]\n margin: 1em 0\n display: flex\n\n .table-wrapper\n margin: 0\n padding: 0\n\npre\n margin: 0\n padding: 0\n\n // Needed to have more specificity than pygments' \"pre\" selector. :(\n article[role=\"main\"] .highlight &\n line-height: 1.5\n\n &.literal-block,\n .highlight &\n font-size: var(--code-font-size)\n padding: $code-spacing-vertical $code-spacing-horizontal\n overflow: auto\n\n // Make it look like all the other blocks.\n &.literal-block\n margin-top: 1rem\n margin-bottom: 1rem\n\n border-radius: 0.2rem\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n\n// All code is always contained in this.\n.highlight\n width: 100%\n border-radius: 0.2rem\n\n // Make line numbers and prompts un-selectable.\n .gp, span.linenos\n user-select: none\n pointer-events: none\n\n // Expand the line-highlighting.\n .hll\n display: block\n margin-left: -$code-spacing-horizontal\n margin-right: -$code-spacing-horizontal\n padding-left: $code-spacing-horizontal\n padding-right: $code-spacing-horizontal\n\n/* Make code block captions be nicely integrated */\n.code-block-caption\n display: flex\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n border-radius: 0.25rem\n border-bottom-left-radius: 0\n border-bottom-right-radius: 0\n font-weight: 300\n border-bottom: 1px solid\n\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n border-color: var(--color-background-border)\n\n + div[class]\n margin-top: 0\n pre\n border-top-left-radius: 0\n border-top-right-radius: 0\n\n// When `html_codeblock_linenos_style` is table.\n.highlighttable\n width: 100%\n display: block\n tbody\n display: block\n\n tr\n display: flex\n\n // Line numbers\n td.linenos\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n padding: $code-spacing-vertical $code-spacing-horizontal\n padding-right: 0\n border-top-left-radius: 0.2rem\n border-bottom-left-radius: 0.2rem\n\n .linenodiv\n padding-right: $code-spacing-horizontal\n font-size: var(--code-font-size)\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n\n // Actual code\n td.code\n padding: 0\n display: block\n flex: 1\n overflow: hidden\n\n .highlight\n border-top-left-radius: 0\n border-bottom-left-radius: 0\n\n// When `html_codeblock_linenos_style` is inline.\n.highlight\n span.linenos\n display: inline-block\n padding-left: 0\n padding-right: $code-spacing-horizontal\n margin-right: $code-spacing-horizontal\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n","// Inline Footnote Reference\n.footnote-reference\n font-size: var(--font-size--small--4)\n vertical-align: super\n\n// Definition list, listing the content of each note.\ndl.footnote.brackets\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\n display: grid\n grid-template-columns: max-content auto\n dt\n margin: 0\n > .fn-backref\n margin-left: 0.25rem\n\n &:after\n content: \":\"\n\n .brackets\n &:before\n content: \"[\"\n &:after\n content: \"]\"\n\n dd\n margin: 0\n padding: 0 1rem\n","//\n// Figures\n//\nimg\n box-sizing: border-box\n max-width: 100%\n height: auto\n\narticle\n figure, .figure\n border-radius: 0.2rem\n\n margin: 0\n :last-child\n margin-bottom: 0\n\n .align-left\n float: left\n clear: left\n margin: 0 1rem 1rem\n\n .align-right\n float: right\n clear: right\n margin: 0 1rem 1rem\n\n .align-default,\n .align-center\n display: block\n text-align: center\n margin-left: auto\n margin-right: auto\n\n // WELL, table needs to be stylised like a table.\n table.align-default\n display: table\n text-align: initial\n",".genindex-jumpbox, .domainindex-jumpbox\n border-top: 1px solid var(--color-background-border)\n border-bottom: 1px solid var(--color-background-border)\n padding: 0.25rem\n\n.genindex-section, .domainindex-section\n h2\n margin-top: 0.75rem\n margin-bottom: 0.5rem\n ul\n margin-top: 0\n margin-bottom: 0\n","ul,\nol\n padding-left: 1.2rem\n\n // Space lists out like paragraphs\n margin-top: 1rem\n margin-bottom: 1rem\n // reduce margins within li.\n li\n > p:first-child\n margin-top: 0.25rem\n margin-bottom: 0.25rem\n\n > p:last-child\n margin-top: 0.25rem\n\n > ul,\n > ol\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n\nol\n &.arabic\n list-style: decimal\n &.loweralpha\n list-style: lower-alpha\n &.upperalpha\n list-style: upper-alpha\n &.lowerroman\n list-style: lower-roman\n &.upperroman\n list-style: upper-roman\n\n// Don't space lists out when they're \"simple\" or in a `.. toctree::`\n.simple,\n.toctree-wrapper\n li\n > ul,\n > ol\n margin-top: 0\n margin-bottom: 0\n\n// Definition Lists\n.field-list,\n.option-list,\ndl:not([class]),\ndl.simple,\ndl.footnote,\ndl.glossary\n dt\n font-weight: 500\n margin-top: 0.25rem\n + dt\n margin-top: 0\n\n .classifier::before\n content: \":\"\n margin-left: 0.2rem\n margin-right: 0.2rem\n\n dd\n > p:first-child,\n ul\n margin-top: 0.125rem\n\n ul\n margin-bottom: 0.125rem\n",".math-wrapper\n width: 100%\n overflow-x: auto\n\ndiv.math\n position: relative\n text-align: center\n\n .headerlink,\n &:focus .headerlink\n display: none\n\n &:hover .headerlink\n display: inline-block\n\n span.eqno\n position: absolute\n right: 0.5rem\n top: 50%\n transform: translate(0, -50%)\n z-index: 1\n","// Abbreviations\nabbr[title]\n cursor: help\n\n// \"Problematic\" content, as identified by Sphinx\n.problematic\n color: var(--color-problematic)\n\n// Keyboard / Mouse \"instructions\"\nkbd:not(.compound)\n margin: 0 0.2rem\n padding: 0 0.2rem\n border-radius: 0.2rem\n border: 1px solid var(--color-foreground-border)\n color: var(--color-foreground-primary)\n vertical-align: text-bottom\n\n font-size: var(--font-size--small--3)\n display: inline-block\n\n box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.2), inset 0 0 0 0.125rem var(--color-background-primary)\n\n background-color: var(--color-background-secondary)\n\n// Blockquote\nblockquote\n border-left: 4px solid var(--color-background-border)\n background: var(--color-background-secondary)\n\n margin-left: 0\n margin-right: 0\n padding: 0.5rem 1rem\n\n .attribution\n font-weight: 600\n text-align: right\n\n &.pull-quote,\n &.highlights\n font-size: 1.25em\n\n &.epigraph,\n &.pull-quote\n border-left-width: 0\n border-radius: 0.5rem\n\n &.highlights\n border-left-width: 0\n background: transparent\n\n// Center align embedded-in-text images\np .reference img\n vertical-align: middle\n","p.rubric\n line-height: 1.25\n font-weight: bold\n font-size: 1.125em\n","article .sidebar\n float: right\n clear: right\n width: 30%\n\n margin-left: 1rem\n margin-right: 0\n\n border-radius: 0.2rem\n background-color: var(--color-background-secondary)\n border: var(--color-background-border) 1px solid\n\n > *\n padding-left: 1rem\n padding-right: 1rem\n\n > ul, > ol // lists need additional padding, because bullets.\n padding-left: 2.2rem\n\n .sidebar-title\n margin: 0\n padding: 0.5rem 1rem\n border-bottom: var(--color-background-border) 1px solid\n\n font-weight: 500\n\n// TODO: subtitle\n// TODO: dedicated variables?\n",".table-wrapper\n width: 100%\n overflow-x: auto\n margin-top: 1rem\n margin-bottom: 0.5rem\n padding: 0.2rem 0.2rem 0.75rem\n\ntable.docutils\n border-radius: 0.2rem\n border-spacing: 0\n border-collapse: collapse\n\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n th\n background: var(--color-table-header-background)\n\n td,\n th\n // Space things out properly\n padding: 0 0.25rem\n\n // Get the borders looking just-right.\n border-left: 1px solid var(--color-table-border)\n border-right: 1px solid var(--color-table-border)\n border-bottom: 1px solid var(--color-table-border)\n\n p\n margin: 0.25rem\n\n &:first-child\n border-left: none\n &:last-child\n border-right: none\n",":target\n scroll-margin-top: 0.5rem\n\n@media (max-width: $full-width - $sidebar-width)\n :target\n scroll-margin-top: calc(0.5rem + var(--header-height))\n\n // When a heading is selected\n section > span:target\n scroll-margin-top: calc(0.8rem + var(--header-height))\n\n// Permalinks\n.headerlink\n font-weight: 100\n user-select: none\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\ndl dt,\np.caption,\nfigcaption p,\ntable > caption,\n.code-block-caption\n > .headerlink\n margin-left: 0.5rem\n visibility: hidden\n &:hover > .headerlink\n visibility: visible\n\n // Don't change to link-like, if someone adds the contents directive.\n > .toc-backref\n color: inherit\n text-decoration-line: none\n\n// Figure and table captions are special.\nfigure:hover > figcaption > p > .headerlink,\ntable:hover > caption > .headerlink\n visibility: visible\n\n:target >, // Regular section[id] style anchors\nspan:target ~ // Non-regular span[id] style \"extra\" anchors\n h1,\n h2,\n h3,\n h4,\n h5,\n h6\n &:nth-of-type(1)\n background-color: var(--color-highlight-on-target)\n // .headerlink\n // visibility: visible\n code.literal\n background-color: transparent\n\ntable:target > caption,\nfigure:target\n background-color: var(--color-highlight-on-target)\n\n// Inline page contents\n.this-will-duplicate-information-and-it-is-still-useful-here li :target\n background-color: var(--color-highlight-on-target)\n\n// Code block permalinks\n.literal-block-wrapper:target .code-block-caption\n background-color: var(--color-highlight-on-target)\n\n// When a definition list item is selected\n//\n// There isn't really an alternative to !important here, due to the\n// high-specificity of API documentation's selector.\ndt:target\n background-color: var(--color-highlight-on-target) !important\n\n// When a footnote reference is selected\n.footnote > dt:target + dd,\n.footnote-reference:target\n background-color: var(--color-highlight-on-target)\n",".guilabel\n background-color: var(--color-guilabel-background)\n border: 1px solid var(--color-guilabel-border)\n color: var(--color-guilabel-text)\n\n padding: 0 0.3em\n border-radius: 0.5em\n font-size: 0.9em\n","// This file contains the styles used for stylizing the footer that's shown\n// below the content.\n\nfooter\n font-size: var(--font-size--small)\n display: flex\n flex-direction: column\n\n margin-top: 2rem\n\n// Bottom of page information\n.bottom-of-page\n display: flex\n align-items: center\n justify-content: space-between\n\n margin-top: 1rem\n padding-top: 1rem\n padding-bottom: 1rem\n\n color: var(--color-foreground-secondary)\n border-top: 1px solid var(--color-background-border)\n\n line-height: 1.5\n\n @media (max-width: $content-width)\n text-align: center\n flex-direction: column-reverse\n gap: 0.25rem\n\n .left-details\n font-size: var(--font-size--small)\n\n .right-details\n display: flex\n flex-direction: column\n gap: 0.25rem\n text-align: right\n\n .icons\n display: flex\n justify-content: flex-end\n gap: 0.25rem\n font-size: 1rem\n\n a\n text-decoration: none\n\n svg,\n img\n font-size: 1.125rem\n height: 1em\n width: 1em\n\n// Next/Prev page information\n.related-pages\n a\n display: flex\n align-items: center\n\n text-decoration: none\n &:hover .page-info .title\n text-decoration: underline\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n svg,\n svg > use\n flex-shrink: 0\n\n color: var(--color-foreground-border)\n\n width: 0.75rem\n height: 0.75rem\n margin: 0 0.5rem\n\n &.next-page\n max-width: 50%\n\n float: right\n clear: right\n text-align: right\n\n &.prev-page\n max-width: 50%\n\n float: left\n clear: left\n\n svg\n transform: rotate(180deg)\n\n.page-info\n display: flex\n flex-direction: column\n overflow-wrap: anywhere\n\n .next-page &\n align-items: flex-end\n\n .context\n display: flex\n align-items: center\n\n padding-bottom: 0.1rem\n\n color: var(--color-foreground-muted)\n font-size: var(--font-size--small)\n text-decoration: none\n","//\n// Search Page Listing\n//\nul.search\n padding-left: 0\n list-style: none\n\n li\n padding: 1rem 0\n border-bottom: 1px solid var(--color-background-border)\n\n//\n// Highlighted by links in search page\n//\n[role=main] .highlighted\n background-color: var(--color-highlighted-background)\n color: var(--color-highlighted-text)\n","// This file contains the styles for the contents of the left sidebar, which\n// contains the navigation tree, logo, search etc.\n\n////////////////////////////////////////////////////////////////////////////////\n// Brand on top of the scrollable tree.\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-brand\n display: flex\n flex-direction: column\n flex-shrink: 0\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n text-decoration: none\n\n.sidebar-brand-text\n color: var(--color-sidebar-brand-text)\n overflow-wrap: break-word\n margin: var(--sidebar-item-spacing-vertical) 0\n font-size: 1.5rem\n\n.sidebar-logo-container\n margin: var(--sidebar-item-spacing-vertical) 0\n\n.sidebar-logo\n margin: 0 auto\n display: block\n max-width: 100%\n\n////////////////////////////////////////////////////////////////////////////////\n// Search\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-search-container\n display: flex\n align-items: center\n margin-top: var(--sidebar-search-space-above)\n\n position: relative\n\n background: var(--color-sidebar-search-background)\n &:hover,\n &:focus-within\n background: var(--color-sidebar-search-background--focus)\n\n &::before\n content: \"\"\n position: absolute\n left: var(--sidebar-item-spacing-horizontal)\n width: var(--sidebar-search-icon-size)\n height: var(--sidebar-search-icon-size)\n\n background-color: var(--color-sidebar-search-icon)\n mask-image: var(--icon-search)\n\n.sidebar-search\n box-sizing: border-box\n\n border: none\n border-top: 1px solid var(--color-sidebar-search-border)\n border-bottom: 1px solid var(--color-sidebar-search-border)\n\n padding-top: var(--sidebar-search-input-spacing-vertical)\n padding-bottom: var(--sidebar-search-input-spacing-vertical)\n padding-right: var(--sidebar-search-input-spacing-horizontal)\n padding-left: calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size))\n\n width: 100%\n\n color: var(--color-sidebar-search-foreground)\n background: transparent\n z-index: 10\n\n &:focus\n outline: none\n\n &::placeholder\n font-size: var(--sidebar-search-input-font-size)\n\n//\n// Hide Search Matches link\n//\n#searchbox .highlight-link\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0\n margin: 0\n text-align: center\n\n a\n color: var(--color-sidebar-search-icon)\n font-size: var(--font-size--small--2)\n\n////////////////////////////////////////////////////////////////////////////////\n// Structure/Skeleton of the navigation tree (left)\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-tree\n font-size: var(--sidebar-item-font-size)\n margin-top: var(--sidebar-tree-space-above)\n margin-bottom: var(--sidebar-item-spacing-vertical)\n\n ul\n padding: 0\n margin-top: 0\n margin-bottom: 0\n\n display: flex\n flex-direction: column\n\n list-style: none\n\n li\n position: relative\n margin: 0\n\n > ul\n margin-left: var(--sidebar-item-spacing-horizontal)\n\n .icon\n color: var(--color-sidebar-link-text)\n\n .reference\n box-sizing: border-box\n color: var(--color-sidebar-link-text)\n\n // Fill the parent.\n display: inline-block\n line-height: var(--sidebar-item-line-height)\n text-decoration: none\n\n // Don't allow long words to cause wrapping.\n overflow-wrap: anywhere\n\n height: 100%\n width: 100%\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n &:hover\n background: var(--color-sidebar-item-background--hover)\n\n // Add a nice little \"external-link\" arrow here.\n &.external::after\n content: url('data:image/svg+xml,')\n margin: 0 0.25rem\n vertical-align: middle\n color: var(--color-sidebar-link-text)\n\n // Make the current page reference bold.\n .current-page > .reference\n font-weight: bold\n\n label\n position: absolute\n top: 0\n right: 0\n height: var(--sidebar-item-height)\n width: var(--sidebar-expander-width)\n\n cursor: pointer\n\n display: flex\n justify-content: center\n align-items: center\n\n .caption, :not(.caption) > .caption-text\n font-size: var(--sidebar-caption-font-size)\n color: var(--color-sidebar-caption-text)\n\n font-weight: bold\n text-transform: uppercase\n\n margin: var(--sidebar-caption-space-above) 0 0 0\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n // If it has children, add a bit more padding to wrap the content to avoid\n // overlapping with the