Compare commits

...

15 Commits

Author SHA1 Message Date
Rhet Turnbull
c8ea0b0452 Added re to photosdb for use with query_eval 2021-04-18 13:24:02 -07:00
Rhet Turnbull
81fd51c793 Cleaned up queryoptions.py 2021-04-18 09:07:24 -07:00
Rhet Turnbull
648d399524 Updated CHANGELOG.md, [skip ci] 2021-04-18 09:05:28 -07:00
Rhet Turnbull
345c052353 Refactored _query to PhotosDB.query() 2021-04-18 08:32:13 -07:00
Rhet Turnbull
952f1a6c3c Fixed setup.py 2021-04-17 17:58:15 -07:00
Rhet Turnbull
7ae5b8aae7 Added --min-size, --max-size query options, #425 2021-04-17 17:56:48 -07:00
Rhet Turnbull
2e189d771e Updated docs, added build.sh 2021-04-17 10:00:34 -07:00
Rhet Turnbull
7fa7de1563 Added {newline}, #426 2021-04-17 09:29:11 -07:00
Rhet Turnbull
70d68a25ba Updated docs, closes #424 2021-04-17 03:03:23 -07:00
Rhet Turnbull
bfc4371d9e Updated CHANGELOG.md, [skip ci] 2021-04-17 02:57:55 -07:00
Rhet Turnbull
6a288676a1 Fixed bug for multi-field templates and --xattr-template, #422 2021-04-17 02:41:29 -07:00
Rhet Turnbull
874ad2fa34 Add @ubrandes as a contributor 2021-04-15 06:46:10 -07:00
Rhet Turnbull
a233167471 Updated CHANGELOG.md, [skip ci] 2021-04-14 22:17:00 -07:00
Rhet Turnbull
21dc0d388f Added {function} template, #419 2021-04-14 22:00:04 -07:00
Rhet Turnbull
eff8e7a63f Added template_function.py to examples 2021-04-14 20:20:46 -07:00
33 changed files with 1083 additions and 479 deletions

View File

@@ -193,6 +193,15 @@
"contributions": [
"code"
]
},
{
"login": "ubrandes",
"name": "ubrandes ",
"avatar_url": "https://avatars.githubusercontent.com/u/59647284?v=4",
"profile": "https://github.com/ubrandes",
"contributions": [
"ideas"
]
}
],
"contributorsPerLine": 7,

View File

@@ -4,6 +4,36 @@ 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.4](https://github.com/RhetTbull/osxphotos/compare/v0.42.3...v0.42.4)
> 18 April 2021
- Added --min-size, --max-size query options, #425 [`7ae5b8a`](https://github.com/RhetTbull/osxphotos/commit/7ae5b8aae78621c5b7501f9faa5e0f7f4d815ba1)
- Updated docs, added build.sh [`2e189d7`](https://github.com/RhetTbull/osxphotos/commit/2e189d771edaf18c1ebffd558e3e84e43bff2f08)
- Fixed setup.py [`952f1a6`](https://github.com/RhetTbull/osxphotos/commit/952f1a6c3c3f3c7a55c0a270e73a13c4da6d2375)
#### [v0.42.3](https://github.com/RhetTbull/osxphotos/compare/v0.42.2...v0.42.3)
> 17 April 2021
- Updated docs, closes #424 [`#424`](https://github.com/RhetTbull/osxphotos/issues/424)
- Added {newline}, #426 [`7fa7de1`](https://github.com/RhetTbull/osxphotos/commit/7fa7de15631958a973514fe1a9c2cbf4301b6301)
#### [v0.42.2](https://github.com/RhetTbull/osxphotos/compare/v0.42.1...v0.42.2)
> 17 April 2021
- Fixed bug for multi-field templates and --xattr-template, #422 [`6a28867`](https://github.com/RhetTbull/osxphotos/commit/6a288676a14ce23380181d43db19128afdda7731)
- Add @ubrandes as a contributor [`874ad2f`](https://github.com/RhetTbull/osxphotos/commit/874ad2fa34d8306c071cd479625a9aa97f6488b2)
#### [v0.42.1](https://github.com/RhetTbull/osxphotos/compare/v0.41.11...v0.42.1)
> 15 April 2021
- Implements conditional expressions for template system, #417 [`03f8b2b`](https://github.com/RhetTbull/osxphotos/commit/03f8b2bc6ed53d3176f9d1ac51c3e4469db3e94b)
- Added {function} template, #419 [`21dc0d3`](https://github.com/RhetTbull/osxphotos/commit/21dc0d388f508c33526ba7510d78c71abd1151a9)
- Added template_function.py to examples [`eff8e7a`](https://github.com/RhetTbull/osxphotos/commit/eff8e7a63ff77e80fff0ce53fe56f5a010f55ab5)
#### [v0.41.11](https://github.com/RhetTbull/osxphotos/compare/v0.41.10...v0.41.11)
> 12 April 2021

View File

@@ -4,7 +4,7 @@
[![tests](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/osxphotos)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-20-orange.svg?style=flat)](#contributors)
[![All Contributors](https://img.shields.io/badge/all_contributors-21-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. 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.
@@ -315,6 +315,22 @@ Options:
albums.
--not-in-album Search for photos that are not in any albums.
--min-size SIZE Search for photos with size >= SIZE bytes. The
size evaluated is the photo's original size
(when imported to Photos). Size may be
specified as integer bytes or using SI or NIST
units. For example, the following are all
valid and equivalent sizes: '1048576'
'1.048576MB', '1 MiB'.
--max-size SIZE Search for photos with size <= SIZE bytes. The
size evaluated is the photo's original size
(when imported to Photos). Size may be
specified as integer bytes or using SI or NIST
units. For example, the following are all
valid and equivalent sizes: '1048576'
'1.048576MB', '1 MiB'.
--query-eval CRITERIA Evaluate CRITERIA to filter photos. CRITERIA
will be evaluated in context of the following
python list comprehension: `photos = [photo
@@ -858,7 +874,7 @@ e.g. If Photo is in Album1 in Folder1:
• "{folder_album(>)}" renders to ["Folder1>Album1"]
• "{folder_album()}" renders to ["Folder1Album1"]
[find|replace]: optional text replacement to perform on rendered template value.
[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:
@@ -1219,6 +1235,10 @@ Substitution Description
{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'
The following substitutions may result in multiple values. Thus if specified for
--directory these could result in multiple copies of a photo being being
@@ -1236,7 +1256,11 @@ Substitution Description
{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)
(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:
@@ -1281,6 +1305,16 @@ Substitution Description
https://rhettbull.github.io/osxphotos/ for additional
documentation on the PhotoInfo class.
{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.
```
@@ -2213,7 +2247,7 @@ e.g. If Photo is in `Album1` in `Folder1`:
- `"{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.
`[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:
@@ -2879,11 +2913,15 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{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'|
|{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|
|{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)|
|{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.|
@@ -2892,6 +2930,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{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.|
|{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.|
<!-- OSXPHOTOS-TEMPLATE-TABLE:END -->
### Utility Functions
@@ -3026,6 +3065,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/davidjroos"><img src="https://avatars.githubusercontent.com/u/15630844?v=4?s=75" width="75px;" alt=""/><br /><sub><b>davidjroos </b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=davidjroos" title="Documentation">📖</a></td>
<td align="center"><a href="https://neilpa.me"><img src="https://avatars.githubusercontent.com/u/42419?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Neil Pankey</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=neilpa" title="Code">💻</a></td>
<td align="center"><a href="https://aaronweb.net/"><img src="https://avatars.githubusercontent.com/u/604665?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Aaron van Geffen</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=AaronVanGeffen" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ubrandes"><img src="https://avatars.githubusercontent.com/u/59647284?v=4?s=75" width="75px;" alt=""/><br /><sub><b>ubrandes </b></sub></a><br /><a href="#ideas-ubrandes" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr>
</table>

11
build.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
# script to help build osxphotos release
# this is unique to my own dev setup
activate osxphotos
rm -rf dist; rm -rf build
python3 utils/update_readme.py
(cd docsrc && make github && make pdf)
python3 setup.py sdist bdist_wheel
./make_cli_exe.sh

View File

@@ -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: 3a4409f9ef528dee2e4ce25074f0e795
config: 041dede95604f7f8f5de01377fc1b414
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.42.00 documentation</title>
<title>Overview: module code &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.42.00',
VERSION: '0.42.4',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.00 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
@@ -495,6 +495,18 @@ to modify this behavior.</p>
<dd><p>Search for photos that are not in any albums.</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-export-min-size">
<code class="sig-name descname"><span class="pre">--min-size</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;SIZE&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-export-min-size" title="Permalink to this definition"></a></dt>
<dd><p>Search for photos with size &gt;= SIZE bytes. The size evaluated is the photos original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: 1048576 1.048576MB, 1 MiB.</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-export-max-size">
<code class="sig-name descname"><span class="pre">--max-size</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;SIZE&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-export-max-size" title="Permalink to this definition"></a></dt>
<dd><p>Search for photos with size &lt;= SIZE bytes. The size evaluated is the photos original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: 1048576 1.048576MB, 1 MiB.</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-export-query-eval">
<code class="sig-name descname"><span class="pre">--query-eval</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;CRITERIA&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-export-query-eval" title="Permalink to this definition"></a></dt>
@@ -1347,6 +1359,18 @@ if more than one option is provided, they are treated as “AND”
<dd><p>Search for photos that are not in any albums.</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-query-min-size">
<code class="sig-name descname"><span class="pre">--min-size</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;SIZE&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-query-min-size" title="Permalink to this definition"></a></dt>
<dd><p>Search for photos with size &gt;= SIZE bytes. The size evaluated is the photos original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: 1048576 1.048576MB, 1 MiB.</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-query-max-size">
<code class="sig-name descname"><span class="pre">--max-size</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;SIZE&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-query-max-size" title="Permalink to this definition"></a></dt>
<dd><p>Search for photos with size &lt;= SIZE bytes. The size evaluated is the photos original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: 1048576 1.048576MB, 1 MiB.</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-query-query-eval">
<code class="sig-name descname"><span class="pre">--query-eval</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;CRITERIA&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-query-query-eval" title="Permalink to this definition"></a></dt>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.42.00 documentation</title>
<title>Index &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
@@ -509,6 +509,24 @@
<ul>
<li><a href="cli.html#cmdoption-osxphotos-export-load-config">osxphotos-export command line option</a>
</li>
</ul></li>
<li>
--max-size &lt;SIZE&gt;
<ul>
<li><a href="cli.html#cmdoption-osxphotos-export-max-size">osxphotos-export command line option</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-max-size">osxphotos-query command line option</a>
</li>
</ul></li>
<li>
--min-size &lt;SIZE&gt;
<ul>
<li><a href="cli.html#cmdoption-osxphotos-export-min-size">osxphotos-export command line option</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-min-size">osxphotos-query command line option</a>
</li>
</ul></li>
<li>
@@ -547,6 +565,8 @@
<li><a href="cli.html#cmdoption-osxphotos-query-no-description">osxphotos-query command line option</a>
</li>
</ul></li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li>
--no-likes
@@ -556,8 +576,6 @@
<li><a href="cli.html#cmdoption-osxphotos-query-no-likes">osxphotos-query command line option</a>
</li>
</ul></li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li>
--no-place
@@ -1555,6 +1573,10 @@
<li><a href="cli.html#cmdoption-osxphotos-export-live">--live</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-export-load-config">--load-config &lt;config file path&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-export-max-size">--max-size &lt;SIZE&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-export-min-size">--min-size &lt;SIZE&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-export-missing">--missing</a>
</li>
@@ -1811,6 +1833,10 @@
<li><a href="cli.html#cmdoption-osxphotos-query-label">--label &lt;LABEL&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-live">--live</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-max-size">--max-size &lt;SIZE&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-min-size">--min-size &lt;SIZE&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-missing">--missing</a>
</li>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.00 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos &#8212; osxphotos 0.42.00 documentation</title>
<title>osxphotos &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

Binary file not shown.

Binary file not shown.

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos package &#8212; osxphotos 0.42.00 documentation</title>
<title>osxphotos package &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.42.00 documentation</title>
<title>Search &#8212; osxphotos 0.42.4 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
""" Example showing how to use a custom function for osxphotos {function} template
Use: osxphotos export /path/to/export --filename "{function:/path/to/template_function.py::example}"
You may place more than one template function in a single file as each is called by name using the {function:file.py::function_name} format
"""
import pathlib
from typing import List, Union
import osxphotos
def example(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
""" example function for {function} template; adds suffix of # if photo has adjustments and ! if photo is a favorite
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
"""
filename = pathlib.Path(photo.original_filename).stem
if photo.hasadjustments:
filename += "#"
if photo.favorite:
filename += "!"
return filename

View File

@@ -3,6 +3,7 @@ from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate
from .queryoptions import QueryOptions
from .utils import _debug, _get_logger, _set_debug
# TODO: Add test for imageTimeZoneOffsetSeconds = None

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.00"
__version__ = "0.42.6"

View File

@@ -11,6 +11,7 @@ import sys
import time
import unicodedata
import bitmath
import click
import osxmetadata
import yaml
@@ -50,6 +51,7 @@ from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .queryoptions import QueryOptions
from .utils import get_preferred_uti_extension
# global variable to control verbose output
@@ -71,19 +73,6 @@ def verbose_(*args, **kwargs):
click.echo(*styled_args, **kwargs)
def normalize_unicode(value):
""" normalize unicode data """
if value is not None:
if isinstance(value, tuple):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
elif isinstance(value, str):
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
return value
else:
return None
def get_photos_db(*db_options):
"""Return path to photos db, select first non-None db_options
If no db_options are non-None, try to find library to use in
@@ -131,6 +120,26 @@ class DateTimeISO8601(click.ParamType):
)
class BitMathSize(click.ParamType):
name = "BITMATH"
def convert(self, value, param, ctx):
try:
value = bitmath.parse_string(value)
except ValueError:
# no units specified
try:
value = int(value)
value = bitmath.Byte(value)
except ValueError as e:
self.fail(
f"{value} must be specified as bytes or using SI/NIST units. "
+ "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'."
)
return value
class TimeISO8601(click.ParamType):
name = "TIME"
@@ -202,7 +211,7 @@ def deleted_options(f):
return f
def query_options(f):
def QUERY_OPTIONS(f):
o = click.option
options = [
o(
@@ -447,6 +456,24 @@ def query_options(f):
is_flag=True,
help="Search for photos that are not in any albums.",
),
o(
"--min-size",
metavar="SIZE",
type=BitMathSize(),
help="Search for photos with size >= SIZE bytes. "
"The size evaluated is the photo's original size (when imported to Photos). "
"Size may be specified as integer bytes or using SI or NIST units. "
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
),
o(
"--max-size",
metavar="SIZE",
type=BitMathSize(),
help="Search for photos with size <= SIZE bytes. "
"The size evaluated is the photo's original size (when imported to Photos). "
"Size may be specified as integer bytes or using SI or NIST units. "
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
),
o(
"--query-eval",
metavar="CRITERIA",
@@ -480,7 +507,7 @@ def cli(ctx, db, json_, debug):
@cli.command(cls=ExportCommand)
@DB_OPTION
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
@query_options
@QUERY_OPTIONS
@click.option(
"--missing",
is_flag=True,
@@ -998,6 +1025,8 @@ def export(
beta,
in_album,
not_in_album,
min_size,
max_size,
query_eval,
):
"""Export photos from the Photos database.
@@ -1146,6 +1175,8 @@ def export(
only_new = cfg.only_new
in_album = cfg.in_album
not_in_album = cfg.not_in_album
min_size = cfg.min_size
max_size = cfg.max_size
query_eval = cfg.query_eval
# config file might have changed verbose
@@ -1295,11 +1326,11 @@ def export(
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
verbose_(f"exiftool path: {exiftool_path}")
isphoto = ismovie = True # default searches for everything
photos = movies = True # default searches for everything
if only_movies:
isphoto = False
photos = False
if only_photos:
ismovie = False
movies = False
# load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file:
@@ -1381,8 +1412,7 @@ def export(
# enable beta features if requested
photosdb._beta = beta
photos = _query(
photosdb=photosdb,
query_options = QueryOptions(
keyword=keyword,
person=person,
album=album,
@@ -1403,8 +1433,8 @@ def export(
not_missing=None,
shared=shared,
not_shared=not_shared,
isphoto=isphoto,
ismovie=ismovie,
photos=photos,
movies=movies,
uti=uti,
burst=burst,
not_burst=not_burst,
@@ -1449,9 +1479,22 @@ def export(
# skip missing bursts if using --download-missing by itself as AppleScript otherwise causes errors
missing_bursts=(download_missing and use_photokit) or not download_missing,
name=name,
min_size=min_size,
max_size=max_size,
query_eval=query_eval,
)
try:
photos = photosdb.query(query_options)
except ValueError as e:
if "Invalid query_eval CRITERIA:" in str(e):
msg = str(e).split(":")[1]
raise click.BadOptionUsage(
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
)
else:
raise ValueError(e)
if photos:
if only_new:
# ignore previously exported files
@@ -1634,7 +1677,7 @@ def help(ctx, topic, **kw):
@cli.command()
@DB_OPTION
@JSON_OPTION
@query_options
@QUERY_OPTIONS
@deleted_options
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
@click.option(
@@ -1735,6 +1778,8 @@ def query(
is_reference,
in_album,
not_in_album,
min_size,
max_size,
query_eval,
):
"""Query the Photos database using 1 or more search options;
@@ -1763,6 +1808,8 @@ def query(
label,
is_reference,
query_eval,
min_size,
max_size,
]
exclusive = [
(favorite, not_favorite),
@@ -1798,11 +1845,11 @@ def query(
return
# actually have something to query
isphoto = ismovie = True # default searches for everything
photos = movies = True # default searches for everything
if only_movies:
isphoto = False
photos = False
if only_photos:
ismovie = False
movies = False
# load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file:
@@ -1820,8 +1867,7 @@ def query(
return
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
photos = _query(
photosdb=photosdb,
query_options = QueryOptions(
keyword=keyword,
person=person,
album=album,
@@ -1842,8 +1888,8 @@ def query(
not_missing=not_missing,
shared=shared,
not_shared=not_shared,
isphoto=isphoto,
ismovie=ismovie,
photos=photos,
movies=movies,
uti=uti,
burst=burst,
not_burst=not_burst,
@@ -1885,9 +1931,22 @@ def query(
in_album=in_album,
not_in_album=not_in_album,
name=name,
min_size=min_size,
max_size=max_size,
query_eval=query_eval,
)
try:
photos = photosdb.query(query_options)
except ValueError as e:
if "Invalid query_eval CRITERIA:" in str(e):
msg = str(e).split(":")[1]
raise click.BadOptionUsage(
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
)
else:
raise ValueError(e)
# below needed for to make CliRunner work for testing
cli_json = cli_obj.json if cli_obj is not None else None
print_photo_info(photos, cli_json or json_)
@@ -1997,409 +2056,6 @@ def print_photo_info(photos, json=False):
csv_writer.writerow(row)
def _query(
photosdb,
keyword=None,
person=None,
album=None,
folder=None,
uuid=None,
title=None,
no_title=None,
description=None,
no_description=None,
ignore_case=None,
edited=None,
external_edit=None,
favorite=None,
not_favorite=None,
hidden=None,
not_hidden=None,
missing=None,
not_missing=None,
shared=None,
not_shared=None,
isphoto=None,
ismovie=None,
uti=None,
burst=None,
not_burst=None,
live=None,
not_live=None,
cloudasset=None,
not_cloudasset=None,
incloud=None,
not_incloud=None,
from_date=None,
to_date=None,
from_time=None,
to_time=None,
portrait=None,
not_portrait=None,
screenshot=None,
not_screenshot=None,
slow_mo=None,
not_slow_mo=None,
time_lapse=None,
not_time_lapse=None,
hdr=None,
not_hdr=None,
selfie=None,
not_selfie=None,
panorama=None,
not_panorama=None,
has_raw=None,
place=None,
no_place=None,
label=None,
deleted=False,
deleted_only=False,
has_comment=False,
no_comment=False,
has_likes=False,
no_likes=False,
is_reference=False,
in_album=False,
not_in_album=False,
burst_photos=None,
missing_bursts=None,
name=None,
query_eval=None,
):
"""Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands
Args:
photosdb: PhotosDB object
"""
if deleted or deleted_only:
photos = photosdb.photos(
uuid=uuid,
images=isphoto,
movies=ismovie,
from_date=from_date,
to_date=to_date,
intrash=True,
)
else:
photos = []
if not deleted_only:
photos += photosdb.photos(
uuid=uuid,
images=isphoto,
movies=ismovie,
from_date=from_date,
to_date=to_date,
)
person = normalize_unicode(person)
keyword = normalize_unicode(keyword)
album = normalize_unicode(album)
folder = normalize_unicode(folder)
title = normalize_unicode(title)
description = normalize_unicode(description)
place = normalize_unicode(place)
label = normalize_unicode(label)
if album:
photos = get_photos_by_attribute(photos, "albums", album, ignore_case)
if keyword:
photos = get_photos_by_attribute(photos, "keywords", keyword, ignore_case)
if person:
photos = get_photos_by_attribute(photos, "persons", person, ignore_case)
if label:
photos = get_photos_by_attribute(photos, "labels", label, ignore_case)
if folder:
# search for photos in an album in folder
# finds photos that have albums whose top level folder matches folder
photo_list = []
for f in folder:
photo_list.extend(
[
p
for p in photos
if p.album_info
and f in [a.folder_names[0] for a in p.album_info if a.folder_names]
]
)
photos = photo_list
if title:
# search title field for text
# if more than one, find photos with all title values in title
photo_list = []
if ignore_case:
# case-insensitive
for t in title:
t = t.lower()
photo_list.extend(
[p for p in photos if p.title and t in p.title.lower()]
)
else:
for t in title:
photo_list.extend([p for p in photos if p.title and t in p.title])
photos = photo_list
elif no_title:
photos = [p for p in photos if not p.title]
if description:
# search description field for text
# if more than one, find photos with all description values in description
photo_list = []
if ignore_case:
# case-insensitive
for d in description:
d = d.lower()
photo_list.extend(
[p for p in photos if p.description and d in p.description.lower()]
)
else:
for d in description:
photo_list.extend(
[p for p in photos if p.description and d in p.description]
)
photos = photo_list
elif no_description:
photos = [p for p in photos if not p.description]
if place:
# search place.names for text matching place
# if more than one place, find photos with all place values in description
if ignore_case:
# case-insensitive
for place_name in place:
place_name = place_name.lower()
photos = [
p
for p in photos
if p.place
and any(
pname
for pname in p.place.names
if any(
pvalue for pvalue in pname if place_name in pvalue.lower()
)
)
]
else:
for place_name in place:
photos = [
p
for p in photos
if p.place
and any(
pname
for pname in p.place.names
if any(pvalue for pvalue in pname if place_name in pvalue)
)
]
elif no_place:
photos = [p for p in photos if not p.place]
if edited:
photos = [p for p in photos if p.hasadjustments]
if external_edit:
photos = [p for p in photos if p.external_edit]
if favorite:
photos = [p for p in photos if p.favorite]
elif not_favorite:
photos = [p for p in photos if not p.favorite]
if hidden:
photos = [p for p in photos if p.hidden]
elif not_hidden:
photos = [p for p in photos if not p.hidden]
if missing:
photos = [p for p in photos if not p.path]
elif not_missing:
photos = [p for p in photos if p.path]
if shared:
photos = [p for p in photos if p.shared]
elif not_shared:
photos = [p for p in photos if not p.shared]
if shared:
photos = [p for p in photos if p.shared]
elif not_shared:
photos = [p for p in photos if not p.shared]
if uti:
photos = [p for p in photos if uti in p.uti_original]
if burst:
photos = [p for p in photos if p.burst]
elif not_burst:
photos = [p for p in photos if not p.burst]
if live:
photos = [p for p in photos if p.live_photo]
elif not_live:
photos = [p for p in photos if not p.live_photo]
if portrait:
photos = [p for p in photos if p.portrait]
elif not_portrait:
photos = [p for p in photos if not p.portrait]
if screenshot:
photos = [p for p in photos if p.screenshot]
elif not_screenshot:
photos = [p for p in photos if not p.screenshot]
if slow_mo:
photos = [p for p in photos if p.slow_mo]
elif not_slow_mo:
photos = [p for p in photos if not p.slow_mo]
if time_lapse:
photos = [p for p in photos if p.time_lapse]
elif not_time_lapse:
photos = [p for p in photos if not p.time_lapse]
if hdr:
photos = [p for p in photos if p.hdr]
elif not_hdr:
photos = [p for p in photos if not p.hdr]
if selfie:
photos = [p for p in photos if p.selfie]
elif not_selfie:
photos = [p for p in photos if not p.selfie]
if panorama:
photos = [p for p in photos if p.panorama]
elif not_panorama:
photos = [p for p in photos if not p.panorama]
if cloudasset:
photos = [p for p in photos if p.iscloudasset]
elif not_cloudasset:
photos = [p for p in photos if not p.iscloudasset]
if incloud:
photos = [p for p in photos if p.incloud]
elif not_incloud:
photos = [p for p in photos if not p.incloud]
if has_raw:
photos = [p for p in photos if p.has_raw]
if has_comment:
photos = [p for p in photos if p.comments]
elif no_comment:
photos = [p for p in photos if not p.comments]
if has_likes:
photos = [p for p in photos if p.likes]
elif no_likes:
photos = [p for p in photos if not p.likes]
if is_reference:
photos = [p for p in photos if p.isreference]
if in_album:
photos = [p for p in photos if p.albums]
elif not_in_album:
photos = [p for p in photos if not p.albums]
if from_time:
photos = [p for p in photos if p.date.time() >= from_time]
if to_time:
photos = [p for p in photos if p.date.time() <= to_time]
if burst_photos:
# add the burst_photos to the export set
photos_burst = [p for p in photos if p.burst]
for burst in photos_burst:
if missing_bursts:
# include burst photos that are missing
photos.extend(burst.burst_photos)
else:
# don't include missing burst images (these can't be downloaded with AppleScript)
photos.extend([p for p in burst.burst_photos if not p.ismissing])
# remove duplicates as each burst photo in the set that's selected would
# result in the entire set being added above
# can't use set() because PhotoInfo not hashable
seen_uuids = {}
for p in photos:
if p.uuid in seen_uuids:
continue
seen_uuids[p.uuid] = p
photos = list(seen_uuids.values())
if name:
# search filename fields for text
# if more than one, find photos with all title values in filename
photo_list = []
if ignore_case:
# case-insensitive
for n in name:
n = n.lower()
photo_list.extend(
[
p
for p in photos
if n in p.filename.lower() or n in p.original_filename.lower()
]
)
else:
for n in name:
photo_list.extend(
[p for p in photos if n in p.filename or n in p.original_filename]
)
photos = photo_list
if query_eval:
for q in query_eval:
query_string = f"[photo for photo in photos if {q}]"
try:
photos = eval(query_string)
except Exception as e:
raise click.BadOptionUsage(
"query_eval", f"Invalid query-eval CRITERIA: {e}"
)
return photos
def get_photos_by_attribute(photos, attribute, values, ignore_case):
"""Search for photos based on values being in PhotoInfo.attribute
Args:
photos: a list of PhotoInfo objects
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
values: list of values to search in property
ignore_case: ignore case when searching
Returns:
list of PhotoInfo objects matching search criteria
"""
photos_search = []
if ignore_case:
# case-insensitive
for x in values:
x = x.lower()
photos_search.extend(
p
for p in photos
if x in [attr.lower() for attr in getattr(p, attribute)]
)
else:
for x in values:
photos_search.extend(p for p in photos if x in getattr(p, attribute))
return photos_search
def export_photo(
photo=None,
dest=None,
@@ -3307,7 +2963,7 @@ def write_finder_tags(
# filter out any template values that didn't match by looking for sentinel
rendered_tags = [
tag for tag in rendered_tags if _OSXPHOTOS_NONE_SENTINEL not in tag
value.replace(_OSXPHOTOS_NONE_SENTINEL, "") for value in rendered_tags
]
tags.extend(rendered_tags)
@@ -3358,10 +3014,10 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
),
err=True,
)
# filter out any template values that didn't match by looking for sentinel
rendered = [
value for value in rendered if _OSXPHOTOS_NONE_SENTINEL not in value
]
rendered = [value.replace(_OSXPHOTOS_NONE_SENTINEL, "") for value in rendered]
try:
attributes[xattr].extend(rendered)
except KeyError:

View File

@@ -7,4 +7,4 @@ PhotosDB.photos() returns a list of PhotoInfo objects
from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo
from .photoinfo import PhotoInfo

View File

@@ -8,10 +8,14 @@ import os
import os.path
import pathlib
import platform
import re
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pprint import pformat
from typing import List
import bitmath
from .._constants import (
_DB_TABLE_NAMES,
@@ -47,6 +51,7 @@ from ..utils import (
noop,
normalize_unicode,
)
from ..queryoptions import QueryOptions
from .photosdb_utils import get_db_model_version, get_db_version
# TODO: Add test for imageTimeZoneOffsetSeconds = None
@@ -2833,6 +2838,346 @@ class PhotosDB:
pass
return photos
def query(self, options: QueryOptions) -> List[PhotoInfo]:
"""Run a query against PhotosDB to extract the photos based on user supplied options
Args:
options: a QueryOptions instance
"""
if options.deleted or options.deleted_only:
photos = self.photos(
uuid=options.uuid,
images=options.photos,
movies=options.movies,
from_date=options.from_date,
to_date=options.to_date,
intrash=True,
)
else:
photos = []
if not options.deleted_only:
photos += self.photos(
uuid=options.uuid,
images=options.photos,
movies=options.movies,
from_date=options.from_date,
to_date=options.to_date,
)
person = normalize_unicode(options.person)
keyword = normalize_unicode(options.keyword)
album = normalize_unicode(options.album)
folder = normalize_unicode(options.folder)
title = normalize_unicode(options.title)
description = normalize_unicode(options.description)
place = normalize_unicode(options.place)
label = normalize_unicode(options.label)
name = normalize_unicode(options.name)
if album:
photos = _get_photos_by_attribute(
photos, "albums", album, options.ignore_case
)
if keyword:
photos = _get_photos_by_attribute(
photos, "keywords", keyword, options.ignore_case
)
if person:
photos = _get_photos_by_attribute(
photos, "persons", person, options.ignore_case
)
if label:
photos = _get_photos_by_attribute(
photos, "labels", label, options.ignore_case
)
if folder:
# search for photos in an album in folder
# finds photos that have albums whose top level folder matches folder
photo_list = []
for f in folder:
photo_list.extend(
[
p
for p in photos
if p.album_info
and f
in [a.folder_names[0] for a in p.album_info if a.folder_names]
]
)
photos = photo_list
if title:
# search title field for text
# if more than one, find photos with all title values in title
photo_list = []
if options.ignore_case:
# case-insensitive
for t in title:
t = t.lower()
photo_list.extend(
[p for p in photos if p.title and t in p.title.lower()]
)
else:
for t in title:
photo_list.extend([p for p in photos if p.title and t in p.title])
photos = photo_list
elif options.no_title:
photos = [p for p in photos if not p.title]
if description:
# search description field for text
# if more than one, find photos with all description values in description
photo_list = []
if options.ignore_case:
# case-insensitive
for d in description:
d = d.lower()
photo_list.extend(
[
p
for p in photos
if p.description and d in p.description.lower()
]
)
else:
for d in description:
photo_list.extend(
[p for p in photos if p.description and d in p.description]
)
photos = photo_list
elif options.no_description:
photos = [p for p in photos if not p.description]
if place:
# search place.names for text matching place
# if more than one place, find photos with all place values in description
if options.ignore_case:
# case-insensitive
for place_name in place:
place_name = place_name.lower()
photos = [
p
for p in photos
if p.place
and any(
pname
for pname in p.place.names
if any(
pvalue
for pvalue in pname
if place_name in pvalue.lower()
)
)
]
else:
for place_name in place:
photos = [
p
for p in photos
if p.place
and any(
pname
for pname in p.place.names
if any(pvalue for pvalue in pname if place_name in pvalue)
)
]
elif options.no_place:
photos = [p for p in photos if not p.place]
if options.edited:
photos = [p for p in photos if p.hasadjustments]
if options.external_edit:
photos = [p for p in photos if p.external_edit]
if options.favorite:
photos = [p for p in photos if p.favorite]
elif options.not_favorite:
photos = [p for p in photos if not p.favorite]
if options.hidden:
photos = [p for p in photos if p.hidden]
elif options.not_hidden:
photos = [p for p in photos if not p.hidden]
if options.missing:
photos = [p for p in photos if not p.path]
elif options.not_missing:
photos = [p for p in photos if p.path]
if options.shared:
photos = [p for p in photos if p.shared]
elif options.not_shared:
photos = [p for p in photos if not p.shared]
if options.shared:
photos = [p for p in photos if p.shared]
elif options.not_shared:
photos = [p for p in photos if not p.shared]
if options.uti:
photos = [p for p in photos if options.uti in p.uti_original]
if options.burst:
photos = [p for p in photos if p.burst]
elif options.not_burst:
photos = [p for p in photos if not p.burst]
if options.live:
photos = [p for p in photos if p.live_photo]
elif options.not_live:
photos = [p for p in photos if not p.live_photo]
if options.portrait:
photos = [p for p in photos if p.portrait]
elif options.not_portrait:
photos = [p for p in photos if not p.portrait]
if options.screenshot:
photos = [p for p in photos if p.screenshot]
elif options.not_screenshot:
photos = [p for p in photos if not p.screenshot]
if options.slow_mo:
photos = [p for p in photos if p.slow_mo]
elif options.not_slow_mo:
photos = [p for p in photos if not p.slow_mo]
if options.time_lapse:
photos = [p for p in photos if p.time_lapse]
elif options.not_time_lapse:
photos = [p for p in photos if not p.time_lapse]
if options.hdr:
photos = [p for p in photos if p.hdr]
elif options.not_hdr:
photos = [p for p in photos if not p.hdr]
if options.selfie:
photos = [p for p in photos if p.selfie]
elif options.not_selfie:
photos = [p for p in photos if not p.selfie]
if options.panorama:
photos = [p for p in photos if p.panorama]
elif options.not_panorama:
photos = [p for p in photos if not p.panorama]
if options.cloudasset:
photos = [p for p in photos if p.iscloudasset]
elif options.not_cloudasset:
photos = [p for p in photos if not p.iscloudasset]
if options.incloud:
photos = [p for p in photos if p.incloud]
elif options.not_incloud:
photos = [p for p in photos if not p.incloud]
if options.has_raw:
photos = [p for p in photos if p.has_raw]
if options.has_comment:
photos = [p for p in photos if p.comments]
elif options.no_comment:
photos = [p for p in photos if not p.comments]
if options.has_likes:
photos = [p for p in photos if p.likes]
elif options.no_likes:
photos = [p for p in photos if not p.likes]
if options.is_reference:
photos = [p for p in photos if p.isreference]
if options.in_album:
photos = [p for p in photos if p.albums]
elif options.not_in_album:
photos = [p for p in photos if not p.albums]
if options.from_time:
photos = [p for p in photos if p.date.time() >= options.from_time]
if options.to_time:
photos = [p for p in photos if p.date.time() <= options.to_time]
if options.burst_photos:
# add the burst_photos to the export set
photos_burst = [p for p in photos if p.burst]
for burst in photos_burst:
if options.missing_bursts:
# include burst photos that are missing
photos.extend(burst.burst_photos)
else:
# don't include missing burst images (these can't be downloaded with AppleScript)
photos.extend([p for p in burst.burst_photos if not p.ismissing])
# remove duplicates as each burst photo in the set that's selected would
# result in the entire set being added above
# can't use set() because PhotoInfo not hashable
seen_uuids = {}
for p in photos:
if p.uuid in seen_uuids:
continue
seen_uuids[p.uuid] = p
photos = list(seen_uuids.values())
if name:
# search filename fields for text
# if more than one, find photos with all title values in filename
photo_list = []
if options.ignore_case:
# case-insensitive
for n in name:
n = n.lower()
photo_list.extend(
[
p
for p in photos
if n in p.filename.lower()
or n in p.original_filename.lower()
]
)
else:
for n in name:
photo_list.extend(
[
p
for p in photos
if n in p.filename or n in p.original_filename
]
)
photos = photo_list
if options.min_size:
photos = [
p
for p in photos
if bitmath.Byte(p.original_filesize) >= options.min_size
]
if options.max_size:
photos = [
p
for p in photos
if bitmath.Byte(p.original_filesize) <= options.max_size
]
if options.query_eval:
for q in options.query_eval:
query_string = f"[photo for photo in photos if {q}]"
try:
photos = eval(query_string)
except Exception as e:
raise ValueError(f"Invalid query_eval CRITERIA: {e}")
return photos
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"
@@ -2848,3 +3193,32 @@ class PhotosDB:
Includes recently deleted photos and non-selected burst images
"""
return len(self._dbphotos)
def _get_photos_by_attribute(photos, attribute, values, ignore_case):
"""Search for photos based on values being in PhotoInfo.attribute
Args:
photos: a list of PhotoInfo objects
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
values: list of values to search in property
ignore_case: ignore case when searching
Returns:
list of PhotoInfo objects matching search criteria
"""
photos_search = []
if ignore_case:
# case-insensitive
for x in values:
x = x.lower()
photos_search.extend(
p
for p in photos
if x in [attr.lower() for attr in getattr(p, attribute)]
)
else:
for x in values:
photos_search.extend(p for p in photos if x in getattr(p, attribute))
return photos_search

View File

@@ -60,7 +60,7 @@ e.g. If Photo is in `Album1` in `Folder1`:
- `"{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.
`[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:

View File

@@ -11,6 +11,7 @@ from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .utils import load_function
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
@@ -128,6 +129,10 @@ TEMPLATE_SUBSTITUTIONS = {
"{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'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
@@ -136,7 +141,9 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{folder_album}": "Folder path + album 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)",
"{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. "
@@ -152,6 +159,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
+ "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.",
"{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 = {
@@ -193,6 +204,10 @@ PUNCTUATION = {
"openbracket": "[",
"closebracket": "]",
"questionmark": "?",
"newline": "\n",
"lf": "\n",
"cr": "\r",
"crlf": "\r\n",
}
@@ -463,6 +478,14 @@ class PhotoTemplate:
vals = self.get_template_value_exiftool(
subfield, filename=filename, dirname=dirname
)
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, filename=filename, dirname=dirname
)
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi(
field, path_sep=path_sep, filename=filename, dirname=dirname
@@ -1064,6 +1087,39 @@ class PhotoTemplate:
return values
def get_template_value_function(self, subfield, filename=None, dirname=None):
"""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("::")
print(filename, funcname)
if not pathlib.Path(filename).is_file():
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname)
values = template_func(self.photo)
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 filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
values = [sanitize_dirname(value) for value in values]
return values
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)

View File

@@ -51,6 +51,10 @@ Field:
FIELD_WORD+
;
FIELD_WORD:
/[\.\w]+/
;
SubField:
(
":"-
@@ -58,12 +62,8 @@ SubField:
)?
;
FIELD_WORD:
/[\.\w]+/
;
SUBFIELD_WORD:
/[\.\w:]+/
/[\.\w:\/]+/
;
Filter:

82
osxphotos/queryoptions.py Normal file
View File

@@ -0,0 +1,82 @@
""" QueryOptions class for PhotosDB.query """
from dataclasses import dataclass
from typing import Optional, Iterable
import datetime
import bitmath
@dataclass
class QueryOptions:
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
query_eval: Optional[Iterable[str]] = None
def asdict(self):
return asdict(self)

View File

@@ -1,5 +1,8 @@
""" Utility functions used in osxphotos """
import fnmatch
import glob
import importlib
import inspect
import logging
import os
@@ -13,6 +16,7 @@ import sys
import unicodedata
import urllib.parse
from plistlib import load as plistload
from typing import Callable
import CoreFoundation
import CoreServices
@@ -369,13 +373,16 @@ def _db_is_locked(dbname):
def normalize_unicode(value):
""" normalize unicode data """
if value is None:
if value is not None:
if isinstance(value, (tuple, list)):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
elif isinstance(value, str):
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
return value
else:
return None
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
def increment_filename(filepath):
""" Return filename (1).ext, etc if filename.ext exists
@@ -401,3 +408,28 @@ def increment_filename(filepath):
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
return str(dest)
def load_function(pyfile: str, function_name: str) -> Callable:
""" 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")
module_dir = module_file.parent or pathlib.Path(os.getcwd())
module_name = module_file.stem
# store old sys.path and ensure module_dir at beginning of path
syspath = sys.path
sys.path = [str(module_dir)] + syspath
module = importlib.import_module(module_name)
try:
func = getattr(module, function_name)
except AttributeError:
raise ValueError(f"'{function_name}' not found in module '{module_name}'")
finally:
# restore sys.path
sys.path = syspath
return func

View File

@@ -9,6 +9,7 @@ atomicwrites==1.3.0
attrs==19.1.0
backcall==0.1.0
better-exceptions-fork==0.2.1.post6
bitmath==1.3.3.1
bleach==3.3.0
bpylist2==3.0.2
certifi==2020.4.5.1

View File

@@ -86,6 +86,7 @@ setup(
"osxmetadata>=0.99.13",
"textx==2.3.0",
"rich>=9.11.1",
"bitmath==1.3.3.1",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

File diff suppressed because one or more lines are too long

View 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"

View File

@@ -462,7 +462,17 @@ CLI_FINDER_TAGS = {
"XMP:Description": "Girl holding pumpkin",
"XMP:PersonInImage": "Katie",
"XMP:Subject": "Kids",
}
},
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
"File:FileName": "wedding.jpg",
"IPTC:Keywords": ["Maria", "wedding"],
"XMP:TagsList": ["Maria", "wedding"],
"XMP:Title": None,
"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:PersonInImage": "Maria",
"XMP:Subject": ["Maria", "wedding"],
},
}
LABELS_JSON = {
@@ -5330,7 +5340,7 @@ def test_export_finder_tag_template_multiple():
keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in keywords + persons]
expected = [Tag(x) for x in set(keywords + persons)]
assert sorted(md.tags) == sorted(expected)
@@ -5368,7 +5378,42 @@ def test_export_finder_tag_template_keywords():
keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in keywords + persons]
expected = [Tag(x) for x in set(keywords + persons)]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template_multi_field():
""" test --finder-tag-template with multiple fields (issue #422) """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{title};{descr}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or ""
descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or ""
expected = [Tag(f"{title};{descr}")]
assert sorted(md.tags) == sorted(expected)
@@ -5397,7 +5442,7 @@ def test_export_xattr_template():
"{person}",
"--xattr-template",
"comment",
"{title}",
"{title};{descr}",
"--uuid",
f"{uuid}",
],
@@ -5408,7 +5453,9 @@ def test_export_xattr_template():
expected = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
expected = [expected] if type(expected) != list else expected
assert sorted(md.keywords) == sorted(expected)
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or ""
descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or ""
assert md.comment == f"{title};{descr}"
# run again with --update, should skip writing extended attributes
result = runner.invoke(
@@ -5422,7 +5469,7 @@ def test_export_xattr_template():
"{person}",
"--xattr-template",
"comment",
"{title}",
"{title};{descr}",
"--uuid",
f"{uuid}",
"--update",
@@ -5768,6 +5815,7 @@ def test_export_name():
files = glob.glob("*")
assert len(files) == 1
def test_query_eval():
""" test export --query-eval """
import glob
@@ -5778,7 +5826,14 @@ def test_query_eval():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--query-eval", "'DSC03584' in photo.original_filename"]
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--query-eval",
"'DSC03584' in photo.original_filename",
],
)
assert result.exit_code == 0
files = glob.glob("*")
@@ -5795,7 +5850,146 @@ def test_bad_query_eval():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--query-eval", "'DSC03584' in photo.originalfilename"]
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--query-eval",
"'DSC03584' in photo.originalfilename",
],
)
assert result.exit_code != 0
assert "Error: Invalid query-eval CRITERIA" in result.output
assert "Error: Invalid query-eval CRITERIA" in result.output
def test_query_min_size_1():
""" test query --min-size """
import json
import os
import os.path
import osxphotos
from osxphotos.cli import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "10MB"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 2
def test_query_min_size_2():
""" test query --min-size """
import json
import os
import os.path
import osxphotos
from osxphotos.cli import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_7),
"--min-size",
"10_000_000",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 2
def test_query_max_size_1():
""" test query --max-size """
import json
import os
import os.path
import osxphotos
from osxphotos.cli import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--max-size", "500 kB"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_max_size_2():
""" test query --max-size """
import json
import os
import os.path
import osxphotos
from osxphotos.cli import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--max-size", "500_000"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_min_max_size():
""" test query --max-size with --min-size"""
import json
import os
import os.path
import osxphotos
from osxphotos.cli import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_7),
"--min-size",
"48MB",
"--max-size",
"49MB",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_min_size_error():
""" test query --max-size with invalid size """
import json
import os
import os.path
import osxphotos
from osxphotos.cli import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "500 foo"],
)
assert result.exit_code != 0

View File

@@ -381,7 +381,7 @@ def test_lookup_multi(photosdb_places):
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst in ["{exiftool}", "{photo}"]:
if subst in ["{exiftool}", "{photo}", "{function}"]:
continue
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
assert isinstance(lookup, list)
@@ -965,3 +965,20 @@ def test_conditional(photosdb):
for template in UUID_CONDITIONAL[uuid]:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template])
def test_function(photosdb):
""" Test {function} """
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
rendered, _ = photo.render_template("{function:tests/template_function.py::foo}")
assert rendered == [f"{photo.original_filename}-FOO"]
def test_function_bad(photosdb):
""" Test invalid {function} """
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
with pytest.raises(ValueError):
rendered, _ = photo.render_template(
"{function:tests/template_function.py::foobar}"
)