Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09323b9fb | ||
|
|
213d84e964 | ||
|
|
6c57fb2df9 | ||
|
|
9c0b910046 | ||
|
|
1f40161950 | ||
|
|
d1aa4e92bd | ||
|
|
e7a17a8635 | ||
|
|
68754273de | ||
|
|
d28a2fe9bb | ||
|
|
382d097285 | ||
|
|
93de53da51 | ||
|
|
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/
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ cli.spec
|
||||
docsrc/_build/
|
||||
venv/
|
||||
.python-version
|
||||
cov.xml
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -4,6 +4,51 @@ 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.6](https://github.com/RhetTbull/osxphotos/compare/v0.47.5...v0.47.6)
|
||||
|
||||
> 27 March 2022
|
||||
|
||||
- fix verbose output when redirected to file, #661 [`382d097`](https://github.com/RhetTbull/osxphotos/commit/382d097285519e274bdb0cd16d6805152aa4a918)
|
||||
- Updated docs [skip ci] [`6875427`](https://github.com/RhetTbull/osxphotos/commit/68754273de33ac9ed767fd43edcfe8b9c92e3a56)
|
||||
- version bump [`d28a2fe`](https://github.com/RhetTbull/osxphotos/commit/d28a2fe9bb69034c19446c3dc70555b6043a43a0)
|
||||
|
||||
#### [v0.47.5](https://github.com/RhetTbull/osxphotos/compare/v0.47.4...v0.47.5)
|
||||
|
||||
> 12 March 2022
|
||||
|
||||
- Richify [`#653`](https://github.com/RhetTbull/osxphotos/pull/653)
|
||||
- Added --watch, --breakpoint [`#652`](https://github.com/RhetTbull/osxphotos/pull/652)
|
||||
- Hack to fix #654 when utime fails on NAS [`#654`](https://github.com/RhetTbull/osxphotos/issues/654)
|
||||
- Debug updates [`de1900f`](https://github.com/RhetTbull/osxphotos/commit/de1900f10aaac8d703ef5d850a64f18dd5e01d40)
|
||||
- Updated docs [skip ci] [`fccd746`](https://github.com/RhetTbull/osxphotos/commit/fccd746c581a319a8d1d5063133cbb9d5a4e1778)
|
||||
- Changed return val of _should_update_photo to enum for easier debugging [`bbcc3ac`](https://github.com/RhetTbull/osxphotos/commit/bbcc3acba9a4dd3a9ebcc8136782005d35e46255)
|
||||
- Updated crash_reporter to include crash data [`1227465`](https://github.com/RhetTbull/osxphotos/commit/1227465aa7e1d5a2fdaba4fc45cf917728d31170)
|
||||
- Fixed missing pdb.py issue for pyinstaller, partial for #659 [`e272e95`](https://github.com/RhetTbull/osxphotos/commit/e272e95a856e5b448eb6ac85818757b974dfd6d4)
|
||||
|
||||
#### [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
|
||||
|
||||
@@ -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: 61bf98593db44b8d320314e5cbec33cf
|
||||
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.7 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.7',
|
||||
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.7 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.7 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.7 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.7 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.7 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.7 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
@@ -1,10 +1,12 @@
|
||||
""" Example function for use with osxphotos export --post-function option """
|
||||
|
||||
from osxphotos import PhotoInfo, ExportResults
|
||||
from typing import Callable
|
||||
|
||||
from osxphotos import ExportResults, PhotoInfo
|
||||
|
||||
|
||||
def post_function(
|
||||
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
|
||||
photo: PhotoInfo, results: ExportResults, verbose: Callable, **kwargs
|
||||
):
|
||||
"""Call this with osxphotos export /path/to/export --post-function post_function.py::post_function
|
||||
This will get called immediately after the photo has been exported
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,8 @@ import os.path
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
APP_NAME = "osxphotos"
|
||||
|
||||
OSXPHOTOS_URL = "https://github.com/RhetTbull/osxphotos"
|
||||
|
||||
# Time delta: add this to Photos times to get unix time
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.47.2"
|
||||
__version__ = "0.47.7"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,7 @@ from .places import places
|
||||
from .query import query
|
||||
from .repl import repl
|
||||
from .snap_diff import diff, snap
|
||||
from .theme import theme
|
||||
from .tutorial import tutorial
|
||||
from .uuid import uuid
|
||||
|
||||
@@ -31,8 +32,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,7 +76,9 @@ for command in [
|
||||
places,
|
||||
query,
|
||||
repl,
|
||||
run,
|
||||
snap,
|
||||
theme,
|
||||
tutorial,
|
||||
uninstall,
|
||||
uuid,
|
||||
|
||||
268
osxphotos/cli/click_rich_echo.py
Normal file
268
osxphotos/cli/click_rich_echo.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""click.echo replacement that supports rich text formatting"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
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
|
||||
console = Console(
|
||||
force_terminal=True,
|
||||
theme=theme or get_rich_theme(),
|
||||
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
|
||||
with console.capture() as capture:
|
||||
console.print(message, end=end, highlight=highlight, **kwargs)
|
||||
click.echo(capture.get(), **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.console or Console(theme=theme)
|
||||
|
||||
color = kwargs.pop("color", True)
|
||||
|
||||
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)
|
||||
194
osxphotos/cli/color_themes.py
Normal file
194
osxphotos/cli/color_themes.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Support for colorized output for osxphotos cli using rich"""
|
||||
|
||||
import pathlib
|
||||
from typing import List, Optional
|
||||
|
||||
import click
|
||||
from rich.style import Style
|
||||
from rich_theme_manager import Theme, ThemeManager
|
||||
|
||||
from .common import get_config_dir, noop
|
||||
from .darkmode import is_dark_mode
|
||||
|
||||
DEFAULT_THEME_NAME = "default"
|
||||
|
||||
__all__ = [
|
||||
"get_default_theme",
|
||||
"get_theme",
|
||||
"get_theme_dir",
|
||||
"get_theme_manager",
|
||||
DEFAULT_THEME_NAME,
|
||||
]
|
||||
|
||||
|
||||
THEME_STYLES = [
|
||||
"color",
|
||||
"count",
|
||||
"error",
|
||||
"filename",
|
||||
"filepath",
|
||||
"highlight",
|
||||
"num",
|
||||
"time",
|
||||
"uuid",
|
||||
"warning",
|
||||
"bar.back",
|
||||
"bar.complete",
|
||||
"bar.finished",
|
||||
"bar.pulse",
|
||||
"progress.elapsed",
|
||||
"progress.percentage",
|
||||
"progress.remaining",
|
||||
]
|
||||
|
||||
COLOR_THEMES = {
|
||||
"dark": Theme(
|
||||
name="dark",
|
||||
description="Dark mode theme",
|
||||
tags=["dark"],
|
||||
styles={
|
||||
# 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)"),
|
||||
# "headers": Style(color="rgb(165,194,97)"),
|
||||
# "options": Style(color="rgb(255,198,109)"),
|
||||
# "metavar": Style(color="rgb(12,125,157)"),
|
||||
},
|
||||
),
|
||||
"light": Theme(
|
||||
name="light",
|
||||
description="Light mode theme",
|
||||
styles={
|
||||
"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),
|
||||
# "headers": Style(color="rgb(254,212,66)"),
|
||||
# "options": Style(color="rgb(227,98,9)"),
|
||||
# "metavar": Style(color="rgb(111,66,193)"),
|
||||
},
|
||||
),
|
||||
"mono": Theme(
|
||||
name="mono",
|
||||
description="Monochromatic theme",
|
||||
tags=["mono", "colorblind"],
|
||||
styles={
|
||||
"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",
|
||||
# "headers": "bold",
|
||||
# "options": "bold",
|
||||
# "metavar": "bold",
|
||||
},
|
||||
),
|
||||
"plain": Theme(
|
||||
name="plain",
|
||||
description="Plain theme with no colors",
|
||||
tags=["colorblind"],
|
||||
styles={
|
||||
"color": "",
|
||||
"count": "",
|
||||
"error": "",
|
||||
"filename": "",
|
||||
"filepath": "",
|
||||
"highlight": "",
|
||||
"num": "",
|
||||
"time": "",
|
||||
"uuid": "",
|
||||
"warning": "",
|
||||
"bar.back": "",
|
||||
"bar.complete": "",
|
||||
"bar.finished": "",
|
||||
"bar.pulse": "",
|
||||
"progress.elapsed": "",
|
||||
"progress.percentage": "",
|
||||
"progress.remaining": "",
|
||||
# "headers": "",
|
||||
# "options": "",
|
||||
# "metavar": "",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_theme_dir() -> pathlib.Path:
|
||||
"""Return the theme config dir, creating it if necessary"""
|
||||
theme_dir = get_config_dir() / "themes"
|
||||
if not theme_dir.exists():
|
||||
theme_dir.mkdir()
|
||||
return theme_dir
|
||||
|
||||
|
||||
def get_theme_manager() -> ThemeManager:
|
||||
"""Return theme manager instance"""
|
||||
return ThemeManager(theme_dir=str(get_theme_dir()), themes=COLOR_THEMES.values())
|
||||
|
||||
|
||||
def get_theme(
|
||||
theme_name: Optional[str] = None,
|
||||
):
|
||||
"""Get theme by name, or default theme if no name is provided"""
|
||||
|
||||
if theme_name is None:
|
||||
return get_default_theme()
|
||||
|
||||
theme_manager = get_theme_manager()
|
||||
try:
|
||||
return theme_manager.get(theme_name)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(
|
||||
f"Theme '{theme_name}' not found. "
|
||||
f"Available themes: {', '.join(t.name for t in theme_manager.themes)}"
|
||||
) from e
|
||||
|
||||
|
||||
def get_default_theme():
|
||||
"""Get the default color theme"""
|
||||
theme_manager = get_theme_manager()
|
||||
try:
|
||||
return theme_manager.get(DEFAULT_THEME_NAME)
|
||||
except ValueError:
|
||||
return (
|
||||
theme_manager.get("dark") if is_dark_mode() else theme_manager.get("light")
|
||||
)
|
||||
@@ -1,23 +1,18 @@
|
||||
"""Globals and constants use by the CLI commands"""
|
||||
|
||||
import datetime
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Callable
|
||||
from datetime import datetime
|
||||
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import APP_NAME
|
||||
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 +20,26 @@ 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",
|
||||
"THEME_OPTION",
|
||||
"get_photos_db",
|
||||
"load_uuid_from_file",
|
||||
"noop",
|
||||
"time_stamp",
|
||||
]
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
@@ -47,50 +47,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 +470,47 @@ 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
|
||||
|
||||
|
||||
THEME_OPTION = click.option(
|
||||
"--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.",
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
@@ -536,3 +536,11 @@ def load_uuid_from_file(filename):
|
||||
if len(line) and line[0] != "#":
|
||||
uuid.append(line)
|
||||
return uuid
|
||||
|
||||
|
||||
def get_config_dir() -> pathlib.Path:
|
||||
"""Get the directory where config files are stored."""
|
||||
config_dir = pathlib.Path.home() / ".config" / APP_NAME
|
||||
if not config_dir.is_dir():
|
||||
config_dir.mkdir(parents=True)
|
||||
return config_dir
|
||||
|
||||
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,45 @@ 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,
|
||||
THEME_OPTION,
|
||||
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 +576,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,
|
||||
@@ -619,14 +643,8 @@ from .param_types import ExportDBType, FunctionCall
|
||||
f"Can be specified multiple times. Valid options are: {PROFILE_SORT_KEYS}. "
|
||||
"Default = 'cumulative'.",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
help="Enable debug output.",
|
||||
)
|
||||
@THEME_OPTION
|
||||
@DEBUG_OPTIONS
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -670,6 +688,7 @@ def export(
|
||||
to_time,
|
||||
verbose,
|
||||
timestamp,
|
||||
no_progress,
|
||||
missing,
|
||||
update,
|
||||
force_update,
|
||||
@@ -756,6 +775,7 @@ def export(
|
||||
add_missing_to_album,
|
||||
exportdb,
|
||||
ramdb,
|
||||
tmpdir,
|
||||
load_config,
|
||||
save_config,
|
||||
config_only,
|
||||
@@ -778,7 +798,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 +815,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 +843,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 +938,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 +991,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 +1009,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 +1064,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 +1087,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 +1113,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 +1153,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 +1181,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 +1207,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 +1234,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 +1362,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 +1389,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 +1444,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 +1554,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 +1604,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 +1688,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 +1736,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 +1759,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 +1855,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 +1901,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 +1969,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 +2015,7 @@ def export_photo(
|
||||
use_photos_export=use_photos_export,
|
||||
use_photokit=use_photokit,
|
||||
verbose_=verbose_,
|
||||
tmpdir=tmpdir,
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -2068,6 +2100,7 @@ def export_photo_to_directory(
|
||||
use_photos_export,
|
||||
use_photokit,
|
||||
verbose_,
|
||||
tmpdir,
|
||||
):
|
||||
"""Export photo to directory dest_path"""
|
||||
|
||||
@@ -2087,7 +2120,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 +2163,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 +2528,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 +2551,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 +2609,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 +2635,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 +2695,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 +2774,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,8 @@
|
||||
"""Help text helper class for osxphotos CLI """
|
||||
|
||||
import io
|
||||
import inspect
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
import click
|
||||
import osxmetadata
|
||||
@@ -21,6 +22,13 @@ from osxphotos.phototemplate import (
|
||||
get_template_help,
|
||||
)
|
||||
|
||||
from .click_rich_echo import rich_echo_via_pager
|
||||
from .color_themes import get_theme
|
||||
from .common import OSXPHOTOS_HIDDEN
|
||||
|
||||
HELP_WIDTH = 110
|
||||
HIGHLIGHT_COLOR = "yellow"
|
||||
|
||||
__all__ = [
|
||||
"ExportCommand",
|
||||
"template_help",
|
||||
@@ -40,19 +48,161 @@ def get_help_msg(command):
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--width",
|
||||
default=HELP_WIDTH,
|
||||
help="Width of help text",
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
)
|
||||
@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, width, **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:
|
||||
|
||||
global HELP_WIDTH
|
||||
HELP_WIDTH = width
|
||||
|
||||
wrap_text_original = click.formatting.wrap_text
|
||||
|
||||
def wrap_text(
|
||||
text: str,
|
||||
width: int = HELP_WIDTH,
|
||||
initial_indent: str = "",
|
||||
subsequent_indent: str = "",
|
||||
preserve_paragraphs: bool = False,
|
||||
) -> str:
|
||||
return wrap_text_original(
|
||||
text,
|
||||
width=width,
|
||||
initial_indent=initial_indent,
|
||||
subsequent_indent=subsequent_indent,
|
||||
preserve_paragraphs=preserve_paragraphs,
|
||||
)
|
||||
|
||||
click.formatting.wrap_text = wrap_text
|
||||
click.wrap_text = wrap_text
|
||||
|
||||
if subtopic:
|
||||
cmd = ctx.obj.group.commands[topic]
|
||||
rich_echo_via_pager(
|
||||
get_subtopic_help(cmd, ctx, subtopic),
|
||||
theme=get_theme(),
|
||||
width=HELP_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(width=HELP_WIDTH)
|
||||
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(width=HELP_WIDTH)
|
||||
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
|
||||
@@ -61,11 +211,9 @@ class ExportCommand(click.Command):
|
||||
|
||||
def get_help(self, ctx):
|
||||
help_text = super().get_help(ctx)
|
||||
formatter = click.HelpFormatter()
|
||||
# passed to click.HelpFormatter.write_dl for formatting
|
||||
|
||||
formatter.write("\n\n")
|
||||
formatter.write(rich_text("[bold]** Export **[/bold]", width=formatter.width))
|
||||
formatter = click.HelpFormatter(width=HELP_WIDTH)
|
||||
formatter.write("\n")
|
||||
formatter.write(rich_text("## Export", width=formatter.width, markdown=True))
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"When exporting photos, osxphotos creates a database in the top-level "
|
||||
@@ -100,9 +248,9 @@ class ExportCommand(click.Command):
|
||||
+ "You can always run export without the --update option to re-export the entire library thus "
|
||||
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width)
|
||||
rich_text("## Extended Attributes", width=formatter.width, markdown=True)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
@@ -123,25 +271,33 @@ The following attributes may be used with '--xattr-template':
|
||||
|
||||
"""
|
||||
)
|
||||
formatter.write_dl(
|
||||
[
|
||||
attr_tuples = [
|
||||
(
|
||||
rich_text("[bold]Attribute[/bold]", width=formatter.width),
|
||||
rich_text("[bold]Description[/bold]", width=formatter.width),
|
||||
),
|
||||
*[
|
||||
(
|
||||
attr,
|
||||
f"{osxmetadata.ATTRIBUTES[attr].help} ({osxmetadata.ATTRIBUTES[attr].constant})",
|
||||
)
|
||||
for attr in EXTENDED_ATTRIBUTE_NAMES
|
||||
]
|
||||
)
|
||||
],
|
||||
]
|
||||
formatter.write_dl(attr_tuples)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Templating System **[/bold]", width=formatter.width)
|
||||
rich_text("## Templating System", width=formatter.width, markdown=True)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(template_help(width=formatter.width))
|
||||
help_text += formatter.getvalue()
|
||||
help_text += template_help(width=formatter.width)
|
||||
formatter = click.HelpFormatter(width=HELP_WIDTH)
|
||||
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"With the --directory and --filename options you may specify a template for the "
|
||||
@@ -169,12 +325,15 @@ The following attributes may be used with '--xattr-template':
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
rich_text(
|
||||
"[bold]** Template Substitutions **[/bold]", width=formatter.width
|
||||
)
|
||||
rich_text("## Template Substitutions", width=formatter.width, markdown=True)
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples = [
|
||||
(
|
||||
rich_text("[bold]Substitution[/bold]", width=formatter.width),
|
||||
rich_text("[bold]Description[/bold]", width=formatter.width),
|
||||
)
|
||||
]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
@@ -189,7 +348,12 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "2019/Vacation, 2019/Family"
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples = [
|
||||
(
|
||||
rich_text("[bold]Substitution[/bold]", width=formatter.width),
|
||||
rich_text("[bold]Description[/bold]", width=formatter.width),
|
||||
)
|
||||
]
|
||||
templ_tuples.extend(
|
||||
(k, v) for k, v in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items()
|
||||
)
|
||||
@@ -227,10 +391,11 @@ The following attributes may be used with '--xattr-template':
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Command **[/bold]", width=formatter.width)
|
||||
rich_text("## Post Command", width=formatter.width, markdown=True)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"You can run commands on the exported photos for post-processing "
|
||||
+ "using the '--post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. "
|
||||
@@ -273,10 +438,11 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "first to ensure your commands are as expected. This will not actually run the commands but will "
|
||||
+ "print out the exact command string which would be executed."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Function **[/bold]", width=formatter.width)
|
||||
rich_text("## Post Function", width=formatter.width, markdown=True)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"You can run your own python functions on the exported photos for post-processing "
|
||||
+ "using the '--post-function' option. '--post-function' is passed the name a python file "
|
||||
@@ -294,23 +460,19 @@ The following attributes may be used with '--xattr-template':
|
||||
|
||||
def template_help(width=78):
|
||||
"""Return formatted string for template system"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
template_help_md = strip_md_header_and_links(get_template_help())
|
||||
console.print(Markdown(template_help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
return help_str
|
||||
console = Console(force_terminal=True, width=width)
|
||||
with console.capture() as capture:
|
||||
console.print(Markdown(template_help_md))
|
||||
return capture.get()
|
||||
|
||||
|
||||
def rich_text(text, width=78):
|
||||
def rich_text(text, width=78, markdown=False):
|
||||
"""Return rich formatted text"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
console.print(text)
|
||||
rich_text = sio.getvalue()
|
||||
sio.close()
|
||||
return rich_text
|
||||
console = Console(force_terminal=True, width=width)
|
||||
with console.capture() as capture:
|
||||
console.print(Markdown(text) if markdown else text, end="")
|
||||
return capture.get()
|
||||
|
||||
|
||||
def strip_md_header_and_links(md):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -142,7 +142,7 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
|
||||
"- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportOptions, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n"
|
||||
)
|
||||
print("The following variables are defined:")
|
||||
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
|
||||
print(f"- photosdb: PhotosDB() instance for '{photosdb.library_path}'")
|
||||
print(
|
||||
f"- photos: list of PhotoInfo objects for all photos filtered with any query options passed on command line (len={len(photos)})"
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
132
osxphotos/cli/theme.py
Normal file
132
osxphotos/cli/theme.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""theme command for osxphotos for managing color themes"""
|
||||
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich_theme_manager import Theme
|
||||
|
||||
from .click_rich_echo import rich_click_echo
|
||||
from .color_themes import get_default_theme, get_theme, get_theme_dir, get_theme_manager
|
||||
from .help import get_help_msg
|
||||
|
||||
|
||||
@click.command(name="theme")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@click.option("--default", is_flag=True, help="Show default theme.")
|
||||
@click.option("--list", "list_", is_flag=True, help="List all themes.")
|
||||
@click.option(
|
||||
"--config",
|
||||
metavar="[THEME]",
|
||||
is_flag=False,
|
||||
flag_value="_DEFAULT_",
|
||||
default=None,
|
||||
help="Print configuration for THEME (or default theme if not specified).",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
metavar="[THEME]",
|
||||
is_flag=False,
|
||||
flag_value="_DEFAULT_",
|
||||
default=None,
|
||||
help="Preview THEME (or default theme if not specified).",
|
||||
)
|
||||
@click.option(
|
||||
"--edit",
|
||||
metavar="[THEME]",
|
||||
is_flag=False,
|
||||
flag_value="_DEFAULT_",
|
||||
default=None,
|
||||
help="Edit THEME (or default theme if not specified).",
|
||||
)
|
||||
@click.option(
|
||||
"--clone",
|
||||
metavar="THEME NEW_THEME",
|
||||
nargs=2,
|
||||
type=str,
|
||||
help="Clone THEME to NEW_THEME.",
|
||||
)
|
||||
@click.option("--delete", metavar="THEME", help="Delete THEME.")
|
||||
def theme(ctx, cli_obj, default, list_, config, preview, edit, clone, delete):
|
||||
"""Manage osxphotos color themes."""
|
||||
|
||||
subcommands = [default, list_, config, preview, edit, clone, delete]
|
||||
subcommand_names = (
|
||||
"--default, --list, --config, --preview, --edit, --clone, --delete"
|
||||
)
|
||||
if not any(subcommands):
|
||||
click.echo(
|
||||
f"Must specify exactly one of: {subcommand_names}\n",
|
||||
err=True,
|
||||
)
|
||||
rich_click_echo(get_help_msg(theme), err=True)
|
||||
return
|
||||
|
||||
if sum(bool(cmd) for cmd in subcommands) != 1:
|
||||
# only a single subcommand may be specified
|
||||
raise click.ClickException(f"Must specify exactly one of: {subcommand_names}")
|
||||
|
||||
theme_manager = get_theme_manager()
|
||||
console = Console(theme=get_default_theme())
|
||||
|
||||
if default:
|
||||
default = get_default_theme()
|
||||
theme_manager.list_themes(theme_names=[default.name])
|
||||
return
|
||||
|
||||
if list_:
|
||||
theme_manager.list_themes()
|
||||
return
|
||||
|
||||
if config:
|
||||
if config == "_DEFAULT_":
|
||||
print(get_default_theme().config)
|
||||
else:
|
||||
print(get_theme(config).config)
|
||||
return
|
||||
|
||||
if preview:
|
||||
theme_ = get_default_theme() if preview == "_DEFAULT_" else get_theme(preview)
|
||||
theme_manager.preview_theme(theme_)
|
||||
return
|
||||
|
||||
if edit:
|
||||
theme_ = get_default_theme() if edit == "_DEFAULT_" else get_theme(edit)
|
||||
config_file = pathlib.Path(theme_.path)
|
||||
console.print(f"Opening [filepath]{config_file}[/] in $EDITOR")
|
||||
click.edit(filename=str(config_file))
|
||||
return
|
||||
|
||||
if clone:
|
||||
src_theme = get_theme(clone[0])
|
||||
dest_path = get_theme_dir() / f"{clone[1]}.theme"
|
||||
if dest_path.exists():
|
||||
raise click.ClickException(
|
||||
f"Theme '{clone[1]}' already exists at {dest_path}"
|
||||
)
|
||||
dest_theme = Theme(
|
||||
name=clone[1],
|
||||
description=src_theme.description,
|
||||
inherit=src_theme.inherit,
|
||||
tags=src_theme.tags,
|
||||
styles={
|
||||
style_name: src_theme.styles[style_name]
|
||||
for style_name in src_theme.style_names
|
||||
},
|
||||
)
|
||||
theme_manager = get_theme_manager()
|
||||
theme_manager.add(dest_theme)
|
||||
theme_ = get_theme(dest_theme.name)
|
||||
console.print(
|
||||
f"Cloned theme '[filename]{clone[0]}[/]' to '[filename]{clone[1]}[/]' "
|
||||
f"at [filepath]{theme_.path}[/]"
|
||||
)
|
||||
return
|
||||
|
||||
if delete:
|
||||
theme_ = get_theme(delete)
|
||||
click.confirm(f"Are you sure you want to delete theme {delete}?", abort=True)
|
||||
theme_manager.remove(theme_)
|
||||
console.print(f"Deleted theme [filepath]{theme_.path}[/]")
|
||||
return
|
||||
143
osxphotos/cli/verbose.py
Normal file
143
osxphotos/cli/verbose.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""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
|
||||
_console.console = Console(theme=theme, width=10_000)
|
||||
|
||||
# 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]}"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ Template statements may contain one or more modifiers. The full syntax is:
|
||||
|
||||
Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement.
|
||||
|
||||
`pretext` and `posttext` are free form text. For example, if a photo has title "My Photo Title". the template statement `"The title of the photo is {title}"`, resolves to `"The title of the photo is My Photo Title"`. The `pretext` in this example is `"The title if the photo is "` and the template_field is `{title}`.
|
||||
`pretext` and `posttext` are free form text. For example, if a photo has title "My Photo Title" the template statement `"The title of the photo is {title}"`, resolves to `"The title of the photo is My Photo Title"`. The `pretext` in this example is `"The title if the photo is "` and the template_field is `{title}`.
|
||||
|
||||
|
||||
`delim`: optional delimiter string to use when expanding multi-valued template values in-place
|
||||
|
||||
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"]
|
||||
@@ -224,7 +224,7 @@ You can use the `--report` option to create a report, in comma-separated values
|
||||
|
||||
### Exporting only certain photos
|
||||
|
||||
By default, osxphotos will export your entire Photos library. If you want to export only certain photos, osxphotos provides a rich set of "query options" that allow you to query the Photos database to filter out only certain photos that match your query criteria. The tutorial does not cover all the query options as there are over 50 of them--read the help text (`osxphotos help export`) to better understand the available query options. No matter which subset of photos you would like to export, there is almost certainly a way for osxphotos to filter these. For example, you can filter for only images that contain certain keywords or images without a title, images from a specific time of day or specific date range, images contained in specific albums, etc.
|
||||
By default, osxphotos will export your entire Photos library. If you want to export only certain photos, osxphotos provides a rich set of "query options" that allow you to query the Photos database to filter out only certain photos that match your query criteria. The tutorial does not cover all the query options as there are over 50 of them--read the help text (`osxphotos help export`) to better understand the available query options. No matter which subset of photos you would like to export, there is almost certainly a way for osxphotos to filter these. For example, you can filter for only images that contain certain keywords or images without a title, images from a specific time of day or specific date range, images contained in specific albums, etc.
|
||||
|
||||
For example, to export only photos with keyword `Travel`:
|
||||
|
||||
@@ -266,13 +266,13 @@ osxphotos can, for example, write any keywords in the image to Finder tags so th
|
||||
|
||||
`osxphotos export /path/to/export --finder-tag-keywords`
|
||||
|
||||
`--finder-tag-keywords` also works with `--keyword-template` as described above in the section on `exiftool`:
|
||||
`--finder-tag-keywords` also works with `--keyword-template` as described above in the section on `exiftool`:
|
||||
|
||||
`osxphotos export /path/to/export --finder-tag-keywords --keyword-template "{label}"`
|
||||
|
||||
The `--xattr-template` option allows you to set a variety of other extended attributes. It is used in the format `--xattr-template ATTRIBUTE TEMPLATE` where ATTRIBUTE is one of 'authors','comment', 'copyright', 'description', 'findercomment', 'headline', 'keywords'.
|
||||
The `--xattr-template` option allows you to set a variety of other extended attributes. It is used in the format `--xattr-template ATTRIBUTE TEMPLATE` where ATTRIBUTE is one of 'authors','comment', 'copyright', 'description', 'findercomment', 'headline', 'keywords'.
|
||||
|
||||
For example, to set Finder comment to the photo's title and description:
|
||||
For example, to set Finder comment to the photo's title and description:
|
||||
|
||||
`osxphotos export /path/to/export --xattr-template findercomment "{title}{newline}{descr}"`
|
||||
|
||||
@@ -310,7 +310,7 @@ See Extended Attributes section in the help for `osxphotos export` for additiona
|
||||
|
||||
### Saving and loading options
|
||||
|
||||
If you repeatedly run a complex osxphotos export command (for example, to regularly back-up your Photos library), you can save all the options to a configuration file for future use (`--save-config FILE`) and then load them (`--load-config FILE`) instead of repeating each option on the command line.
|
||||
If you repeatedly run a complex osxphotos export command (for example, to regularly back-up your Photos library), you can save all the options to a configuration file for future use (`--save-config FILE`) and then load them (`--load-config FILE`) instead of repeating each option on the command line.
|
||||
|
||||
To save the configuration:
|
||||
|
||||
@@ -320,7 +320,7 @@ Then the next to you run osxphotos, you can simply do this:
|
||||
|
||||
`osxphotos export /path/to/export --load-config osxphotos.toml`
|
||||
|
||||
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
|
||||
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
|
||||
|
||||
### Run commands on exported photos for post-processing
|
||||
|
||||
@@ -353,8 +353,7 @@ Another example: if you had `exiftool` installed and wanted to wipe all metadata
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`
|
||||
|
||||
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
|
||||
|
||||
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
|
||||
|
||||
### An example from an actual osxphotos user
|
||||
|
||||
@@ -390,6 +389,10 @@ Here's a comprehensive use case from an actual osxphotos user that integrates ma
|
||||
|
||||
`osxphotos export ~/Desktop/folder for exported videos/ --keyword Quik --only-movies --db /path to my.photoslibrary --touch-file --finder-tag-keywords --person-keyword --xattr-template findercomment "{title}{title?{descr?{newline},},}{descr}" --exiftool-merge-keywords --exiftool-merge-persons --exiftool --strip`
|
||||
|
||||
### Color Themes
|
||||
|
||||
Some osxphotos commands such as export use color themes to colorize the output to make it more legible. The theme may be specified with the `--theme` option. For example: `osxphotos export /path/to/export --verbose --theme dark` uses a theme suited for dark terminals. If you don't specify the color theme, osxphotos will select a default theme based on the current terminal settings. You can also specify your own default theme. See `osxphotos help theme` for more information on themes and for commands to help manage themes. Themes are defined in `.theme` files in the `~/.osxphotos/themes` directory and use style specifications compatible with the [rich](https://rich.readthedocs.io/en/stable/style.html) library.
|
||||
|
||||
### Conclusion
|
||||
|
||||
osxphotos is very flexible. If you merely want to backup your Photos library, then spending a few minutes to understand the `--directory` option is likely all you need and you can be up and running in minutes. However, if you have a more complex workflow, osxphotos likely provides options to implement your workflow. This tutorial does not attempt to cover every option offered by osxphotos but hopefully it provides a good understanding of what kinds of things are possible and where to explore if you want to learn more.
|
||||
osxphotos is very flexible. If you merely want to backup your Photos library, then spending a few minutes to understand the `--directory` option is likely all you need and you can be up and running in minutes. However, if you have a more complex workflow, osxphotos likely provides options to implement your workflow. This tutorial does not attempt to cover every option offered by osxphotos but hopefully it provides a good understanding of what kinds of things are possible and where to explore if you want to learn more.
|
||||
|
||||
@@ -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
|
||||
@@ -20,6 +20,8 @@ pyobjc-framework-Quartz>=7.3,<9.0
|
||||
pyobjc-framework-Vision>=7.3,<9.0
|
||||
PyYAML>=5.4.1,<6.0.0
|
||||
rich>=11.2.0,<12.0.0
|
||||
rich_theme_manager>=0.7.0
|
||||
textx>=2.3.0,<2.4.0
|
||||
toml>=0.10.2,<0.11.0
|
||||
wurlitzer>=2.1.0,<2.2.0
|
||||
wrapt>=1.13.3,<1.14.0
|
||||
wurlitzer>=2.1.0,<2.2.0
|
||||
|
||||
2
setup.py
2
setup.py
@@ -95,8 +95,10 @@ setup(
|
||||
"pyobjc-framework-Quartz>=7.3,<9.0",
|
||||
"pyobjc-framework-Vision>=7.3,<9.0",
|
||||
"rich>=11.2.0,<12.0.0",
|
||||
"rich_theme_manager>=0.7.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/__init__.py
Normal file
0
tests/__init__.py
Normal file
@@ -8,6 +8,8 @@ from photoscript.utils import ditto
|
||||
|
||||
from osxphotos.exiftool import _ExifToolProc
|
||||
|
||||
from .test_catalina_10_15_7 import UUID_DICT_LOCAL
|
||||
|
||||
|
||||
def get_os_version():
|
||||
import platform
|
||||
|
||||
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"
|
||||
@@ -17,7 +17,6 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from conftest import copy_photos_library_to_path
|
||||
from osxmetadata import OSXMetaData, Tag
|
||||
|
||||
import osxphotos
|
||||
@@ -37,6 +36,8 @@ from osxphotos.exiftool import ExifTool, get_exiftool_path
|
||||
from osxphotos.fileutil import FileUtil
|
||||
from osxphotos.utils import noop, normalize_fs_path, normalize_unicode
|
||||
|
||||
from .conftest import copy_photos_library_to_path
|
||||
|
||||
CLI_PHOTOS_DB = "tests/Test-10.15.7.photoslibrary"
|
||||
LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary"
|
||||
RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary"
|
||||
@@ -58,6 +59,11 @@ UUID_BURST_ALBUM = {
|
||||
"TestBurst/IMG_9815.JPG",
|
||||
"TestBurst/IMG_9816.JPG",
|
||||
"TestBurst2/IMG_9814.JPG",
|
||||
"osxphotos/IMG_9812.JPG", # in my personal library, IMG_9812.JPG == "9A5B4CE6-6A9F-4917-95D4-1C98D14FCE4F"
|
||||
"osxphotos/IMG_9813.JPG",
|
||||
"osxphotos/IMG_9814.JPG",
|
||||
"osxphotos/IMG_9815.JPG",
|
||||
"osxphotos/IMG_9816.JPG",
|
||||
],
|
||||
"75154738-83AA-4DCD-A913-632D5D1C0FEE": [
|
||||
"TestBurst/IMG_9812.JPG",
|
||||
@@ -66,6 +72,11 @@ UUID_BURST_ALBUM = {
|
||||
"TestBurst/IMG_9815.JPG",
|
||||
"TestBurst/IMG_9816.JPG",
|
||||
"TestBurst2/IMG_9814.JPG",
|
||||
"osxphotos/IMG_9812.JPG", # in my personal library, IMG_9812.JPG == "9A5B4CE6-6A9F-4917-95D4-1C98D14FCE4F"
|
||||
"osxphotos/IMG_9813.JPG",
|
||||
"osxphotos/IMG_9814.JPG",
|
||||
"osxphotos/IMG_9815.JPG",
|
||||
"osxphotos/IMG_9816.JPG",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1384,7 +1395,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 +1406,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 +1838,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"""
|
||||
@@ -6406,8 +6467,7 @@ def test_export_burst_uuid():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
# subtract 1 from len because one photo in two albums so shows up twice in the list
|
||||
assert f"exported: {len(UUID_BURST_ALBUM[uuid]) - 1}" in result.output
|
||||
assert f"exported: 5" in result.output
|
||||
|
||||
# export again with --skip-bursts
|
||||
result = runner.invoke(
|
||||
@@ -6503,7 +6563,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()
|
||||
@@ -12,40 +12,40 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
UUID_DICT = {
|
||||
"has_adjustments": "C925CFDC-FF2B-4E71-AC9D-C669B6453A8B", # IMG_1929.JPG
|
||||
"no_adjustments": "16A6AF6B-D8FC-4256-AE33-889733E3EEAB", # IMG_9847.JPG
|
||||
"live": "8EC216A2-0032-4934-BD3F-04C6259B3304", # IMG_3259.HEIC
|
||||
"has_adjustments": "C925CFDC-FF2B-4E71-AC9D-C669B6453A8B", # IMG_1929.JPG
|
||||
"no_adjustments": "16A6AF6B-D8FC-4256-AE33-889733E3EEAB", # IMG_9847.JPG
|
||||
"live": "8EC216A2-0032-4934-BD3F-04C6259B3304", # IMG_3259.HEIC
|
||||
}
|
||||
|
||||
UUID_BURSTS = {
|
||||
"9A5B4CE6-6A9F-4917-95D4-1C98D14FCE4F": {
|
||||
"selected": False,
|
||||
"filename": "IMG_9812.JPG",
|
||||
"burst_albums": ["TestBurst"],
|
||||
"albums": ["TestBurst"],
|
||||
"burst_albums": ["TestBurst", "osxphotos"],
|
||||
"albums": ["TestBurst", "osxphotos"],
|
||||
},
|
||||
"89E235DD-B9AC-4E8D-BDA2-986981CA7582": {
|
||||
"selected": False,
|
||||
"filename": "IMG_9813.JPG",
|
||||
"burst_albums": ["TestBurst"],
|
||||
"burst_albums": ["TestBurst", "osxphotos"],
|
||||
"albums": [],
|
||||
},
|
||||
"75154738-83AA-4DCD-A913-632D5D1C0FEE": {
|
||||
"selected": True,
|
||||
"filename": "IMG_9814.JPG",
|
||||
"burst_albums": ["TestBurst", "TestBurst2"],
|
||||
"burst_albums": ["TestBurst", "TestBurst2", "osxphotos"],
|
||||
"albums": ["TestBurst2"],
|
||||
},
|
||||
"4A836160-51B2-4E32-907D-ECDDB2CEC657": {
|
||||
"selected": False,
|
||||
"filename": "IMG_9815.JPG",
|
||||
"burst_albums": ["TestBurst"],
|
||||
"burst_albums": ["TestBurst", "osxphotos"],
|
||||
"albums": [],
|
||||
},
|
||||
"F5E6BD24-B493-44E9-BDA2-7AD9D2CC8C9D": {
|
||||
"selected": True,
|
||||
"filename": "IMG_9816.JPG",
|
||||
"burst_albums": ["TestBurst"],
|
||||
"burst_albums": ["TestBurst", "osxphotos"],
|
||||
"albums": [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,7 +3,6 @@ import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from photoinfo_mock import PhotoInfoMock
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
@@ -15,6 +14,8 @@ from osxphotos.phototemplate import (
|
||||
RenderOptions,
|
||||
)
|
||||
|
||||
from .photoinfo_mock import PhotoInfoMock
|
||||
|
||||
try:
|
||||
exiftool = get_exiftool_path()
|
||||
except:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -73,11 +73,11 @@ def generate_help_text(command):
|
||||
|
||||
# get current help text
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(cli_main, ["help", command])
|
||||
result = runner.invoke(cli_main, ["help", command, "--width", 78])
|
||||
help_txt = result.output
|
||||
|
||||
# running the help command above doesn't output the full "Usage" line
|
||||
help_txt = help_txt.replace(f"Usage: cli-main", f"Usage: osxphotos")
|
||||
help_txt = help_txt.replace("Usage: cli-main", "Usage: osxphotos")
|
||||
return help_txt
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user