Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64fd852535 | ||
|
|
3fbfc55e84 | ||
|
|
49317582c4 | ||
|
|
5ea01df69b | ||
|
|
4a9f8a9ef5 | ||
|
|
49adff1f3b | ||
|
|
377e165be4 | ||
|
|
07da8031c6 | ||
|
|
be363b9727 | ||
|
|
870a59a2fa | ||
|
|
500cf71f7e | ||
|
|
821e338b75 | ||
|
|
987c91a9ff | ||
|
|
233942c9b6 | ||
|
|
a0ab64a841 | ||
|
|
0cd8f32893 | ||
|
|
904acbc576 | ||
|
|
37dc023fcb | ||
|
|
876ff17e3f | ||
|
|
130df1a767 | ||
|
|
5d7dea3fc3 | ||
|
|
ca8397bc97 | ||
|
|
91023ac8ec | ||
|
|
0ad59e9e29 | ||
|
|
42c551de8a | ||
|
|
62d49a7138 | ||
|
|
bc5cd93e97 | ||
|
|
7bd1ba8075 | ||
|
|
64bb07a026 |
3
.isort.cfg
Normal file
3
.isort.cfg
Normal file
@@ -0,0 +1,3 @@
|
||||
[settings]
|
||||
profile=black
|
||||
multi_line_output=3
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -4,6 +4,75 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.42.45](https://github.com/RhetTbull/osxphotos/compare/v0.42.44...v0.42.45)
|
||||
|
||||
> 20 June 2021
|
||||
|
||||
- Implemented --query-function, #430 [`07da803`](https://github.com/RhetTbull/osxphotos/commit/07da8031c63487eb42cb3e524f20971e6d2fc929)
|
||||
- Added query function [skip ci] [`be363b9`](https://github.com/RhetTbull/osxphotos/commit/be363b9727d6fca6e747b0d952cd3252ddfe6e3b)
|
||||
- Updated README.md [skip ci] [`377e165`](https://github.com/RhetTbull/osxphotos/commit/377e165be48b84c7678ca2f86fc2ffdcbcb93736)
|
||||
|
||||
#### [v0.42.44](https://github.com/RhetTbull/osxphotos/compare/v0.42.43...v0.42.44)
|
||||
|
||||
> 20 June 2021
|
||||
|
||||
- Added --location, --no-location, #474 [`870a59a`](https://github.com/RhetTbull/osxphotos/commit/870a59a2fa10766361b384216594af36d3605850)
|
||||
|
||||
#### [v0.42.43](https://github.com/RhetTbull/osxphotos/compare/v0.42.42...v0.42.43)
|
||||
|
||||
> 20 June 2021
|
||||
|
||||
- Implemented --post-function, #442 [`987c91a`](https://github.com/RhetTbull/osxphotos/commit/987c91a9ff4b9936d479d7d238a5e5b842265dec)
|
||||
- Added post_function.py [`233942c`](https://github.com/RhetTbull/osxphotos/commit/233942c9b6836fb6fa9907e9264ec3513322930b)
|
||||
- Fixed function names to work around Click.runner issue [`821e338`](https://github.com/RhetTbull/osxphotos/commit/821e338b7575c6e053b8d3d958c481dfa62a00bc)
|
||||
|
||||
#### [v0.42.42](https://github.com/RhetTbull/osxphotos/compare/v0.42.41...v0.42.42)
|
||||
|
||||
> 19 June 2021
|
||||
|
||||
- Bug fix for --download-missing, #456 [`0cd8f32`](https://github.com/RhetTbull/osxphotos/commit/0cd8f32893046b679ea6280822f4dba5aa7de1fd)
|
||||
- Updated README.md [skip ci] [`37dc023`](https://github.com/RhetTbull/osxphotos/commit/37dc023fcbfddca8abd2b72119138d72e0bfed53)
|
||||
- Added isort cfg to match black [`904acbc`](https://github.com/RhetTbull/osxphotos/commit/904acbc576b27d7d05d770e061a6c01a439b8fad)
|
||||
|
||||
#### [v0.42.41](https://github.com/RhetTbull/osxphotos/compare/v0.42.40...v0.42.41)
|
||||
|
||||
> 19 June 2021
|
||||
|
||||
- Added repl command to CLI; closes #472 [`#472`](https://github.com/RhetTbull/osxphotos/issues/472)
|
||||
- Updated README.md [skip ci] [`130df1a`](https://github.com/RhetTbull/osxphotos/commit/130df1a76794f77bc0e8f148185c6407d6b480bc)
|
||||
|
||||
#### [v0.42.40](https://github.com/RhetTbull/osxphotos/compare/v0.42.39...v0.42.40)
|
||||
|
||||
> 19 June 2021
|
||||
|
||||
- Added tutorial, closes #432 [`#432`](https://github.com/RhetTbull/osxphotos/issues/432)
|
||||
|
||||
#### [v0.42.39](https://github.com/RhetTbull/osxphotos/compare/v0.42.38...v0.42.39)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Updated help text, #469 [`42c551d`](https://github.com/RhetTbull/osxphotos/commit/42c551de8a1e6f682c04b6071c1147eb8039ed3a)
|
||||
|
||||
#### [v0.42.38](https://github.com/RhetTbull/osxphotos/compare/v0.42.37...v0.42.38)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Added error handling for --add-to-album [`bc5cd93`](https://github.com/RhetTbull/osxphotos/commit/bc5cd93e974214e2327d604ff92b3c6b6ce62f04)
|
||||
- Updated README.md [skip ci] [`62d49a7`](https://github.com/RhetTbull/osxphotos/commit/62d49a7138971c43625e55518f069b1b36b787ff)
|
||||
|
||||
#### [v0.42.37](https://github.com/RhetTbull/osxphotos/compare/v0.42.36...v0.42.37)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Added additional info to error message for --add-to-album [`64bb07a`](https://github.com/RhetTbull/osxphotos/commit/64bb07a0267f2fdd024a7150fe1788b07218ac2f)
|
||||
|
||||
#### [v0.42.36](https://github.com/RhetTbull/osxphotos/compare/v0.42.35...v0.42.36)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
- Fix for #471 [`8e3f8fc`](https://github.com/RhetTbull/osxphotos/commit/8e3f8fc7d089b644b85e8e52fe220519133d2bea)
|
||||
- Updated README.md [skip ci] [`f1902b7`](https://github.com/RhetTbull/osxphotos/commit/f1902b7fd4d22c47bcf9fd101b077bbbabb71a9a)
|
||||
|
||||
#### [v0.42.35](https://github.com/RhetTbull/osxphotos/compare/v0.42.34...v0.42.35)
|
||||
|
||||
> 18 June 2021
|
||||
|
||||
79
README.md
79
README.md
@@ -50,11 +50,14 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Big Sur (10.16/11.1).
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Big Sur (10.16/11.3).
|
||||
|
||||
If you have access to the macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please visit the [Discussions](https://github.com/RhetTbull/osxphotos/discussions) page and let me know!
|
||||
|
||||
| macOS Version | macOS name | Photos.app version |
|
||||
| ----------------- |------------|:-------------------|
|
||||
| 10.16, 11.0-11.3 | Big Sur | 6.0 ✅ |
|
||||
| 12.0 | Monterey | ?.0 UNKNOWN |
|
||||
| 10.16, 11.0-11.4 | Big Sur | 6.0 ✅ |
|
||||
| 10.15.1 - 10.15.7 | Catalina | 5.0 ✅ |
|
||||
| 10.14.5, 10.14.6 | Mojave | 4.0 ✅ |
|
||||
| 10.13.6 | High Sierra| 3.0 ✅ |
|
||||
@@ -118,6 +121,7 @@ This package will install a command line utility called `osxphotos` that allows
|
||||
|
||||
```
|
||||
> osxphotos
|
||||
|
||||
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Options:
|
||||
@@ -146,6 +150,8 @@ Commands:
|
||||
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 shell
|
||||
tutorial Display osxphotos tutorial.
|
||||
```
|
||||
|
||||
To get help on a specific command, use `osxphotos help <command_name>`
|
||||
@@ -610,6 +616,10 @@ Options:
|
||||
geolocation info
|
||||
--no-place Search for photos with no associated place
|
||||
name info (no reverse geolocation info)
|
||||
--location Search for photos with associated location
|
||||
info (e.g. GPS coordinates)
|
||||
--no-location Search for photos with no associated location
|
||||
info (e.g. no GPS coordinates)
|
||||
--label LABEL Search for photos with image classification
|
||||
label LABEL (Photos 5 only). If more than one
|
||||
label, treated as "OR", e.g. find photos
|
||||
@@ -723,6 +733,22 @@ Options:
|
||||
https://rhettbull.github.io/osxphotos/ for
|
||||
additional documentation on the PhotoInfo
|
||||
class.
|
||||
--query-function filename.py::function
|
||||
Run function to filter photos. Use this in
|
||||
format: --query-function filename.py::function
|
||||
where filename.py is a python file you've
|
||||
created and function is the name of the
|
||||
function in the python file you want to call.
|
||||
Your function will be passed a list of
|
||||
PhotoInfo objects and is expected to return a
|
||||
filtered list of PhotoInfo objects. You may
|
||||
use more than one function by repeating the
|
||||
--query-function option with a different
|
||||
value. Your query function will be called
|
||||
after all other query options have been
|
||||
evaluated. See https://github.com/RhetTbull/os
|
||||
xphotos/blob/master/examples/query_function.py
|
||||
for example of how to use this option.
|
||||
--missing Export only photos missing from the Photos
|
||||
library; must be used with --download-missing.
|
||||
--deleted Include photos from the 'Recently Deleted'
|
||||
@@ -784,19 +810,25 @@ Options:
|
||||
the library if a photo is a burst photo.
|
||||
--skip-live Do not export the associated live video
|
||||
component of a live photo.
|
||||
--skip-raw Do not export associated raw images of a
|
||||
RAW+JPEG pair. Note: this does not skip raw
|
||||
photos if the raw photo does not have an
|
||||
associated jpeg image (e.g. the raw file was
|
||||
imported to Photos without a jpeg preview).
|
||||
--skip-raw Do not export associated RAW image of a
|
||||
RAW+JPEG pair. Note: this does not skip RAW
|
||||
photos if the RAW photo does not have an
|
||||
associated JPEG image (e.g. the RAW file was
|
||||
imported to Photos without a JPEG preview).
|
||||
--current-name Use photo's current filename instead of
|
||||
original filename for export. Note: Starting
|
||||
with Photos 5, all photos are renamed upon
|
||||
import. By default, photos are exported with
|
||||
the the original name they had before import.
|
||||
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
|
||||
PNG, etc) to JPEG upon export. Only works if
|
||||
your Mac has a GPU.
|
||||
--convert-to-jpeg Convert all non-JPEG images (e.g. RAW, HEIC,
|
||||
PNG, etc) to JPEG upon export. Note: does not
|
||||
convert the RAW component of a RAW+JPEG pair
|
||||
as the associated JPEG image will be exported.
|
||||
You can use --skip-raw to skip exporting the
|
||||
associated RAW image of a RAW+JPEG pair. See
|
||||
also --jpeg-quality and --jpeg-ext. Only works
|
||||
if your Mac has a GPU (thus may not work on
|
||||
virtual machines).
|
||||
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
|
||||
--convert-to-jpeg. A value of 1.0 specifies
|
||||
best quality, a value of 0.0 specifies maximum
|
||||
@@ -1061,6 +1093,18 @@ Options:
|
||||
command by repeating the '--post-command'
|
||||
option with different arguments. See Post
|
||||
Command below.
|
||||
--post-function filename.py::function
|
||||
Run function on exported files. Use this in
|
||||
format: --post-function filename.py::function
|
||||
where filename.py is a python file you've
|
||||
created and function is the name of the
|
||||
function in the python file you want to call.
|
||||
The function will be passed information about
|
||||
the photo that's been exported and a list of
|
||||
all exported files associated with the photo.
|
||||
You can run more than one function by
|
||||
repeating the '--post-function' option with
|
||||
different arguments. See Post Function below.
|
||||
--exportdb EXPORTDB_FILE Specify alternate name for database file which
|
||||
stores state information for export and
|
||||
--update. If --exportdb is not specified,
|
||||
@@ -1559,7 +1603,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.42.36'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.46'
|
||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
@@ -1717,6 +1761,17 @@ to ensure your commands are as expected. This will not actually run the commands
|
||||
but will print out the exact command string which would be executed.
|
||||
|
||||
|
||||
** Post Function **
|
||||
You can run your own python functions on the exported photos for post-processing
|
||||
using the '--post-function' option. '--post-function' is passed the name a
|
||||
python file and the name of the function in the file to call using format
|
||||
'filename.py::function_name'. See the example function at
|
||||
https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py You
|
||||
may specify multiple functions to run by repeating the --post-function option.
|
||||
All post functions will be called immediately after export of each photo and
|
||||
immediately before any --post-command commands. Post functions will not be
|
||||
called if the --dry-run flag is set.
|
||||
|
||||
|
||||
|
||||
```
|
||||
@@ -3347,7 +3402,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.42.36'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.46'|
|
||||
|{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|
|
||||
|
||||
@@ -18,6 +18,8 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -108,6 +110,8 @@ Alternatively, you can also run the command line utility like this: ``python3 -m
|
||||
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 shell
|
||||
tutorial Display osxphotos tutorial.
|
||||
|
||||
To get help on a specific command, use ``osxphotos help <command_name>``
|
||||
|
||||
|
||||
54
examples/post_function.py
Normal file
54
examples/post_function.py
Normal file
@@ -0,0 +1,54 @@
|
||||
""" Example function for use with osxphotos export --post-function option """
|
||||
|
||||
from osxphotos import PhotoInfo, ExportResults
|
||||
|
||||
|
||||
def post_function(
|
||||
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
|
||||
):
|
||||
"""Call this with osxphotos export /path/to/export --post-function post_function.py::post_function
|
||||
This will get called immediately after the photo has been exported
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo instance for the photo that's just been exported
|
||||
results: ExportResults instance with information about the files associated with the exported photo
|
||||
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
|
||||
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
|
||||
|
||||
Notes:
|
||||
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
|
||||
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
|
||||
Will not be called if --dry-run flag is enabled
|
||||
Will be called immediately after export and before any --post-command commands are executed
|
||||
"""
|
||||
|
||||
# ExportResults has the following properties
|
||||
# fields with filenames contain the full path to the file
|
||||
# exported: list of all files exported
|
||||
# new: list of all new files exported (--update)
|
||||
# updated: list of all files updated (--update)
|
||||
# skipped: list of all files skipped (--update)
|
||||
# exif_updated: list of all files that were updated with --exiftool
|
||||
# touched: list of all files that had date updated with --touch-file
|
||||
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
|
||||
# sidecar_json_written: list of all JSON sidecar files written
|
||||
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
|
||||
# sidecar_exiftool_written: list of all exiftool sidecar files written
|
||||
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
|
||||
# sidecar_xmp_written: list of all XMP sidecar files written
|
||||
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
|
||||
# missing: list of all missing files
|
||||
# error: list tuples of (filename, error) for any errors generated during export
|
||||
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
|
||||
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
|
||||
# xattr_written: list of files that had extended attributes written
|
||||
# xattr_skipped: list of files that where extended attributes were skipped (--update)
|
||||
# deleted_files: list of deleted files
|
||||
# deleted_directories: list of deleted directories
|
||||
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
|
||||
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
|
||||
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
|
||||
|
||||
for filename in results.exported:
|
||||
# do your processing here
|
||||
verbose(f"post_function: {photo.original_filename} exported as {filename}")
|
||||
31
examples/query_function.py
Normal file
31
examples/query_function.py
Normal file
@@ -0,0 +1,31 @@
|
||||
""" example function for osxphotos --query-function """
|
||||
|
||||
from typing import List
|
||||
|
||||
from osxphotos import PhotoInfo
|
||||
|
||||
|
||||
# call this with --query-function examples/query_function.py::best_selfies
|
||||
def best_selfies(photos: List[PhotoInfo]) -> List[PhotoInfo]:
|
||||
"""your query function should take a list of PhotoInfo objects and return a list of PhotoInfo objects (or empty list)"""
|
||||
# this example finds your best selfie for every year
|
||||
|
||||
# get list of selfies sorted by date
|
||||
photos = sorted([p for p in photos if p.selfie], key=lambda p: p.date)
|
||||
if not photos:
|
||||
return []
|
||||
|
||||
start_year = photos[0].date.year
|
||||
stop_year = photos[-1].date.year
|
||||
best_selfies = []
|
||||
for year in range(start_year, stop_year + 1):
|
||||
# find best selfie each year as determined by overall aesthetic score
|
||||
selfies = sorted(
|
||||
[p for p in photos if p.date.year == year],
|
||||
key=lambda p: p.score.overall,
|
||||
reverse=True,
|
||||
)
|
||||
if selfies:
|
||||
best_selfies.append(selfies[0])
|
||||
|
||||
return best_selfies
|
||||
@@ -8,41 +8,50 @@ import importlib
|
||||
pathex = os.getcwd()
|
||||
|
||||
# include necessary data files
|
||||
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates'), ('osxphotos/templates/xmp_sidecar_beta.mako', 'osxphotos/templates'), ('osxphotos/phototemplate.tx', 'osxphotos'), ('osxphotos/phototemplate.md', 'osxphotos')]
|
||||
package_imports = [['photoscript', ['photoscript.applescript']]]
|
||||
datas = [
|
||||
("osxphotos/templates/xmp_sidecar.mako", "osxphotos/templates"),
|
||||
("osxphotos/templates/xmp_sidecar_beta.mako", "osxphotos/templates"),
|
||||
("osxphotos/phototemplate.tx", "osxphotos"),
|
||||
("osxphotos/phototemplate.md", "osxphotos"),
|
||||
("osxphotos/tutorial.md", "osxphotos"),
|
||||
]
|
||||
package_imports = [["photoscript", ["photoscript.applescript"]]]
|
||||
for package, files in package_imports:
|
||||
proot = os.path.dirname(importlib.import_module(package).__file__)
|
||||
datas.extend((os.path.join(proot, f), package) for f in files)
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(['cli.py'],
|
||||
pathex=[pathex],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=['pkg_resources.py2_warn'],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
a = Analysis(
|
||||
["cli.py"],
|
||||
pathex=[pathex],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=["pkg_resources.py2_warn"],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='osxphotos',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True )
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="osxphotos",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
)
|
||||
|
||||
@@ -71,6 +71,7 @@ _TESTED_OS_VERSIONS = [
|
||||
("11", "1"),
|
||||
("11", "2"),
|
||||
("11", "3"),
|
||||
("11", "4"),
|
||||
]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.36"
|
||||
__version__ = "0.42.46"
|
||||
|
||||
201
osxphotos/cli.py
201
osxphotos/cli.py
@@ -1,5 +1,6 @@
|
||||
"""Command line interface for osxphotos """
|
||||
|
||||
import code
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
@@ -16,6 +17,7 @@ import bitmath
|
||||
import click
|
||||
import osxmetadata
|
||||
import yaml
|
||||
from rich import pretty
|
||||
|
||||
import osxphotos
|
||||
|
||||
@@ -39,7 +41,7 @@ from ._constants import (
|
||||
SIDECAR_XMP,
|
||||
)
|
||||
from ._version import __version__
|
||||
from .cli_help import ExportCommand
|
||||
from .cli_help import ExportCommand, tutorial_help
|
||||
from .configoptions import (
|
||||
ConfigOptions,
|
||||
ConfigOptionsInvalidError,
|
||||
@@ -55,7 +57,7 @@ from .photokit import check_photokit_authorization, request_photokit_authorizati
|
||||
from .photosalbum import PhotosAlbum
|
||||
from .phototemplate import PhotoTemplate, RenderOptions
|
||||
from .queryoptions import QueryOptions
|
||||
from .utils import get_preferred_uti_extension
|
||||
from .utils import get_preferred_uti_extension, load_function, expand_and_validate_filepath
|
||||
|
||||
# global variable to control verbose output
|
||||
# set via --verbose/-V
|
||||
@@ -118,7 +120,7 @@ class DateTimeISO8601(click.ParamType):
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
except Exception:
|
||||
self.fail(
|
||||
f"Invalid value for --{param.name}: invalid datetime format {value}. "
|
||||
f"Invalid datetime format {value}. "
|
||||
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
|
||||
)
|
||||
|
||||
@@ -152,12 +154,36 @@ class TimeISO8601(click.ParamType):
|
||||
return datetime.time.fromisoformat(value).replace(tzinfo=None)
|
||||
except Exception:
|
||||
self.fail(
|
||||
f"Invalid value for --{param.name}: invalid time format {value}. "
|
||||
f"Invalid time format {value}. "
|
||||
"Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] "
|
||||
"however, note that timezone will be ignored."
|
||||
)
|
||||
|
||||
|
||||
class FunctionCall(click.ParamType):
|
||||
name = "FUNCTION"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
if "::" not in value:
|
||||
self.fail(
|
||||
f"Could not parse function name from '{value}'. "
|
||||
"Valid format filename.py::function"
|
||||
)
|
||||
|
||||
filename, funcname = value.split("::")
|
||||
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
self.fail(f"'{filename}' does not appear to be a file")
|
||||
|
||||
try:
|
||||
function = load_function(filename_validated, funcname)
|
||||
except Exception as e:
|
||||
self.fail(f"Could not load function {funcname} from {filename_validated}")
|
||||
|
||||
return (function, value)
|
||||
|
||||
|
||||
# Click CLI object & context settings
|
||||
class CLI_Obj:
|
||||
def __init__(self, db=None, json=False, debug=False):
|
||||
@@ -307,6 +333,16 @@ def QUERY_OPTIONS(f):
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated place name info (no reverse geolocation info)",
|
||||
),
|
||||
o(
|
||||
"--location",
|
||||
is_flag=True,
|
||||
help="Search for photos with associated location info (e.g. GPS coordinates)",
|
||||
),
|
||||
o(
|
||||
"--no-location",
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated location info (e.g. no GPS coordinates)",
|
||||
),
|
||||
o(
|
||||
"--label",
|
||||
metavar="LABEL",
|
||||
@@ -508,6 +544,18 @@ def QUERY_OPTIONS(f):
|
||||
"CRITERIA must be a valid python expression. "
|
||||
"See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
|
||||
),
|
||||
o(
|
||||
"--query-function",
|
||||
metavar="filename.py::function",
|
||||
multiple=True,
|
||||
type=FunctionCall(),
|
||||
help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python "
|
||||
+ "file you've created and function is the name of the function in the python file you want to call. "
|
||||
+ "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. "
|
||||
+ "You may use more than one function by repeating the --query-function option with a different value. "
|
||||
+ "Your query function will be called after all other query options have been evaluated. "
|
||||
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.",
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
@@ -623,9 +671,9 @@ def cli(ctx, db, json_, debug):
|
||||
@click.option(
|
||||
"--skip-raw",
|
||||
is_flag=True,
|
||||
help="Do not export associated raw images of a RAW+JPEG pair. "
|
||||
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
|
||||
"(e.g. the raw file was imported to Photos without a jpeg preview).",
|
||||
help="Do not export associated RAW image of a RAW+JPEG pair. "
|
||||
"Note: this does not skip RAW photos if the RAW photo does not have an associated JPEG image "
|
||||
"(e.g. the RAW file was imported to Photos without a JPEG preview).",
|
||||
)
|
||||
@click.option(
|
||||
"--current-name",
|
||||
@@ -637,8 +685,11 @@ def cli(ctx, db, json_, debug):
|
||||
@click.option(
|
||||
"--convert-to-jpeg",
|
||||
is_flag=True,
|
||||
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
|
||||
"to JPEG upon export. Only works if your Mac has a GPU.",
|
||||
help="Convert all non-JPEG images (e.g. RAW, HEIC, PNG, etc) to JPEG upon export. "
|
||||
"Note: does not convert the RAW component of a RAW+JPEG pair as the associated JPEG image "
|
||||
"will be exported. You can use --skip-raw to skip exporting the associated RAW image of "
|
||||
"a RAW+JPEG pair. See also --jpeg-quality and --jpeg-ext. "
|
||||
"Only works if your Mac has a GPU (thus may not work on virtual machines).",
|
||||
)
|
||||
@click.option(
|
||||
"--jpeg-quality",
|
||||
@@ -926,6 +977,18 @@ def cli(ctx, db, json_, debug):
|
||||
"You can run more than one command by repeating the '--post-command' option with different arguments. "
|
||||
"See Post Command below.",
|
||||
)
|
||||
@click.option(
|
||||
"--post-function",
|
||||
metavar="filename.py::function",
|
||||
nargs=1,
|
||||
type=FunctionCall(),
|
||||
multiple=True,
|
||||
help="Run function on exported files. Use this in format: --post-function filename.py::function where filename.py is a python "
|
||||
"file you've created and function is the name of the function in the python file you want to call. The function will be "
|
||||
"passed information about the photo that's been exported and a list of all exported files associated with the photo. "
|
||||
"You can run more than one function by repeating the '--post-function' option with different arguments. "
|
||||
"See Post Function below.",
|
||||
)
|
||||
@click.option(
|
||||
"--exportdb",
|
||||
metavar="EXPORTDB_FILE",
|
||||
@@ -1064,6 +1127,8 @@ def export(
|
||||
original_suffix,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
has_comment,
|
||||
no_comment,
|
||||
has_likes,
|
||||
@@ -1089,8 +1154,10 @@ def export(
|
||||
max_size,
|
||||
regex,
|
||||
query_eval,
|
||||
query_function,
|
||||
duplicate,
|
||||
post_command,
|
||||
post_function,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1222,6 +1289,8 @@ def export(
|
||||
original_suffix = cfg.original_suffix
|
||||
place = cfg.place
|
||||
no_place = cfg.no_place
|
||||
location = cfg.location
|
||||
no_location = cfg.no_location
|
||||
has_comment = cfg.has_comment
|
||||
no_comment = cfg.no_comment
|
||||
has_likes = cfg.has_likes
|
||||
@@ -1245,8 +1314,10 @@ def export(
|
||||
max_size = cfg.max_size
|
||||
regex = cfg.regex
|
||||
query_eval = cfg.query_eval
|
||||
query_function = cfg.query_function
|
||||
duplicate = cfg.duplicate
|
||||
post_command = cfg.post_command
|
||||
post_function = cfg.post_function
|
||||
|
||||
# config file might have changed verbose
|
||||
VERBOSE = bool(verbose)
|
||||
@@ -1281,6 +1352,7 @@ def export(
|
||||
("has_comment", "no_comment"),
|
||||
("has_likes", "no_likes"),
|
||||
("in_album", "not_in_album"),
|
||||
("location", "no_location"),
|
||||
]
|
||||
dependent_options = [
|
||||
("missing", ("download_missing", "use_photos_export")),
|
||||
@@ -1534,6 +1606,8 @@ def export(
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
location=location,
|
||||
no_location=no_location,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
@@ -1552,6 +1626,7 @@ def export(
|
||||
max_size=max_size,
|
||||
regex=regex,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
|
||||
@@ -1650,6 +1725,20 @@ def export(
|
||||
export_dir=dest,
|
||||
)
|
||||
|
||||
if post_function:
|
||||
for function in post_function:
|
||||
# post function is tuple of (function, filename.py::function_name)
|
||||
verbose_(f"Calling post-function {function[1]}")
|
||||
if not dry_run:
|
||||
try:
|
||||
function[0](p, export_results, verbose_)
|
||||
except Exception as e:
|
||||
click.secho(
|
||||
f"Error running post-function {function[1]}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
err=True,
|
||||
)
|
||||
|
||||
run_post_command(
|
||||
photo=p,
|
||||
post_command=post_command,
|
||||
@@ -1923,6 +2012,8 @@ def query(
|
||||
has_raw,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
@@ -1938,6 +2029,7 @@ def query(
|
||||
max_size,
|
||||
regex,
|
||||
query_eval,
|
||||
query_function,
|
||||
add_to_album,
|
||||
):
|
||||
"""Query the Photos database using 1 or more search options;
|
||||
@@ -1966,6 +2058,7 @@ def query(
|
||||
label,
|
||||
is_reference,
|
||||
query_eval,
|
||||
query_function,
|
||||
min_size,
|
||||
max_size,
|
||||
regex,
|
||||
@@ -1995,6 +2088,7 @@ def query(
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
(in_album, not_in_album),
|
||||
(location, no_location),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if any(all(bb) for bb in exclusive) or not any(
|
||||
@@ -2080,6 +2174,8 @@ def query(
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
location=location,
|
||||
no_location=no_location,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
@@ -2094,6 +2190,7 @@ def query(
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
regex=regex,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
@@ -2124,7 +2221,7 @@ def query(
|
||||
album_query.add_list(photos)
|
||||
except Exception as e:
|
||||
click.secho(
|
||||
f"Error adding photos to album {add_to_album}",
|
||||
f"Error adding photos to album {add_to_album}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
err=True,
|
||||
)
|
||||
@@ -3756,3 +3853,87 @@ SOFTWARE.
|
||||
click.echo("")
|
||||
click.echo(f"Source code available at: {OSXPHOTOS_URL}")
|
||||
click.echo(license)
|
||||
|
||||
|
||||
@cli.command(name="tutorial")
|
||||
@click.argument(
|
||||
"WIDTH",
|
||||
nargs=-1,
|
||||
type=click.INT,
|
||||
)
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def tutorial(ctx, cli_obj, width):
|
||||
"""Display osxphotos tutorial."""
|
||||
width = width[0] if width else 100
|
||||
click.echo_via_pager(tutorial_help(width=width))
|
||||
|
||||
|
||||
def _show_photo(photo):
|
||||
"""open image with default image viewer
|
||||
|
||||
Note: This is for debugging only -- it will actually open any filetype which could
|
||||
be very, very bad.
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo object or a path to a photo on disk
|
||||
"""
|
||||
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
return f"'{photopath}' does not appear to be a valid photo path"
|
||||
|
||||
os.system(f"open '{photopath}'")
|
||||
|
||||
|
||||
def _load_photos_db(dbpath):
|
||||
print("Loading database")
|
||||
tic = time.perf_counter()
|
||||
photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print)
|
||||
toc = time.perf_counter()
|
||||
tictoc = toc - tic
|
||||
print(f"Done: took {tictoc:0.2f} seconds")
|
||||
return photosdb
|
||||
|
||||
|
||||
def _get_photos(photosdb):
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
||||
return photos
|
||||
|
||||
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def repl(ctx, cli_obj, db):
|
||||
"""Run interactive osxphotos shell"""
|
||||
pretty.install()
|
||||
print(f"python version: {sys.version}")
|
||||
print(f"osxphotos version: {osxphotos._version.__version__}")
|
||||
db = db or get_photos_db()
|
||||
photosdb = _load_photos_db(db)
|
||||
print("Getting photos")
|
||||
tic = time.perf_counter()
|
||||
photos = _get_photos(photosdb)
|
||||
toc = time.perf_counter()
|
||||
tictoc = toc - tic
|
||||
|
||||
# shortcut for helper functions
|
||||
get_photo = photosdb.get_photo
|
||||
show = _show_photo
|
||||
|
||||
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds")
|
||||
print("The following variables are defined:")
|
||||
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
|
||||
print(
|
||||
f"- photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash"
|
||||
)
|
||||
print(f"\nThe following functions may be helpful:")
|
||||
print(f"- get_photo(uuid): return a PhotoInfo object for photo with uuid")
|
||||
print(f"- show(photo): open a photo object in the default viewer")
|
||||
print(
|
||||
f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
|
||||
)
|
||||
print(f"- quit(): exit this interactive shell\n")
|
||||
code.interact(banner="", local=locals())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Help text helper class for osxphotos CLI """
|
||||
|
||||
import io
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import click
|
||||
@@ -241,6 +242,19 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "print out the exact command string which would be executed."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Function **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write_text(
|
||||
"You can run your own python functions on the exported photos for post-processing "
|
||||
+ "using the '--post-function' option. '--post-function' is passed the name a python file "
|
||||
+ "and the name of the function in the file to call using format 'filename.py::function_name'. "
|
||||
+ "See the example function at https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py "
|
||||
+ "You may specify multiple functions to run by repeating the --post-function option. "
|
||||
+ "All post functions will be called immediately after export of each photo and immediately before any --post-command commands. "
|
||||
+ "Post functions will not be called if the --dry-run flag is set."
|
||||
)
|
||||
formatter.write("\n")
|
||||
|
||||
help_text += formatter.getvalue()
|
||||
return help_text
|
||||
@@ -250,13 +264,26 @@ def template_help(width=78):
|
||||
"""Return formatted string for template system"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
template_help_md = strip_md_links(get_template_help())
|
||||
template_help_md = strip_md_header_and_links(get_template_help())
|
||||
console.print(Markdown(template_help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
return help_str
|
||||
|
||||
|
||||
def tutorial_help(width=78):
|
||||
"""Return formatted string for tutorial"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
help_md = get_tutorial_text()
|
||||
help_md = strip_html_comments(help_md)
|
||||
help_md = strip_md_links(help_md)
|
||||
console.print(Markdown(help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
return help_str
|
||||
|
||||
|
||||
def rich_text(text, width=78):
|
||||
"""Return rich formatted text"""
|
||||
sio = io.StringIO()
|
||||
@@ -267,6 +294,26 @@ def rich_text(text, width=78):
|
||||
return rich_text
|
||||
|
||||
|
||||
def strip_md_header_and_links(md):
|
||||
"""strip markdown headers and links from markdown text md
|
||||
|
||||
Args:
|
||||
md: str, markdown text
|
||||
|
||||
Returns:
|
||||
str with markdown headers and links removed
|
||||
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
but works for the links in the osxphotos docs
|
||||
"""
|
||||
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
|
||||
|
||||
def subfn(match):
|
||||
return match.group(1)
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_md_links(md):
|
||||
"""strip markdown links from markdown text md
|
||||
|
||||
@@ -279,9 +326,23 @@ def strip_md_links(md):
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
but works for the links in the osxphotos docs
|
||||
"""
|
||||
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
|
||||
links = r"\[(.*?)\]\(.+?\)"
|
||||
|
||||
def subfn(match):
|
||||
return match.group(1)
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_html_comments(text):
|
||||
"""Strip html comments from text (which doesn't need to be valid HTML)"""
|
||||
return re.sub(r"<!--(.|\s|\n)*?-->", "", text)
|
||||
|
||||
|
||||
def get_tutorial_text():
|
||||
"""Load tutorial text from file"""
|
||||
# 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 / "tutorial.md"
|
||||
with open(help_file, "r") as fd:
|
||||
md = fd.read()
|
||||
return md
|
||||
|
||||
@@ -221,6 +221,7 @@ def _export_photo_uuid_applescript(
|
||||
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
|
||||
@@ -300,6 +301,8 @@ def _export_photo_uuid_applescript(
|
||||
# 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
|
||||
@@ -1194,6 +1197,7 @@ def _export_photo_with_photos_export(
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
@@ -1244,6 +1248,7 @@ def _export_photo_with_photos_export(
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
|
||||
@@ -29,7 +29,12 @@ class PhotosAlbum:
|
||||
)
|
||||
|
||||
def add_list(self, photo_list: List[PhotoInfo]):
|
||||
photos = [photoscript.Photo(p.uuid) for p in photo_list]
|
||||
photos = []
|
||||
for p in photo_list:
|
||||
try:
|
||||
photos.append(photoscript.Photo(p.uuid))
|
||||
except Exception as e:
|
||||
self.verbose(f"Error creating Photo object for photo {p.uuid}: {e}")
|
||||
for photolist in chunked(photos, 10):
|
||||
self.album.add(photolist)
|
||||
photo_len = len(photos)
|
||||
|
||||
@@ -3254,6 +3254,15 @@ class PhotosDB:
|
||||
if options.deleted_only:
|
||||
photos = [p for p in photos if p.intrash]
|
||||
|
||||
if options.location:
|
||||
photos = [p for p in photos if p.location != (None, None)]
|
||||
elif options.no_location:
|
||||
photos = [p for p in photos if p.location == (None, None)]
|
||||
|
||||
if options.function:
|
||||
for function in options.function:
|
||||
photos = function[0](photos)
|
||||
|
||||
return photos
|
||||
|
||||
def _duplicate_signature(self, uuid):
|
||||
|
||||
@@ -4,8 +4,10 @@ import datetime
|
||||
import locale
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import shlex
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from textx import TextXSyntaxError, metamodel_from_file
|
||||
|
||||
@@ -14,9 +16,7 @@ from ._version import __version__
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import ExifToolCaching
|
||||
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||
from .utils import load_function
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from .utils import expand_and_validate_filepath, load_function
|
||||
|
||||
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties
|
||||
|
||||
@@ -1177,10 +1177,11 @@ class PhotoTemplate:
|
||||
|
||||
filename, funcname = subfield.split("::")
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
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, funcname)
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
values = template_func(self.photo)
|
||||
|
||||
if not isinstance(values, (str, list)):
|
||||
@@ -1211,10 +1212,11 @@ class PhotoTemplate:
|
||||
|
||||
filename, funcname = filter_.split("::")
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
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, funcname)
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
|
||||
if not isinstance(values, (list, tuple)):
|
||||
values = [values]
|
||||
|
||||
@@ -63,7 +63,8 @@ SubField:
|
||||
;
|
||||
|
||||
SUBFIELD_WORD:
|
||||
/[\.\w:\/]+/
|
||||
/[\.\w:\/\-\~\'\"\%\@\#\^\’]+/
|
||||
/\\\s/?
|
||||
;
|
||||
|
||||
Filter:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
""" QueryOptions class for PhotosDB.query """
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional, Iterable, Tuple
|
||||
import datetime
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
import bitmath
|
||||
|
||||
|
||||
@@ -79,6 +80,9 @@ class QueryOptions:
|
||||
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
|
||||
|
||||
def asdict(self):
|
||||
return asdict(self)
|
||||
|
||||
@@ -38,7 +38,7 @@ if not _DEBUG:
|
||||
|
||||
def _get_logger():
|
||||
"""Used only for testing
|
||||
|
||||
|
||||
Returns:
|
||||
logging.Logger object -- logging.Logger object for osxphotos
|
||||
"""
|
||||
@@ -46,7 +46,7 @@ def _get_logger():
|
||||
|
||||
|
||||
def _set_debug(debug):
|
||||
""" Enable or disable debug logging """
|
||||
"""Enable or disable debug logging"""
|
||||
global _DEBUG
|
||||
_DEBUG = debug
|
||||
if debug:
|
||||
@@ -56,18 +56,18 @@ def _set_debug(debug):
|
||||
|
||||
|
||||
def _debug():
|
||||
""" returns True if debugging turned on (via _set_debug), otherwise, false """
|
||||
"""returns True if debugging turned on (via _set_debug), otherwise, false"""
|
||||
return _DEBUG
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
""" do nothing (no operation) """
|
||||
"""do nothing (no operation)"""
|
||||
pass
|
||||
|
||||
|
||||
def lineno(filename):
|
||||
""" Returns string with filename and current line number in caller as '(filename): line_num'
|
||||
Will trim filename to just the name, dropping path, if any. """
|
||||
"""Returns string with filename and current line number in caller as '(filename): line_num'
|
||||
Will trim filename to just the name, dropping path, if any."""
|
||||
line = inspect.currentframe().f_back.f_lineno
|
||||
filename = pathlib.Path(filename).name
|
||||
return f"{filename}: {line}"
|
||||
@@ -92,14 +92,14 @@ def _get_os_version():
|
||||
|
||||
|
||||
def _check_file_exists(filename):
|
||||
""" returns true if file exists and is not a directory
|
||||
otherwise returns false """
|
||||
"""returns true if file exists and is not a directory
|
||||
otherwise returns false"""
|
||||
filename = os.path.abspath(filename)
|
||||
return os.path.exists(filename) and not os.path.isdir(filename)
|
||||
|
||||
|
||||
def _get_resource_loc(model_id):
|
||||
""" returns folder_id and file_id needed to find location of edited photo """
|
||||
"""returns folder_id and file_id needed to find location of edited photo"""
|
||||
""" and live photos for version <= Photos 4.0 """
|
||||
# determine folder where Photos stores edited version
|
||||
# edited images are stored in:
|
||||
@@ -117,7 +117,7 @@ def _get_resource_loc(model_id):
|
||||
|
||||
|
||||
def _dd_to_dms(dd):
|
||||
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
|
||||
"""convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds"""
|
||||
""" return tuple of int(deg), int(min), float(sec) """
|
||||
dd = float(dd)
|
||||
negative = dd < 0
|
||||
@@ -136,7 +136,7 @@ def _dd_to_dms(dd):
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
"""convert latitude, longitude in degrees to degrees, minutes, seconds as string"""
|
||||
""" lat: latitude in degrees """
|
||||
""" lon: longitude in degrees """
|
||||
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
|
||||
@@ -165,7 +165,7 @@ def dd_to_dms_str(lat, lon):
|
||||
|
||||
|
||||
def get_system_library_path():
|
||||
""" return the path to the system Photos library as string """
|
||||
"""return the path to the system Photos library as string"""
|
||||
""" only works on MacOS 10.15 """
|
||||
""" on earlier versions, returns None """
|
||||
_, major, _ = _get_os_version()
|
||||
@@ -190,8 +190,8 @@ def get_system_library_path():
|
||||
|
||||
|
||||
def get_last_library_path():
|
||||
""" returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None """
|
||||
"""returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None"""
|
||||
plist_file = pathlib.Path(
|
||||
str(pathlib.Path.home())
|
||||
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
|
||||
@@ -241,7 +241,7 @@ def get_last_library_path():
|
||||
|
||||
|
||||
def list_photo_libraries():
|
||||
""" returns list of Photos libraries found on the system """
|
||||
"""returns list of Photos libraries found on the system"""
|
||||
""" on MacOS < 10.15, this may omit some libraries """
|
||||
|
||||
# On 10.15, mdfind appears to find all libraries
|
||||
@@ -266,9 +266,9 @@ def list_photo_libraries():
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
""" get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str or None if cannot be determined """
|
||||
"""get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str or None if cannot be determined"""
|
||||
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
with objc.autorelease_pool():
|
||||
@@ -288,8 +288,8 @@ def get_preferred_uti_extension(uti):
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
"""Returns list of filenames from path_ matched by pattern
|
||||
shell pattern. Matching is case-insensitive.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
shell pattern. Matching is case-insensitive.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
if not os.path.isdir(path_):
|
||||
return []
|
||||
# See: https://gist.github.com/techtonik/5694830
|
||||
@@ -316,8 +316,8 @@ def findfiles(pattern, path_):
|
||||
|
||||
|
||||
def _open_sql_file(dbname):
|
||||
""" opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor) """
|
||||
"""opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor)"""
|
||||
try:
|
||||
dbpath = pathlib.Path(dbname).resolve()
|
||||
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
|
||||
@@ -328,9 +328,9 @@ def _open_sql_file(dbname):
|
||||
|
||||
|
||||
def _db_is_locked(dbname):
|
||||
""" check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test """
|
||||
"""check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test"""
|
||||
|
||||
# first, check to see if lock file exists, if so, assume the file is locked
|
||||
lock_name = f"{dbname}.lock"
|
||||
@@ -381,7 +381,7 @@ def _db_is_locked(dbname):
|
||||
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
"""normalize unicode data"""
|
||||
if value is not None:
|
||||
if isinstance(value, (tuple, list)):
|
||||
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
|
||||
@@ -394,9 +394,9 @@ def normalize_unicode(value):
|
||||
|
||||
|
||||
def increment_filename(filepath):
|
||||
""" Return filename (1).ext, etc if filename.ext exists
|
||||
"""Return filename (1).ext, etc if filename.ext exists
|
||||
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
add (1), (2), etc. until a non-existing filename is found.
|
||||
|
||||
Args:
|
||||
@@ -419,8 +419,22 @@ def increment_filename(filepath):
|
||||
return str(dest)
|
||||
|
||||
|
||||
def expand_and_validate_filepath(path: str) -> str:
|
||||
"""validate and expand ~ in filepath, also un-escapes spaces
|
||||
|
||||
Returns:
|
||||
expanded path if path is valid file, else None
|
||||
"""
|
||||
|
||||
path = re.sub(r"\\ ", " ", path)
|
||||
path = pathlib.Path(path).expanduser()
|
||||
if path.is_file():
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
def load_function(pyfile: str, function_name: str) -> Callable:
|
||||
""" Load function_name from python file pyfile """
|
||||
"""Load function_name from python file pyfile"""
|
||||
module_file = pathlib.Path(pyfile)
|
||||
if not module_file.is_file():
|
||||
raise FileNotFoundError(f"module {pyfile} does not appear to exist")
|
||||
|
||||
3
tests/hyphen-dir/README.md
Normal file
3
tests/hyphen-dir/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Contents
|
||||
|
||||
This directory used by test_template.py for testing {function} templates with hyphenated directory names
|
||||
20
tests/hyphen-dir/template_function.py
Normal file
20
tests/hyphen-dir/template_function.py
Normal file
@@ -0,0 +1,20 @@
|
||||
""" Example showing how to use a custom function for osxphotos {function} template """
|
||||
|
||||
import pathlib
|
||||
from typing import List, Union
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def foo(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
|
||||
""" example function for {function} template
|
||||
|
||||
Args:
|
||||
photo: osxphotos.PhotoInfo object
|
||||
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
|
||||
|
||||
Returns:
|
||||
str or list of str of values that should be substituted for the {function} template
|
||||
"""
|
||||
|
||||
return photo.original_filename + "-FOO"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ VARS = {"foo": "bar", "bar": False, "test1": (), "test2": None, "test2_setting":
|
||||
def test_init():
|
||||
cfg = ConfigOptions("test", VARS)
|
||||
assert isinstance(cfg, ConfigOptions)
|
||||
assert cfg.foo is "bar"
|
||||
assert cfg.foo == "bar"
|
||||
assert cfg.bar == False
|
||||
assert type(cfg.test1) == tuple
|
||||
|
||||
|
||||
@@ -82,7 +82,9 @@ TEMPLATE_VALUES_TITLE = {
|
||||
"{title|titlecase}": ["Tulips Tied Together At A Flower Shop"],
|
||||
"{title|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
|
||||
"{title|titlecase|lower|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
|
||||
"{title|titlecase|lower|upper|shell_quote}": ["'TULIPS TIED TOGETHER AT A FLOWER SHOP'"],
|
||||
"{title|titlecase|lower|upper|shell_quote}": [
|
||||
"'TULIPS TIED TOGETHER AT A FLOWER SHOP'"
|
||||
],
|
||||
"{title|upper|titlecase}": ["Tulips Tied Together At A Flower Shop"],
|
||||
"{title|capitalize}": ["Tulips tied together at a flower shop"],
|
||||
"{title[ ,_]}": ["Tulips_tied_together_at_a_flower_shop"],
|
||||
@@ -388,7 +390,9 @@ def test_lookup_multi(photosdb_places):
|
||||
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
||||
if subst in ["{exiftool}", "{photo}", "{function}"]:
|
||||
continue
|
||||
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep, default=[])
|
||||
lookup = template.get_template_value_multi(
|
||||
lookup_str, path_sep=os.path.sep, default=[]
|
||||
)
|
||||
assert isinstance(lookup, list)
|
||||
|
||||
|
||||
@@ -975,6 +979,15 @@ def test_conditional(photosdb):
|
||||
assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template])
|
||||
|
||||
|
||||
def test_function_hyphen_dir(photosdb):
|
||||
"""Test {function} with a hyphenated directory (#477)"""
|
||||
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
|
||||
rendered, _ = photo.render_template(
|
||||
"{function:tests/hyphen-dir/template_function.py::foo}"
|
||||
)
|
||||
assert rendered == [f"{photo.original_filename}-FOO"]
|
||||
|
||||
|
||||
def test_function(photosdb):
|
||||
"""Test {function}"""
|
||||
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
|
||||
|
||||
@@ -22,7 +22,7 @@ from osxphotos.phototemplate import (
|
||||
)
|
||||
|
||||
TEMPLATE_HELP = "osxphotos/phototemplate.md"
|
||||
TUTORIAL_HELP = "docsrc/source/tutorial.md"
|
||||
TUTORIAL_HELP = "osxphotos/tutorial.md"
|
||||
|
||||
USAGE_START = (
|
||||
"<!-- OSXPHOTOS-EXPORT-USAGE:START - Do not remove or modify this section -->"
|
||||
|
||||
Reference in New Issue
Block a user