Compare commits

...

31 Commits

Author SHA1 Message Date
Rhet Turnbull
b09323b9fb Updated docs [skip ci] 2022-04-17 22:56:53 -07:00
Rhet Turnbull
213d84e964 Version bump 2022-04-17 22:54:33 -07:00
Rhet Turnbull
6c57fb2df9 Theme (#664)
* Initial theme manager, not yet done

* Added rich_theme_manager

* Updated rich-theme-manager

* Switched to rich_theme_manager for theme management

* Updated dependencies

* Added rich paging to subtopic help

* Fixed clone to clone only styles specified in cloned theme

* Added placeholder for help colors

* Updated config dir, help methods
2022-04-17 22:53:42 -07:00
Rhet Turnbull
9c0b910046 Added cov.xml [skip ci] 2022-04-03 09:03:55 -07:00
Rhet Turnbull
1f40161950 Fixed typing in examples 2022-04-01 18:16:23 -07:00
Rhet Turnbull
d1aa4e92bd Quoted path in repl 2022-03-29 06:13:57 -07:00
Rhet Turnbull
e7a17a8635 Updated CHANGELOG.md [skip ci] 2022-03-27 17:50:19 -07:00
Rhet Turnbull
68754273de Updated docs [skip ci] 2022-03-27 17:34:38 -07:00
Rhet Turnbull
d28a2fe9bb version bump 2022-03-27 09:53:03 -07:00
Rhet Turnbull
382d097285 fix verbose output when redirected to file, #661 2022-03-27 09:52:23 -07:00
Rhet Turnbull
93de53da51 Updated CHANGELOG.md [skip ci] 2022-03-12 10:00:48 -08:00
Rhet Turnbull
e272e95a85 Fixed missing pdb.py issue for pyinstaller, partial for #659 2022-03-12 09:00:41 -08:00
Rhet Turnbull
84a96bd4d0 Cleaned up fileutil, rolled back changes for #654 2022-03-11 06:06:21 -08:00
Rhet Turnbull
d26b625d57 Cleaned up fileutil, rolled back changes for #654 2022-03-11 06:04:21 -08:00
Rhet Turnbull
8731e7d5bc Hack to fix #654 when utime fails on NAS 2022-03-09 21:24:56 -08:00
Rhet Turnbull
2e501e6a9b Added run command which had gotten dropped, #656 2022-03-09 07:10:51 -08:00
Rhet Turnbull
7f4c981abe Added --no-progress, #655 2022-03-09 07:08:34 -08:00
Rhet Turnbull
bbcc3acba9 Changed return val of _should_update_photo to enum for easier debugging 2022-03-09 06:52:17 -08:00
Rhet Turnbull
fccd746c58 Updated docs [skip ci] 2022-03-06 07:20:57 -08:00
Rhet Turnbull
adb90a3364 Version bump 2022-03-06 07:17:53 -08:00
Rhet Turnbull
445010e7e5 Richify (#653)
* Improved rich_echo, added rich_echo_via_pager

* Initial implementation for #647, added rich output
2022-03-06 07:17:09 -08:00
Rhet Turnbull
1227465aa7 Updated crash_reporter to include crash data 2022-03-04 20:26:19 -08:00
Rhet Turnbull
de1900f10a Debug updates 2022-03-04 20:05:15 -08:00
Rhet Turnbull
ed315fffd2 Added --watch, --breakpoint (#652) 2022-03-04 06:45:57 -08:00
Rhet Turnbull
be1f3a98d9 Updated CHANGELOG.md [skip ci] 2022-03-02 07:09:53 -08:00
Rhet Turnbull
d8802368fc Added --tmpdir, #650 (#651) 2022-03-02 06:58:23 -08:00
Rhet Turnbull
f132e9a843 Version bump 2022-02-27 20:30:22 -08:00
Rhet Turnbull
6b342a1733 Version bump 2022-02-27 20:29:23 -08:00
Rhet Turnbull
9dec028448 Help topic (#644)
* Initial implementation for #607

* Implemented #607, add help for sub topics

* Updated test workflow
2022-02-27 16:53:11 -08:00
Rhet Turnbull
8be6a98c32 Added -v to pytest 2022-02-27 16:39:12 -08:00
Rhet Turnbull
ce73c9cab8 updated docs [skip ci] 2022-02-27 14:14:52 -08:00
66 changed files with 2554 additions and 1037 deletions

View File

@@ -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
View File

@@ -17,3 +17,4 @@ cli.spec
docsrc/_build/
venv/
.python-version
cov.xml

View File

@@ -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

887
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: bc3dce8a14bcd1b0c8a34e4d16f0011f
config: 61bf98593db44b8d320314e5cbec33cf
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.47.1 documentation</title>
<title>Overview: module code &#8212; 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 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo &#8212; osxphotos 0.46.4 documentation</title>
<title>osxphotos.photoinfo &#8212; 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">&quot;PhotoInfo&quot;</span><span class="p">,</span> <span class="s2">&quot;PhotoInfoNone&quot;</span><span class="p">]</span>
@@ -1856,7 +1856,7 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photosdb.photosdb &#8212; osxphotos 0.46.6 documentation</title>
<title>osxphotos.photosdb.photosdb &#8212; 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">&quot;&quot;&quot;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">&quot;verbose must be callable&quot;</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">&quot;filepath&quot;</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">&quot;filename&quot;</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">&quot;uuid&quot;</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">&quot;num&quot;</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">&quot;dbfile = </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2">&quot;</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">&quot;dbfile </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2"> does not exist&quot;</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">&quot;dbfile = </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2">&quot;</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">&quot;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">&quot;</span><span class="p">)</span>
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;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">&quot;</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">&quot;dbfile </span><span class="si">{</span><span class="n">dbfile</span><span class="si">}</span><span class="s2"> does not exist&quot;</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">&quot;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">&quot;</span><span class="p">)</span>
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;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">&quot;</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">&quot;Database locked, creating temporary copy.&quot;</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">&quot;_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">&quot;</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">&quot;originals&quot;</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">&quot;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">&quot;</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">&lt;=</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">&quot;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">&quot;</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(&quot;Error linking &quot; + fname + &quot; to &quot; + 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">&quot;Processing database.&quot;</span><span class="p">)</span>
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;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">.&quot;</span><span class="p">)</span>
<span class="n">verbose</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;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">.&quot;</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">&quot;type&quot;</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">&quot;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">&quot;</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">&quot;type&quot;</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">&quot;edit_resource_id&quot;</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">&quot;WARNING: found more than one edit_resource_id for &quot;</span>
<span class="sa">f</span><span class="s2">&quot;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">&quot;</span>
@@ -1614,7 +1622,7 @@
<span class="sd"> but it works so don&#39;t touch it.</span>
<span class="sd"> &quot;&quot;&quot;</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">&quot;_process_database5&quot;</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">&quot;Processing database.&quot;</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">&quot;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">.&quot;</span><span class="p">)</span>
<span class="n">verbose</span><span class="p">(</span>
<span class="sa">f</span><span class="s2">&quot;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">.&quot;</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">&quot;ASSET&quot;</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">&quot;KEYWORD_JOIN&quot;</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">&quot;ASSET_ALBUM_TABLE&quot;</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">&quot;HDR_TYPE&quot;</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">&quot;Getting information about persons&quot;</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">&quot;type&quot;</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">&quot;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">&quot;</span><span class="p">)</span>
<span class="n">info</span><span class="p">[</span><span class="s2">&quot;type&quot;</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">&quot;extendedDescription&quot;</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">&quot;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">&quot;</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">&quot;adjustmentFormatID&quot;</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">&quot;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">&quot;</span>
<span class="p">)</span>
@@ -3607,7 +3617,7 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

View File

@@ -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-"] {

View File

@@ -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);
},
/**

View File

@@ -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',

View File

@@ -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.
*
*/

View File

@@ -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.
*
*/

View File

@@ -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) &#8212; osxphotos 0.47.1 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; 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 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.47.1 documentation</title>
<title>Index &#8212; 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 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

View File

@@ -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 osxphotoss documentation! &#8212; osxphotos 0.47.1 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; 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=
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

View File

@@ -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 &#8212; osxphotos 0.47.1 documentation</title>
<title>osxphotos &#8212; 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 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

View File

@@ -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 &#8212; osxphotos 0.47.1 documentation</title>
<title>osxphotos package &#8212; 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>
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.47.1 documentation</title>
<title>Search &#8212; 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 @@
&copy;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>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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__)

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

View File

@@ -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

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.47.2"
__version__ = "0.47.7"

View File

@@ -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",

View File

@@ -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,

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

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

View File

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

View File

@@ -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)

View File

@@ -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}'
)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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 = [

View File

@@ -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)})"
)

View 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,
)

View File

@@ -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
View 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
View 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_

View File

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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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]}"
)

View File

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

View File

@@ -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.

View File

@@ -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
View File

@@ -0,0 +1,2 @@
[pytest]
addopts = -p tests.plugins.env_vars

View File

@@ -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

View File

@@ -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
View File

View 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

View File

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

View File

@@ -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
View 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()

View File

@@ -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": [],
},
}

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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