Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e272e95a85 | ||
|
|
84a96bd4d0 | ||
|
|
d26b625d57 | ||
|
|
8731e7d5bc | ||
|
|
2e501e6a9b | ||
|
|
7f4c981abe | ||
|
|
bbcc3acba9 | ||
|
|
fccd746c58 | ||
|
|
adb90a3364 | ||
|
|
445010e7e5 | ||
|
|
1227465aa7 | ||
|
|
de1900f10a | ||
|
|
ed315fffd2 | ||
|
|
be1f3a98d9 | ||
|
|
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
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
python -m pytest tests/
|
||||
python -m pytest -v tests/
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -4,6 +4,30 @@ 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.47.4](https://github.com/RhetTbull/osxphotos/compare/v0.47.3...v0.47.4)
|
||||
|
||||
> 2 March 2022
|
||||
|
||||
- Added --tmpdir, #650 [`#651`](https://github.com/RhetTbull/osxphotos/pull/651)
|
||||
- Version bump [`6b342a1`](https://github.com/RhetTbull/osxphotos/commit/6b342a1733fa66d1663de2e3a234970f427a679f)
|
||||
- Version bump [`f132e9a`](https://github.com/RhetTbull/osxphotos/commit/f132e9a8438023e4c69c7fb767d24dea7465db4d)
|
||||
|
||||
#### [v0.47.3](https://github.com/RhetTbull/osxphotos/compare/v0.47.2...v0.47.3)
|
||||
|
||||
> 27 February 2022
|
||||
|
||||
- Help topic [`#644`](https://github.com/RhetTbull/osxphotos/pull/644)
|
||||
- updated docs [skip ci] [`ce73c9c`](https://github.com/RhetTbull/osxphotos/commit/ce73c9cab81fdd223dd49f2ff38608d553198412)
|
||||
- Added -v to pytest [`8be6a98`](https://github.com/RhetTbull/osxphotos/commit/8be6a98c3208b5da0fa620d1884a17275ab56599)
|
||||
|
||||
#### [v0.47.2](https://github.com/RhetTbull/osxphotos/compare/v0.47.1...v0.47.2)
|
||||
|
||||
> 27 February 2022
|
||||
|
||||
- Updated README.md [`b275280`](https://github.com/RhetTbull/osxphotos/commit/b275280a1f3e1b1a61dcc95aefebd4e326a47377)
|
||||
- Updated docs [skip ci] [`c95f682`](https://github.com/RhetTbull/osxphotos/commit/c95f682ca647f9b9e0718da658bdf7c735571f84)
|
||||
- Fix for --load-config, #643 [`feb9538`](https://github.com/RhetTbull/osxphotos/commit/feb9538d1c5ad6569232e5900393befb8ec1a57e)
|
||||
|
||||
#### [v0.47.1](https://github.com/RhetTbull/osxphotos/compare/v0.47.0...v0.47.1)
|
||||
|
||||
> 26 February 2022
|
||||
|
||||
54
README.md
54
README.md
@@ -162,7 +162,41 @@ Commands:
|
||||
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
|
||||
|
||||
@@ -602,6 +636,7 @@ Options:
|
||||
~/Pictures/Photos Library.photoslibrary
|
||||
-V, --verbose Print verbose output.
|
||||
--timestamp Add time stamp to verbose output
|
||||
--no-progress Do not display progress bar during export.
|
||||
--keyword KEYWORD Search for photos with keyword KEYWORD. If
|
||||
more than one keyword, treated as "OR", e.g.
|
||||
find photos matching any keyword
|
||||
@@ -1178,6 +1213,14 @@ Options:
|
||||
network or slow disk but could result in
|
||||
losing update state information if the program
|
||||
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 options from file as written with --save-
|
||||
config. This allows you to save a complex
|
||||
@@ -1196,6 +1239,10 @@ Options:
|
||||
--config-only If specified, saves the config file but does
|
||||
not export any files; must be used with
|
||||
--save-config.
|
||||
--theme THEME Specify the color theme to use for --verbose
|
||||
output. Valid themes are 'dark', 'light',
|
||||
'mono', and 'plain'. Defaults to 'dark' or
|
||||
'light' depending on system dark mode setting.
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
** Export **
|
||||
@@ -1741,7 +1788,7 @@ Substitution Description
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{crlf} a carriage return + line feed: '\r\n'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.47.1'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.47.5'
|
||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
@@ -3645,7 +3692,7 @@ The following template field substitutions are availabe for use the templating s
|
||||
|{lf}|A line feed: '\n', alias for {newline}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.47.1'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.47.5'|
|
||||
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||
@@ -3794,6 +3841,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_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.
|
||||
- tmpfile (str): optional path to use for temporary files
|
||||
|
||||
#### `ExportResults`
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
build
|
||||
m2r2
|
||||
pdbpp
|
||||
pyinstaller==4.4
|
||||
pyinstaller==4.10
|
||||
pytest-mock
|
||||
pytest==7.0.1
|
||||
Sphinx
|
||||
@@ -9,4 +9,4 @@ sphinx_click
|
||||
sphinx_rtd_theme
|
||||
sphinxcontrib-programoutput
|
||||
twine
|
||||
wheel
|
||||
wheel
|
||||
|
||||
@@ -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: bc3dce8a14bcd1b0c8a34e4d16f0011f
|
||||
config: eb2f8fde33a1941a916d56936e3d2c64
|
||||
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="../_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../_static/alabaster.css" />
|
||||
<script data-url_root="../" id="documentation_options" src="../_static/documentation_options.js"></script>
|
||||
@@ -89,7 +89,7 @@
|
||||
©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>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos.photoinfo — osxphotos 0.46.4 documentation</title>
|
||||
<title>osxphotos.photoinfo — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../../_static/alabaster.css" />
|
||||
<script data-url_root="../../" id="documentation_options" src="../../_static/documentation_options.js"></script>
|
||||
@@ -87,7 +87,7 @@
|
||||
<span class="kn">from</span> <span class="nn">.searchinfo</span> <span class="kn">import</span> <span class="n">SearchInfo</span>
|
||||
<span class="kn">from</span> <span class="nn">.text_detection</span> <span class="kn">import</span> <span class="n">detect_text</span>
|
||||
<span class="kn">from</span> <span class="nn">.uti</span> <span class="kn">import</span> <span class="n">get_preferred_uti_extension</span><span class="p">,</span> <span class="n">get_uti_for_extension</span>
|
||||
<span class="kn">from</span> <span class="nn">.utils</span> <span class="kn">import</span> <span class="n">_debug</span><span class="p">,</span> <span class="n">_get_resource_loc</span><span class="p">,</span> <span class="n">list_directory</span><span class="p">,</span> <span class="n">_debug</span>
|
||||
<span class="kn">from</span> <span class="nn">.utils</span> <span class="kn">import</span> <span class="n">_get_resource_loc</span><span class="p">,</span> <span class="n">list_directory</span>
|
||||
|
||||
<span class="n">__all__</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"PhotoInfo"</span><span class="p">,</span> <span class="s2">"PhotoInfoNone"</span><span class="p">]</span>
|
||||
|
||||
@@ -1856,7 +1856,7 @@
|
||||
©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>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos.photosdb.photosdb — osxphotos 0.46.6 documentation</title>
|
||||
<title>osxphotos.photosdb.photosdb — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../../_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../../../_static/alabaster.css" />
|
||||
<script data-url_root="../../../" id="documentation_options" src="../../../_static/documentation_options.js"></script>
|
||||
@@ -83,15 +83,16 @@
|
||||
<span class="kn">from</span> <span class="nn">.._version</span> <span class="kn">import</span> <span class="n">__version__</span>
|
||||
<span class="kn">from</span> <span class="nn">..albuminfo</span> <span class="kn">import</span> <span class="n">AlbumInfo</span><span class="p">,</span> <span class="n">FolderInfo</span><span class="p">,</span> <span class="n">ImportInfo</span><span class="p">,</span> <span class="n">ProjectInfo</span>
|
||||
<span class="kn">from</span> <span class="nn">..datetime_utils</span> <span class="kn">import</span> <span class="n">datetime_has_tz</span><span class="p">,</span> <span class="n">datetime_naive_to_local</span>
|
||||
<span class="kn">from</span> <span class="nn">..debug</span> <span class="kn">import</span> <span class="n">is_debug</span>
|
||||
<span class="kn">from</span> <span class="nn">..fileutil</span> <span class="kn">import</span> <span class="n">FileUtil</span>
|
||||
<span class="kn">from</span> <span class="nn">..personinfo</span> <span class="kn">import</span> <span class="n">PersonInfo</span>
|
||||
<span class="kn">from</span> <span class="nn">..photoinfo</span> <span class="kn">import</span> <span class="n">PhotoInfo</span>
|
||||
<span class="kn">from</span> <span class="nn">..phototemplate</span> <span class="kn">import</span> <span class="n">RenderOptions</span>
|
||||
<span class="kn">from</span> <span class="nn">..queryoptions</span> <span class="kn">import</span> <span class="n">QueryOptions</span>
|
||||
<span class="kn">from</span> <span class="nn">..rich_utils</span> <span class="kn">import</span> <span class="n">add_rich_markup_tag</span>
|
||||
<span class="kn">from</span> <span class="nn">..utils</span> <span class="kn">import</span> <span class="p">(</span>
|
||||
<span class="n">_check_file_exists</span><span class="p">,</span>
|
||||
<span class="n">_db_is_locked</span><span class="p">,</span>
|
||||
<span class="n">_debug</span><span class="p">,</span>
|
||||
<span class="n">_get_os_version</span><span class="p">,</span>
|
||||
<span class="n">_open_sql_file</span><span class="p">,</span>
|
||||
<span class="n">get_last_library_path</span><span class="p">,</span>
|
||||
@@ -123,13 +124,14 @@
|
||||
<span class="n">labels_normalized_as_dict</span><span class="p">,</span>
|
||||
<span class="p">)</span>
|
||||
|
||||
<span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">dbfile</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">verbose</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">exiftool</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
|
||||
<span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">dbfile</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">verbose</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">exiftool</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">rich</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
|
||||
<span class="sd">"""Create a new PhotosDB object.</span>
|
||||
|
||||
<span class="sd"> Args:</span>
|
||||
<span class="sd"> dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.</span>
|
||||
<span class="sd"> verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.</span>
|
||||
<span class="sd"> exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH</span>
|
||||
<span class="sd"> rich: use rich with verbose output</span>
|
||||
|
||||
<span class="sd"> Raises:</span>
|
||||
<span class="sd"> FileNotFoundError if dbfile is not a valid Photos library.</span>
|
||||
@@ -152,6 +154,12 @@
|
||||
<span class="k">raise</span> <span class="ne">TypeError</span><span class="p">(</span><span class="s2">"verbose must be callable"</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_verbose</span> <span class="o">=</span> <span class="n">verbose</span>
|
||||
|
||||
<span class="c1"># define functions for adding markup</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_filepath</span> <span class="o">=</span> <span class="n">add_rich_markup_tag</span><span class="p">(</span><span class="s2">"filepath"</span><span class="p">,</span> <span class="n">rich</span><span class="o">=</span><span class="n">rich</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_filename</span> <span class="o">=</span> <span class="n">add_rich_markup_tag</span><span class="p">(</span><span class="s2">"filename"</span><span class="p">,</span> <span class="n">rich</span><span class="o">=</span><span class="n">rich</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_uuid</span> <span class="o">=</span> <span class="n">add_rich_markup_tag</span><span class="p">(</span><span class="s2">"uuid"</span><span class="p">,</span> <span class="n">rich</span><span class="o">=</span><span class="n">rich</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_num</span> <span class="o">=</span> <span class="n">add_rich_markup_tag</span><span class="p">(</span><span class="s2">"num"</span><span class="p">,</span> <span class="n">rich</span><span class="o">=</span><span class="n">rich</span><span class="p">)</span>
|
||||
|
||||
<span class="c1"># enable beta features</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_beta</span> <span class="o">=</span> <span class="kc">False</span>
|
||||
|
||||
@@ -297,7 +305,7 @@
|
||||
<span class="c1"># key is Z_PK of ZMOMENT table and values are the moment info</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_db_moment_pk</span> <span class="o">=</span> <span class="p">{}</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"dbfile = </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">dbfile</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
|
||||
@@ -314,7 +322,7 @@
|
||||
<span class="k">if</span> <span class="ow">not</span> <span class="n">_check_file_exists</span><span class="p">(</span><span class="n">dbfile</span><span class="p">):</span>
|
||||
<span class="k">raise</span> <span class="ne">FileNotFoundError</span><span class="p">(</span><span class="sa">f</span><span class="s2">"dbfile </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2"> does not exist"</span><span class="p">,</span> <span class="n">dbfile</span><span class="p">)</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"dbfile = </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
|
||||
<span class="c1"># init database names</span>
|
||||
@@ -328,7 +336,7 @@
|
||||
<span class="c1"># or photosanalysisd</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_dbfile</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_tmp_db</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">abspath</span><span class="p">(</span><span class="n">dbfile</span><span class="p">)</span>
|
||||
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Processing database </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Processing database </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_filepath</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
|
||||
<span class="c1"># if database is exclusively locked, make a copy of it and use the copy</span>
|
||||
<span class="c1"># Photos maintains an exclusive lock on the database file while Photos is open</span>
|
||||
@@ -348,13 +356,13 @@
|
||||
<span class="k">raise</span> <span class="ne">FileNotFoundError</span><span class="p">(</span><span class="sa">f</span><span class="s2">"dbfile </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2"> does not exist"</span><span class="p">,</span> <span class="n">dbfile</span><span class="p">)</span>
|
||||
<span class="k">else</span><span class="p">:</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_tmp_db</span> <span class="o">=</span> <span class="n">dbfile</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Processing database </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Processing database </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_filepath</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
<span class="c1"># if database is exclusively locked, make a copy of it and use the copy</span>
|
||||
<span class="k">if</span> <span class="n">_db_is_locked</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span><span class="p">):</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Database locked, creating temporary copy."</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_tmp_db</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_copy_db_file</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span><span class="p">)</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span>
|
||||
<span class="sa">f</span><span class="s2">"_dbfile = </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile</span><span class="si">}</span><span class="s2">, _dbfile_actual = </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_dbfile_actual</span><span class="si">}</span><span class="s2">"</span>
|
||||
<span class="p">)</span>
|
||||
@@ -369,7 +377,7 @@
|
||||
<span class="n">masters_path</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">library_path</span><span class="p">,</span> <span class="s2">"originals"</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_masters_path</span> <span class="o">=</span> <span class="n">masters_path</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"library = </span><span class="si">{</span><span class="n">library_path</span><span class="si">}</span><span class="s2">, masters = </span><span class="si">{</span><span class="n">masters_path</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
|
||||
<span class="k">if</span> <span class="nb">int</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_db_version</span><span class="p">)</span> <span class="o"><=</span> <span class="nb">int</span><span class="p">(</span><span class="n">_PHOTOS_4_VERSION</span><span class="p">):</span>
|
||||
@@ -625,7 +633,7 @@
|
||||
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Error copying</span><span class="si">{</span><span class="n">fname</span><span class="si">}</span><span class="s2"> to </span><span class="si">{</span><span class="n">dest_path</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
|
||||
<span class="k">raise</span> <span class="ne">Exception</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="n">dest_path</span><span class="p">)</span>
|
||||
|
||||
<span class="k">return</span> <span class="n">dest_path</span>
|
||||
@@ -652,7 +660,7 @@
|
||||
<span class="c1"># print("Error linking " + fname + " to " + dest_path, file=sys.stderr)</span>
|
||||
<span class="c1"># raise Exception</span>
|
||||
|
||||
<span class="c1"># if _debug():</span>
|
||||
<span class="c1"># if is_debug():</span>
|
||||
<span class="c1"># logging.debug(dest_path)</span>
|
||||
|
||||
<span class="c1"># return dest_path</span>
|
||||
@@ -663,7 +671,7 @@
|
||||
|
||||
<span class="n">verbose</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_verbose</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="s2">"Processing database."</span><span class="p">)</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Database version: </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_db_version</span><span class="si">}</span><span class="s2">."</span><span class="p">)</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Database version: </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_num</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_db_version</span><span class="p">)</span><span class="si">}</span><span class="s2">."</span><span class="p">)</span>
|
||||
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_photos_ver</span> <span class="o">=</span> <span class="mi">4</span> <span class="c1"># only used in Photos 5+</span>
|
||||
|
||||
@@ -1112,7 +1120,7 @@
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">"type"</span><span class="p">]</span> <span class="o">=</span> <span class="n">_MOVIE_TYPE</span>
|
||||
<span class="k">else</span><span class="p">:</span>
|
||||
<span class="c1"># unknown</span>
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"WARNING: </span><span class="si">{</span><span class="n">uuid</span><span class="si">}</span><span class="s2"> found unknown type </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">21</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">"type"</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
|
||||
|
||||
@@ -1335,7 +1343,7 @@
|
||||
<span class="ow">and</span> <span class="n">row</span><span class="p">[</span><span class="mi">6</span><span class="p">]</span> <span class="o">==</span> <span class="mi">2</span>
|
||||
<span class="p">):</span>
|
||||
<span class="k">if</span> <span class="s2">"edit_resource_id"</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">]:</span>
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span>
|
||||
<span class="sa">f</span><span class="s2">"WARNING: found more than one edit_resource_id for "</span>
|
||||
<span class="sa">f</span><span class="s2">"UUID </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2">,adjustmentUUID </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">}</span><span class="s2">, modelID </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span>
|
||||
@@ -1614,7 +1622,7 @@
|
||||
<span class="sd"> but it works so don't touch it.</span>
|
||||
<span class="sd"> """</span>
|
||||
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"_process_database5"</span><span class="p">)</span>
|
||||
<span class="n">verbose</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_verbose</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Processing database."</span><span class="p">)</span>
|
||||
@@ -1623,7 +1631,9 @@
|
||||
<span class="c1"># some of the tables/columns have different names in different versions of Photos</span>
|
||||
<span class="n">photos_ver</span> <span class="o">=</span> <span class="n">get_db_model_version</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_tmp_db</span><span class="p">)</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_photos_ver</span> <span class="o">=</span> <span class="n">photos_ver</span>
|
||||
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Database version: </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_db_version</span><span class="si">}</span><span class="s2">, </span><span class="si">{</span><span class="n">photos_ver</span><span class="si">}</span><span class="s2">."</span><span class="p">)</span>
|
||||
<span class="n">verbose</span><span class="p">(</span>
|
||||
<span class="sa">f</span><span class="s2">"Database version: </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_num</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_db_version</span><span class="p">)</span><span class="si">}</span><span class="s2">, </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">_num</span><span class="p">(</span><span class="n">photos_ver</span><span class="p">)</span><span class="si">}</span><span class="s2">."</span>
|
||||
<span class="p">)</span>
|
||||
<span class="n">asset_table</span> <span class="o">=</span> <span class="n">_DB_TABLE_NAMES</span><span class="p">[</span><span class="n">photos_ver</span><span class="p">][</span><span class="s2">"ASSET"</span><span class="p">]</span>
|
||||
<span class="n">keyword_join</span> <span class="o">=</span> <span class="n">_DB_TABLE_NAMES</span><span class="p">[</span><span class="n">photos_ver</span><span class="p">][</span><span class="s2">"KEYWORD_JOIN"</span><span class="p">]</span>
|
||||
<span class="n">asset_album_table</span> <span class="o">=</span> <span class="n">_DB_TABLE_NAMES</span><span class="p">[</span><span class="n">photos_ver</span><span class="p">][</span><span class="s2">"ASSET_ALBUM_TABLE"</span><span class="p">]</span>
|
||||
@@ -1636,7 +1646,7 @@
|
||||
<span class="n">hdr_type_column</span> <span class="o">=</span> <span class="n">_DB_TABLE_NAMES</span><span class="p">[</span><span class="n">photos_ver</span><span class="p">][</span><span class="s2">"HDR_TYPE"</span><span class="p">]</span>
|
||||
|
||||
<span class="c1"># Look for all combinations of persons and pictures</span>
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Getting information about persons"</span><span class="p">)</span>
|
||||
|
||||
<span class="c1"># get info to associate persons with photos</span>
|
||||
@@ -2045,7 +2055,7 @@
|
||||
<span class="k">elif</span> <span class="n">row</span><span class="p">[</span><span class="mi">17</span><span class="p">]</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
|
||||
<span class="n">info</span><span class="p">[</span><span class="s2">"type"</span><span class="p">]</span> <span class="o">=</span> <span class="n">_MOVIE_TYPE</span>
|
||||
<span class="k">else</span><span class="p">:</span>
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s2">"WARNING: </span><span class="si">{</span><span class="n">uuid</span><span class="si">}</span><span class="s2"> found unknown type </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">17</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
|
||||
<span class="n">info</span><span class="p">[</span><span class="s2">"type"</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
|
||||
|
||||
@@ -2244,7 +2254,7 @@
|
||||
<span class="k">if</span> <span class="n">uuid</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">:</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">"extendedDescription"</span><span class="p">]</span> <span class="o">=</span> <span class="n">normalize_unicode</span><span class="p">(</span><span class="n">row</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
|
||||
<span class="k">else</span><span class="p">:</span>
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span>
|
||||
<span class="sa">f</span><span class="s2">"WARNING: found description </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">}</span><span class="s2"> but no photo for </span><span class="si">{</span><span class="n">uuid</span><span class="si">}</span><span class="s2">"</span>
|
||||
<span class="p">)</span>
|
||||
@@ -2263,7 +2273,7 @@
|
||||
<span class="k">if</span> <span class="n">uuid</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">:</span>
|
||||
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">"adjustmentFormatID"</span><span class="p">]</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>
|
||||
<span class="k">else</span><span class="p">:</span>
|
||||
<span class="k">if</span> <span class="n">_debug</span><span class="p">():</span>
|
||||
<span class="k">if</span> <span class="n">is_debug</span><span class="p">():</span>
|
||||
<span class="n">logging</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span>
|
||||
<span class="sa">f</span><span class="s2">"WARNING: found adjustmentformatidentifier </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span><span class="si">}</span><span class="s2"> but no photo for uuid </span><span class="si">{</span><span class="n">row</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span>
|
||||
<span class="p">)</span>
|
||||
@@ -3607,7 +3617,7 @@
|
||||
©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>
|
||||
|
||||
</div>
|
||||
|
||||
3
docs/_static/basic.css
vendored
3
docs/_static/basic.css
vendored
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
@@ -757,6 +757,7 @@ span.pre {
|
||||
-ms-hyphens: none;
|
||||
-webkit-hyphens: none;
|
||||
hyphens: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* :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.
|
||||
*
|
||||
*/
|
||||
@@ -264,6 +264,9 @@ var Documentation = {
|
||||
hideSearchWords : function() {
|
||||
$('#searchbox .highlight-link').fadeOut(300);
|
||||
$('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 = {
|
||||
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
||||
VERSION: '0.47.1',
|
||||
VERSION: '0.47.5',
|
||||
LANGUAGE: 'None',
|
||||
COLLAPSE_INDEX: false,
|
||||
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,
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
2
docs/_static/searchtools.js
vendored
2
docs/_static/searchtools.js
vendored
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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/" />
|
||||
|
||||
<title>osxphotos command line interface (CLI) — osxphotos 0.47.1 documentation</title>
|
||||
<title>osxphotos command line interface (CLI) — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
@@ -94,7 +94,7 @@
|
||||
©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>
|
||||
|
||||
|
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Index — osxphotos 0.47.1 documentation</title>
|
||||
<title>Index — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
@@ -528,7 +528,7 @@
|
||||
©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>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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/" />
|
||||
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.47.1 documentation</title>
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<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.
|
||||
|
||||
|
|
||||
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>
|
||||
|
||||
|
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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/" />
|
||||
|
||||
<title>osxphotos — osxphotos 0.47.1 documentation</title>
|
||||
<title>osxphotos — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
@@ -92,7 +92,7 @@
|
||||
©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>
|
||||
|
||||
|
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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/" />
|
||||
|
||||
<title>osxphotos package — osxphotos 0.47.1 documentation</title>
|
||||
<title>osxphotos package — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
@@ -38,7 +38,7 @@
|
||||
<h2>osxphotos module<a class="headerlink" href="#osxphotos-module" title="Permalink to this headline">¶</a></h2>
|
||||
<dl class="py class">
|
||||
<dt class="sig sig-object py" id="osxphotos.PhotosDB">
|
||||
<em class="property"><span class="pre">class</span><span class="w"> </span></em><span class="sig-prename descclassname"><span class="pre">osxphotos.</span></span><span class="sig-name descname"><span class="pre">PhotosDB</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">dbfile</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">verbose</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">exiftool</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em><span class="sig-paren">)</span><a class="reference internal" href="_modules/osxphotos/photosdb/photosdb.html#PhotosDB"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#osxphotos.PhotosDB" title="Permalink to this definition">¶</a></dt>
|
||||
<em class="property"><span class="pre">class</span><span class="w"> </span></em><span class="sig-prename descclassname"><span class="pre">osxphotos.</span></span><span class="sig-name descname"><span class="pre">PhotosDB</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">dbfile</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">verbose</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">exiftool</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">rich</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em><span class="sig-paren">)</span><a class="reference internal" href="_modules/osxphotos/photosdb/photosdb.html#PhotosDB"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#osxphotos.PhotosDB" title="Permalink to this definition">¶</a></dt>
|
||||
<dd><p>Processes a Photos.app library database to extract information about photos</p>
|
||||
<dl class="py property">
|
||||
<dt class="sig sig-object py" id="osxphotos.PhotosDB.album_info">
|
||||
@@ -975,7 +975,7 @@ Returns None if no associated RAW image</p>
|
||||
©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>
|
||||
|
||||
|
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Search — osxphotos 0.47.1 documentation</title>
|
||||
<title>Search — osxphotos 0.47.5 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
©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>
|
||||
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,20 +2,27 @@
|
||||
# spec file for pyinstaller
|
||||
# run `pyinstaller osxphotos.spec`
|
||||
|
||||
|
||||
import os
|
||||
import importlib
|
||||
|
||||
pathex = os.getcwd()
|
||||
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
# include necessary data files
|
||||
datas = [
|
||||
("osxphotos/templates/xmp_sidecar.mako", "osxphotos/templates"),
|
||||
("osxphotos/templates/xmp_sidecar_beta.mako", "osxphotos/templates"),
|
||||
("osxphotos/phototemplate.tx", "osxphotos"),
|
||||
("osxphotos/phototemplate.md", "osxphotos"),
|
||||
("osxphotos/tutorial.md", "osxphotos"),
|
||||
("osxphotos/exiftool_filetypes.json", "osxphotos"),
|
||||
]
|
||||
datas = collect_data_files("osxphotos")
|
||||
datas.extend(
|
||||
[
|
||||
("osxphotos/templates/xmp_sidecar.mako", "osxphotos/templates"),
|
||||
("osxphotos/templates/xmp_sidecar_beta.mako", "osxphotos/templates"),
|
||||
("osxphotos/phototemplate.tx", "osxphotos"),
|
||||
("osxphotos/phototemplate.md", "osxphotos"),
|
||||
("osxphotos/tutorial.md", "osxphotos"),
|
||||
("osxphotos/exiftool_filetypes.json", "osxphotos"),
|
||||
]
|
||||
)
|
||||
|
||||
package_imports = [["photoscript", ["photoscript.applescript"]]]
|
||||
for package, files in package_imports:
|
||||
proot = os.path.dirname(importlib.import_module(package).__file__)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import logging
|
||||
|
||||
from ._constants import AlbumSortOrder
|
||||
from ._version import __version__
|
||||
from .debug import is_debug, set_debug
|
||||
from .exiftool import ExifTool
|
||||
from .export_db import ExportDB
|
||||
from .fileutil import FileUtil, FileUtilNoOp
|
||||
@@ -14,13 +17,14 @@ from .placeinfo import PlaceInfo
|
||||
from .queryoptions import QueryOptions
|
||||
from .scoreinfo import ScoreInfo
|
||||
from .searchinfo import SearchInfo
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
from .utils import _get_logger
|
||||
|
||||
if not is_debug():
|
||||
logging.disable(logging.DEBUG)
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"_debug",
|
||||
"_get_logger",
|
||||
"_set_debug",
|
||||
"AlbumSortOrder",
|
||||
"CommentInfo",
|
||||
"ExifTool",
|
||||
@@ -30,6 +34,7 @@ __all__ = [
|
||||
"ExportResults",
|
||||
"FileUtil",
|
||||
"FileUtilNoOp",
|
||||
"is_debug",
|
||||
"LikeInfo",
|
||||
"MomentInfo",
|
||||
"PersonInfo",
|
||||
@@ -41,4 +46,5 @@ __all__ = [
|
||||
"QueryOptions",
|
||||
"ScoreInfo",
|
||||
"SearchInfo",
|
||||
"set_debug",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.47.2"
|
||||
__version__ = "0.47.5"
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
"""cli package for osxphotos"""
|
||||
import sys
|
||||
|
||||
from rich import print
|
||||
from rich.traceback import install as install_traceback
|
||||
|
||||
from osxphotos.debug import (
|
||||
debug_breakpoint,
|
||||
debug_watch,
|
||||
get_debug_flags,
|
||||
get_debug_options,
|
||||
set_debug,
|
||||
wrap_function,
|
||||
)
|
||||
|
||||
# apply any debug functions
|
||||
# need to do this before importing anything else so that the debug functions
|
||||
# wrap the right function references
|
||||
# if a module does something like "from exiftool import ExifTool" and the user tries
|
||||
# to wrap 'osxphotos.exiftool.ExifTool.asdict', the original ExifTool.asdict will be
|
||||
# wrapped but the caller will have a reference to the function before it was wrapped
|
||||
# reference: https://github.com/GrahamDumpleton/wrapt/blob/develop/blog/13-ordering-issues-when-monkey-patching-in-python.md
|
||||
args = get_debug_options(["--watch", "--breakpoint"], sys.argv)
|
||||
for func_name in args.get("--watch", []):
|
||||
try:
|
||||
wrap_function(func_name, debug_watch)
|
||||
print(f"Watching {func_name}")
|
||||
except AttributeError:
|
||||
print(f"{func_name} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
for func_name in args.get("--breakpoint", []):
|
||||
try:
|
||||
wrap_function(func_name, debug_breakpoint)
|
||||
print(f"Breakpoint added for {func_name}")
|
||||
except AttributeError:
|
||||
print(f"{func_name} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
args = get_debug_flags(["--debug"], sys.argv)
|
||||
if args.get("--debug", False):
|
||||
set_debug(True)
|
||||
print("Debugging enabled")
|
||||
|
||||
from .about import about
|
||||
from .albums import albums
|
||||
from .cli import cli_main
|
||||
from .common import get_photos_db, load_uuid_from_file, set_debug
|
||||
from .common import get_photos_db, load_uuid_from_file
|
||||
from .debug_dump import debug_dump
|
||||
from .dump import dump
|
||||
from .export import export
|
||||
@@ -50,6 +90,7 @@ __all__ = [
|
||||
"query",
|
||||
"repl",
|
||||
"run",
|
||||
"set_debug",
|
||||
"snap",
|
||||
"tutorial",
|
||||
"uuid",
|
||||
|
||||
@@ -31,8 +31,6 @@ from .uuid import uuid
|
||||
# Click CLI object & context settings
|
||||
class CLI_Obj:
|
||||
def __init__(self, db=None, json=False, debug=False, group=None):
|
||||
if debug:
|
||||
osxphotos._set_debug(True)
|
||||
self.db = db
|
||||
self.json = json
|
||||
self.group = group
|
||||
@@ -77,6 +75,7 @@ for command in [
|
||||
places,
|
||||
query,
|
||||
repl,
|
||||
run,
|
||||
snap,
|
||||
tutorial,
|
||||
uninstall,
|
||||
|
||||
272
osxphotos/cli/click_rich_echo.py
Normal file
272
osxphotos/cli/click_rich_echo.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""click.echo replacement that supports rich text formatting"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import typing as t
|
||||
from io import StringIO
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.theme import Theme
|
||||
|
||||
from .common import time_stamp
|
||||
|
||||
__all__ = [
|
||||
"get_rich_console",
|
||||
"get_rich_theme",
|
||||
"rich_click_echo",
|
||||
"rich_echo",
|
||||
"rich_echo_error",
|
||||
"rich_echo_via_pager",
|
||||
"set_rich_console",
|
||||
"set_rich_theme",
|
||||
"set_rich_timestamp",
|
||||
]
|
||||
|
||||
# TODO: this should really be a class instead of a module with a bunch of globals
|
||||
|
||||
# include emoji's in rich_echo_error output
|
||||
ERROR_EMOJI = True
|
||||
|
||||
|
||||
class _Console:
|
||||
"""Store console object for rich output"""
|
||||
|
||||
def __init__(self):
|
||||
self._console: t.Optional[Console] = None
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
return self._console
|
||||
|
||||
@console.setter
|
||||
def console(self, console: Console):
|
||||
self._console = console
|
||||
|
||||
|
||||
_console = _Console()
|
||||
|
||||
_theme = None
|
||||
|
||||
_timestamp = False
|
||||
|
||||
# set to 1 if running tests
|
||||
OSXPHOTOS_IS_TESTING = bool(os.getenv("OSXPHOTOS_IS_TESTING", default=False))
|
||||
|
||||
|
||||
def set_rich_console(console: Console) -> None:
|
||||
"""Set the console object to use for rich_echo and rich_echo_via_pager"""
|
||||
global _console
|
||||
_console.console = console
|
||||
|
||||
|
||||
def get_rich_console() -> Console:
|
||||
"""Get console object
|
||||
|
||||
Returns:
|
||||
Console object
|
||||
"""
|
||||
global _console
|
||||
return _console.console
|
||||
|
||||
|
||||
def set_rich_theme(theme: Theme) -> None:
|
||||
"""Set the theme to use for rich_click_echo"""
|
||||
global _theme
|
||||
_theme = theme
|
||||
|
||||
|
||||
def get_rich_theme() -> t.Optional[Theme]:
|
||||
"""Get the theme to use for rich_click_echo"""
|
||||
global _theme
|
||||
return _theme
|
||||
|
||||
|
||||
def set_rich_timestamp(timestamp: bool) -> None:
|
||||
"""Set whether to print timestamp with rich_echo, rich_echo_error, and rich_click_error"""
|
||||
global _timestamp
|
||||
_timestamp = timestamp
|
||||
|
||||
|
||||
def rich_echo(
|
||||
message: t.Optional[t.Any] = None,
|
||||
theme=None,
|
||||
markdown=False,
|
||||
highlight=False,
|
||||
**kwargs: t.Any,
|
||||
) -> None:
|
||||
"""Echo text to the console with rich formatting.
|
||||
|
||||
Args:
|
||||
message: The string or bytes to output. Other objects are converted to strings.
|
||||
theme: optional rich.theme.Theme object to use for formatting
|
||||
markdown: if True, interpret message as Markdown
|
||||
highlight: if True, use automatic rich.print highlighting
|
||||
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
|
||||
|
||||
width = kwargs.pop("width", None)
|
||||
if width is None and OSXPHOTOS_IS_TESTING:
|
||||
# if not outputting to terminal, use a huge width to avoid wrapping
|
||||
# otherwise tests fail
|
||||
width = 10_000
|
||||
console = get_rich_console() or Console(theme=theme, width=width)
|
||||
if markdown:
|
||||
message = Markdown(message)
|
||||
# Markdown always adds a new line so disable unless explicitly specified
|
||||
global _timestamp
|
||||
if _timestamp:
|
||||
message = time_stamp() + message
|
||||
console.print(message, highlight=highlight, **kwargs)
|
||||
|
||||
|
||||
def rich_echo_error(
|
||||
message: t.Optional[t.Any] = None,
|
||||
theme=None,
|
||||
markdown=False,
|
||||
highlight=False,
|
||||
**kwargs: t.Any,
|
||||
) -> None:
|
||||
"""Echo text to the console with rich formatting and if stdout is redirected, echo to stderr
|
||||
|
||||
Args:
|
||||
message: The string or bytes to output. Other objects are converted to strings.
|
||||
theme: optional rich.theme.Theme object to use for formatting
|
||||
markdown: if True, interpret message as Markdown
|
||||
highlight: if True, use automatic rich.print highlighting
|
||||
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()
|
||||
"""
|
||||
|
||||
global ERROR_EMOJI
|
||||
if ERROR_EMOJI:
|
||||
if "[error]" in message:
|
||||
message = f":cross_mark-emoji: {message}"
|
||||
elif "[warning]" in message:
|
||||
message = f":warning-emoji: {message}"
|
||||
|
||||
console = get_rich_console() or Console(theme=theme or get_rich_theme())
|
||||
if not console.is_terminal:
|
||||
# if stdout is redirected, echo to stderr
|
||||
rich_click_echo(
|
||||
message,
|
||||
theme=theme or get_rich_theme(),
|
||||
markdown=markdown,
|
||||
highlight=highlight,
|
||||
**kwargs,
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
rich_echo(
|
||||
message,
|
||||
theme=theme or get_rich_theme(),
|
||||
markdown=markdown,
|
||||
highlight=highlight,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def rich_click_echo(
|
||||
message: t.Optional[t.Any] = None,
|
||||
theme=None,
|
||||
markdown=False,
|
||||
highlight=False,
|
||||
**kwargs: t.Any,
|
||||
) -> None:
|
||||
"""Echo text to the console with rich formatting using click.echo
|
||||
|
||||
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.
|
||||
theme: optional rich.theme.Theme object to use for formatting
|
||||
markdown: if True, interpret message as Markdown
|
||||
highlight: if True, use automatic rich.print highlighting
|
||||
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", "")
|
||||
|
||||
if width := kwargs.pop("width", None) is None:
|
||||
# if not outputting to terminal, use a huge width to avoid wrapping
|
||||
# otherwise tests fail
|
||||
temp_console = Console()
|
||||
width = temp_console.width if temp_console.is_terminal else 10_000
|
||||
output = StringIO()
|
||||
console = Console(
|
||||
force_terminal=True,
|
||||
theme=theme or get_rich_theme(),
|
||||
file=output,
|
||||
width=width,
|
||||
)
|
||||
if markdown:
|
||||
message = Markdown(message)
|
||||
# Markdown always adds a new line so disable unless explicitly specified
|
||||
echo_args["nl"] = echo_args.get("nl") is True
|
||||
global _timestamp
|
||||
if _timestamp:
|
||||
message = time_stamp() + message
|
||||
console.print(message, end=end, highlight=highlight, **kwargs)
|
||||
click.echo(output.getvalue(), **echo_args)
|
||||
|
||||
|
||||
def rich_echo_via_pager(
|
||||
text_or_generator: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str],
|
||||
theme: t.Optional[Theme] = None,
|
||||
highlight=False,
|
||||
markdown: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""This function takes a text and shows it via an environment specific
|
||||
pager on stdout.
|
||||
|
||||
Args:
|
||||
text_or_generator: the text to page, or alternatively, a generator emitting the text to page.
|
||||
theme: optional rich.theme.Theme object to use for formatting
|
||||
markdown: if True, interpret message as Markdown
|
||||
highlight: if True, use automatic rich.print highlighting
|
||||
**kwargs: if "color" in kwargs, works the same as click.echo_via_pager(color=color)
|
||||
otherwise any kwargs are passed to rich.Console.print()
|
||||
"""
|
||||
if inspect.isgeneratorfunction(text_or_generator):
|
||||
text_or_generator = t.cast(t.Callable[[], t.Iterable[str]], text_or_generator)()
|
||||
elif isinstance(text_or_generator, str):
|
||||
text_or_generator = [text_or_generator]
|
||||
else:
|
||||
try:
|
||||
text_or_generator = iter(text_or_generator)
|
||||
except TypeError:
|
||||
text_or_generator = [text_or_generator]
|
||||
|
||||
console = _console or Console(theme=theme)
|
||||
|
||||
color = kwargs.pop("color", None)
|
||||
if color is None:
|
||||
color = bool(console.color_system)
|
||||
|
||||
with console.pager(styles=color):
|
||||
for x in text_or_generator:
|
||||
if isinstance(x, str) and markdown:
|
||||
x = Markdown(x)
|
||||
console.print(x, highlight=highlight, **kwargs)
|
||||
124
osxphotos/cli/color_themes.py
Normal file
124
osxphotos/cli/color_themes.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Support for colorized output for photos_time_warp"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from rich.style import Style
|
||||
from rich.themes import Theme
|
||||
|
||||
from .common import noop
|
||||
from .darkmode import is_dark_mode
|
||||
|
||||
__all__ = ["get_theme"]
|
||||
|
||||
|
||||
COLOR_THEMES = {
|
||||
"dark": Theme(
|
||||
{
|
||||
# color pallette from https://github.com/dracula/dracula-theme
|
||||
"color": Style(color="rgb(248,248,242)"),
|
||||
"count": Style(color="rgb(139,233,253)"),
|
||||
"error": Style(color="rgb(255,85,85)", bold=True),
|
||||
"filename": Style(color="rgb(189,147,249)", bold=True),
|
||||
"filepath": Style(color="rgb(80,250,123)", bold=True),
|
||||
"highlight": Style(color="#000000", bgcolor="#d73a49", bold=True),
|
||||
"num": Style(color="rgb(139,233,253)", bold=True),
|
||||
"time": Style(color="rgb(139,233,253)", bold=True),
|
||||
"uuid": Style(color="rgb(255,184,108)"),
|
||||
"warning": Style(color="rgb(241,250,140)", bold=True),
|
||||
"bar.back": Style(color="rgb(68,71,90)"),
|
||||
"bar.complete": Style(color="rgb(249,38,114)"),
|
||||
"bar.finished": Style(color="rgb(80,250,123)"),
|
||||
"bar.pulse": Style(color="rgb(98,114,164)"),
|
||||
"progress.elapsed": Style(color="rgb(139,233,253)"),
|
||||
"progress.percentage": Style(color="rgb(255,121,198)"),
|
||||
"progress.remaining": Style(color="rgb(139,233,253)"),
|
||||
}
|
||||
),
|
||||
"light": Theme(
|
||||
{
|
||||
"color": Style(color="#000000"),
|
||||
"count": Style(color="#005cc5", bold=True),
|
||||
"error": Style(color="#b31d28", bold=True, underline=True, italic=True),
|
||||
"filename": Style(color="#6f42c1", bold=True),
|
||||
"filepath": Style(color="#22863a", bold=True),
|
||||
"highlight": Style(color="#ffffff", bgcolor="#d73a49", bold=True),
|
||||
"num": Style(color="#005cc5", bold=True),
|
||||
"time": Style(color="#032f62", bold=True),
|
||||
"uuid": Style(color="#d73a49", bold=True),
|
||||
"warning": Style(color="#e36209", bold=True, underline=True, italic=True),
|
||||
"bar.back": Style(color="grey23"),
|
||||
"bar.complete": Style(color="rgb(249,38,114)"),
|
||||
"bar.finished": Style(color="rgb(114,156,31)"),
|
||||
"bar.pulse": Style(color="rgb(249,38,114)"),
|
||||
"progress.elapsed": Style(color="#032f62", bold=True),
|
||||
"progress.percentage": Style(color="#6f42c1", bold=True),
|
||||
"progress.remaining": Style(color="#032f62", bold=True),
|
||||
}
|
||||
),
|
||||
"mono": Theme(
|
||||
{
|
||||
"count": "bold",
|
||||
"error": "reverse italic",
|
||||
"filename": "bold",
|
||||
"filepath": "bold underline",
|
||||
"highlight": "reverse italic",
|
||||
"num": "bold",
|
||||
"time": "bold",
|
||||
"uuid": "bold",
|
||||
"warning": "bold italic",
|
||||
"bar.back": "",
|
||||
"bar.complete": "reverse",
|
||||
"bar.finished": "bold",
|
||||
"bar.pulse": "bold",
|
||||
"progress.elapsed": "",
|
||||
"progress.percentage": "bold",
|
||||
"progress.remaining": "bold",
|
||||
}
|
||||
),
|
||||
"plain": Theme(
|
||||
{
|
||||
"color": "",
|
||||
"count": "",
|
||||
"error": "",
|
||||
"filename": "",
|
||||
"filepath": "",
|
||||
"highlight": "",
|
||||
"num": "",
|
||||
"time": "",
|
||||
"uuid": "",
|
||||
"warning": "",
|
||||
"bar.back": "",
|
||||
"bar.complete": "",
|
||||
"bar.finished": "",
|
||||
"bar.pulse": "",
|
||||
"progress.elapsed": "",
|
||||
"progress.percentage": "",
|
||||
"progress.remaining": "",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_theme(
|
||||
theme_name: Optional[str] = None,
|
||||
theme_file: Optional[str] = None,
|
||||
verbose=None,
|
||||
):
|
||||
"""Get the color theme based on the color flags or load from config file"""
|
||||
if not verbose:
|
||||
verbose = noop
|
||||
# figure out which color theme to use
|
||||
theme_name = theme_name or "default"
|
||||
if theme_name == "default" and theme_file and theme_file.is_file():
|
||||
# load theme from file
|
||||
verbose(f"Loading color theme from {theme_file}")
|
||||
try:
|
||||
theme = Theme.read(theme_file)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error reading theme file {theme_file}: {e}")
|
||||
elif theme_name == "default":
|
||||
# try to auto-detect dark/light mode
|
||||
theme = COLOR_THEMES["dark"] if is_dark_mode() else COLOR_THEMES["light"]
|
||||
else:
|
||||
theme = COLOR_THEMES[theme_name]
|
||||
return theme
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Globals and constants use by the CLI commands"""
|
||||
|
||||
import datetime
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Callable
|
||||
from datetime import datetime
|
||||
|
||||
import click
|
||||
|
||||
@@ -12,12 +12,6 @@ from osxphotos._version import __version__
|
||||
|
||||
from .param_types import *
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
# global variable to control debug output
|
||||
# set via --debug
|
||||
DEBUG = False
|
||||
|
||||
# used to show/hide hidden commands
|
||||
OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False))
|
||||
|
||||
@@ -25,21 +19,25 @@ OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False))
|
||||
OSXPHOTOS_SNAPSHOT_DIR = "/private/tmp/osxphotos_snapshots"
|
||||
|
||||
# where to write the crash report if osxphotos crashes
|
||||
OSXPHOTOS_CRASH_LOG = os.getcwd() + "/osxphotos_crash.log"
|
||||
OSXPHOTOS_CRASH_LOG = f"{os.getcwd()}/osxphotos_crash.log"
|
||||
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
|
||||
|
||||
def set_debug(debug: bool):
|
||||
"""set debug flag"""
|
||||
global DEBUG
|
||||
DEBUG = debug
|
||||
|
||||
|
||||
def is_debug():
|
||||
"""return debug flag"""
|
||||
return DEBUG
|
||||
__all__ = [
|
||||
"CLI_COLOR_ERROR",
|
||||
"CLI_COLOR_WARNING",
|
||||
"DB_ARGUMENT",
|
||||
"DB_OPTION",
|
||||
"DEBUG_OPTIONS",
|
||||
"DELETED_OPTIONS",
|
||||
"JSON_OPTION",
|
||||
"QUERY_OPTIONS",
|
||||
"get_photos_db",
|
||||
"load_uuid_from_file",
|
||||
"noop",
|
||||
"time_stamp",
|
||||
]
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
@@ -47,50 +45,9 @@ def noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def verbose_print(
|
||||
verbose: bool = True, timestamp: bool = False, rich=False
|
||||
) -> Callable:
|
||||
"""Create verbose function to print output
|
||||
|
||||
Args:
|
||||
verbose: if True, returns verbose print function otherwise returns no-op function
|
||||
timestamp: if True, includes timestamp in verbose output
|
||||
rich: use rich.print instead of click.echo
|
||||
|
||||
Returns:
|
||||
function to print output
|
||||
"""
|
||||
if not verbose:
|
||||
return noop
|
||||
|
||||
# closure to capture timestamp
|
||||
def verbose_(*args, **kwargs):
|
||||
"""print output if verbose flag set"""
|
||||
styled_args = []
|
||||
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
arg = timestamp_str + arg
|
||||
if "error" in arg.lower():
|
||||
arg = click.style(arg, fg=CLI_COLOR_ERROR)
|
||||
elif "warning" in arg.lower():
|
||||
arg = click.style(arg, fg=CLI_COLOR_WARNING)
|
||||
styled_args.append(arg)
|
||||
click.echo(*styled_args, **kwargs)
|
||||
|
||||
def rich_verbose_(*args, **kwargs):
|
||||
"""print output if verbose flag set using rich.print"""
|
||||
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
arg = timestamp_str + arg
|
||||
if "error" in arg.lower():
|
||||
arg = f"[{CLI_COLOR_ERROR}]{arg}[/{CLI_COLOR_ERROR}]"
|
||||
elif "warning" in arg.lower():
|
||||
arg = f"[{CLI_COLOR_WARNING}]{arg}[/{CLI_COLOR_WARNING}]"
|
||||
rprint(arg, **kwargs)
|
||||
|
||||
return rich_verbose_ if rich else verbose_
|
||||
def time_stamp() -> str:
|
||||
"""return timestamp"""
|
||||
return f"[time]{str(datetime.now())}[/time] -- "
|
||||
|
||||
|
||||
def get_photos_db(*db_options):
|
||||
@@ -511,6 +468,37 @@ def QUERY_OPTIONS(f):
|
||||
return f
|
||||
|
||||
|
||||
def DEBUG_OPTIONS(f):
|
||||
o = click.option
|
||||
options = [
|
||||
o(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
help="Enable debug output.",
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
),
|
||||
o(
|
||||
"--watch",
|
||||
metavar="FUNCTION_PATH",
|
||||
multiple=True,
|
||||
help="Watch function calls. For example, to watch all calls to FileUtil.copy: "
|
||||
"'--watch osxphotos.fileutil.FileUtil.copy'. More than one --watch option can be specified.",
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
),
|
||||
o(
|
||||
"--breakpoint",
|
||||
metavar="FUNCTION_PATH",
|
||||
multiple=True,
|
||||
help="Add breakpoint to function calls. For example, to add breakpoint to FileUtil.copy: "
|
||||
"'--breakpoint osxphotos.fileutil.FileUtil.copy'. More than one --breakpoint option can be specified.",
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
return f
|
||||
|
||||
|
||||
def load_uuid_from_file(filename):
|
||||
"""Load UUIDs from file. Does not validate UUIDs.
|
||||
Format is 1 UUID per line, any line beginning with # is ignored.
|
||||
|
||||
19
osxphotos/cli/darkmode.py
Normal file
19
osxphotos/cli/darkmode.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Detect dark mode on MacOS >= 10.14"""
|
||||
|
||||
import objc
|
||||
import Foundation
|
||||
|
||||
|
||||
def theme():
|
||||
with objc.autorelease_pool():
|
||||
user_defaults = Foundation.NSUserDefaults.standardUserDefaults()
|
||||
system_theme = user_defaults.stringForKey_("AppleInterfaceStyle")
|
||||
return "dark" if system_theme == "Dark" else "light"
|
||||
|
||||
|
||||
def is_dark_mode():
|
||||
return theme() == "dark"
|
||||
|
||||
|
||||
def is_light_mode():
|
||||
return theme() == "light"
|
||||
@@ -9,15 +9,9 @@ from rich import print
|
||||
import osxphotos
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
||||
|
||||
from .common import (
|
||||
DB_ARGUMENT,
|
||||
DB_OPTION,
|
||||
JSON_OPTION,
|
||||
OSXPHOTOS_HIDDEN,
|
||||
get_photos_db,
|
||||
verbose_print,
|
||||
)
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN, get_photos_db
|
||||
from .list import _list_libraries
|
||||
from .verbose import verbose_print
|
||||
|
||||
|
||||
@click.command(hidden=OSXPHOTOS_HIDDEN)
|
||||
|
||||
@@ -39,8 +39,9 @@ from osxphotos.configoptions import (
|
||||
ConfigOptionsInvalidError,
|
||||
ConfigOptionsLoadError,
|
||||
)
|
||||
from osxphotos.crash_reporter import crash_reporter
|
||||
from osxphotos.crash_reporter import crash_reporter, set_crash_data
|
||||
from osxphotos.datetime_formatter import DateTimeFormatter
|
||||
from osxphotos.debug import is_debug, set_debug
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
from osxphotos.export_db import ExportDB, ExportDBInMemory
|
||||
from osxphotos.fileutil import FileUtil, FileUtilNoOp
|
||||
@@ -56,32 +57,44 @@ from osxphotos.queryoptions import QueryOptions
|
||||
from osxphotos.uti import get_preferred_uti_extension
|
||||
from osxphotos.utils import format_sec_to_hhmmss, normalize_fs_path
|
||||
|
||||
from .click_rich_echo import (
|
||||
rich_click_echo,
|
||||
rich_echo,
|
||||
rich_echo_error,
|
||||
set_rich_console,
|
||||
set_rich_theme,
|
||||
set_rich_timestamp,
|
||||
)
|
||||
from .color_themes import get_theme
|
||||
from .common import (
|
||||
CLI_COLOR_ERROR,
|
||||
CLI_COLOR_WARNING,
|
||||
DB_ARGUMENT,
|
||||
DB_OPTION,
|
||||
DEBUG_OPTIONS,
|
||||
DELETED_OPTIONS,
|
||||
JSON_OPTION,
|
||||
OSXPHOTOS_CRASH_LOG,
|
||||
OSXPHOTOS_HIDDEN,
|
||||
QUERY_OPTIONS,
|
||||
get_photos_db,
|
||||
is_debug,
|
||||
load_uuid_from_file,
|
||||
noop,
|
||||
set_debug,
|
||||
verbose_print,
|
||||
)
|
||||
from .help import ExportCommand, get_help_msg
|
||||
from .list import _list_libraries
|
||||
from .param_types import ExportDBType, FunctionCall
|
||||
from .rich_progress import rich_progress
|
||||
from .verbose import get_verbose_console, time_stamp, verbose_print
|
||||
|
||||
|
||||
@click.command(cls=ExportCommand)
|
||||
@DB_OPTION
|
||||
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
|
||||
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output")
|
||||
@click.option(
|
||||
"--no-progress", is_flag=True, help="Do not display progress bar during export."
|
||||
)
|
||||
@QUERY_OPTIONS
|
||||
@click.option(
|
||||
"--missing",
|
||||
@@ -562,6 +575,16 @@ from .param_types import ExportDBType, FunctionCall
|
||||
"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.",
|
||||
)
|
||||
@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(
|
||||
"--load-config",
|
||||
required=False,
|
||||
@@ -620,13 +643,14 @@ from .param_types import ExportDBType, FunctionCall
|
||||
"Default = 'cumulative'.",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
help="Enable debug output.",
|
||||
"--theme",
|
||||
metavar="THEME",
|
||||
type=click.Choice(["dark", "light", "mono", "plain"], case_sensitive=False),
|
||||
help="Specify the color theme to use for --verbose output. "
|
||||
"Valid themes are 'dark', 'light', 'mono', and 'plain'. "
|
||||
"Defaults to 'dark' or 'light' depending on system dark mode setting.",
|
||||
)
|
||||
@DEBUG_OPTIONS
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -670,6 +694,7 @@ def export(
|
||||
to_time,
|
||||
verbose,
|
||||
timestamp,
|
||||
no_progress,
|
||||
missing,
|
||||
update,
|
||||
force_update,
|
||||
@@ -756,6 +781,7 @@ def export(
|
||||
add_missing_to_album,
|
||||
exportdb,
|
||||
ramdb,
|
||||
tmpdir,
|
||||
load_config,
|
||||
save_config,
|
||||
config_only,
|
||||
@@ -778,7 +804,10 @@ def export(
|
||||
preview_if_missing,
|
||||
profile,
|
||||
profile_sort,
|
||||
debug,
|
||||
theme,
|
||||
debug, # debug, watch, breakpoint handled in cli/__init__.py
|
||||
watch,
|
||||
breakpoint,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -792,9 +821,10 @@ def export(
|
||||
to modify this behavior.
|
||||
"""
|
||||
|
||||
if is_debug():
|
||||
set_debug(True)
|
||||
osxphotos._set_debug(True)
|
||||
# capture locals for use with ConfigOptions before changing any of them
|
||||
locals_ = locals()
|
||||
|
||||
set_crash_data("locals", locals_)
|
||||
|
||||
if profile:
|
||||
click.echo("Profiling...")
|
||||
@@ -819,22 +849,32 @@ def export(
|
||||
# do so below after load_config and save_config are handled.
|
||||
cfg = ConfigOptions(
|
||||
"export",
|
||||
locals(),
|
||||
locals_,
|
||||
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
|
||||
)
|
||||
|
||||
verbose_ = verbose_print(verbose, timestamp)
|
||||
color_theme = get_theme(theme)
|
||||
verbose_ = verbose_print(
|
||||
verbose, timestamp, rich=True, theme=color_theme, highlight=False
|
||||
)
|
||||
# set console for rich_echo to be same as for verbose_
|
||||
set_rich_console(get_verbose_console())
|
||||
set_rich_theme(color_theme)
|
||||
set_rich_timestamp(timestamp)
|
||||
|
||||
if load_config:
|
||||
try:
|
||||
cfg.load_from_file(load_config)
|
||||
except ConfigOptionsLoadError as e:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Error parsing {load_config} config file: {e.message}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
# click.echo(
|
||||
# click.style(
|
||||
# f"Error parsing {load_config} config file: {e.message}",
|
||||
# fg=CLI_COLOR_ERROR,
|
||||
# ),
|
||||
# err=True,
|
||||
# )
|
||||
rich_click_echo(
|
||||
f"[error]Error parsing {load_config} config file: {e.message}", err=True
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -904,6 +944,7 @@ def export(
|
||||
no_likes = cfg.no_likes
|
||||
no_location = cfg.no_location
|
||||
no_place = cfg.no_place
|
||||
no_progress = cfg.no_progress
|
||||
no_title = cfg.no_title
|
||||
not_burst = cfg.not_burst
|
||||
not_favorite = cfg.not_favorite
|
||||
@@ -956,9 +997,11 @@ def export(
|
||||
skip_uuid_from_file = cfg.skip_uuid_from_file
|
||||
slow_mo = cfg.slow_mo
|
||||
strip = cfg.strip
|
||||
theme = cfg.theme
|
||||
time_lapse = cfg.time_lapse
|
||||
timestamp = cfg.timestamp
|
||||
title = cfg.title
|
||||
tmpdir = cfg.tmpdir
|
||||
to_date = cfg.to_date
|
||||
to_time = cfg.to_time
|
||||
touch_file = cfg.touch_file
|
||||
@@ -972,8 +1015,17 @@ def export(
|
||||
xattr_template = cfg.xattr_template
|
||||
|
||||
# config file might have changed verbose
|
||||
verbose_ = verbose_print(verbose, timestamp)
|
||||
verbose_(f"Loaded options from file {load_config}")
|
||||
color_theme = get_theme(theme)
|
||||
verbose_ = verbose_print(
|
||||
verbose, timestamp, rich=True, theme=color_theme, highlight=False
|
||||
)
|
||||
# set console for rich_echo to be same as for verbose_
|
||||
set_rich_console(get_verbose_console())
|
||||
set_rich_timestamp(timestamp)
|
||||
|
||||
verbose_(f"Loaded options from file [filepath]{load_config}")
|
||||
|
||||
set_crash_data("cfg", cfg.asdict())
|
||||
|
||||
verbose_(f"osxphotos version {__version__}")
|
||||
|
||||
@@ -1018,28 +1070,22 @@ def export(
|
||||
try:
|
||||
cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True)
|
||||
except ConfigOptionsInvalidError as e:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Incompatible export options: {e.message}", fg=CLI_COLOR_ERROR
|
||||
),
|
||||
rich_click_echo(
|
||||
f"[error]Incompatible export options: {e.message}",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if config_only and not save_config:
|
||||
click.secho(
|
||||
"--config-only must be used with --save-config",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
rich_click_echo(
|
||||
"[error]--config-only must be used with --save-config",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if all(x in [s.lower() for s in sidecar] for x in ["json", "exiftool"]):
|
||||
click.echo(
|
||||
click.style(
|
||||
"Cannot use --sidecar json with --sidecar exiftool due to name collisions",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
rich_click_echo(
|
||||
"[error]Cannot use --sidecar json with --sidecar exiftool due to name collisions",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
@@ -1047,21 +1093,18 @@ def export(
|
||||
if xattr_template:
|
||||
for attr, _ in xattr_template:
|
||||
if attr not in EXTENDED_ATTRIBUTE_NAMES:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Invalid attribute '{attr}' for --xattr-template; "
|
||||
f"valid values are {', '.join(EXTENDED_ATTRIBUTE_NAMES_QUOTED)}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
rich_click_echo(
|
||||
f"[error]Invalid attribute '{attr}' for --xattr-template; "
|
||||
f"valid values are {', '.join(EXTENDED_ATTRIBUTE_NAMES_QUOTED)}",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if save_config:
|
||||
verbose_(f"Saving options to config file '{save_config}'")
|
||||
verbose_(f"Saving options to config file '[filepath]{save_config}'")
|
||||
cfg.write_to_file(save_config)
|
||||
if config_only:
|
||||
click.echo(f"Saved config file to '{save_config}'")
|
||||
rich_echo(f"Saved config file to '[filepath]{save_config}'")
|
||||
sys.exit(0)
|
||||
|
||||
# set defaults for options that need them
|
||||
@@ -1076,18 +1119,14 @@ def export(
|
||||
retry = 0 if not retry else retry
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
click.echo(
|
||||
click.style(f"DEST {dest} must be valid path", fg=CLI_COLOR_ERROR), err=True
|
||||
)
|
||||
rich_click_echo(f"[error]DEST {dest} must be valid path", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
dest = str(pathlib.Path(dest).resolve())
|
||||
|
||||
if report and os.path.isdir(report):
|
||||
click.echo(
|
||||
click.style(
|
||||
f"report is a directory, must be file name", fg=CLI_COLOR_ERROR
|
||||
),
|
||||
rich_click_echo(
|
||||
f"[error]report is a directory, must be file name",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
@@ -1120,18 +1159,15 @@ def export(
|
||||
try:
|
||||
exiftool_path = get_exiftool_path()
|
||||
except FileNotFoundError:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Could not find exiftool. Please download and install"
|
||||
" from https://exiftool.org/",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
rich_click_echo(
|
||||
"[error]Could not find exiftool. Please download and install"
|
||||
" from https://exiftool.org/",
|
||||
err=True,
|
||||
)
|
||||
ctx.exit(2)
|
||||
ctx.exit(1)
|
||||
|
||||
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
|
||||
verbose_(f"exiftool path: {exiftool_path}")
|
||||
verbose_(f"exiftool path: [filepath]{exiftool_path}")
|
||||
|
||||
# default searches for everything
|
||||
photos = True
|
||||
@@ -1151,26 +1187,24 @@ def export(
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if not db:
|
||||
click.echo(get_help_msg(export), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
rich_click_echo(get_help_msg(export), err=True)
|
||||
rich_click_echo(
|
||||
"\n\nLocated the following Photos library databases: ", err=True
|
||||
)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
# sanity check exportdb
|
||||
if exportdb and exportdb != OSXPHOTOS_EXPORT_DB:
|
||||
if pathlib.Path(pathlib.Path(dest) / OSXPHOTOS_EXPORT_DB).exists():
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: export database is '{exportdb}' but found '{OSXPHOTOS_EXPORT_DB}' in {dest}; using '{exportdb}'",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
)
|
||||
rich_click_echo(
|
||||
f"[warning]Warning: export database is '{exportdb}' but found '{OSXPHOTOS_EXPORT_DB}' in {dest}; using '{exportdb}'",
|
||||
err=True,
|
||||
)
|
||||
if pathlib.Path(exportdb).resolve().parent != pathlib.Path(dest):
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: export database '{pathlib.Path(exportdb).resolve()}' is in a different directory than export destination '{dest}'",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
)
|
||||
rich_click_echo(
|
||||
f"[warning]Warning: export database '{pathlib.Path(exportdb).resolve()}' is in a different directory than export destination '{dest}'",
|
||||
err=True,
|
||||
)
|
||||
|
||||
# open export database
|
||||
@@ -1179,21 +1213,18 @@ def export(
|
||||
# check that export isn't in the parent or child of a previously exported library
|
||||
other_db_files = find_files_in_branch(dest, OSXPHOTOS_EXPORT_DB)
|
||||
if other_db_files:
|
||||
click.echo(
|
||||
click.style(
|
||||
"WARNING: found other export database files in this destination directory branch. "
|
||||
+ "This likely means you are attempting to export files into a directory "
|
||||
+ "that is either the parent or a child directory of a previous export. "
|
||||
+ "Proceeding may cause your exported files to be overwritten.",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
),
|
||||
rich_click_echo(
|
||||
"[warning]WARNING: found other export database files in this destination directory branch. "
|
||||
+ "This likely means you are attempting to export files into a directory "
|
||||
+ "that is either the parent or a child directory of a previous export. "
|
||||
+ "Proceeding may cause your exported files to be overwritten.",
|
||||
err=True,
|
||||
)
|
||||
click.echo(
|
||||
rich_click_echo(
|
||||
f"You are exporting to {dest}, found {OSXPHOTOS_EXPORT_DB} files in:"
|
||||
)
|
||||
for other_db in other_db_files:
|
||||
click.echo(f"{other_db}")
|
||||
rich_click_echo(f"{other_db}")
|
||||
click.confirm("Do you want to continue?", abort=True)
|
||||
|
||||
if dry_run:
|
||||
@@ -1209,19 +1240,21 @@ def export(
|
||||
|
||||
if verbose_:
|
||||
if export_db.was_created:
|
||||
verbose_(f"Created export database {export_db_path}")
|
||||
verbose_(f"Created export database [filepath]{export_db_path}")
|
||||
else:
|
||||
verbose_(f"Using export database {export_db_path}")
|
||||
verbose_(f"Using export database [filepath]{export_db_path}")
|
||||
upgraded = export_db.was_upgraded
|
||||
if upgraded:
|
||||
verbose_(
|
||||
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
|
||||
f"Upgraded export database [filepath]{export_db_path}[/] from version [num]{upgraded[0]}[/] to [num]{upgraded[1]}[/]"
|
||||
)
|
||||
|
||||
# save config to export_db
|
||||
export_db.set_config(cfg.write_to_str())
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_, exiftool=exiftool_path)
|
||||
photosdb = osxphotos.PhotosDB(
|
||||
dbfile=db, verbose=verbose_, exiftool=exiftool_path, rich=True
|
||||
)
|
||||
|
||||
# enable beta features if requested
|
||||
photosdb._beta = beta
|
||||
@@ -1335,7 +1368,9 @@ def export(
|
||||
num_photos = len(photos)
|
||||
# TODO: photos or photo appears several times, pull into a separate function
|
||||
photo_str = "photos" if num_photos > 1 else "photo"
|
||||
click.echo(f"Exporting {num_photos} {photo_str} to {dest}...")
|
||||
rich_echo(
|
||||
f"Exporting [num]{num_photos}[/num] {photo_str} to [filepath]{dest}[/]..."
|
||||
)
|
||||
start_time = time.perf_counter()
|
||||
# though the command line option is current_name, internally all processing
|
||||
# logic uses original_name which is the boolean inverse of current_name
|
||||
@@ -1360,10 +1395,11 @@ def export(
|
||||
)
|
||||
|
||||
photo_num = 0
|
||||
# send progress bar output to /dev/null if verbose to hide the progress bar
|
||||
fp = open(os.devnull, "w") if verbose else None
|
||||
with click.progressbar(photos, show_pos=True, file=fp) as bar:
|
||||
for p in bar:
|
||||
with rich_progress(console=get_verbose_console(), mock=no_progress) as progress:
|
||||
task = progress.add_task(
|
||||
f"Exporting [num]{num_photos}[/] photos", total=num_photos
|
||||
)
|
||||
for p in photos:
|
||||
photo_num += 1
|
||||
export_results = export_photo(
|
||||
photo=p,
|
||||
@@ -1414,20 +1450,19 @@ def export(
|
||||
use_photokit=use_photokit,
|
||||
use_photos_export=use_photos_export,
|
||||
verbose_=verbose_,
|
||||
tmpdir=tmpdir,
|
||||
)
|
||||
|
||||
if post_function:
|
||||
for function in post_function:
|
||||
# post function is tuple of (function, filename.py::function_name)
|
||||
verbose_(f"Calling post-function {function[1]}")
|
||||
verbose_(f"Calling post-function [bold]{function[1]}")
|
||||
if not dry_run:
|
||||
try:
|
||||
function[0](p, export_results, verbose_)
|
||||
except Exception as e:
|
||||
click.secho(
|
||||
f"Error running post-function {function[1]}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
err=True,
|
||||
rich_echo_error(
|
||||
f"[error]Error running post-function [italic]{function[1]}[/italic]: {e}"
|
||||
)
|
||||
|
||||
run_post_command(
|
||||
@@ -1525,32 +1560,31 @@ def export(
|
||||
results.xattr_written.extend(xattr_written)
|
||||
results.xattr_skipped.extend(xattr_skipped)
|
||||
|
||||
if fp is not None:
|
||||
fp.close()
|
||||
progress.advance(task)
|
||||
|
||||
photo_str_total = "photos" if len(photos) != 1 else "photo"
|
||||
if update or force_update:
|
||||
summary = (
|
||||
f"Processed: {len(photos)} {photo_str_total}, "
|
||||
f"exported: {len(results.new)}, "
|
||||
f"updated: {len(results.updated)}, "
|
||||
f"skipped: {len(results.skipped)}, "
|
||||
f"updated EXIF data: {len(results.exif_updated)}, "
|
||||
f"Processed: [num]{len(photos)}[/] {photo_str_total}, "
|
||||
f"exported: [num]{len(results.new)}[/], "
|
||||
f"updated: [num]{len(results.updated)}[/], "
|
||||
f"skipped: [num]{len(results.skipped)}[/], "
|
||||
f"updated EXIF data: [num]{len(results.exif_updated)}[/], "
|
||||
)
|
||||
else:
|
||||
summary = (
|
||||
f"Processed: {len(photos)} {photo_str_total}, "
|
||||
f"exported: {len(results.exported)}, "
|
||||
f"Processed: [num]{len(photos)}[/] {photo_str_total}, "
|
||||
f"exported: [num]{len(results.exported)}[/], "
|
||||
)
|
||||
summary += f"missing: {len(results.missing)}, "
|
||||
summary += f"error: {len(results.error)}"
|
||||
summary += f"missing: [num]{len(results.missing)}[/], "
|
||||
summary += f"error: [num]{len(results.error)}[/]"
|
||||
if touch_file:
|
||||
summary += f", touched date: {len(results.touched)}"
|
||||
click.echo(summary)
|
||||
summary += f", touched date: [num]{len(results.touched)}[/]"
|
||||
rich_echo(summary)
|
||||
stop_time = time.perf_counter()
|
||||
click.echo(f"Elapsed time: {format_sec_to_hhmmss(stop_time-start_time)}")
|
||||
rich_echo(f"Elapsed time: [time]{format_sec_to_hhmmss(stop_time-start_time)}")
|
||||
else:
|
||||
click.echo("Did not find any photos to export")
|
||||
rich_echo("Did not find any photos to export")
|
||||
|
||||
# cleanup files and do report if needed
|
||||
if cleanup:
|
||||
@@ -1576,25 +1610,25 @@ def export(
|
||||
+ [r[0] for r in results.error]
|
||||
+ db_files
|
||||
)
|
||||
click.echo(f"Cleaning up {dest}")
|
||||
rich_echo(f"Cleaning up [filepath]{dest}")
|
||||
cleaned_files, cleaned_dirs = cleanup_files(
|
||||
dest, all_files, fileutil, verbose_=verbose_
|
||||
)
|
||||
file_str = "files" if len(cleaned_files) != 1 else "file"
|
||||
dir_str = "directories" if len(cleaned_dirs) != 1 else "directory"
|
||||
click.echo(
|
||||
f"Deleted: {len(cleaned_files)} {file_str}, {len(cleaned_dirs)} {dir_str}"
|
||||
rich_echo(
|
||||
f"Deleted: [num]{len(cleaned_files)}[/num] {file_str}, [num]{len(cleaned_dirs)}[/num] {dir_str}"
|
||||
)
|
||||
results.deleted_files = cleaned_files
|
||||
results.deleted_directories = cleaned_dirs
|
||||
|
||||
if report:
|
||||
verbose_(f"Writing export report to {report}")
|
||||
verbose_(f"Writing export report to [filepath]{report}")
|
||||
write_export_report(report, results)
|
||||
|
||||
# close export_db and write changes if needed
|
||||
if ramdb and not dry_run:
|
||||
verbose_(f"Writing export database changes back to {export_db.path}")
|
||||
verbose_(f"Writing export database changes back to [filepath]{export_db.path}")
|
||||
export_db.write_to_disk()
|
||||
export_db.close()
|
||||
|
||||
@@ -1660,6 +1694,7 @@ def export_photo(
|
||||
preview_if_missing=False,
|
||||
photo_num=1,
|
||||
num_photos=1,
|
||||
tmpdir=None,
|
||||
):
|
||||
"""Helper function for export that does the actual export
|
||||
|
||||
@@ -1707,6 +1742,7 @@ def export_photo(
|
||||
update: bool, only export updated photos
|
||||
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
|
||||
verbose_: callable for verbose output
|
||||
tmpdir: optional str; temporary directory to use for export
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
|
||||
@@ -1729,7 +1765,7 @@ def export_photo(
|
||||
export_original = True
|
||||
export_edited = False
|
||||
verbose_(
|
||||
f"Edited file for {photo.original_filename} is missing, exporting original"
|
||||
f"Edited file for [filename]{photo.original_filename}[/] is missing, exporting original"
|
||||
)
|
||||
|
||||
# check for missing photos before downloading
|
||||
@@ -1825,7 +1861,7 @@ def export_photo(
|
||||
original_filename = str(original_filename)
|
||||
|
||||
verbose_(
|
||||
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename} ({photo_num}/{num_photos})"
|
||||
f"Exporting [filename]{photo.original_filename}[/] ([filename]{photo.filename}[/]) as [filepath]{original_filename}[/] ([count]{photo_num}/{num_photos}[/])"
|
||||
)
|
||||
|
||||
results += export_photo_to_directory(
|
||||
@@ -1871,6 +1907,7 @@ def export_photo(
|
||||
use_photos_export=use_photos_export,
|
||||
use_photokit=use_photokit,
|
||||
verbose_=verbose_,
|
||||
tmpdir=tmpdir,
|
||||
)
|
||||
|
||||
if export_edited and photo.hasadjustments:
|
||||
@@ -1938,7 +1975,7 @@ def export_photo(
|
||||
)
|
||||
|
||||
verbose_(
|
||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||
f"Exporting edited version of [filename]{photo.original_filename}[/filename] ([filename]{photo.filename}[/filename]) as [filepath]{edited_filename}[/filepath]"
|
||||
)
|
||||
|
||||
results += export_photo_to_directory(
|
||||
@@ -1984,6 +2021,7 @@ def export_photo(
|
||||
use_photos_export=use_photos_export,
|
||||
use_photokit=use_photokit,
|
||||
verbose_=verbose_,
|
||||
tmpdir=tmpdir,
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -2068,6 +2106,7 @@ def export_photo_to_directory(
|
||||
use_photos_export,
|
||||
use_photokit,
|
||||
verbose_,
|
||||
tmpdir,
|
||||
):
|
||||
"""Export photo to directory dest_path"""
|
||||
|
||||
@@ -2087,7 +2126,7 @@ def export_photo_to_directory(
|
||||
render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path)
|
||||
|
||||
if not export_original and not edited:
|
||||
verbose_(f"Skipping original version of {photo.original_filename}")
|
||||
verbose_(f"Skipping original version of [filename]{photo.original_filename}")
|
||||
return results
|
||||
|
||||
tries = 0
|
||||
@@ -2130,69 +2169,62 @@ def export_photo_to_directory(
|
||||
use_photokit=use_photokit,
|
||||
use_photos_export=use_photos_export,
|
||||
verbose=verbose_,
|
||||
tmpdir=tmpdir,
|
||||
rich=True,
|
||||
)
|
||||
exporter = PhotoExporter(photo)
|
||||
export_results = exporter.export(
|
||||
dest=dest_path, filename=filename, options=export_options
|
||||
)
|
||||
for warning_ in export_results.exiftool_warning:
|
||||
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
|
||||
verbose_(
|
||||
f"[warning]exiftool warning for file {warning_[0]}: {warning_[1]}"
|
||||
)
|
||||
for error_ in export_results.exiftool_error:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"exiftool error for file {error_[0]}: {error_[1]}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
rich_echo_error(
|
||||
f"[error]exiftool error for file {error_[0]}: {error_[1]}"
|
||||
)
|
||||
for error_ in export_results.error:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Error exporting photo ({photo.uuid}: {photo.original_filename}) as {error_[0]}: {error_[1]}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
rich_echo_error(
|
||||
f"[error]Error exporting photo ({photo.uuid}: {photo.original_filename}) as {error_[0]}: {error_[1]}"
|
||||
)
|
||||
error += 1
|
||||
if not error or tries > retry:
|
||||
results += export_results
|
||||
break
|
||||
else:
|
||||
click.echo(
|
||||
f"Retrying export for photo ({photo.uuid}: {photo.original_filename})"
|
||||
rich_echo(
|
||||
f"Retrying export for photo ([uuid]{photo.uuid}[/uuid]: [filename]{photo.original_filename}[/filename])"
|
||||
)
|
||||
except Exception as e:
|
||||
if is_debug():
|
||||
# if debug mode, don't swallow the exceptions
|
||||
raise e
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Error exporting photo ({photo.uuid}: {photo.original_filename}) as {filename}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
rich_echo(
|
||||
f"[error]Error exporting photo ([uuid]{photo.uuid}[/uuid]: [filename]{photo.original_filename}[/filename]) as [filepath]{filename}[/filepath]: {e}",
|
||||
err=True,
|
||||
)
|
||||
if tries > retry:
|
||||
results.error.append((str(pathlib.Path(dest) / filename), e))
|
||||
break
|
||||
else:
|
||||
click.echo(
|
||||
f"Retrying export for photo ({photo.uuid}: {photo.original_filename})"
|
||||
rich_echo(
|
||||
f"Retrying export for photo ([uuid]{photo.uuid}[/uuid]: [filename]{photo.original_filename}[/filename])"
|
||||
)
|
||||
|
||||
if verbose_:
|
||||
if update or force_update:
|
||||
for new in results.new:
|
||||
verbose_(f"Exported new file {new}")
|
||||
verbose_(f"Exported new file [filepath]{new}")
|
||||
for updated in results.updated:
|
||||
verbose_(f"Exported updated file {updated}")
|
||||
verbose_(f"Exported updated file [filepath]{updated}")
|
||||
for skipped in results.skipped:
|
||||
verbose_(f"Skipped up to date file {skipped}")
|
||||
verbose_(f"Skipped up to date file [filepath]{skipped}")
|
||||
else:
|
||||
for exported in results.exported:
|
||||
verbose_(f"Exported {exported}")
|
||||
verbose_(f"Exported [filepath]{exported}")
|
||||
for touched in results.touched:
|
||||
verbose_(f"Touched date on file {touched}")
|
||||
verbose_(f"Touched date on file [filepath]{touched}")
|
||||
|
||||
return results
|
||||
|
||||
@@ -2502,10 +2534,7 @@ def write_export_report(report_file, results):
|
||||
for data in [result for result in all_results.values()]:
|
||||
writer.writerow(data)
|
||||
except IOError:
|
||||
click.echo(
|
||||
click.style("Could not open output file for writing", fg=CLI_COLOR_ERROR),
|
||||
err=True,
|
||||
)
|
||||
rich_echo_error("[error]Could not open output file for writing"),
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -2528,7 +2557,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil, verbose_):
|
||||
deleted_files = []
|
||||
for p in pathlib.Path(dest_path).rglob("*"):
|
||||
if p.is_file() and normalize_fs_path(str(p).lower()) not in keepers:
|
||||
verbose_(f"Deleting {p}")
|
||||
verbose_(f"Deleting [filepath]{p}")
|
||||
fileutil.unlink(p)
|
||||
deleted_files.append(str(p))
|
||||
|
||||
@@ -2586,6 +2615,7 @@ def write_finder_tags(
|
||||
use_persons_as_keywords=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
merge_exif_keywords=exiftool_merge_keywords,
|
||||
rich=True,
|
||||
)
|
||||
exif = PhotoExporter(photo)._exiftool_dict(options=export_options)
|
||||
try:
|
||||
@@ -2611,12 +2641,8 @@ def write_finder_tags(
|
||||
)
|
||||
|
||||
if unmatched:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: unknown field for template: {template_str} unknown field = {unmatched}",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
),
|
||||
err=True,
|
||||
rich_echo(
|
||||
f"[warning]Warning: unknown field for template: {template_str} unknown field = {unmatched}"
|
||||
)
|
||||
rendered_tags.extend(rendered)
|
||||
|
||||
@@ -2675,12 +2701,8 @@ def write_extended_attributes(
|
||||
f"Invalid template for --xattr-template '{template_str}': {e}",
|
||||
)
|
||||
if unmatched:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: unmatched template substitution for template: {template_str} unknown field={unmatched}",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
),
|
||||
err=True,
|
||||
rich_echo(
|
||||
f"[warning]Warning: unmatched template substitution for template: {template_str} unknown field={unmatched}"
|
||||
)
|
||||
|
||||
# filter out any template values that didn't match by looking for sentinel
|
||||
@@ -2758,10 +2780,6 @@ def run_post_command(
|
||||
finally:
|
||||
run_error = run_error or run_results.returncode
|
||||
if run_error:
|
||||
click.echo(
|
||||
click.style(
|
||||
f'Error running command "{command}": {run_error}',
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
rich_echo_error(
|
||||
f'[error]Error running command "{command}": {run_error}'
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ from osxphotos.export_db_utils import (
|
||||
export_db_vacuum,
|
||||
)
|
||||
|
||||
from .common import OSXPHOTOS_HIDDEN, verbose_print
|
||||
from .common import OSXPHOTOS_HIDDEN
|
||||
from .verbose import verbose_print
|
||||
|
||||
|
||||
@click.command(name="exportdb", hidden=OSXPHOTOS_HIDDEN)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Help text helper class for osxphotos CLI """
|
||||
|
||||
import inspect
|
||||
import io
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
import click
|
||||
import osxmetadata
|
||||
@@ -21,6 +23,9 @@ from osxphotos.phototemplate import (
|
||||
get_template_help,
|
||||
)
|
||||
|
||||
from .click_rich_echo import rich_echo
|
||||
from .color_themes import get_theme
|
||||
|
||||
__all__ = [
|
||||
"ExportCommand",
|
||||
"template_help",
|
||||
@@ -32,6 +37,8 @@ __all__ = [
|
||||
"get_help_msg",
|
||||
]
|
||||
|
||||
HIGHLIGHT_COLOR = "yellow"
|
||||
|
||||
|
||||
def get_help_msg(command):
|
||||
"""get help message for a Click command"""
|
||||
@@ -41,18 +48,132 @@ def get_help_msg(command):
|
||||
|
||||
@click.command()
|
||||
@click.argument("topic", default=None, required=False, nargs=1)
|
||||
@click.argument("subtopic", default=None, required=False, nargs=1)
|
||||
@click.pass_context
|
||||
def help(ctx, topic, **kw):
|
||||
def help(ctx, topic, subtopic, **kw):
|
||||
"""Print help; for help on commands: help <command>."""
|
||||
if topic is None:
|
||||
click.echo(ctx.parent.get_help())
|
||||
return
|
||||
elif topic in ctx.obj.group.commands:
|
||||
|
||||
if subtopic:
|
||||
cmd = ctx.obj.group.commands[topic]
|
||||
theme = get_theme("light")
|
||||
rich_echo(
|
||||
get_subtopic_help(cmd, ctx, subtopic),
|
||||
theme=theme,
|
||||
width=click.HelpFormatter().width,
|
||||
)
|
||||
return
|
||||
|
||||
if topic in ctx.obj.group.commands:
|
||||
ctx.info_name = topic
|
||||
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]{subtopic}[/highlight]':\n")
|
||||
formatter.write_paragraph()
|
||||
formatter.write(option_str)
|
||||
else:
|
||||
click.echo(f"Invalid command: {topic}", err=True)
|
||||
click.echo(ctx.parent.get_help())
|
||||
formatter.write(f"No options match '[highlight]{subtopic}[/highlight]'")
|
||||
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})",
|
||||
"[highlight]\\1[/highlight]",
|
||||
record[0],
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
record[1] = re.sub(
|
||||
f"({highlight})",
|
||||
"[highlight]\\1[/highlight]",
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.debug import set_debug
|
||||
from osxphotos.photosalbum import PhotosAlbum
|
||||
from osxphotos.queryoptions import QueryOptions
|
||||
|
||||
@@ -17,7 +18,6 @@ from .common import (
|
||||
QUERY_OPTIONS,
|
||||
get_photos_db,
|
||||
load_uuid_from_file,
|
||||
set_debug,
|
||||
)
|
||||
from .list import _list_libraries
|
||||
from .print_photo_info import print_photo_info
|
||||
@@ -149,17 +149,13 @@ def query(
|
||||
query_eval,
|
||||
query_function,
|
||||
add_to_album,
|
||||
debug,
|
||||
debug, # handled in cli/__init__.py
|
||||
):
|
||||
"""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 debug:
|
||||
set_debug(True)
|
||||
osxphotos._set_debug(True)
|
||||
|
||||
# if no query terms, show help and return
|
||||
# sanity check input args
|
||||
nonexclusive = [
|
||||
|
||||
68
osxphotos/cli/rich_progress.py
Normal file
68
osxphotos/cli/rich_progress.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""rich Progress bar factory that can return a rich Progress bar or a mock Progress bar"""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import GetTimeCallable, Progress, ProgressColumn, TaskID
|
||||
|
||||
# set to 1 if running tests
|
||||
OSXPHOTOS_IS_TESTING = bool(os.getenv("OSXPHOTOS_IS_TESTING", default=False))
|
||||
|
||||
|
||||
class MockProgress:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def add_task(
|
||||
self,
|
||||
description: str,
|
||||
start: bool = True,
|
||||
total: float = 100.0,
|
||||
completed: int = 0,
|
||||
visible: bool = True,
|
||||
**fields: Any,
|
||||
) -> TaskID:
|
||||
pass
|
||||
|
||||
def advance(self, task_id: TaskID, advance: float = 1) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
def rich_progress(
|
||||
*columns: Union[str, ProgressColumn],
|
||||
console: Optional[Console] = None,
|
||||
auto_refresh: bool = True,
|
||||
refresh_per_second: float = 10,
|
||||
speed_estimate_period: float = 30.0,
|
||||
transient: bool = False,
|
||||
redirect_stdout: bool = True,
|
||||
redirect_stderr: bool = True,
|
||||
get_time: Optional[GetTimeCallable] = None,
|
||||
disable: bool = False,
|
||||
expand: bool = False,
|
||||
mock: bool = False,
|
||||
) -> None:
|
||||
"""Return a rich.progress.Progress object unless mock=True or os.getenv("OSXPHOTOS_IS_TESTING") is set"""
|
||||
# if OSXPHOTOS_IS_TESTING is set or mock=True, return a MockProgress object
|
||||
if mock or OSXPHOTOS_IS_TESTING:
|
||||
return MockProgress()
|
||||
return Progress(
|
||||
*columns,
|
||||
console=console,
|
||||
auto_refresh=auto_refresh,
|
||||
refresh_per_second=refresh_per_second,
|
||||
speed_estimate_period=speed_estimate_period,
|
||||
transient=transient,
|
||||
redirect_stdout=redirect_stdout,
|
||||
redirect_stderr=redirect_stderr,
|
||||
get_time=get_time,
|
||||
disable=disable,
|
||||
expand=expand,
|
||||
)
|
||||
@@ -12,7 +12,8 @@ from rich.syntax import Syntax
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import DB_OPTION, OSXPHOTOS_SNAPSHOT_DIR, get_photos_db, verbose_print
|
||||
from .common import DB_OPTION, OSXPHOTOS_SNAPSHOT_DIR, get_photos_db
|
||||
from .verbose import verbose_print
|
||||
|
||||
|
||||
@click.command(name="snap")
|
||||
|
||||
144
osxphotos/cli/verbose.py
Normal file
144
osxphotos/cli/verbose.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""helper functions for printing verbose output"""
|
||||
|
||||
import os
|
||||
import typing as t
|
||||
from datetime import datetime
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.theme import Theme
|
||||
|
||||
from .click_rich_echo import rich_click_echo
|
||||
from .common import CLI_COLOR_ERROR, CLI_COLOR_WARNING, time_stamp
|
||||
|
||||
# set to 1 if running tests
|
||||
OSXPHOTOS_IS_TESTING = bool(os.getenv("OSXPHOTOS_IS_TESTING", default=False))
|
||||
|
||||
# include error/warning emoji's in verbose output
|
||||
ERROR_EMOJI = True
|
||||
|
||||
__all__ = ["get_verbose_console", "verbose_print"]
|
||||
|
||||
|
||||
class _Console:
|
||||
"""Store console object for verbose output"""
|
||||
|
||||
def __init__(self):
|
||||
self._console: t.Optional[Console] = None
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
return self._console
|
||||
|
||||
@console.setter
|
||||
def console(self, console: Console):
|
||||
self._console = console
|
||||
|
||||
|
||||
_console = _Console()
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
"""no-op function"""
|
||||
pass
|
||||
|
||||
|
||||
def get_verbose_console() -> Console:
|
||||
"""Get console object
|
||||
|
||||
Returns:
|
||||
Console object
|
||||
"""
|
||||
global _console
|
||||
if _console.console is None:
|
||||
_console.console = Console(force_terminal=True)
|
||||
return _console.console
|
||||
|
||||
|
||||
def verbose_print(
|
||||
verbose: bool = True,
|
||||
timestamp: bool = False,
|
||||
rich: bool = False,
|
||||
highlight: bool = False,
|
||||
theme: t.Optional[Theme] = None,
|
||||
**kwargs: t.Any,
|
||||
) -> t.Callable:
|
||||
"""Create verbose function to print output
|
||||
|
||||
Args:
|
||||
verbose: if True, returns verbose print function otherwise returns no-op function
|
||||
timestamp: if True, includes timestamp in verbose output
|
||||
rich: use rich.print instead of click.echo
|
||||
highlight: if True, use automatic rich.print highlighting
|
||||
theme: optional rich.theme.Theme object to use for formatting
|
||||
kwargs: any extra arguments to pass to click.echo or rich.print depending on whether rich==True
|
||||
|
||||
Returns:
|
||||
function to print output
|
||||
"""
|
||||
if not verbose:
|
||||
return noop
|
||||
|
||||
global _console
|
||||
width = 10_000 if OSXPHOTOS_IS_TESTING else None
|
||||
_console.console = Console(theme=theme, width=width)
|
||||
|
||||
# closure to capture timestamp
|
||||
def verbose_(*args):
|
||||
"""print output if verbose flag set"""
|
||||
styled_args = []
|
||||
timestamp_str = f"{str(datetime.now())} -- " if timestamp else ""
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
arg = timestamp_str + arg
|
||||
if "error" in arg.lower():
|
||||
arg = click.style(arg, fg=CLI_COLOR_ERROR)
|
||||
elif "warning" in arg.lower():
|
||||
arg = click.style(arg, fg=CLI_COLOR_WARNING)
|
||||
styled_args.append(arg)
|
||||
click.echo(*styled_args, **kwargs)
|
||||
|
||||
def rich_verbose_(*args):
|
||||
"""rich.print output if verbose flag set"""
|
||||
global ERROR_EMOJI
|
||||
timestamp_str = time_stamp() if timestamp else ""
|
||||
new_args = []
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
if "error" in arg.lower():
|
||||
arg = f"[error]{arg}"
|
||||
if ERROR_EMOJI:
|
||||
arg = f":cross_mark-emoji: {arg}"
|
||||
elif "warning" in arg.lower():
|
||||
arg = f"[warning]{arg}"
|
||||
if ERROR_EMOJI:
|
||||
arg = f":warning-emoji: {arg}"
|
||||
arg = timestamp_str + arg
|
||||
new_args.append(arg)
|
||||
_console.console.print(*new_args, highlight=highlight, **kwargs)
|
||||
|
||||
def rich_verbose_testing_(*args):
|
||||
"""print output if verbose flag set using rich.print"""
|
||||
global ERROR_EMOJI
|
||||
timestamp_str = time_stamp() if timestamp else ""
|
||||
new_args = []
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
if "error" in arg.lower():
|
||||
arg = f"[error]{arg}"
|
||||
if ERROR_EMOJI:
|
||||
arg = f":cross_mark-emoji: {arg}"
|
||||
elif "warning" in arg.lower():
|
||||
arg = f"[warning]{arg}"
|
||||
if ERROR_EMOJI:
|
||||
arg = f":warning-emoji: {arg}"
|
||||
arg = timestamp_str + arg
|
||||
new_args.append(arg)
|
||||
rich_click_echo(*new_args, theme=theme, **kwargs)
|
||||
|
||||
if rich and not OSXPHOTOS_IS_TESTING:
|
||||
return rich_verbose_
|
||||
elif rich:
|
||||
return rich_verbose_testing_
|
||||
else:
|
||||
return verbose_
|
||||
@@ -8,6 +8,16 @@ import traceback
|
||||
|
||||
from rich import print
|
||||
|
||||
from ._version import __version__
|
||||
|
||||
# store data to print out in crash log, set by set_crash_data
|
||||
CRASH_DATA = {}
|
||||
|
||||
|
||||
def set_crash_data(key_, data):
|
||||
"""Set data to be printed in crash log"""
|
||||
CRASH_DATA[key_] = data
|
||||
|
||||
|
||||
def crash_reporter(filename, message, title, postamble, *extra_args):
|
||||
"""Create a crash dump file on error named filename
|
||||
@@ -30,9 +40,13 @@ def crash_reporter(filename, message, title, postamble, *extra_args):
|
||||
with open(filename, "w") as f:
|
||||
f.write(f"{title}\n")
|
||||
f.write(f"Created: {datetime.datetime.now()}\n")
|
||||
f.write(f"Python version: {sys.version}\n")
|
||||
f.write(f"osxphotos version: {__version__}\n")
|
||||
f.write(f"Platform: {platform.platform()}\n")
|
||||
f.write(f"Python version: {sys.version}\n")
|
||||
f.write(f"sys.argv: {sys.argv}\n")
|
||||
f.write("CRASH_DATA: \\n")
|
||||
for k, v in CRASH_DATA.items():
|
||||
f.write(f"{k}: {v}\n")
|
||||
for arg in extra_args:
|
||||
f.write(f"{arg}\n")
|
||||
f.write(f"Error: {e}\n")
|
||||
|
||||
103
osxphotos/debug.py
Normal file
103
osxphotos/debug.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Utilities for debugging"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
import wrapt
|
||||
from rich import print
|
||||
|
||||
# global variable to control debug output
|
||||
# set via --debug
|
||||
DEBUG = False
|
||||
|
||||
|
||||
def set_debug(debug: bool):
|
||||
"""set debug flag"""
|
||||
global DEBUG
|
||||
DEBUG = debug
|
||||
logging.disable(logging.NOTSET if debug else logging.DEBUG)
|
||||
|
||||
|
||||
def is_debug():
|
||||
"""return debug flag"""
|
||||
return DEBUG
|
||||
|
||||
|
||||
def debug_watch(wrapped, instance, args, kwargs):
|
||||
"""For use with wrapt.wrap_function_wrapper to watch calls to a function"""
|
||||
caller = sys._getframe().f_back.f_code.co_name
|
||||
name = wrapped.__name__
|
||||
timestamp = datetime.now().isoformat()
|
||||
print(
|
||||
f"{timestamp} {name} called from {caller} with args: {args} and kwargs: {kwargs}"
|
||||
)
|
||||
start_t = time.perf_counter()
|
||||
rv = wrapped(*args, **kwargs)
|
||||
stop_t = time.perf_counter()
|
||||
print(f"{timestamp} {name} returned: {rv}, elapsed time: {stop_t - start_t} sec")
|
||||
return rv
|
||||
|
||||
|
||||
def debug_breakpoint(wrapped, instance, args, kwargs):
|
||||
"""For use with wrapt.wrap_function_wrapper to set breakpoint on a function"""
|
||||
breakpoint()
|
||||
return wrapped(*args, **kwargs)
|
||||
|
||||
|
||||
def wrap_function(function_path, wrapper):
|
||||
"""Wrap a function with wrapper function"""
|
||||
module, name = function_path.split(".", 1)
|
||||
try:
|
||||
return wrapt.wrap_function_wrapper(module, name, wrapper)
|
||||
except AttributeError as e:
|
||||
raise AttributeError(f"{module}.{name} does not exist") from e
|
||||
|
||||
|
||||
def get_debug_options(arg_names: List, argv: List) -> Dict:
|
||||
"""Get the options for the debug options;
|
||||
Some of the debug options like --watch and --breakpoint need to be processed before any other packages are loaded
|
||||
so they can't be handled in the normal click argument processing, thus this function is called
|
||||
from osxphotos/cli/__init__.py
|
||||
|
||||
Assumes multi-valued options are OK and that all options take form of --option VALUE or --option=VALUE
|
||||
"""
|
||||
# argv[0] is the program name
|
||||
# argv[1] is the command
|
||||
# argv[2:] are the arguments
|
||||
args = {}
|
||||
for arg_name in arg_names:
|
||||
for idx, arg in enumerate(argv[1:]):
|
||||
if arg.startswith(f"{arg_name}="):
|
||||
arg_value = arg.split("=")[1]
|
||||
try:
|
||||
args[arg].append(arg_value)
|
||||
except KeyError:
|
||||
args[arg] = [arg_value]
|
||||
elif arg == arg_name:
|
||||
try:
|
||||
args[arg].append(argv[idx + 2])
|
||||
except KeyError:
|
||||
try:
|
||||
args[arg] = [argv[idx + 2]]
|
||||
except IndexError as e:
|
||||
raise ValueError(f"Missing value for {arg}") from e
|
||||
except IndexError as e:
|
||||
raise ValueError(f"Missing value for {arg}") from e
|
||||
return args
|
||||
|
||||
|
||||
def get_debug_flags(arg_names: List, argv: List) -> Dict:
|
||||
"""Get the flags for the debug options;
|
||||
Processes flags like --debug that resolve to True or False
|
||||
"""
|
||||
# argv[0] is the program name
|
||||
# argv[1] is the command
|
||||
# argv[2:] are the arguments
|
||||
args = {arg_name: False for arg_name in arg_names}
|
||||
for arg_name in arg_names:
|
||||
if arg_name in argv[1:]:
|
||||
args[arg_name] = True
|
||||
return args
|
||||
@@ -3,9 +3,10 @@
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -67,6 +68,13 @@ class FileUtilABC(ABC):
|
||||
def rename(cls, src, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def tmpdir(
|
||||
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
|
||||
) -> tempfile.TemporaryDirectory:
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
"""Various file utilities"""
|
||||
@@ -84,11 +92,10 @@ class FileUtilMacOS(FileUtilABC):
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
os.link(src, dest)
|
||||
except Exception as e:
|
||||
raise e
|
||||
raise e from e
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest):
|
||||
@@ -222,6 +229,17 @@ class FileUtilMacOS(FileUtilABC):
|
||||
os.rename(str(src), str(dest))
|
||||
return dest
|
||||
|
||||
@classmethod
|
||||
def tmpdir(
|
||||
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
|
||||
) -> tempfile.TemporaryDirectory:
|
||||
"""Securely creates a temporary directory using the same rules as mkdtemp().
|
||||
The resulting object can be used as a context manager.
|
||||
On completion of the context or destruction of the temporary directory object,
|
||||
the newly created temporary directory and all its contents are removed from the filesystem.
|
||||
"""
|
||||
return TemporaryDirectory(prefix=prefix, dir=dir)
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
"""return tuple of (mode, size, mtime) of file based on os.stat
|
||||
@@ -240,7 +258,7 @@ class FileUtil(FileUtilMacOS):
|
||||
|
||||
class FileUtilNoOp(FileUtil):
|
||||
"""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
|
||||
file_cmp returns mock data
|
||||
"""
|
||||
@@ -249,8 +267,6 @@ class FileUtilNoOp(FileUtil):
|
||||
def noop(*args):
|
||||
pass
|
||||
|
||||
verbose = noop
|
||||
|
||||
def __new__(cls, verbose=None):
|
||||
if verbose:
|
||||
if callable(verbose):
|
||||
@@ -261,33 +277,43 @@ class FileUtilNoOp(FileUtil):
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
cls.verbose(f"hardlink: {src} {dest}")
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
cls.verbose(f"copy: {src} {dest}")
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def rmdir(cls, dest):
|
||||
cls.verbose(f"rmdir: {dest}")
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
cls.verbose(f"utime: {path}, {times}")
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, file1):
|
||||
cls.verbose(f"file_sig: {file1}")
|
||||
return (42, 42, 42)
|
||||
|
||||
@classmethod
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
cls.verbose(f"convert_to_jpeg: {src_file}, {dest_file}, {compression_quality}")
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def rename(cls, src, dest):
|
||||
cls.verbose(f"rename: {src}, {dest}")
|
||||
pass
|
||||
|
||||
@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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
""" PhotoExport class to export photos
|
||||
"""
|
||||
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
@@ -10,9 +9,10 @@ import os
|
||||
import pathlib
|
||||
import re
|
||||
import tempfile
|
||||
import typing as t
|
||||
from collections import namedtuple # pylint: disable=syntax-error
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
|
||||
import photoscript
|
||||
from mako.template import Template
|
||||
@@ -43,6 +43,7 @@ from .photokit import (
|
||||
PhotoLibrary,
|
||||
)
|
||||
from .phototemplate import RenderOptions
|
||||
from .rich_utils import add_rich_markup_tag
|
||||
from .uti import get_preferred_uti_extension
|
||||
from .utils import increment_filename, lineno, list_directory
|
||||
|
||||
@@ -55,12 +56,23 @@ __all__ = [
|
||||
"rename_jpeg_files",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if t.TYPE_CHECKING:
|
||||
from .photoinfo import PhotoInfo
|
||||
|
||||
# retry if download_missing/use_photos_export fails the first time (which sometimes it does)
|
||||
MAX_PHOTOSCRIPT_RETRIES = 3
|
||||
|
||||
# return values for _should_update_photo
|
||||
class ShouldUpdate(Enum):
|
||||
NOT_IN_DATABASE = 1
|
||||
HARDLINK_DIFFERENT_FILES = 2
|
||||
NOT_HARDLINK_SAME_FILES = 3
|
||||
DEST_SIG_DIFFERENT = 4
|
||||
EXPORT_OPTIONS_DIFFERENT = 5
|
||||
EXIFTOOL_DIFFERENT = 6
|
||||
EDITED_SIG_DIFFERENT = 7
|
||||
DIGEST_DIFFERENT = 8
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
"""error during export"""
|
||||
@@ -74,11 +86,11 @@ class ExportOptions:
|
||||
|
||||
Attributes:
|
||||
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)
|
||||
dry_run: (bool, default=False): set to True to run in "dry run" mode
|
||||
edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version
|
||||
exiftool_flags (list of str): 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
|
||||
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
|
||||
@@ -97,11 +109,12 @@ class ExportOptions:
|
||||
merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
|
||||
overwrite (bool, default=False): if True will overwrite files if they already exist
|
||||
persons (bool): if True, include persons in exported metadata
|
||||
preview_suffix (str): 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
|
||||
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
|
||||
rich (bool): if True, will use rich markup with verbose output
|
||||
sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
|
||||
sidecar: bit field (int): set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
|
||||
- SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json;
|
||||
@@ -117,27 +130,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_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
|
||||
use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
|
||||
verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
verbose (callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
tmpdir: (str, default=None): Optional directory to use for temporary files, if None (default) uses system tmp directory
|
||||
"""
|
||||
|
||||
convert_to_jpeg: bool = False
|
||||
description_template: Optional[str] = None
|
||||
description_template: t.Optional[str] = None
|
||||
download_missing: bool = False
|
||||
dry_run: bool = False
|
||||
edited: bool = False
|
||||
exiftool_flags: Optional[List] = None
|
||||
exiftool_flags: t.Optional[t.List] = None
|
||||
exiftool: bool = False
|
||||
export_as_hardlink: bool = False
|
||||
export_db: Optional[ExportDB] = None
|
||||
export_db: t.Optional[ExportDB] = None
|
||||
face_regions: bool = True
|
||||
fileutil: Optional[FileUtil] = None
|
||||
fileutil: t.Optional[FileUtil] = None
|
||||
force_update: bool = False
|
||||
ignore_date_modified: bool = False
|
||||
ignore_signature: bool = False
|
||||
increment: bool = True
|
||||
jpeg_ext: Optional[str] = None
|
||||
jpeg_ext: t.Optional[str] = None
|
||||
jpeg_quality: float = 1.0
|
||||
keyword_template: Optional[List[str]] = None
|
||||
keyword_template: t.Optional[t.List[str]] = None
|
||||
live_photo: bool = False
|
||||
location: bool = True
|
||||
merge_exif_keywords: bool = False
|
||||
@@ -147,8 +161,9 @@ class ExportOptions:
|
||||
preview_suffix: str = DEFAULT_PREVIEW_SUFFIX
|
||||
preview: bool = False
|
||||
raw_photo: bool = False
|
||||
render_options: Optional[RenderOptions] = None
|
||||
render_options: t.Optional[RenderOptions] = None
|
||||
replace_keywords: bool = False
|
||||
rich: bool = False
|
||||
sidecar_drop_ext: bool = False
|
||||
sidecar: int = 0
|
||||
strip: bool = False
|
||||
@@ -159,7 +174,8 @@ class ExportOptions:
|
||||
use_persons_as_keywords: bool = False
|
||||
use_photokit: 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):
|
||||
return asdict(self)
|
||||
@@ -176,13 +192,13 @@ class StagedFiles:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
original: Optional[str] = None,
|
||||
original_live: Optional[str] = None,
|
||||
edited: Optional[str] = None,
|
||||
edited_live: Optional[str] = None,
|
||||
preview: Optional[str] = None,
|
||||
raw: Optional[str] = None,
|
||||
error: Optional[List[str]] = None,
|
||||
original: t.Optional[str] = None,
|
||||
original_live: t.Optional[str] = None,
|
||||
edited: t.Optional[str] = None,
|
||||
edited_live: t.Optional[str] = None,
|
||||
preview: t.Optional[str] = None,
|
||||
raw: t.Optional[str] = None,
|
||||
error: t.Optional[t.List[str]] = None,
|
||||
):
|
||||
self.original = original
|
||||
self.original_live = original_live
|
||||
@@ -359,23 +375,27 @@ class ExportResults:
|
||||
|
||||
|
||||
class PhotoExporter:
|
||||
def __init__(self, photo: "PhotoInfo"):
|
||||
def __init__(self, photo: "PhotoInfo", tmpdir: t.Optional[str] = None):
|
||||
self.photo = photo
|
||||
self._render_options = RenderOptions()
|
||||
self._verbose = self.photo._verbose
|
||||
|
||||
# define functions for adding markup
|
||||
self._filepath = add_rich_markup_tag("filepath", rich=False)
|
||||
self._filename = add_rich_markup_tag("filename", rich=False)
|
||||
self._uuid = add_rich_markup_tag("uuid", rich=False)
|
||||
self._num = add_rich_markup_tag("num", rich=False)
|
||||
|
||||
# temp directory for staging downloaded missing files
|
||||
self._temp_dir = tempfile.TemporaryDirectory(
|
||||
prefix=f"osxphotos_photo_exporter_{self.photo.uuid}_"
|
||||
)
|
||||
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
|
||||
self._temp_dir = None
|
||||
self._temp_dir_path = None
|
||||
self.fileutil = FileUtil
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
filename=None,
|
||||
options: Optional[ExportOptions] = None,
|
||||
options: t.Optional[ExportOptions] = None,
|
||||
) -> ExportResults:
|
||||
"""export photo
|
||||
|
||||
@@ -389,7 +409,7 @@ class PhotoExporter:
|
||||
in which case export will use the extension provided by Photos upon export.
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
options (ExportOptions): optional ExportOptions instance
|
||||
options (ExportOptions): t.Optional ExportOptions instance
|
||||
|
||||
Returns: ExportResults instance
|
||||
|
||||
@@ -399,10 +419,19 @@ class PhotoExporter:
|
||||
|
||||
options = options or ExportOptions()
|
||||
|
||||
# temp dir must be initialized before any of the methods called by export() are called
|
||||
self._init_temp_dir(options)
|
||||
|
||||
verbose = options.verbose or self._verbose
|
||||
if verbose and not callable(verbose):
|
||||
raise TypeError("verbose must be callable")
|
||||
|
||||
# define functions for adding markup
|
||||
self._filepath = add_rich_markup_tag("filepath", rich=options.rich)
|
||||
self._filename = add_rich_markup_tag("filename", rich=options.rich)
|
||||
self._uuid = add_rich_markup_tag("uuid", rich=options.rich)
|
||||
self._num = add_rich_markup_tag("num", rich=options.rich)
|
||||
|
||||
# can't use export_as_hardlink with download_missing, use_photos_export as can't hardlink the temporary files downloaded
|
||||
if options.export_as_hardlink and options.download_missing:
|
||||
raise ValueError(
|
||||
@@ -462,7 +491,7 @@ class PhotoExporter:
|
||||
)
|
||||
else:
|
||||
verbose(
|
||||
f"Skipping missing {'edited' if options.edited else 'original'} photo {self.photo.original_filename} ({self.photo.uuid})"
|
||||
f"Skipping missing {'edited' if options.edited else 'original'} photo {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})"
|
||||
)
|
||||
all_results.missing.append(dest)
|
||||
|
||||
@@ -479,7 +508,7 @@ class PhotoExporter:
|
||||
)
|
||||
else:
|
||||
verbose(
|
||||
f"Skipping missing live photo for {self.photo.original_filename} ({self.photo.uuid})"
|
||||
f"Skipping missing live photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})"
|
||||
)
|
||||
all_results.missing.append(live_name)
|
||||
|
||||
@@ -495,7 +524,7 @@ class PhotoExporter:
|
||||
)
|
||||
else:
|
||||
verbose(
|
||||
f"Skipping missing edited live photo for {self.photo.original_filename} ({self.photo.uuid})"
|
||||
f"Skipping missing edited live photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})"
|
||||
)
|
||||
all_results.missing.append(live_name)
|
||||
|
||||
@@ -516,7 +545,7 @@ class PhotoExporter:
|
||||
raw_name = dest.parent / f"{dest.stem}.{raw_ext}"
|
||||
all_results.missing.append(raw_name)
|
||||
verbose(
|
||||
f"Skipping missing raw photo for {self.photo.original_filename} ({self.photo.uuid})"
|
||||
f"Skipping missing raw photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})"
|
||||
)
|
||||
|
||||
# copy preview image if requested
|
||||
@@ -547,14 +576,30 @@ class PhotoExporter:
|
||||
preview_name = dest.parent / f"{dest.stem}{options.preview_suffix}.jpeg"
|
||||
all_results.missing.append(preview_name)
|
||||
verbose(
|
||||
f"Skipping missing preview photo for {self.photo.original_filename} ({self.photo.uuid})"
|
||||
f"Skipping missing preview photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})"
|
||||
)
|
||||
|
||||
all_results += self._write_sidecar_files(dest=dest, options=options)
|
||||
|
||||
return all_results
|
||||
|
||||
def _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"""
|
||||
fileutil = options.fileutil
|
||||
touch_results = []
|
||||
@@ -646,7 +691,7 @@ class PhotoExporter:
|
||||
|
||||
def _should_update_photo(
|
||||
self, src: pathlib.Path, dest: pathlib.Path, options: ExportOptions
|
||||
) -> bool:
|
||||
) -> t.Literal[True, False]:
|
||||
"""Return True if photo should be updated, else False"""
|
||||
export_db = options.export_db
|
||||
fileutil = options.fileutil
|
||||
@@ -655,42 +700,45 @@ class PhotoExporter:
|
||||
|
||||
if not file_record:
|
||||
# photo doesn't exist in database, should update
|
||||
return True
|
||||
return ShouldUpdate.NOT_IN_DATABASE
|
||||
|
||||
if options.export_as_hardlink and not dest.samefile(src):
|
||||
# different files, should update
|
||||
return True
|
||||
return ShouldUpdate.HARDLINK_DIFFERENT_FILES
|
||||
|
||||
if not options.export_as_hardlink and dest.samefile(src):
|
||||
# same file but not exporting as hardlink, should update
|
||||
return True
|
||||
return ShouldUpdate.NOT_HARDLINK_SAME_FILES
|
||||
|
||||
if not options.ignore_signature and not fileutil.cmp_file_sig(
|
||||
dest, file_record.dest_sig
|
||||
):
|
||||
# destination file doesn't match what was last exported
|
||||
return True
|
||||
return ShouldUpdate.DEST_SIG_DIFFERENT
|
||||
|
||||
if file_record.export_options != options.bit_flags:
|
||||
# exporting with different set of options (e.g. exiftool), should update
|
||||
# need to check this before exiftool in case exiftool options are different
|
||||
# and export database is missing; this will always be True if database is missing
|
||||
# as it'll be None and bit_flags will be an int
|
||||
return True
|
||||
return ShouldUpdate.EXPORT_OPTIONS_DIFFERENT
|
||||
|
||||
if options.exiftool:
|
||||
current_exifdata = self._exiftool_json_sidecar(options=options)
|
||||
return current_exifdata != file_record.exifdata
|
||||
rv = current_exifdata != file_record.exifdata
|
||||
# if using exiftool, don't need to continue checking edited below
|
||||
# as exiftool will be used to update edited file
|
||||
return ShouldUpdate.EXIFTOOL_DIFFERENT if rv else False
|
||||
|
||||
if options.edited and not fileutil.cmp_file_sig(src, file_record.src_sig):
|
||||
# edited file in Photos doesn't match what was last exported
|
||||
return True
|
||||
return ShouldUpdate.EDITED_SIG_DIFFERENT
|
||||
|
||||
if options.force_update:
|
||||
current_digest = hexdigest(self.photo.json())
|
||||
if current_digest != file_record.digest:
|
||||
# metadata in Photos changed, force update
|
||||
return True
|
||||
return ShouldUpdate.DIGEST_DIFFERENT
|
||||
|
||||
# photo should not be updated
|
||||
return False
|
||||
@@ -731,21 +779,6 @@ class PhotoExporter:
|
||||
if options.live_photo and self.photo.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
|
||||
if options.download_missing:
|
||||
live_photo = staged.edited_live if options.edited else staged.original_live
|
||||
@@ -904,7 +937,7 @@ class PhotoExporter:
|
||||
results = StagedFiles()
|
||||
|
||||
try:
|
||||
exported = _export_photo_uuid_applescript(
|
||||
exported = self._export_photo_uuid_applescript(
|
||||
self.photo.uuid,
|
||||
dest.parent,
|
||||
filestem=dest.stem,
|
||||
@@ -955,7 +988,7 @@ class PhotoExporter:
|
||||
|
||||
def _should_convert_to_jpeg(
|
||||
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
|
||||
and return the new destination and ExportOptions instance with the convert_to_jpeg flag set appropriately
|
||||
"""
|
||||
@@ -1090,6 +1123,15 @@ class PhotoExporter:
|
||||
|
||||
if options.exiftool:
|
||||
# if exiftool, write the metadata
|
||||
# need to copy the file to a temp file before writing metadata
|
||||
src = pathlib.Path(src)
|
||||
tmp_file = increment_filename(
|
||||
self._temp_dir_path / f"{src.stem}_exiftool{src.suffix}"
|
||||
)
|
||||
fileutil.copy(src, tmp_file)
|
||||
# point src to the tmp_file so that the original source is not modified
|
||||
# and the export grabs the new file
|
||||
src = tmp_file
|
||||
exif_results = self._write_exif_metadata_to_file(
|
||||
src, dest, options=options
|
||||
)
|
||||
@@ -1138,6 +1180,109 @@ class PhotoExporter:
|
||||
|
||||
return results
|
||||
|
||||
def _export_photo_uuid_applescript(
|
||||
self,
|
||||
uuid: str,
|
||||
dest: str,
|
||||
filestem=None,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
timeout=120,
|
||||
burst=False,
|
||||
dry_run=False,
|
||||
overwrite=False,
|
||||
):
|
||||
"""Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
|
||||
Args:
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
||||
|
||||
Returns: list of paths to exported file(s) or None if export failed
|
||||
|
||||
Raises: ExportError if error during export
|
||||
|
||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
"""
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError(f"dest {dest} must be a directory")
|
||||
|
||||
if not original ^ edited:
|
||||
raise ValueError("edited or original must be True but not both")
|
||||
|
||||
# export to a subdirectory of tmpdir
|
||||
tmpdir = self.fileutil.tmpdir(
|
||||
"osxphotos_applescript_export_", dir=self._temp_dir_path
|
||||
)
|
||||
|
||||
exported_files = []
|
||||
filename = None
|
||||
try:
|
||||
# I've seen intermittent failures with the PhotoScript export so retry if
|
||||
# export doesn't return anything
|
||||
retries = 0
|
||||
while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES:
|
||||
photo = photoscript.Photo(uuid)
|
||||
filename = photo.filename
|
||||
exported_files = photo.export(
|
||||
tmpdir.name, original=original, timeout=timeout
|
||||
)
|
||||
retries += 1
|
||||
except Exception as e:
|
||||
raise ExportError(e)
|
||||
|
||||
if not exported_files or not filename:
|
||||
# nothing got exported
|
||||
raise ExportError(f"Could not export photo {uuid} ({lineno(__file__)})")
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||
# TemporaryDirectory will cleanup on return
|
||||
filename_stem = pathlib.Path(filename).stem
|
||||
exported_paths = []
|
||||
for fname in exported_files:
|
||||
path = pathlib.Path(tmpdir.name) / fname
|
||||
if (
|
||||
len(exported_files) > 1
|
||||
and not live_photo
|
||||
and path.suffix.lower() == ".mov"
|
||||
):
|
||||
# it's the .mov part of live photo but not requested, so don't export
|
||||
continue
|
||||
if len(exported_files) > 1 and burst and path.stem != filename_stem:
|
||||
# skip any burst photo that's not the one we asked for
|
||||
continue
|
||||
if filestem:
|
||||
# rename the file based on filestem, keeping original extension
|
||||
dest_new = dest / f"{filestem}{path.suffix}"
|
||||
else:
|
||||
# use the name Photos provided
|
||||
dest_new = dest / path.name
|
||||
if not dry_run:
|
||||
if overwrite and dest_new.exists():
|
||||
FileUtil.unlink(dest_new)
|
||||
FileUtil.copy(str(path), str(dest_new))
|
||||
exported_paths.append(str(dest_new))
|
||||
return exported_paths
|
||||
|
||||
def _write_sidecar_files(
|
||||
self,
|
||||
dest: pathlib.Path,
|
||||
@@ -1236,14 +1381,18 @@ class PhotoExporter:
|
||||
)
|
||||
)
|
||||
if write_sidecar:
|
||||
verbose(f"Writing {sidecar_type} sidecar {sidecar_filename}")
|
||||
verbose(
|
||||
f"Writing {sidecar_type} sidecar {self._filepath(sidecar_filename)}"
|
||||
)
|
||||
files_written.append(str(sidecar_filename))
|
||||
if not options.dry_run:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
sidecar_record.digest = sidecar_digest
|
||||
sidecar_record.dest_sig = fileutil.file_sig(sidecar_filename)
|
||||
else:
|
||||
verbose(f"Skipped up to date {sidecar_type} sidecar {sidecar_filename}")
|
||||
verbose(
|
||||
f"Skipped up to date {sidecar_type} sidecar {self._filepath(sidecar_filename)}"
|
||||
)
|
||||
files_skipped.append(str(sidecar_filename))
|
||||
|
||||
results = ExportResults(
|
||||
@@ -1306,7 +1455,9 @@ class PhotoExporter:
|
||||
# determine if we need to write the exif metadata
|
||||
# if we are not updating, we always write
|
||||
# else, need to check the database to determine if we need to write
|
||||
verbose(f"Writing metadata with exiftool for {pathlib.Path(dest).name}")
|
||||
verbose(
|
||||
f"Writing metadata with exiftool for {self._filepath(pathlib.Path(dest).name)}"
|
||||
)
|
||||
if not options.dry_run:
|
||||
warning_, error_ = self._write_exif_data(src, options=options)
|
||||
if warning_:
|
||||
@@ -1366,7 +1517,9 @@ class PhotoExporter:
|
||||
return exiftool.warning, exiftool.error
|
||||
|
||||
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.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
@@ -1668,9 +1821,9 @@ class PhotoExporter:
|
||||
|
||||
def _exiftool_json_sidecar(
|
||||
self,
|
||||
options: Optional[ExportOptions] = None,
|
||||
options: t.Optional[ExportOptions] = None,
|
||||
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.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
@@ -1721,13 +1874,15 @@ class PhotoExporter:
|
||||
return json.dumps([exif])
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
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()
|
||||
@@ -1859,102 +2014,6 @@ def hexdigest(strval):
|
||||
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):
|
||||
"""Helper function for exporting photos to check file extensions of destination path.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ from .scoreinfo import ScoreInfo
|
||||
from .searchinfo import SearchInfo
|
||||
from .text_detection import detect_text
|
||||
from .uti import get_preferred_uti_extension, get_uti_for_extension
|
||||
from .utils import _debug, _get_resource_loc, list_directory, _debug
|
||||
from .utils import _get_resource_loc, list_directory
|
||||
|
||||
__all__ = ["PhotoInfo", "PhotoInfoNone"]
|
||||
|
||||
|
||||
@@ -50,15 +50,16 @@ from .._constants import (
|
||||
from .._version import __version__
|
||||
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo, ProjectInfo
|
||||
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
|
||||
from ..debug import is_debug
|
||||
from ..fileutil import FileUtil
|
||||
from ..personinfo import PersonInfo
|
||||
from ..photoinfo import PhotoInfo
|
||||
from ..phototemplate import RenderOptions
|
||||
from ..queryoptions import QueryOptions
|
||||
from ..rich_utils import add_rich_markup_tag
|
||||
from ..utils import (
|
||||
_check_file_exists,
|
||||
_db_is_locked,
|
||||
_debug,
|
||||
_get_os_version,
|
||||
_open_sql_file,
|
||||
get_last_library_path,
|
||||
@@ -90,13 +91,14 @@ class PhotosDB:
|
||||
labels_normalized_as_dict,
|
||||
)
|
||||
|
||||
def __init__(self, dbfile=None, verbose=None, exiftool=None):
|
||||
def __init__(self, dbfile=None, verbose=None, exiftool=None, rich=None):
|
||||
"""Create a new PhotosDB object.
|
||||
|
||||
Args:
|
||||
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
|
||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
|
||||
rich: use rich with verbose output
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if dbfile is not a valid Photos library.
|
||||
@@ -119,6 +121,12 @@ class PhotosDB:
|
||||
raise TypeError("verbose must be callable")
|
||||
self._verbose = verbose
|
||||
|
||||
# define functions for adding markup
|
||||
self._filepath = add_rich_markup_tag("filepath", rich=rich)
|
||||
self._filename = add_rich_markup_tag("filename", rich=rich)
|
||||
self._uuid = add_rich_markup_tag("uuid", rich=rich)
|
||||
self._num = add_rich_markup_tag("num", rich=rich)
|
||||
|
||||
# enable beta features
|
||||
self._beta = False
|
||||
|
||||
@@ -264,7 +272,7 @@ class PhotosDB:
|
||||
# key is Z_PK of ZMOMENT table and values are the moment info
|
||||
self._db_moment_pk = {}
|
||||
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"dbfile = {dbfile}")
|
||||
|
||||
if dbfile is None:
|
||||
@@ -281,7 +289,7 @@ class PhotosDB:
|
||||
if not _check_file_exists(dbfile):
|
||||
raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
|
||||
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"dbfile = {dbfile}")
|
||||
|
||||
# init database names
|
||||
@@ -295,7 +303,7 @@ class PhotosDB:
|
||||
# or photosanalysisd
|
||||
self._dbfile = self._dbfile_actual = self._tmp_db = os.path.abspath(dbfile)
|
||||
|
||||
verbose(f"Processing database {self._dbfile}")
|
||||
verbose(f"Processing database {self._filepath(self._dbfile)}")
|
||||
|
||||
# if database is exclusively locked, make a copy of it and use the copy
|
||||
# Photos maintains an exclusive lock on the database file while Photos is open
|
||||
@@ -315,13 +323,13 @@ class PhotosDB:
|
||||
raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
|
||||
else:
|
||||
self._dbfile_actual = self._tmp_db = dbfile
|
||||
verbose(f"Processing database {self._dbfile_actual}")
|
||||
verbose(f"Processing database {self._filepath(self._dbfile_actual)}")
|
||||
# if database is exclusively locked, make a copy of it and use the copy
|
||||
if _db_is_locked(self._dbfile_actual):
|
||||
verbose(f"Database locked, creating temporary copy.")
|
||||
self._tmp_db = self._copy_db_file(self._dbfile_actual)
|
||||
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(
|
||||
f"_dbfile = {self._dbfile}, _dbfile_actual = {self._dbfile_actual}"
|
||||
)
|
||||
@@ -336,7 +344,7 @@ class PhotosDB:
|
||||
masters_path = os.path.join(library_path, "originals")
|
||||
self._masters_path = masters_path
|
||||
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"library = {library_path}, masters = {masters_path}")
|
||||
|
||||
if int(self._db_version) <= int(_PHOTOS_4_VERSION):
|
||||
@@ -592,7 +600,7 @@ class PhotosDB:
|
||||
print(f"Error copying{fname} to {dest_path}", file=sys.stderr)
|
||||
raise Exception
|
||||
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(dest_path)
|
||||
|
||||
return dest_path
|
||||
@@ -619,7 +627,7 @@ class PhotosDB:
|
||||
# print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
|
||||
# raise Exception
|
||||
|
||||
# if _debug():
|
||||
# if is_debug():
|
||||
# logging.debug(dest_path)
|
||||
|
||||
# return dest_path
|
||||
@@ -630,7 +638,7 @@ class PhotosDB:
|
||||
|
||||
verbose = self._verbose
|
||||
verbose("Processing database.")
|
||||
verbose(f"Database version: {self._db_version}.")
|
||||
verbose(f"Database version: {self._num(self._db_version)}.")
|
||||
|
||||
self._photos_ver = 4 # only used in Photos 5+
|
||||
|
||||
@@ -1079,7 +1087,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["type"] = _MOVIE_TYPE
|
||||
else:
|
||||
# unknown
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"WARNING: {uuid} found unknown type {row[21]}")
|
||||
self._dbphotos[uuid]["type"] = None
|
||||
|
||||
@@ -1302,7 +1310,7 @@ class PhotosDB:
|
||||
and row[6] == 2
|
||||
):
|
||||
if "edit_resource_id" in self._dbphotos[uuid]:
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(
|
||||
f"WARNING: found more than one edit_resource_id for "
|
||||
f"UUID {row[0]},adjustmentUUID {row[1]}, modelID {row[2]}"
|
||||
@@ -1581,7 +1589,7 @@ class PhotosDB:
|
||||
but it works so don't touch it.
|
||||
"""
|
||||
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"_process_database5")
|
||||
verbose = self._verbose
|
||||
verbose(f"Processing database.")
|
||||
@@ -1590,7 +1598,9 @@ class PhotosDB:
|
||||
# some of the tables/columns have different names in different versions of Photos
|
||||
photos_ver = get_db_model_version(self._tmp_db)
|
||||
self._photos_ver = photos_ver
|
||||
verbose(f"Database version: {self._db_version}, {photos_ver}.")
|
||||
verbose(
|
||||
f"Database version: {self._num(self._db_version)}, {self._num(photos_ver)}."
|
||||
)
|
||||
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
|
||||
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
|
||||
asset_album_table = _DB_TABLE_NAMES[photos_ver]["ASSET_ALBUM_TABLE"]
|
||||
@@ -1603,7 +1613,7 @@ class PhotosDB:
|
||||
hdr_type_column = _DB_TABLE_NAMES[photos_ver]["HDR_TYPE"]
|
||||
|
||||
# Look for all combinations of persons and pictures
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"Getting information about persons")
|
||||
|
||||
# get info to associate persons with photos
|
||||
@@ -2012,7 +2022,7 @@ class PhotosDB:
|
||||
elif row[17] == 1:
|
||||
info["type"] = _MOVIE_TYPE
|
||||
else:
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(f"WARNING: {uuid} found unknown type {row[17]}")
|
||||
info["type"] = None
|
||||
|
||||
@@ -2211,7 +2221,7 @@ class PhotosDB:
|
||||
if uuid in self._dbphotos:
|
||||
self._dbphotos[uuid]["extendedDescription"] = normalize_unicode(row[1])
|
||||
else:
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(
|
||||
f"WARNING: found description {row[1]} but no photo for {uuid}"
|
||||
)
|
||||
@@ -2230,7 +2240,7 @@ class PhotosDB:
|
||||
if uuid in self._dbphotos:
|
||||
self._dbphotos[uuid]["adjustmentFormatID"] = row[2]
|
||||
else:
|
||||
if _debug():
|
||||
if is_debug():
|
||||
logging.debug(
|
||||
f"WARNING: found adjustmentformatidentifier {row[2]} but no photo for uuid {row[0]}"
|
||||
)
|
||||
|
||||
24
osxphotos/rich_utils.py
Normal file
24
osxphotos/rich_utils.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""utilities for working with rich markup"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def add_rich_markup_tag(tag: str, rich=True) -> Callable:
|
||||
"""Returns function that rich markup tags to string"""
|
||||
|
||||
if not rich:
|
||||
return no_markup
|
||||
|
||||
def add_tag(msg: str) -> str:
|
||||
"""Add tag to string"""
|
||||
return f"[{tag}]{msg}[/{tag}]"
|
||||
|
||||
return add_tag
|
||||
|
||||
|
||||
def no_markup(msg: str) -> str:
|
||||
"""Return msg without markup"""
|
||||
return msg
|
||||
|
||||
|
||||
__all__ = ["add_rich_markup_tag", "no_markup"]
|
||||
@@ -20,8 +20,6 @@ from plistlib import load as plistload
|
||||
from typing import Callable, List, Union, Optional
|
||||
|
||||
import CoreFoundation
|
||||
import objc
|
||||
from Foundation import NSFileManager, NSPredicate, NSString
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
|
||||
@@ -41,17 +39,12 @@ __all__ = [
|
||||
"normalize_unicode",
|
||||
]
|
||||
|
||||
_DEBUG = False
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s",
|
||||
)
|
||||
|
||||
if not _DEBUG:
|
||||
logging.disable(logging.DEBUG)
|
||||
|
||||
|
||||
def _get_logger():
|
||||
"""Used only for testing
|
||||
@@ -62,21 +55,6 @@ def _get_logger():
|
||||
return logging.Logger(__name__)
|
||||
|
||||
|
||||
def _set_debug(debug):
|
||||
"""Enable or disable debug logging"""
|
||||
global _DEBUG
|
||||
_DEBUG = debug
|
||||
if debug:
|
||||
logging.disable(logging.NOTSET)
|
||||
else:
|
||||
logging.disable(logging.DEBUG)
|
||||
|
||||
|
||||
def _debug():
|
||||
"""returns True if debugging turned on (via _set_debug), otherwise, false"""
|
||||
return _DEBUG
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
"""do nothing (no operation)"""
|
||||
pass
|
||||
@@ -270,8 +248,7 @@ def list_photo_libraries():
|
||||
)
|
||||
|
||||
# On older OS, may not get all libraries so make sure we get the last one
|
||||
last_lib = get_last_library_path()
|
||||
if last_lib:
|
||||
if last_lib := get_last_library_path():
|
||||
lib_list.append(last_lib)
|
||||
|
||||
output = subprocess.check_output(
|
||||
@@ -279,8 +256,7 @@ def list_photo_libraries():
|
||||
).splitlines()
|
||||
for lib in output:
|
||||
lib_list.append(lib.decode("utf-8"))
|
||||
lib_list = list(set(lib_list))
|
||||
lib_list.sort()
|
||||
lib_list = sorted(set(lib_list))
|
||||
return lib_list
|
||||
|
||||
|
||||
@@ -505,8 +481,11 @@ def load_function(pyfile: str, function_name: str) -> Callable:
|
||||
|
||||
try:
|
||||
func = getattr(module, function_name)
|
||||
except AttributeError:
|
||||
raise ValueError(f"'{function_name}' not found in module '{module_name}'")
|
||||
except AttributeError as e:
|
||||
raise ValueError(
|
||||
f"'{function_name}' not found in module '{module_name}'"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
# restore sys.path
|
||||
sys.path = syspath
|
||||
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
addopts = -p tests.plugins.env_vars
|
||||
@@ -22,4 +22,5 @@ PyYAML>=5.4.1,<6.0.0
|
||||
rich>=11.2.0,<12.0.0
|
||||
textx>=2.3.0,<2.4.0
|
||||
toml>=0.10.2,<0.11.0
|
||||
wrapt>=1.13.3,<1.14.0
|
||||
wurlitzer>=2.1.0,<2.2.0
|
||||
1
setup.py
1
setup.py
@@ -97,6 +97,7 @@ setup(
|
||||
"rich>=11.2.0,<12.0.0",
|
||||
"textx>=2.3.0,<3.0.0",
|
||||
"toml>=0.10.2,<0.11.0",
|
||||
"wrapt>=1.13.3,<1.14.0",
|
||||
"wurlitzer>=2.1.0,<3.0.0",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli_main"]},
|
||||
|
||||
0
tests/plugins/__init__.py
Normal file
0
tests/plugins/__init__.py
Normal file
8
tests/plugins/env_vars.py
Normal file
8
tests/plugins/env_vars.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_load_initial_conftests(args, early_config, parser):
|
||||
os.environ["OSXPHOTOS_IS_TESTING"] = "1"
|
||||
@@ -1384,7 +1384,7 @@ def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected):
|
||||
|
||||
|
||||
def test_export():
|
||||
|
||||
"""test basic export"""
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
@@ -1395,6 +1395,22 @@ def test_export():
|
||||
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():
|
||||
"""Test export with --uuid-from-file"""
|
||||
|
||||
@@ -1811,6 +1827,40 @@ def test_export_exiftool():
|
||||
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")
|
||||
def test_export_exiftool_template_change():
|
||||
"""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,
|
||||
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"""
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
20
tests/test_debug.py
Normal file
20
tests/test_debug.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Test debug"""
|
||||
|
||||
import logging
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.debug import is_debug, set_debug
|
||||
|
||||
|
||||
def test_debug_enable():
|
||||
set_debug(True)
|
||||
logger = osxphotos._get_logger()
|
||||
assert logger.isEnabledFor(logging.DEBUG)
|
||||
assert is_debug()
|
||||
|
||||
|
||||
def test_debug_disable():
|
||||
set_debug(False)
|
||||
logger = osxphotos._get_logger()
|
||||
assert not logger.isEnabledFor(logging.DEBUG)
|
||||
assert not is_debug()
|
||||
@@ -1,8 +1,12 @@
|
||||
""" test FileUtil """
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
TEST_HEIC = "tests/test-images/IMG_3092.heic"
|
||||
TEST_RAW = "tests/test-images/DSC03584.dng"
|
||||
|
||||
@@ -11,6 +15,7 @@ def test_copy_file_valid():
|
||||
# copy file with valid src, dest
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -23,6 +28,7 @@ def test_copy_file_valid():
|
||||
def test_copy_file_invalid():
|
||||
# copy file with invalid src
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -36,6 +42,7 @@ def test_hardlink_file_valid():
|
||||
# hardlink file with valid src, dest
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -49,6 +56,7 @@ def test_hardlink_file_valid():
|
||||
def test_unlink_file():
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -63,6 +71,7 @@ def test_unlink_file():
|
||||
def test_rmdir():
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -77,9 +86,10 @@ def test_rmdir():
|
||||
reason="Skip if running in Github actions, no GPU.",
|
||||
)
|
||||
def test_convert_to_jpeg():
|
||||
""" test convert_to_jpeg """
|
||||
"""test convert_to_jpeg"""
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -95,9 +105,10 @@ def test_convert_to_jpeg():
|
||||
reason="Skip if running in Github actions, no GPU.",
|
||||
)
|
||||
def test_convert_to_jpeg_quality():
|
||||
""" test convert_to_jpeg with compression_quality """
|
||||
"""test convert_to_jpeg with compression_quality"""
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -113,6 +124,7 @@ def test_rename_file():
|
||||
# rename file with valid src, dest
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -125,3 +137,15 @@ def test_rename_file():
|
||||
assert pathlib.Path(dest2).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()
|
||||
|
||||
@@ -21,18 +21,6 @@ from osxphotos.utils import (
|
||||
)
|
||||
|
||||
|
||||
def test_debug_enable():
|
||||
osxphotos._set_debug(True)
|
||||
logger = osxphotos._get_logger()
|
||||
assert logger.isEnabledFor(logging.DEBUG)
|
||||
|
||||
|
||||
def test_debug_disable():
|
||||
osxphotos._set_debug(False)
|
||||
logger = osxphotos._get_logger()
|
||||
assert not logger.isEnabledFor(logging.DEBUG)
|
||||
|
||||
|
||||
def test_dd_to_dms():
|
||||
# expands coverage for edge case in _dd_to_dms
|
||||
|
||||
|
||||
Reference in New Issue
Block a user