Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8802368fc | ||
|
|
f132e9a843 | ||
|
|
6b342a1733 | ||
|
|
9dec028448 | ||
|
|
8be6a98c32 | ||
|
|
ce73c9cab8 |
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -32,4 +32,4 @@ jobs:
|
|||||||
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
python -m pytest tests/
|
python -m pytest -v tests/
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -162,7 +162,41 @@ Commands:
|
|||||||
uuid Print out unique IDs (UUID) of photos selected in Photos
|
uuid Print out unique IDs (UUID) of photos selected in Photos
|
||||||
```
|
```
|
||||||
|
|
||||||
To get help on a specific command, use `osxphotos help <command_name>`
|
To get help on a specific command, use `osxphotos help command_name`, for example, `osxphotos help export` to get help on the `export` command.
|
||||||
|
|
||||||
|
Some of the commands such as `export` and `query` have a large number of options. To search for options related to a specific topic, you can use `osxphotos help command_name topic_name`. For example, `osxphotos help export raw` finds the options related to RAW files (search is case-insensitive):
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
|
||||||
|
|
||||||
|
Export photos from the Photos database. Export path DEST is required.
|
||||||
|
Optionally, query the Photos database using 1 or more search options; if
|
||||||
|
more than one option is provided, they are treated as "AND" (e.g. search for
|
||||||
|
photos matching all options). If no query options are provided, all photos
|
||||||
|
will be exported. By default, all versions of all photos will be exported
|
||||||
|
including edited versions, live photo movies, burst photos, and associated
|
||||||
|
raw images. See --skip-edited, --skip-live, --skip-bursts, and --skip-raw
|
||||||
|
options to modify this behavior.
|
||||||
|
|
||||||
|
Options that match 'raw':
|
||||||
|
|
||||||
|
--has-raw Search for photos with both a jpeg and
|
||||||
|
raw version
|
||||||
|
--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).
|
||||||
|
--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).
|
||||||
|
```
|
||||||
|
|
||||||
### Command line examples
|
### Command line examples
|
||||||
|
|
||||||
@@ -1178,6 +1212,14 @@ Options:
|
|||||||
network or slow disk but could result in
|
network or slow disk but could result in
|
||||||
losing update state information if the program
|
losing update state information if the program
|
||||||
is interrupted or crashes.
|
is interrupted or crashes.
|
||||||
|
--tmpdir DIR Specify alternate temporary directory. Default
|
||||||
|
is system temporary directory. osxphotos needs
|
||||||
|
to create a number of temporary files during
|
||||||
|
export. In some cases, particularly if the
|
||||||
|
Photos library is on an APFS volume that is
|
||||||
|
not the system volume, osxphotos may run
|
||||||
|
faster if you specify a temporary directory on
|
||||||
|
the same volume as the Photos library.
|
||||||
--load-config <config file path>
|
--load-config <config file path>
|
||||||
Load options from file as written with --save-
|
Load options from file as written with --save-
|
||||||
config. This allows you to save a complex
|
config. This allows you to save a complex
|
||||||
@@ -1741,7 +1783,7 @@ Substitution Description
|
|||||||
{lf} A line feed: '\n', alias for {newline}
|
{lf} A line feed: '\n', alias for {newline}
|
||||||
{cr} A carriage return: '\r'
|
{cr} A carriage return: '\r'
|
||||||
{crlf} a carriage return + line feed: '\r\n'
|
{crlf} a carriage return + line feed: '\r\n'
|
||||||
{osxphotos_version} The osxphotos version, e.g. '0.47.1'
|
{osxphotos_version} The osxphotos version, e.g. '0.47.4'
|
||||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||||
|
|
||||||
The following substitutions may result in multiple values. Thus if specified for
|
The following substitutions may result in multiple values. Thus if specified for
|
||||||
@@ -3645,7 +3687,7 @@ The following template field substitutions are availabe for use the templating s
|
|||||||
|{lf}|A line feed: '\n', alias for {newline}|
|
|{lf}|A line feed: '\n', alias for {newline}|
|
||||||
|{cr}|A carriage return: '\r'|
|
|{cr}|A carriage return: '\r'|
|
||||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||||
|{osxphotos_version}|The osxphotos version, e.g. '0.47.1'|
|
|{osxphotos_version}|The osxphotos version, e.g. '0.47.4'|
|
||||||
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
||||||
|{album}|Album(s) photo is contained in|
|
|{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|
|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||||
@@ -3794,6 +3836,7 @@ Attributes:
|
|||||||
- use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
|
- use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
|
||||||
- use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
|
- use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
|
||||||
- verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
- verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||||
|
- tmpfile (str): optional path to use for temporary files
|
||||||
|
|
||||||
#### `ExportResults`
|
#### `ExportResults`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Sphinx build info version 1
|
# 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.
|
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
|
||||||
config: bc3dce8a14bcd1b0c8a34e4d16f0011f
|
config: 19cfb0a2639529f45c9adb1eaa8cab18
|
||||||
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Overview: module code — osxphotos 0.47.1 documentation</title>
|
<title>Overview: module code — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="../_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="../_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="../_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="../_static/alabaster.css" />
|
||||||
<script data-url_root="../" id="documentation_options" src="../_static/documentation_options.js"></script>
|
<script data-url_root="../" id="documentation_options" src="../_static/documentation_options.js"></script>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
3
docs/_static/basic.css
vendored
3
docs/_static/basic.css
vendored
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Sphinx stylesheet -- basic theme.
|
* Sphinx stylesheet -- basic theme.
|
||||||
*
|
*
|
||||||
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
|
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
|
||||||
* :license: BSD, see LICENSE for details.
|
* :license: BSD, see LICENSE for details.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -757,6 +757,7 @@ span.pre {
|
|||||||
-ms-hyphens: none;
|
-ms-hyphens: none;
|
||||||
-webkit-hyphens: none;
|
-webkit-hyphens: none;
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
div[class*="highlight-"] {
|
div[class*="highlight-"] {
|
||||||
|
|||||||
5
docs/_static/doctools.js
vendored
5
docs/_static/doctools.js
vendored
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Sphinx JavaScript utilities for all documentation.
|
* Sphinx JavaScript utilities for all documentation.
|
||||||
*
|
*
|
||||||
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
|
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
|
||||||
* :license: BSD, see LICENSE for details.
|
* :license: BSD, see LICENSE for details.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -264,6 +264,9 @@ var Documentation = {
|
|||||||
hideSearchWords : function() {
|
hideSearchWords : function() {
|
||||||
$('#searchbox .highlight-link').fadeOut(300);
|
$('#searchbox .highlight-link').fadeOut(300);
|
||||||
$('span.highlighted').removeClass('highlighted');
|
$('span.highlighted').removeClass('highlighted');
|
||||||
|
var url = new URL(window.location);
|
||||||
|
url.searchParams.delete('highlight');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
2
docs/_static/documentation_options.js
vendored
2
docs/_static/documentation_options.js
vendored
@@ -1,6 +1,6 @@
|
|||||||
var DOCUMENTATION_OPTIONS = {
|
var DOCUMENTATION_OPTIONS = {
|
||||||
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
||||||
VERSION: '0.47.1',
|
VERSION: '0.47.4',
|
||||||
LANGUAGE: 'None',
|
LANGUAGE: 'None',
|
||||||
COLLAPSE_INDEX: false,
|
COLLAPSE_INDEX: false,
|
||||||
BUILDER: 'html',
|
BUILDER: 'html',
|
||||||
|
|||||||
2
docs/_static/language_data.js
vendored
2
docs/_static/language_data.js
vendored
@@ -5,7 +5,7 @@
|
|||||||
* This script contains the language-specific data used by searchtools.js,
|
* This script contains the language-specific data used by searchtools.js,
|
||||||
* namely the list of stopwords, stemmer, scorer and splitter.
|
* namely the list of stopwords, stemmer, scorer and splitter.
|
||||||
*
|
*
|
||||||
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
|
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
|
||||||
* :license: BSD, see LICENSE for details.
|
* :license: BSD, see LICENSE for details.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|||||||
2
docs/_static/searchtools.js
vendored
2
docs/_static/searchtools.js
vendored
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Sphinx JavaScript utilities for the full-text search.
|
* Sphinx JavaScript utilities for the full-text search.
|
||||||
*
|
*
|
||||||
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
|
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
|
||||||
* :license: BSD, see LICENSE for details.
|
* :license: BSD, see LICENSE for details.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||||
|
|
||||||
<title>osxphotos command line interface (CLI) — osxphotos 0.47.1 documentation</title>
|
<title>osxphotos command line interface (CLI) — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Index — osxphotos 0.47.1 documentation</title>
|
<title>Index — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||||
@@ -528,7 +528,7 @@
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||||
|
|
||||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.47.1 documentation</title>
|
<title>Welcome to osxphotos’s documentation! — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||||
@@ -355,7 +355,7 @@ Alternatively, you can also run the command line utility like this: <code class=
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||||
|
|
||||||
<title>osxphotos — osxphotos 0.47.1 documentation</title>
|
<title>osxphotos — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||||
|
|
||||||
<title>osxphotos package — osxphotos 0.47.1 documentation</title>
|
<title>osxphotos package — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||||
@@ -975,7 +975,7 @@ Returns None if no associated RAW image</p>
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Search — osxphotos 0.47.1 documentation</title>
|
<title>Search — osxphotos 0.47.4 documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
©2021, Rhet Turnbull.
|
©2021, Rhet Turnbull.
|
||||||
|
|
||||||
|
|
|
|
||||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
|
||||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.47.2"
|
__version__ = "0.47.4"
|
||||||
|
|||||||
42
osxphotos/cli/click_rich_echo.py
Normal file
42
osxphotos/cli/click_rich_echo.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""click.echo replacement that supports rich text formatting"""
|
||||||
|
|
||||||
|
import typing as t
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
|
||||||
|
def rich_echo(
|
||||||
|
message: t.Optional[t.Any] = None,
|
||||||
|
**kwargs: t.Any,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Echo text to the console with rich formatting.
|
||||||
|
|
||||||
|
This is a wrapper around click.echo that supports rich text formatting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The string or bytes to output. Other objects are converted to strings.
|
||||||
|
kwargs: any extra arguments are passed to rich.console.Console.print() and click.echo
|
||||||
|
if kwargs contains 'file', 'nl', 'err', 'color', these are passed to click.echo,
|
||||||
|
all other values passed to rich.console.Console.print()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# args for click.echo that may have been passed in kwargs
|
||||||
|
echo_args = {}
|
||||||
|
for arg in ("file", "nl", "err", "color"):
|
||||||
|
val = kwargs.pop(arg, None)
|
||||||
|
if val is not None:
|
||||||
|
echo_args[arg] = val
|
||||||
|
|
||||||
|
# click.echo will include "\n" so don't add it here unless specified
|
||||||
|
end = kwargs.pop("end", "")
|
||||||
|
|
||||||
|
# rich.console.Console defaults to 80 chars if it can't auto-detect, which in this case it won't
|
||||||
|
# so we need to set the width manually to a ridiculously large number
|
||||||
|
width = kwargs.pop("width", 10000)
|
||||||
|
output = StringIO()
|
||||||
|
console = Console(force_terminal=True, file=output, width=width)
|
||||||
|
console.print(message, end=end, **kwargs)
|
||||||
|
click.echo(output.getvalue(), **echo_args)
|
||||||
@@ -3,17 +3,16 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
from typing import Callable
|
import typing as t
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
import osxphotos
|
import osxphotos
|
||||||
from osxphotos._version import __version__
|
from osxphotos._version import __version__
|
||||||
|
|
||||||
|
from .click_rich_echo import rich_echo
|
||||||
from .param_types import *
|
from .param_types import *
|
||||||
|
|
||||||
from rich import print as rprint
|
|
||||||
|
|
||||||
# global variable to control debug output
|
# global variable to control debug output
|
||||||
# set via --debug
|
# set via --debug
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
@@ -48,14 +47,15 @@ def noop(*args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def verbose_print(
|
def verbose_print(
|
||||||
verbose: bool = True, timestamp: bool = False, rich=False
|
verbose: bool = True, timestamp: bool = False, rich=False, **kwargs: t.Any
|
||||||
) -> Callable:
|
) -> t.Callable:
|
||||||
"""Create verbose function to print output
|
"""Create verbose function to print output
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
verbose: if True, returns verbose print function otherwise returns no-op function
|
verbose: if True, returns verbose print function otherwise returns no-op function
|
||||||
timestamp: if True, includes timestamp in verbose output
|
timestamp: if True, includes timestamp in verbose output
|
||||||
rich: use rich.print instead of click.echo
|
rich: use rich.print instead of click.echo
|
||||||
|
kwargs: any extra arguments to pass to click.echo or rich.print depending on whether rich==True
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
function to print output
|
function to print output
|
||||||
@@ -64,10 +64,10 @@ def verbose_print(
|
|||||||
return noop
|
return noop
|
||||||
|
|
||||||
# closure to capture timestamp
|
# closure to capture timestamp
|
||||||
def verbose_(*args, **kwargs):
|
def verbose_(*args):
|
||||||
"""print output if verbose flag set"""
|
"""print output if verbose flag set"""
|
||||||
styled_args = []
|
styled_args = []
|
||||||
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
|
timestamp_str = f"{str(datetime.datetime.now())} -- " if timestamp else ""
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if type(arg) == str:
|
if type(arg) == str:
|
||||||
arg = timestamp_str + arg
|
arg = timestamp_str + arg
|
||||||
@@ -78,9 +78,10 @@ def verbose_print(
|
|||||||
styled_args.append(arg)
|
styled_args.append(arg)
|
||||||
click.echo(*styled_args, **kwargs)
|
click.echo(*styled_args, **kwargs)
|
||||||
|
|
||||||
def rich_verbose_(*args, **kwargs):
|
def rich_verbose_(*args):
|
||||||
"""print output if verbose flag set using rich.print"""
|
"""print output if verbose flag set using rich.print"""
|
||||||
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
|
timestamp_str = f"{str(datetime.datetime.now())} -- " if timestamp else ""
|
||||||
|
new_args = []
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if type(arg) == str:
|
if type(arg) == str:
|
||||||
arg = timestamp_str + arg
|
arg = timestamp_str + arg
|
||||||
@@ -88,7 +89,8 @@ def verbose_print(
|
|||||||
arg = f"[{CLI_COLOR_ERROR}]{arg}[/{CLI_COLOR_ERROR}]"
|
arg = f"[{CLI_COLOR_ERROR}]{arg}[/{CLI_COLOR_ERROR}]"
|
||||||
elif "warning" in arg.lower():
|
elif "warning" in arg.lower():
|
||||||
arg = f"[{CLI_COLOR_WARNING}]{arg}[/{CLI_COLOR_WARNING}]"
|
arg = f"[{CLI_COLOR_WARNING}]{arg}[/{CLI_COLOR_WARNING}]"
|
||||||
rprint(arg, **kwargs)
|
new_args.append(arg)
|
||||||
|
rich_echo(*new_args, **kwargs)
|
||||||
|
|
||||||
return rich_verbose_ if rich else verbose_
|
return rich_verbose_ if rich else verbose_
|
||||||
|
|
||||||
|
|||||||
@@ -562,6 +562,16 @@ from .param_types import ExportDBType, FunctionCall
|
|||||||
"may improve performance when exporting over a network or slow disk but could result in "
|
"may improve performance when exporting over a network or slow disk but could result in "
|
||||||
"losing update state information if the program is interrupted or crashes.",
|
"losing update state information if the program is interrupted or crashes.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--tmpdir",
|
||||||
|
metavar="DIR",
|
||||||
|
help="Specify alternate temporary directory. Default is system temporary directory. "
|
||||||
|
"osxphotos needs to create a number of temporary files during export. In some cases, "
|
||||||
|
"particularly if the Photos library is on an APFS volume that is not the system volume, "
|
||||||
|
"osxphotos may run faster if you specify a temporary directory on the same volume as "
|
||||||
|
"the Photos library.",
|
||||||
|
type=click.Path(dir_okay=True, file_okay=False, exists=True),
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--load-config",
|
"--load-config",
|
||||||
required=False,
|
required=False,
|
||||||
@@ -756,6 +766,7 @@ def export(
|
|||||||
add_missing_to_album,
|
add_missing_to_album,
|
||||||
exportdb,
|
exportdb,
|
||||||
ramdb,
|
ramdb,
|
||||||
|
tmpdir,
|
||||||
load_config,
|
load_config,
|
||||||
save_config,
|
save_config,
|
||||||
config_only,
|
config_only,
|
||||||
@@ -792,7 +803,10 @@ def export(
|
|||||||
to modify this behavior.
|
to modify this behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if is_debug():
|
# capture locals for use with ConfigOptions before changing any of them
|
||||||
|
locals_ = locals()
|
||||||
|
|
||||||
|
if debug:
|
||||||
set_debug(True)
|
set_debug(True)
|
||||||
osxphotos._set_debug(True)
|
osxphotos._set_debug(True)
|
||||||
|
|
||||||
@@ -819,11 +833,11 @@ def export(
|
|||||||
# do so below after load_config and save_config are handled.
|
# do so below after load_config and save_config are handled.
|
||||||
cfg = ConfigOptions(
|
cfg = ConfigOptions(
|
||||||
"export",
|
"export",
|
||||||
locals(),
|
locals_,
|
||||||
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
|
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
|
||||||
)
|
)
|
||||||
|
|
||||||
verbose_ = verbose_print(verbose, timestamp)
|
verbose_ = verbose_print(verbose, timestamp, rich=True, highlight=False)
|
||||||
|
|
||||||
if load_config:
|
if load_config:
|
||||||
try:
|
try:
|
||||||
@@ -956,6 +970,7 @@ def export(
|
|||||||
skip_uuid_from_file = cfg.skip_uuid_from_file
|
skip_uuid_from_file = cfg.skip_uuid_from_file
|
||||||
slow_mo = cfg.slow_mo
|
slow_mo = cfg.slow_mo
|
||||||
strip = cfg.strip
|
strip = cfg.strip
|
||||||
|
tmpdir = cfg.tmpdir
|
||||||
time_lapse = cfg.time_lapse
|
time_lapse = cfg.time_lapse
|
||||||
timestamp = cfg.timestamp
|
timestamp = cfg.timestamp
|
||||||
title = cfg.title
|
title = cfg.title
|
||||||
@@ -972,7 +987,7 @@ def export(
|
|||||||
xattr_template = cfg.xattr_template
|
xattr_template = cfg.xattr_template
|
||||||
|
|
||||||
# config file might have changed verbose
|
# config file might have changed verbose
|
||||||
verbose_ = verbose_print(verbose, timestamp)
|
verbose_ = verbose_print(verbose, timestamp, rich=True, highlight=False)
|
||||||
verbose_(f"Loaded options from file {load_config}")
|
verbose_(f"Loaded options from file {load_config}")
|
||||||
|
|
||||||
verbose_(f"osxphotos version {__version__}")
|
verbose_(f"osxphotos version {__version__}")
|
||||||
@@ -1414,6 +1429,7 @@ def export(
|
|||||||
use_photokit=use_photokit,
|
use_photokit=use_photokit,
|
||||||
use_photos_export=use_photos_export,
|
use_photos_export=use_photos_export,
|
||||||
verbose_=verbose_,
|
verbose_=verbose_,
|
||||||
|
tmpdir=tmpdir,
|
||||||
)
|
)
|
||||||
|
|
||||||
if post_function:
|
if post_function:
|
||||||
@@ -1660,6 +1676,7 @@ def export_photo(
|
|||||||
preview_if_missing=False,
|
preview_if_missing=False,
|
||||||
photo_num=1,
|
photo_num=1,
|
||||||
num_photos=1,
|
num_photos=1,
|
||||||
|
tmpdir=None,
|
||||||
):
|
):
|
||||||
"""Helper function for export that does the actual export
|
"""Helper function for export that does the actual export
|
||||||
|
|
||||||
@@ -1707,6 +1724,7 @@ def export_photo(
|
|||||||
update: bool, only export updated photos
|
update: bool, only export updated photos
|
||||||
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
|
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
|
||||||
verbose_: callable for verbose output
|
verbose_: callable for verbose output
|
||||||
|
tmpdir: optional str; temporary directory to use for export
|
||||||
Returns:
|
Returns:
|
||||||
list of path(s) of exported photo or None if photo was missing
|
list of path(s) of exported photo or None if photo was missing
|
||||||
|
|
||||||
@@ -1871,6 +1889,7 @@ def export_photo(
|
|||||||
use_photos_export=use_photos_export,
|
use_photos_export=use_photos_export,
|
||||||
use_photokit=use_photokit,
|
use_photokit=use_photokit,
|
||||||
verbose_=verbose_,
|
verbose_=verbose_,
|
||||||
|
tmpdir=tmpdir,
|
||||||
)
|
)
|
||||||
|
|
||||||
if export_edited and photo.hasadjustments:
|
if export_edited and photo.hasadjustments:
|
||||||
@@ -1984,6 +2003,7 @@ def export_photo(
|
|||||||
use_photos_export=use_photos_export,
|
use_photos_export=use_photos_export,
|
||||||
use_photokit=use_photokit,
|
use_photokit=use_photokit,
|
||||||
verbose_=verbose_,
|
verbose_=verbose_,
|
||||||
|
tmpdir=tmpdir,
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -2068,6 +2088,7 @@ def export_photo_to_directory(
|
|||||||
use_photos_export,
|
use_photos_export,
|
||||||
use_photokit,
|
use_photokit,
|
||||||
verbose_,
|
verbose_,
|
||||||
|
tmpdir,
|
||||||
):
|
):
|
||||||
"""Export photo to directory dest_path"""
|
"""Export photo to directory dest_path"""
|
||||||
|
|
||||||
@@ -2130,6 +2151,7 @@ def export_photo_to_directory(
|
|||||||
use_photokit=use_photokit,
|
use_photokit=use_photokit,
|
||||||
use_photos_export=use_photos_export,
|
use_photos_export=use_photos_export,
|
||||||
verbose=verbose_,
|
verbose=verbose_,
|
||||||
|
tmpdir=tmpdir,
|
||||||
)
|
)
|
||||||
exporter = PhotoExporter(photo)
|
exporter = PhotoExporter(photo)
|
||||||
export_results = exporter.export(
|
export_results = exporter.export(
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"""Help text helper class for osxphotos CLI """
|
"""Help text helper class for osxphotos CLI """
|
||||||
|
|
||||||
|
import inspect
|
||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
|
import typing as t
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import osxmetadata
|
import osxmetadata
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
|
|
||||||
|
from .click_rich_echo import rich_echo
|
||||||
|
|
||||||
from osxphotos._constants import (
|
from osxphotos._constants import (
|
||||||
EXTENDED_ATTRIBUTE_NAMES,
|
EXTENDED_ATTRIBUTE_NAMES,
|
||||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||||
@@ -32,6 +36,8 @@ __all__ = [
|
|||||||
"get_help_msg",
|
"get_help_msg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
HIGHLIGHT_COLOR = "yellow"
|
||||||
|
|
||||||
|
|
||||||
def get_help_msg(command):
|
def get_help_msg(command):
|
||||||
"""get help message for a Click command"""
|
"""get help message for a Click command"""
|
||||||
@@ -41,18 +47,131 @@ def get_help_msg(command):
|
|||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("topic", default=None, required=False, nargs=1)
|
@click.argument("topic", default=None, required=False, nargs=1)
|
||||||
|
@click.argument("subtopic", default=None, required=False, nargs=1)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def help(ctx, topic, **kw):
|
def help(ctx, topic, subtopic, **kw):
|
||||||
"""Print help; for help on commands: help <command>."""
|
"""Print help; for help on commands: help <command>."""
|
||||||
if topic is None:
|
if topic is None:
|
||||||
click.echo(ctx.parent.get_help())
|
click.echo(ctx.parent.get_help())
|
||||||
return
|
return
|
||||||
elif topic in ctx.obj.group.commands:
|
|
||||||
|
if subtopic:
|
||||||
|
cmd = ctx.obj.group.commands[topic]
|
||||||
|
rich_echo(
|
||||||
|
get_subtopic_help(cmd, ctx, subtopic), width=click.HelpFormatter().width
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if topic in ctx.obj.group.commands:
|
||||||
ctx.info_name = topic
|
ctx.info_name = topic
|
||||||
click.echo_via_pager(ctx.obj.group.commands[topic].get_help(ctx))
|
click.echo_via_pager(ctx.obj.group.commands[topic].get_help(ctx))
|
||||||
|
return
|
||||||
|
|
||||||
|
# didn't find any valid help topics
|
||||||
|
click.echo(f"Invalid command: {topic}", err=True)
|
||||||
|
click.echo(ctx.parent.get_help())
|
||||||
|
|
||||||
|
|
||||||
|
def get_subtopic_help(cmd: click.Command, ctx: click.Context, subtopic: str):
|
||||||
|
"""Get help for a command including only options that match a subtopic"""
|
||||||
|
|
||||||
|
# set ctx.info_name or click prints the wrong usage str (usage for help instead of cmd)
|
||||||
|
ctx.info_name = cmd.name
|
||||||
|
usage_str = cmd.get_help(ctx)
|
||||||
|
usage_str = usage_str.partition("\n")[0]
|
||||||
|
|
||||||
|
info = cmd.to_info_dict(ctx)
|
||||||
|
help_str = info.get("help", "")
|
||||||
|
|
||||||
|
options = get_matching_options(cmd, ctx, subtopic)
|
||||||
|
|
||||||
|
# format help text and options
|
||||||
|
formatter = click.HelpFormatter()
|
||||||
|
formatter.write(usage_str)
|
||||||
|
formatter.write_paragraph()
|
||||||
|
format_help_text(help_str, formatter)
|
||||||
|
formatter.write_paragraph()
|
||||||
|
if options:
|
||||||
|
option_str = format_options_help(options, ctx, highlight=subtopic)
|
||||||
|
formatter.write(
|
||||||
|
f"Options that match '[{HIGHLIGHT_COLOR}]{subtopic}[/{HIGHLIGHT_COLOR}]':\n"
|
||||||
|
)
|
||||||
|
formatter.write_paragraph()
|
||||||
|
formatter.write(option_str)
|
||||||
else:
|
else:
|
||||||
click.echo(f"Invalid command: {topic}", err=True)
|
formatter.write(
|
||||||
click.echo(ctx.parent.get_help())
|
f"No options match '[{HIGHLIGHT_COLOR}]{subtopic}[/{HIGHLIGHT_COLOR}]'"
|
||||||
|
)
|
||||||
|
return formatter.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def get_matching_options(
|
||||||
|
command: click.Command, ctx: click.Context, topic: str
|
||||||
|
) -> t.List:
|
||||||
|
"""Get matching options for a command that contain a topic
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: click.Command
|
||||||
|
ctx: click.Context
|
||||||
|
topic: str, topic to match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of matching click.Option objects
|
||||||
|
|
||||||
|
"""
|
||||||
|
options = []
|
||||||
|
topic = topic.lower()
|
||||||
|
for option in command.params:
|
||||||
|
help_record = option.get_help_record(ctx)
|
||||||
|
if help_record and (topic in help_record[0] or topic in help_record[1]):
|
||||||
|
options.append(option)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def format_options_help(
|
||||||
|
options: t.List[click.Option], ctx: click.Context, highlight: t.Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""Format options help for display
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: list of click.Option objects
|
||||||
|
ctx: click.Context
|
||||||
|
highlight: str, if set, add rich highlighting to options that match highlight str
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str with formatted help
|
||||||
|
|
||||||
|
"""
|
||||||
|
formatter = click.HelpFormatter()
|
||||||
|
opt_help = [opt.get_help_record(ctx) for opt in options]
|
||||||
|
if highlight:
|
||||||
|
# convert list of tuples to list of lists
|
||||||
|
opt_help = [list(opt) for opt in opt_help]
|
||||||
|
for record in opt_help:
|
||||||
|
record[0] = re.sub(
|
||||||
|
f"({highlight})",
|
||||||
|
f"[{HIGHLIGHT_COLOR}]\\1" + f"[/{HIGHLIGHT_COLOR}]",
|
||||||
|
record[0],
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
record[1] = re.sub(
|
||||||
|
f"({highlight})",
|
||||||
|
f"[{HIGHLIGHT_COLOR}]\\1" + f"[/{HIGHLIGHT_COLOR}]",
|
||||||
|
record[1],
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# convert back to list of tuples as that's what write_dl expects
|
||||||
|
opt_help = [tuple(opt) for opt in opt_help]
|
||||||
|
formatter.write_dl(opt_help)
|
||||||
|
return formatter.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def format_help_text(text: str, formatter: click.HelpFormatter):
|
||||||
|
text = inspect.cleandoc(text).partition("\f")[0]
|
||||||
|
formatter.write_paragraph()
|
||||||
|
|
||||||
|
with formatter.indentation():
|
||||||
|
formatter.write_text(text)
|
||||||
|
|
||||||
|
|
||||||
# TODO: The following help text could probably be done as mako template
|
# TODO: The following help text could probably be done as mako template
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import tempfile
|
||||||
import sys
|
import typing as t
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -67,6 +68,13 @@ class FileUtilABC(ABC):
|
|||||||
def rename(cls, src, dest):
|
def rename(cls, src, dest):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def tmpdir(
|
||||||
|
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
|
||||||
|
) -> tempfile.TemporaryDirectory:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FileUtilMacOS(FileUtilABC):
|
class FileUtilMacOS(FileUtilABC):
|
||||||
"""Various file utilities"""
|
"""Various file utilities"""
|
||||||
@@ -84,11 +92,10 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
if not os.path.isfile(src):
|
if not os.path.isfile(src):
|
||||||
raise FileNotFoundError("src file does not appear to exist", src)
|
raise FileNotFoundError("src file does not appear to exist", src)
|
||||||
|
|
||||||
# if error on copy, subprocess will raise CalledProcessError
|
|
||||||
try:
|
try:
|
||||||
os.link(src, dest)
|
os.link(src, dest)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e from e
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def copy(cls, src, dest):
|
def copy(cls, src, dest):
|
||||||
@@ -222,6 +229,17 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
os.rename(str(src), str(dest))
|
os.rename(str(src), str(dest))
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tmpdir(
|
||||||
|
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
|
||||||
|
) -> tempfile.TemporaryDirectory:
|
||||||
|
"""Securely creates a temporary directory using the same rules as mkdtemp().
|
||||||
|
The resulting object can be used as a context manager.
|
||||||
|
On completion of the context or destruction of the temporary directory object,
|
||||||
|
the newly created temporary directory and all its contents are removed from the filesystem.
|
||||||
|
"""
|
||||||
|
return TemporaryDirectory(prefix=prefix, dir=dir)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sig(st):
|
def _sig(st):
|
||||||
"""return tuple of (mode, size, mtime) of file based on os.stat
|
"""return tuple of (mode, size, mtime) of file based on os.stat
|
||||||
@@ -240,7 +258,7 @@ class FileUtil(FileUtilMacOS):
|
|||||||
|
|
||||||
class FileUtilNoOp(FileUtil):
|
class FileUtilNoOp(FileUtil):
|
||||||
"""No-Op implementation of FileUtil for testing / dry-run mode
|
"""No-Op implementation of FileUtil for testing / dry-run mode
|
||||||
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
|
all methods with exception of tmpdir, cmp, cmp_file_sig and file_cmp are no-op
|
||||||
cmp and cmp_file_sig functions as FileUtil methods do
|
cmp and cmp_file_sig functions as FileUtil methods do
|
||||||
file_cmp returns mock data
|
file_cmp returns mock data
|
||||||
"""
|
"""
|
||||||
@@ -291,3 +309,15 @@ class FileUtilNoOp(FileUtil):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def rename(cls, src, dest):
|
def rename(cls, src, dest):
|
||||||
cls.verbose(f"rename: {src}, {dest}")
|
cls.verbose(f"rename: {src}, {dest}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tmpdir(
|
||||||
|
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
|
||||||
|
) -> tempfile.TemporaryDirectory:
|
||||||
|
"""Securely creates a temporary directory using the same rules as mkdtemp().
|
||||||
|
The resulting object can be used as a context manager.
|
||||||
|
On completion of the context or destruction of the temporary directory object,
|
||||||
|
the newly created temporary directory and all its contents are removed from the filesystem.
|
||||||
|
"""
|
||||||
|
cls.verbose(f"tmpdir: {dir}")
|
||||||
|
return TemporaryDirectory(prefix=prefix, dir=dir)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import typing as t
|
||||||
from collections import namedtuple # pylint: disable=syntax-error
|
from collections import namedtuple # pylint: disable=syntax-error
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
|
|
||||||
|
|
||||||
import photoscript
|
import photoscript
|
||||||
from mako.template import Template
|
from mako.template import Template
|
||||||
@@ -55,7 +55,7 @@ __all__ = [
|
|||||||
"rename_jpeg_files",
|
"rename_jpeg_files",
|
||||||
]
|
]
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if t.TYPE_CHECKING:
|
||||||
from .photoinfo import PhotoInfo
|
from .photoinfo import PhotoInfo
|
||||||
|
|
||||||
# retry if download_missing/use_photos_export fails the first time (which sometimes it does)
|
# retry if download_missing/use_photos_export fails the first time (which sometimes it does)
|
||||||
@@ -74,11 +74,11 @@ class ExportOptions:
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
convert_to_jpeg (bool): if True, converts non-jpeg images to jpeg
|
convert_to_jpeg (bool): if True, converts non-jpeg images to jpeg
|
||||||
description_template (str): optional template string that will be rendered for use as photo description
|
description_template (str): t.Optional template string that will be rendered for use as photo description
|
||||||
download_missing: (bool, default=False): if True will attempt to export photo via applescript interaction with Photos if missing (see also use_photokit, use_photos_export)
|
download_missing: (bool, default=False): if True will attempt to export photo via applescript interaction with Photos if missing (see also use_photokit, use_photos_export)
|
||||||
dry_run: (bool, default=False): set to True to run in "dry run" mode
|
dry_run: (bool, default=False): set to True to run in "dry run" mode
|
||||||
edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version
|
edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version
|
||||||
exiftool_flags (list of str): optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
|
exiftool_flags (list of str): t.Optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
|
||||||
exiftool: (bool, default = False): if True, will use exiftool to write metadata to export file
|
exiftool: (bool, default = False): if True, will use exiftool to write metadata to export file
|
||||||
export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them
|
export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them
|
||||||
export_db: (ExportDB): instance of a class that conforms to ExportDB with methods for getting/setting data related to exported files to compare update state
|
export_db: (ExportDB): instance of a class that conforms to ExportDB with methods for getting/setting data related to exported files to compare update state
|
||||||
@@ -97,10 +97,10 @@ class ExportOptions:
|
|||||||
merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
|
merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
|
||||||
overwrite (bool, default=False): if True will overwrite files if they already exist
|
overwrite (bool, default=False): if True will overwrite files if they already exist
|
||||||
persons (bool): if True, include persons in exported metadata
|
persons (bool): if True, include persons in exported metadata
|
||||||
preview_suffix (str): optional string to append to end of filename for preview images
|
preview_suffix (str): t.Optional string to append to end of filename for preview images
|
||||||
preview (bool): if True, also exports preview image
|
preview (bool): if True, also exports preview image
|
||||||
raw_photo (bool, default=False): if True, will also export the associated RAW photo
|
raw_photo (bool, default=False): if True, will also export the associated RAW photo
|
||||||
render_options (RenderOptions): optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
|
render_options (RenderOptions): t.Optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
|
||||||
replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive
|
replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive
|
||||||
sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
|
sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
|
||||||
sidecar: bit field (int): set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
|
sidecar: bit field (int): set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
|
||||||
@@ -117,27 +117,28 @@ class ExportOptions:
|
|||||||
use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
|
use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
|
||||||
use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
|
use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
|
||||||
use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
|
use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
|
||||||
verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
verbose (callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||||
|
tmpdir: (str, default=None): Optional directory to use for temporary files, if None (default) uses system tmp directory
|
||||||
"""
|
"""
|
||||||
|
|
||||||
convert_to_jpeg: bool = False
|
convert_to_jpeg: bool = False
|
||||||
description_template: Optional[str] = None
|
description_template: t.Optional[str] = None
|
||||||
download_missing: bool = False
|
download_missing: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
edited: bool = False
|
edited: bool = False
|
||||||
exiftool_flags: Optional[List] = None
|
exiftool_flags: t.Optional[t.List] = None
|
||||||
exiftool: bool = False
|
exiftool: bool = False
|
||||||
export_as_hardlink: bool = False
|
export_as_hardlink: bool = False
|
||||||
export_db: Optional[ExportDB] = None
|
export_db: t.Optional[ExportDB] = None
|
||||||
face_regions: bool = True
|
face_regions: bool = True
|
||||||
fileutil: Optional[FileUtil] = None
|
fileutil: t.Optional[FileUtil] = None
|
||||||
force_update: bool = False
|
force_update: bool = False
|
||||||
ignore_date_modified: bool = False
|
ignore_date_modified: bool = False
|
||||||
ignore_signature: bool = False
|
ignore_signature: bool = False
|
||||||
increment: bool = True
|
increment: bool = True
|
||||||
jpeg_ext: Optional[str] = None
|
jpeg_ext: t.Optional[str] = None
|
||||||
jpeg_quality: float = 1.0
|
jpeg_quality: float = 1.0
|
||||||
keyword_template: Optional[List[str]] = None
|
keyword_template: t.Optional[t.List[str]] = None
|
||||||
live_photo: bool = False
|
live_photo: bool = False
|
||||||
location: bool = True
|
location: bool = True
|
||||||
merge_exif_keywords: bool = False
|
merge_exif_keywords: bool = False
|
||||||
@@ -147,7 +148,7 @@ class ExportOptions:
|
|||||||
preview_suffix: str = DEFAULT_PREVIEW_SUFFIX
|
preview_suffix: str = DEFAULT_PREVIEW_SUFFIX
|
||||||
preview: bool = False
|
preview: bool = False
|
||||||
raw_photo: bool = False
|
raw_photo: bool = False
|
||||||
render_options: Optional[RenderOptions] = None
|
render_options: t.Optional[RenderOptions] = None
|
||||||
replace_keywords: bool = False
|
replace_keywords: bool = False
|
||||||
sidecar_drop_ext: bool = False
|
sidecar_drop_ext: bool = False
|
||||||
sidecar: int = 0
|
sidecar: int = 0
|
||||||
@@ -159,7 +160,8 @@ class ExportOptions:
|
|||||||
use_persons_as_keywords: bool = False
|
use_persons_as_keywords: bool = False
|
||||||
use_photokit: bool = False
|
use_photokit: bool = False
|
||||||
use_photos_export: bool = False
|
use_photos_export: bool = False
|
||||||
verbose: Optional[Callable] = None
|
verbose: t.Optional[t.Callable] = None
|
||||||
|
tmpdir: t.Optional[str] = None
|
||||||
|
|
||||||
def asdict(self):
|
def asdict(self):
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@@ -176,13 +178,13 @@ class StagedFiles:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
original: Optional[str] = None,
|
original: t.Optional[str] = None,
|
||||||
original_live: Optional[str] = None,
|
original_live: t.Optional[str] = None,
|
||||||
edited: Optional[str] = None,
|
edited: t.Optional[str] = None,
|
||||||
edited_live: Optional[str] = None,
|
edited_live: t.Optional[str] = None,
|
||||||
preview: Optional[str] = None,
|
preview: t.Optional[str] = None,
|
||||||
raw: Optional[str] = None,
|
raw: t.Optional[str] = None,
|
||||||
error: Optional[List[str]] = None,
|
error: t.Optional[t.List[str]] = None,
|
||||||
):
|
):
|
||||||
self.original = original
|
self.original = original
|
||||||
self.original_live = original_live
|
self.original_live = original_live
|
||||||
@@ -359,23 +361,21 @@ class ExportResults:
|
|||||||
|
|
||||||
|
|
||||||
class PhotoExporter:
|
class PhotoExporter:
|
||||||
def __init__(self, photo: "PhotoInfo"):
|
def __init__(self, photo: "PhotoInfo", tmpdir: t.Optional[str] = None):
|
||||||
self.photo = photo
|
self.photo = photo
|
||||||
self._render_options = RenderOptions()
|
self._render_options = RenderOptions()
|
||||||
self._verbose = self.photo._verbose
|
self._verbose = self.photo._verbose
|
||||||
|
|
||||||
# temp directory for staging downloaded missing files
|
# temp directory for staging downloaded missing files
|
||||||
self._temp_dir = tempfile.TemporaryDirectory(
|
self._temp_dir = None
|
||||||
prefix=f"osxphotos_photo_exporter_{self.photo.uuid}_"
|
self._temp_dir_path = None
|
||||||
)
|
|
||||||
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
|
|
||||||
self.fileutil = FileUtil
|
self.fileutil = FileUtil
|
||||||
|
|
||||||
def export(
|
def export(
|
||||||
self,
|
self,
|
||||||
dest,
|
dest,
|
||||||
filename=None,
|
filename=None,
|
||||||
options: Optional[ExportOptions] = None,
|
options: t.Optional[ExportOptions] = None,
|
||||||
) -> ExportResults:
|
) -> ExportResults:
|
||||||
"""export photo
|
"""export photo
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ class PhotoExporter:
|
|||||||
in which case export will use the extension provided by Photos upon export.
|
in which case export will use the extension provided by Photos upon export.
|
||||||
e.g. to get the extension of the edited photo,
|
e.g. to get the extension of the edited photo,
|
||||||
reference PhotoInfo.path_edited
|
reference PhotoInfo.path_edited
|
||||||
options (ExportOptions): optional ExportOptions instance
|
options (ExportOptions): t.Optional ExportOptions instance
|
||||||
|
|
||||||
Returns: ExportResults instance
|
Returns: ExportResults instance
|
||||||
|
|
||||||
@@ -399,6 +399,9 @@ class PhotoExporter:
|
|||||||
|
|
||||||
options = options or ExportOptions()
|
options = options or ExportOptions()
|
||||||
|
|
||||||
|
# temp dir must be initialized before any of the methods called by export() are called
|
||||||
|
self._init_temp_dir(options)
|
||||||
|
|
||||||
verbose = options.verbose or self._verbose
|
verbose = options.verbose or self._verbose
|
||||||
if verbose and not callable(verbose):
|
if verbose and not callable(verbose):
|
||||||
raise TypeError("verbose must be callable")
|
raise TypeError("verbose must be callable")
|
||||||
@@ -554,7 +557,23 @@ class PhotoExporter:
|
|||||||
|
|
||||||
return all_results
|
return all_results
|
||||||
|
|
||||||
def _touch_files(self, touch_files: List, options: ExportOptions) -> ExportResults:
|
def _init_temp_dir(self, options: ExportOptions):
|
||||||
|
"""Initialize (if necessary) the object's temporary directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: ExportOptions object
|
||||||
|
"""
|
||||||
|
if self._temp_dir is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
fileutil = options.fileutil or FileUtil
|
||||||
|
self._temp_dir = fileutil.tmpdir(prefix="osxphotos_export_", dir=options.tmpdir)
|
||||||
|
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _touch_files(
|
||||||
|
self, touch_files: t.List, options: ExportOptions
|
||||||
|
) -> ExportResults:
|
||||||
"""touch file date/time to match photo creation date/time; only touches files if needed"""
|
"""touch file date/time to match photo creation date/time; only touches files if needed"""
|
||||||
fileutil = options.fileutil
|
fileutil = options.fileutil
|
||||||
touch_results = []
|
touch_results = []
|
||||||
@@ -731,21 +750,6 @@ class PhotoExporter:
|
|||||||
if options.live_photo and self.photo.live_photo:
|
if options.live_photo and self.photo.live_photo:
|
||||||
staged.edited_live = self.photo.path_edited_live_photo
|
staged.edited_live = self.photo.path_edited_live_photo
|
||||||
|
|
||||||
if options.exiftool and not options.dry_run and not options.export_as_hardlink:
|
|
||||||
# copy files to temp dir for exiftool to process before export
|
|
||||||
# not needed for download_missing or use_photokit as those files already staged to temp dir
|
|
||||||
for file_type in [
|
|
||||||
"raw",
|
|
||||||
"preview",
|
|
||||||
"original",
|
|
||||||
"original_live",
|
|
||||||
"edited",
|
|
||||||
"edited_live",
|
|
||||||
]:
|
|
||||||
staged_file = getattr(staged, file_type)
|
|
||||||
if staged_file:
|
|
||||||
setattr(staged, file_type, self._copy_to_temp_file(staged_file))
|
|
||||||
|
|
||||||
# download any missing files
|
# download any missing files
|
||||||
if options.download_missing:
|
if options.download_missing:
|
||||||
live_photo = staged.edited_live if options.edited else staged.original_live
|
live_photo = staged.edited_live if options.edited else staged.original_live
|
||||||
@@ -904,7 +908,7 @@ class PhotoExporter:
|
|||||||
results = StagedFiles()
|
results = StagedFiles()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exported = _export_photo_uuid_applescript(
|
exported = self._export_photo_uuid_applescript(
|
||||||
self.photo.uuid,
|
self.photo.uuid,
|
||||||
dest.parent,
|
dest.parent,
|
||||||
filestem=dest.stem,
|
filestem=dest.stem,
|
||||||
@@ -955,7 +959,7 @@ class PhotoExporter:
|
|||||||
|
|
||||||
def _should_convert_to_jpeg(
|
def _should_convert_to_jpeg(
|
||||||
self, dest: pathlib.Path, options: ExportOptions
|
self, dest: pathlib.Path, options: ExportOptions
|
||||||
) -> Tuple[pathlib.Path, ExportOptions]:
|
) -> t.Tuple[pathlib.Path, ExportOptions]:
|
||||||
"""Determine if a file really should be converted to jpeg or not
|
"""Determine if a file really should be converted to jpeg or not
|
||||||
and return the new destination and ExportOptions instance with the convert_to_jpeg flag set appropriately
|
and return the new destination and ExportOptions instance with the convert_to_jpeg flag set appropriately
|
||||||
"""
|
"""
|
||||||
@@ -1090,6 +1094,15 @@ class PhotoExporter:
|
|||||||
|
|
||||||
if options.exiftool:
|
if options.exiftool:
|
||||||
# if exiftool, write the metadata
|
# if exiftool, write the metadata
|
||||||
|
# need to copy the file to a temp file before writing metadata
|
||||||
|
src = pathlib.Path(src)
|
||||||
|
tmp_file = increment_filename(
|
||||||
|
self._temp_dir_path / f"{src.stem}_exiftool{src.suffix}"
|
||||||
|
)
|
||||||
|
fileutil.copy(src, tmp_file)
|
||||||
|
# point src to the tmp_file so that the original source is not modified
|
||||||
|
# and the export grabs the new file
|
||||||
|
src = tmp_file
|
||||||
exif_results = self._write_exif_metadata_to_file(
|
exif_results = self._write_exif_metadata_to_file(
|
||||||
src, dest, options=options
|
src, dest, options=options
|
||||||
)
|
)
|
||||||
@@ -1138,6 +1151,105 @@ class PhotoExporter:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def _export_photo_uuid_applescript(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
dest: str,
|
||||||
|
filestem=None,
|
||||||
|
original=True,
|
||||||
|
edited=False,
|
||||||
|
live_photo=False,
|
||||||
|
timeout=120,
|
||||||
|
burst=False,
|
||||||
|
dry_run=False,
|
||||||
|
overwrite=False,
|
||||||
|
):
|
||||||
|
"""Export photo to dest path using applescript to control Photos
|
||||||
|
If photo is a live photo, exports both the photo and associated .mov file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uuid: UUID of photo to export
|
||||||
|
dest: destination path to export to
|
||||||
|
filestem: (string) if provided, exported filename will be named stem.ext
|
||||||
|
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||||
|
If not provided, file will be named with whatever name Photos uses
|
||||||
|
If filestem.ext exists, it wil be overwritten
|
||||||
|
original: (boolean) if True, export original image; default = True
|
||||||
|
edited: (boolean) if True, export edited photo; default = False
|
||||||
|
If photo not edited and edited=True, will still export the original image
|
||||||
|
caller must verify image has been edited
|
||||||
|
*Note*: must be called with either edited or original but not both,
|
||||||
|
will raise error if called with both edited and original = True
|
||||||
|
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||||
|
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||||
|
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||||
|
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
||||||
|
|
||||||
|
Returns: list of paths to exported file(s) or None if export failed
|
||||||
|
|
||||||
|
Raises: ExportError if error during export
|
||||||
|
|
||||||
|
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||||
|
has not been edited. This is due to how Photos Applescript interface works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dest = pathlib.Path(dest)
|
||||||
|
if not dest.is_dir():
|
||||||
|
raise ValueError(f"dest {dest} must be a directory")
|
||||||
|
|
||||||
|
if not original ^ edited:
|
||||||
|
raise ValueError("edited or original must be True but not both")
|
||||||
|
|
||||||
|
# export to a subdirectory of tmpdir
|
||||||
|
tmpdir = self.fileutil.tmpdir("osxphotos_applescript_export_", dir=self._temp_dir_path)
|
||||||
|
|
||||||
|
exported_files = []
|
||||||
|
filename = None
|
||||||
|
try:
|
||||||
|
# I've seen intermittent failures with the PhotoScript export so retry if
|
||||||
|
# export doesn't return anything
|
||||||
|
retries = 0
|
||||||
|
while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES:
|
||||||
|
photo = photoscript.Photo(uuid)
|
||||||
|
filename = photo.filename
|
||||||
|
exported_files = photo.export(
|
||||||
|
tmpdir.name, original=original, timeout=timeout
|
||||||
|
)
|
||||||
|
retries += 1
|
||||||
|
except Exception as e:
|
||||||
|
raise ExportError(e)
|
||||||
|
|
||||||
|
if not exported_files or not filename:
|
||||||
|
# nothing got exported
|
||||||
|
raise ExportError(f"Could not export photo {uuid} ({lineno(__file__)})")
|
||||||
|
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||||
|
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||||
|
# TemporaryDirectory will cleanup on return
|
||||||
|
filename_stem = pathlib.Path(filename).stem
|
||||||
|
exported_paths = []
|
||||||
|
for fname in exported_files:
|
||||||
|
path = pathlib.Path(tmpdir.name) / fname
|
||||||
|
if len(exported_files) > 1 and not live_photo and path.suffix.lower() == ".mov":
|
||||||
|
# it's the .mov part of live photo but not requested, so don't export
|
||||||
|
continue
|
||||||
|
if len(exported_files) > 1 and burst and path.stem != filename_stem:
|
||||||
|
# skip any burst photo that's not the one we asked for
|
||||||
|
continue
|
||||||
|
if filestem:
|
||||||
|
# rename the file based on filestem, keeping original extension
|
||||||
|
dest_new = dest / f"{filestem}{path.suffix}"
|
||||||
|
else:
|
||||||
|
# use the name Photos provided
|
||||||
|
dest_new = dest / path.name
|
||||||
|
if not dry_run:
|
||||||
|
if overwrite and dest_new.exists():
|
||||||
|
FileUtil.unlink(dest_new)
|
||||||
|
FileUtil.copy(str(path), str(dest_new))
|
||||||
|
exported_paths.append(str(dest_new))
|
||||||
|
return exported_paths
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _write_sidecar_files(
|
def _write_sidecar_files(
|
||||||
self,
|
self,
|
||||||
dest: pathlib.Path,
|
dest: pathlib.Path,
|
||||||
@@ -1366,7 +1478,9 @@ class PhotoExporter:
|
|||||||
return exiftool.warning, exiftool.error
|
return exiftool.warning, exiftool.error
|
||||||
|
|
||||||
def _exiftool_dict(
|
def _exiftool_dict(
|
||||||
self, options: Optional[ExportOptions] = None, filename: Optional[str] = None
|
self,
|
||||||
|
options: t.Optional[ExportOptions] = None,
|
||||||
|
filename: t.Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||||
Does not include all the EXIF fields as those are likely already in the image.
|
Does not include all the EXIF fields as those are likely already in the image.
|
||||||
@@ -1668,9 +1782,9 @@ class PhotoExporter:
|
|||||||
|
|
||||||
def _exiftool_json_sidecar(
|
def _exiftool_json_sidecar(
|
||||||
self,
|
self,
|
||||||
options: Optional[ExportOptions] = None,
|
options: t.Optional[ExportOptions] = None,
|
||||||
tag_groups: bool = True,
|
tag_groups: bool = True,
|
||||||
filename: Optional[str] = None,
|
filename: t.Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||||
Does not include all the EXIF fields as those are likely already in the image.
|
Does not include all the EXIF fields as those are likely already in the image.
|
||||||
@@ -1721,13 +1835,15 @@ class PhotoExporter:
|
|||||||
return json.dumps([exif])
|
return json.dumps([exif])
|
||||||
|
|
||||||
def _xmp_sidecar(
|
def _xmp_sidecar(
|
||||||
self, options: Optional[ExportOptions] = None, extension: Optional[str] = None
|
self,
|
||||||
|
options: t.Optional[ExportOptions] = None,
|
||||||
|
extension: t.Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""returns string for XMP sidecar
|
"""returns string for XMP sidecar
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
options (ExportOptions): options for export
|
options (ExportOptions): options for export
|
||||||
extension (Optional[str]): which extension to use for SidecarForExtension property
|
extension (t.Optional[str]): which extension to use for SidecarForExtension property
|
||||||
"""
|
"""
|
||||||
|
|
||||||
options = options or ExportOptions()
|
options = options or ExportOptions()
|
||||||
@@ -1859,101 +1975,6 @@ def hexdigest(strval):
|
|||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _export_photo_uuid_applescript(
|
|
||||||
uuid,
|
|
||||||
dest,
|
|
||||||
filestem=None,
|
|
||||||
original=True,
|
|
||||||
edited=False,
|
|
||||||
live_photo=False,
|
|
||||||
timeout=120,
|
|
||||||
burst=False,
|
|
||||||
dry_run=False,
|
|
||||||
overwrite=False,
|
|
||||||
):
|
|
||||||
"""Export photo to dest path using applescript to control Photos
|
|
||||||
If photo is a live photo, exports both the photo and associated .mov file
|
|
||||||
|
|
||||||
Args:
|
|
||||||
uuid: UUID of photo to export
|
|
||||||
dest: destination path to export to
|
|
||||||
filestem: (string) if provided, exported filename will be named stem.ext
|
|
||||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
|
||||||
If not provided, file will be named with whatever name Photos uses
|
|
||||||
If filestem.ext exists, it wil be overwritten
|
|
||||||
original: (boolean) if True, export original image; default = True
|
|
||||||
edited: (boolean) if True, export edited photo; default = False
|
|
||||||
If photo not edited and edited=True, will still export the original image
|
|
||||||
caller must verify image has been edited
|
|
||||||
*Note*: must be called with either edited or original but not both,
|
|
||||||
will raise error if called with both edited and original = True
|
|
||||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
|
||||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
|
||||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
|
||||||
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
|
||||||
|
|
||||||
Returns: list of paths to exported file(s) or None if export failed
|
|
||||||
|
|
||||||
Raises: ExportError if error during export
|
|
||||||
|
|
||||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
|
||||||
has not been edited. This is due to how Photos Applescript interface works.
|
|
||||||
"""
|
|
||||||
|
|
||||||
dest = pathlib.Path(dest)
|
|
||||||
if not dest.is_dir():
|
|
||||||
raise ValueError(f"dest {dest} must be a directory")
|
|
||||||
|
|
||||||
if not original ^ edited:
|
|
||||||
raise ValueError("edited or original must be True but not both")
|
|
||||||
|
|
||||||
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
|
||||||
|
|
||||||
exported_files = []
|
|
||||||
filename = None
|
|
||||||
try:
|
|
||||||
# I've seen intermittent failures with the PhotoScript export so retry if
|
|
||||||
# export doesn't return anything
|
|
||||||
retries = 0
|
|
||||||
while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES:
|
|
||||||
photo = photoscript.Photo(uuid)
|
|
||||||
filename = photo.filename
|
|
||||||
exported_files = photo.export(
|
|
||||||
tmpdir.name, original=original, timeout=timeout
|
|
||||||
)
|
|
||||||
retries += 1
|
|
||||||
except Exception as e:
|
|
||||||
raise ExportError(e)
|
|
||||||
|
|
||||||
if not exported_files or not filename:
|
|
||||||
# nothing got exported
|
|
||||||
raise ExportError(f"Could not export photo {uuid} ({lineno(__file__)})")
|
|
||||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
|
||||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
|
||||||
# TemporaryDirectory will cleanup on return
|
|
||||||
filename_stem = pathlib.Path(filename).stem
|
|
||||||
exported_paths = []
|
|
||||||
for fname in exported_files:
|
|
||||||
path = pathlib.Path(tmpdir.name) / fname
|
|
||||||
if len(exported_files) > 1 and not live_photo and path.suffix.lower() == ".mov":
|
|
||||||
# it's the .mov part of live photo but not requested, so don't export
|
|
||||||
continue
|
|
||||||
if len(exported_files) > 1 and burst and path.stem != filename_stem:
|
|
||||||
# skip any burst photo that's not the one we asked for
|
|
||||||
continue
|
|
||||||
if filestem:
|
|
||||||
# rename the file based on filestem, keeping original extension
|
|
||||||
dest_new = dest / f"{filestem}{path.suffix}"
|
|
||||||
else:
|
|
||||||
# use the name Photos provided
|
|
||||||
dest_new = dest / path.name
|
|
||||||
if not dry_run:
|
|
||||||
if overwrite and dest_new.exists():
|
|
||||||
FileUtil.unlink(dest_new)
|
|
||||||
FileUtil.copy(str(path), str(dest_new))
|
|
||||||
exported_paths.append(str(dest_new))
|
|
||||||
return exported_paths
|
|
||||||
|
|
||||||
|
|
||||||
def _check_export_suffix(src, dest, edited):
|
def _check_export_suffix(src, dest, edited):
|
||||||
"""Helper function for exporting photos to check file extensions of destination path.
|
"""Helper function for exporting photos to check file extensions of destination path.
|
||||||
|
|||||||
@@ -1384,7 +1384,7 @@ def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected):
|
|||||||
|
|
||||||
|
|
||||||
def test_export():
|
def test_export():
|
||||||
|
"""test basic export"""
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
# pylint: disable=not-context-manager
|
# pylint: disable=not-context-manager
|
||||||
@@ -1395,6 +1395,22 @@ def test_export():
|
|||||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
|
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_tmpdir():
|
||||||
|
"""test basic export with --tmpdir"""
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
tmpdir = TemporaryDirectory()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--tmpdir", tmpdir.name],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*")
|
||||||
|
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
|
||||||
|
|
||||||
|
|
||||||
def test_export_uuid_from_file():
|
def test_export_uuid_from_file():
|
||||||
"""Test export with --uuid-from-file"""
|
"""Test export with --uuid-from-file"""
|
||||||
|
|
||||||
@@ -1811,6 +1827,40 @@ def test_export_exiftool():
|
|||||||
assert exif[key] == CLI_EXIFTOOL[uuid][key]
|
assert exif[key] == CLI_EXIFTOOL[uuid][key]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||||
|
def test_export_exiftool_tmpdir():
|
||||||
|
"""test --exiftool with --tmpdir"""
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
tmpdir = TemporaryDirectory()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
for uuid in CLI_EXIFTOOL:
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[
|
||||||
|
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--exiftool",
|
||||||
|
"--uuid",
|
||||||
|
f"{uuid}",
|
||||||
|
"--tmpdir",
|
||||||
|
tmpdir.name,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*")
|
||||||
|
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
|
||||||
|
|
||||||
|
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
|
||||||
|
for key in CLI_EXIFTOOL[uuid]:
|
||||||
|
if type(exif[key]) == list:
|
||||||
|
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
|
||||||
|
else:
|
||||||
|
assert exif[key] == CLI_EXIFTOOL[uuid][key]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||||
def test_export_exiftool_template_change():
|
def test_export_exiftool_template_change():
|
||||||
"""Test --exiftool when template changes with --update, #630"""
|
"""Test --exiftool when template changes with --update, #630"""
|
||||||
@@ -6503,7 +6553,7 @@ def test_export_download_missing_preview():
|
|||||||
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
|
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
|
||||||
reason="Skip if not running on author's personal library.",
|
reason="Skip if not running on author's personal library.",
|
||||||
)
|
)
|
||||||
def test_export_download_missing_preview_applesccript():
|
def test_export_download_missing_preview_applescript():
|
||||||
"""test --download-missing --preview and applescript download, #564"""
|
"""test --download-missing --preview and applescript download, #564"""
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
""" test FileUtil """
|
""" test FileUtil """
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
TEST_HEIC = "tests/test-images/IMG_3092.heic"
|
TEST_HEIC = "tests/test-images/IMG_3092.heic"
|
||||||
TEST_RAW = "tests/test-images/DSC03584.dng"
|
TEST_RAW = "tests/test-images/DSC03584.dng"
|
||||||
|
|
||||||
@@ -11,6 +15,7 @@ def test_copy_file_valid():
|
|||||||
# copy file with valid src, dest
|
# copy file with valid src, dest
|
||||||
import os.path
|
import os.path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -23,6 +28,7 @@ def test_copy_file_valid():
|
|||||||
def test_copy_file_invalid():
|
def test_copy_file_invalid():
|
||||||
# copy file with invalid src
|
# copy file with invalid src
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -36,6 +42,7 @@ def test_hardlink_file_valid():
|
|||||||
# hardlink file with valid src, dest
|
# hardlink file with valid src, dest
|
||||||
import os.path
|
import os.path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -49,6 +56,7 @@ def test_hardlink_file_valid():
|
|||||||
def test_unlink_file():
|
def test_unlink_file():
|
||||||
import os.path
|
import os.path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -63,6 +71,7 @@ def test_unlink_file():
|
|||||||
def test_rmdir():
|
def test_rmdir():
|
||||||
import os.path
|
import os.path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -77,9 +86,10 @@ def test_rmdir():
|
|||||||
reason="Skip if running in Github actions, no GPU.",
|
reason="Skip if running in Github actions, no GPU.",
|
||||||
)
|
)
|
||||||
def test_convert_to_jpeg():
|
def test_convert_to_jpeg():
|
||||||
""" test convert_to_jpeg """
|
"""test convert_to_jpeg"""
|
||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -95,9 +105,10 @@ def test_convert_to_jpeg():
|
|||||||
reason="Skip if running in Github actions, no GPU.",
|
reason="Skip if running in Github actions, no GPU.",
|
||||||
)
|
)
|
||||||
def test_convert_to_jpeg_quality():
|
def test_convert_to_jpeg_quality():
|
||||||
""" test convert_to_jpeg with compression_quality """
|
"""test convert_to_jpeg with compression_quality"""
|
||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -113,6 +124,7 @@ def test_rename_file():
|
|||||||
# rename file with valid src, dest
|
# rename file with valid src, dest
|
||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
@@ -125,3 +137,15 @@ def test_rename_file():
|
|||||||
assert pathlib.Path(dest2).exists()
|
assert pathlib.Path(dest2).exists()
|
||||||
assert not pathlib.Path(dest).exists()
|
assert not pathlib.Path(dest).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tempdir():
|
||||||
|
"""Test FileUtil.tmpdir"""
|
||||||
|
tmpdir = FileUtil.tmpdir()
|
||||||
|
assert pathlib.Path(tmpdir.name).is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tempdir_context_mgr():
|
||||||
|
"""Test Fileutil.tmpdir as context manager"""
|
||||||
|
with FileUtil.tmpdir() as tmpdir_name:
|
||||||
|
assert pathlib.Path(tmpdir_name).is_dir()
|
||||||
|
assert not pathlib.Path(tmpdir_name).is_dir()
|
||||||
|
|||||||
Reference in New Issue
Block a user