Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5730dd8ae | ||
|
|
5c1c0c5c5a | ||
|
|
d8593a01e2 | ||
|
|
1dffe894ff | ||
|
|
29721dd4f0 | ||
|
|
6559c4d8f6 | ||
|
|
baf45ccd2a | ||
|
|
aca85ee2aa | ||
|
|
9584a9ccc5 | ||
|
|
182b816e34 | ||
|
|
0262e0d97e | ||
|
|
73f936e061 | ||
|
|
09687cfca4 | ||
|
|
e17ee0e388 | ||
|
|
d7c81adae8 | ||
|
|
37b1e5ca47 | ||
|
|
22355fd446 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -4,6 +4,31 @@ 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.38.3](https://github.com/RhetTbull/osxphotos/compare/v0.38.2...v0.38.3)
|
||||
|
||||
> 13 December 2020
|
||||
|
||||
- Fix for QuickTime date/time, issue #282 [`d8593a0`](https://github.com/RhetTbull/osxphotos/commit/d8593a01e210a0b914d5668ad5f70976fc43b217)
|
||||
|
||||
#### [v0.38.2](https://github.com/RhetTbull/osxphotos/compare/v0.38.0...v0.38.2)
|
||||
|
||||
> 12 December 2020
|
||||
|
||||
- Added --save-config, --load-config [`#290`](https://github.com/RhetTbull/osxphotos/pull/290)
|
||||
- removed extended_attributes reference [`6559c4d`](https://github.com/RhetTbull/osxphotos/commit/6559c4d8f64ad41df925182f9f24f6f67eecd1df)
|
||||
- This is why I never use branches [`baf45cc`](https://github.com/RhetTbull/osxphotos/commit/baf45ccd2aa24858bb1a8f95ef798121ee80af30)
|
||||
- Version bump [`aca85ee`](https://github.com/RhetTbull/osxphotos/commit/aca85ee2aa01fcdece0224332584082280a3f62c)
|
||||
- Merge branch 'master' into save_config [`9584a9c`](https://github.com/RhetTbull/osxphotos/commit/9584a9ccc56ac8c6dc5eb96019adf9224f436690)
|
||||
- Added tests for configoptions.py [`0262e0d`](https://github.com/RhetTbull/osxphotos/commit/0262e0d97e06ee36786b4491efa178608afb5de5)
|
||||
|
||||
#### [v0.38.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
|
||||
|
||||
> 11 December 2020
|
||||
|
||||
- Initial implementation of configoptions for --save-config, --load-config [`22355fd`](https://github.com/RhetTbull/osxphotos/commit/22355fd44609f42e412c580dfc9e5e0b7cf6c464)
|
||||
- Refactoring of save-config/load-config code [`37b1e5c`](https://github.com/RhetTbull/osxphotos/commit/37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2)
|
||||
- Refactored FileUtil to use copy-on-write no APFS, issue #287 [`ec4b53e`](https://github.com/RhetTbull/osxphotos/commit/ec4b53ed9dd2bc1e6b71349efdaf0b81c6d797e5)
|
||||
|
||||
#### [v0.37.7](https://github.com/RhetTbull/osxphotos/compare/v0.37.6...v0.37.7)
|
||||
|
||||
> 7 December 2020
|
||||
|
||||
63
README.md
63
README.md
@@ -45,7 +45,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
||||
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.6 / Photos 5.0.
|
||||
|
||||
Alpha support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
|
||||
Beta support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
|
||||
|
||||
Requires python >= 3.7.
|
||||
|
||||
@@ -277,7 +277,7 @@ Options:
|
||||
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
|
||||
--convert-to-jpeg. A value of 1.0 specifies
|
||||
best quality, a value of 0.0 specifies
|
||||
maximum compression. Defaults to 1.0.
|
||||
maximum compression. Defaults to 1.0
|
||||
--download-missing Attempt to download missing photos from
|
||||
iCloud. The current implementation uses
|
||||
Applescript to interact with Photos to
|
||||
@@ -328,9 +328,9 @@ Options:
|
||||
EXIF:OffsetTimeOriginal; EXIF:ModifyDate
|
||||
(see --ignore-date-modified);
|
||||
IPTC:DateCreated; IPTC:TimeCreated; (video
|
||||
files only): QuickTime:CreationDate (UTC);
|
||||
QuickTime:ModifyDate (UTC) (see also
|
||||
--ignore-date-modified);
|
||||
files only): QuickTime:CreationDate;
|
||||
QuickTime:CreateDate; QuickTime:ModifyDate
|
||||
(see also --ignore-date-modified);
|
||||
QuickTime:GPSCoordinates;
|
||||
UserData:GPSCoordinates.
|
||||
--ignore-date-modified If used with --exiftool or --sidecar, will
|
||||
@@ -374,20 +374,24 @@ Options:
|
||||
do not include an extension in the FILENAME
|
||||
template. See below for additional details
|
||||
on templating system.
|
||||
--edited-suffix SUFFIX Optional suffix for naming edited photos.
|
||||
Default name for edited photos is in form
|
||||
'photoname_edited.ext'. For example, with '
|
||||
--edited-suffix _bearbeiten', the edited
|
||||
photo would be named
|
||||
--edited-suffix SUFFIX Optional suffix template for naming edited
|
||||
photos. Default name for edited photos is
|
||||
in form 'photoname_edited.ext'. For example,
|
||||
with '--edited-suffix _bearbeiten', the
|
||||
edited photo would be named
|
||||
'photoname_bearbeiten.ext'. The default
|
||||
suffix is '_edited'.
|
||||
--original-suffix SUFFIX Optional suffix for naming original photos.
|
||||
Default name for original photos is in form
|
||||
'filename.ext'. For example, with '--
|
||||
original-suffix _original', the original
|
||||
suffix is '_edited'. Multi-value templates
|
||||
(see Templating System) are not permitted
|
||||
with --edited-suffix.
|
||||
--original-suffix SUFFIX Optional suffix template for naming original
|
||||
photos. Default name for original photos is
|
||||
in form 'filename.ext'. For example, with '
|
||||
--original-suffix _original', the original
|
||||
photo would be named
|
||||
'filename_original.ext'. The default suffix
|
||||
is '' (no suffix).
|
||||
is '' (no suffix). Multi-value templates
|
||||
(see Templating System) are not permitted
|
||||
with --original-suffix.
|
||||
--use-photos-export Force the use of AppleScript or PhotoKit to
|
||||
export even if not missing (see also '--
|
||||
download-missing' and '--use-photokit').
|
||||
@@ -398,13 +402,29 @@ Options:
|
||||
work with iTerm2 (use with Terminal.app).
|
||||
This is faster and more reliable than the
|
||||
default AppleScript interface.
|
||||
--report REPORTNAME.CSV Write a CSV formatted report of all files
|
||||
--report <path to export report>
|
||||
Write a CSV formatted report of all files
|
||||
that were exported.
|
||||
--cleanup Cleanup export directory by deleting any
|
||||
files which were not included in this export
|
||||
set. For example, photos which had
|
||||
previously been exported and were
|
||||
subsequently deleted in Photos.
|
||||
--load-config <config file path>
|
||||
Load options from file as written with
|
||||
--save-config. This allows you to save a
|
||||
complex export command to file for later
|
||||
reuse. For example: 'osxphotos export <lots
|
||||
of options here> --save-config
|
||||
osxphotos.toml' then 'osxphotos export
|
||||
/path/to/export --load-config
|
||||
osxphotos.toml'. If any other command line
|
||||
options are used in conjunction with --load-
|
||||
config, they will override the corresponding
|
||||
values in the config file.
|
||||
--save-config <config file path>
|
||||
Save options to file for use with --load-
|
||||
config. File format is TOML.
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
** Export **
|
||||
@@ -553,6 +573,9 @@ Substitution Description
|
||||
'{photo_or_video,photo=fotos;video=videos}'
|
||||
{hdr} Photo is HDR?; True/False value, use in
|
||||
format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'
|
||||
{edited} Photo has been edited (has adjustments)?;
|
||||
True/False value, use in format
|
||||
'{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'
|
||||
{created.date} Photo's creation date in ISO format, e.g.
|
||||
'2020-03-22'
|
||||
{created.year} 4-digit year of photo creation time
|
||||
@@ -2025,6 +2048,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|
||||
|{media_type}|Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. Defaults to 'photo' or 'video' if no special type. Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'|
|
||||
|{photo_or_video}|'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'|
|
||||
|{hdr}|Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|
||||
|{edited}|Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|
||||
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|
||||
|{created.year}|4-digit year of photo creation time|
|
||||
|{created.yy}|2-digit year of photo creation time|
|
||||
@@ -2175,7 +2199,7 @@ if __name__ == "__main__":
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/).
|
||||
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/) or join the [discussion](https://github.com/RhetTbull/osxphotos/discussions).
|
||||
|
||||
I'll gladly consider pull requests for bug fixes or feature implementations.
|
||||
|
||||
@@ -2240,9 +2264,10 @@ For additional details about how osxphotos is implemented or if you would like t
|
||||
- [bpylist2](https://pypi.org/project/bpylist2/)
|
||||
- [pathvalidate](https://pypi.org/project/pathvalidate/)
|
||||
- [wurlitzer](https://pypi.org/project/wurlitzer/)
|
||||
- [toml](https://github.com/uiri/toml)
|
||||
|
||||
|
||||
## Acknowledgements
|
||||
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
|
||||
|
||||
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
|
||||
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
|
||||
|
||||
@@ -19,9 +19,17 @@ from ._constants import (
|
||||
_EXIF_TOOL_URL,
|
||||
_PHOTOS_4_VERSION,
|
||||
_UNKNOWN_PLACE,
|
||||
DEFAULT_JPEG_QUALITY,
|
||||
DEFAULT_EDITED_SUFFIX,
|
||||
DEFAULT_ORIGINAL_SUFFIX,
|
||||
UNICODE_FORMAT,
|
||||
)
|
||||
from ._version import __version__
|
||||
from .configoptions import (
|
||||
ConfigOptions,
|
||||
ConfigOptionsInvalidError,
|
||||
ConfigOptionsLoadError,
|
||||
)
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import get_exiftool_path
|
||||
from .export_db import ExportDB, ExportDBInMemory
|
||||
@@ -39,7 +47,7 @@ VERBOSE = False
|
||||
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
|
||||
|
||||
|
||||
def verbose(*args, **kwargs):
|
||||
def verbose_(*args, **kwargs):
|
||||
""" print output if verbose flag set """
|
||||
if VERBOSE:
|
||||
click.echo(*args, **kwargs)
|
||||
@@ -587,14 +595,14 @@ def cli(ctx, db, json_, debug):
|
||||
help="Use with '--dump photos' to dump only certain UUIDs",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
||||
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_):
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
|
||||
""" Print out debug info """
|
||||
|
||||
global VERBOSE
|
||||
VERBOSE = bool(verbose_)
|
||||
VERBOSE = bool(verbose)
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
@@ -605,7 +613,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_):
|
||||
|
||||
start_t = time.perf_counter()
|
||||
print(f"Opening database: {db}")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
|
||||
stop_t = time.perf_counter()
|
||||
print(f"Done; took {(stop_t-start_t):.2f} seconds")
|
||||
|
||||
@@ -1197,7 +1205,7 @@ def query(
|
||||
|
||||
@cli.command(cls=ExportCommand)
|
||||
@DB_OPTION
|
||||
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
||||
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
|
||||
@query_options
|
||||
@click.option(
|
||||
"--missing",
|
||||
@@ -1283,11 +1291,10 @@ def query(
|
||||
@click.option(
|
||||
"--jpeg-quality",
|
||||
type=click.FloatRange(0.0, 1.0),
|
||||
default=1.0,
|
||||
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
||||
"A value of 1.0 specifies best quality, "
|
||||
"a value of 0.0 specifies maximum compression. "
|
||||
"Defaults to 1.0.",
|
||||
f"Defaults to {DEFAULT_JPEG_QUALITY}",
|
||||
)
|
||||
@click.option(
|
||||
"--download-missing",
|
||||
@@ -1330,7 +1337,7 @@ def query(
|
||||
"XMP:PersonInImage; EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef; EXIF:GPSLatitude; EXIF:GPSLongitude; "
|
||||
"EXIF:GPSPosition; EXIF:DateTimeOriginal; EXIF:OffsetTimeOriginal; "
|
||||
"EXIF:ModifyDate (see --ignore-date-modified); IPTC:DateCreated; IPTC:TimeCreated; "
|
||||
"(video files only): QuickTime:CreationDate (UTC); QuickTime:ModifyDate (UTC) (see also --ignore-date-modified); "
|
||||
"(video files only): QuickTime:CreationDate; QuickTime:CreateDate; QuickTime:ModifyDate (see also --ignore-date-modified); "
|
||||
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
|
||||
)
|
||||
@click.option(
|
||||
@@ -1395,36 +1402,34 @@ def query(
|
||||
@click.option(
|
||||
"--edited-suffix",
|
||||
metavar="SUFFIX",
|
||||
default="_edited",
|
||||
help="Optional suffix for naming edited photos. Default name for edited photos is in form "
|
||||
help="Optional suffix template for naming edited photos. Default name for edited photos is in form "
|
||||
"'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo "
|
||||
"would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.",
|
||||
f"would be named 'photoname_bearbeiten.ext'. The default suffix is '{DEFAULT_EDITED_SUFFIX}'. "
|
||||
"Multi-value templates (see Templating System) are not permitted with --edited-suffix.",
|
||||
)
|
||||
@click.option(
|
||||
"--original-suffix",
|
||||
metavar="SUFFIX",
|
||||
default="",
|
||||
help="Optional suffix for naming original photos. Default name for original photos is in form "
|
||||
help="Optional suffix template for naming original photos. Default name for original photos is in form "
|
||||
"'filename.ext'. For example, with '--original-suffix _original', the original photo "
|
||||
"would be named 'filename_original.ext'. The default suffix is '' (no suffix).",
|
||||
"would be named 'filename_original.ext'. The default suffix is '' (no suffix). "
|
||||
"Multi-value templates (see Templating System) are not permitted with --original-suffix.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-photos-export",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').",
|
||||
)
|
||||
@click.option(
|
||||
"--use-photokit",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. "
|
||||
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
|
||||
"This is faster and more reliable than the default AppleScript interface.",
|
||||
)
|
||||
@click.option(
|
||||
"--report",
|
||||
metavar="REPORTNAME.CSV",
|
||||
metavar="<path to export report>",
|
||||
help="Write a CSV formatted report of all files that were exported.",
|
||||
type=click.Path(),
|
||||
)
|
||||
@@ -1434,6 +1439,29 @@ def query(
|
||||
help="Cleanup export directory by deleting any files which were not included in this export set. "
|
||||
"For example, photos which had previously been exported and were subsequently deleted in Photos.",
|
||||
)
|
||||
@click.option(
|
||||
"--load-config",
|
||||
required=False,
|
||||
metavar="<config file path>",
|
||||
default=None,
|
||||
help=(
|
||||
"Load options from file as written with --save-config. "
|
||||
"This allows you to save a complex export command to file for later reuse. "
|
||||
"For example: 'osxphotos export <lots of options here> --save-config osxphotos.toml' then "
|
||||
" 'osxphotos export /path/to/export --load-config osxphotos.toml'. "
|
||||
"If any other command line options are used in conjunction with --load-config, "
|
||||
"they will override the corresponding values in the config file."
|
||||
),
|
||||
type=click.Path(exists=True),
|
||||
)
|
||||
@click.option(
|
||||
"--save-config",
|
||||
required=False,
|
||||
metavar="<config file path>",
|
||||
default=None,
|
||||
help=("Save options to file for use with --load-config. File format is TOML."),
|
||||
type=click.Path(),
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -1465,7 +1493,7 @@ def export(
|
||||
not_shared,
|
||||
from_date,
|
||||
to_date,
|
||||
verbose_,
|
||||
verbose,
|
||||
missing,
|
||||
update,
|
||||
dry_run,
|
||||
@@ -1528,6 +1556,8 @@ def export(
|
||||
use_photokit,
|
||||
report,
|
||||
cleanup,
|
||||
load_config,
|
||||
save_config,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1541,8 +1571,166 @@ def export(
|
||||
to modify this behavior.
|
||||
"""
|
||||
|
||||
# NOTE: because of the way ConfigOptions works, Click options must not
|
||||
# set defaults which are not None or False. If defaults need to be set
|
||||
# do so below after load_config and save_config are handled.
|
||||
cfg = ConfigOptions(
|
||||
"export",
|
||||
locals(),
|
||||
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config"],
|
||||
)
|
||||
|
||||
global VERBOSE
|
||||
VERBOSE = bool(verbose_)
|
||||
VERBOSE = bool(verbose)
|
||||
|
||||
if load_config:
|
||||
try:
|
||||
cfg.load_from_file(load_config)
|
||||
except ConfigOptionsLoadError as e:
|
||||
click.echo(
|
||||
f"Error parsing {load_config} config file: {e.message}", err=True
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
# re-set the local function vars to the corresponding config value
|
||||
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
|
||||
db = cfg.db
|
||||
photos_library = cfg.photos_library
|
||||
keyword = cfg.keyword
|
||||
person = cfg.person
|
||||
album = cfg.album
|
||||
folder = cfg.folder
|
||||
uuid = cfg.uuid
|
||||
uuid_from_file = cfg.uuid_from_file
|
||||
title = cfg.title
|
||||
no_title = cfg.no_title
|
||||
description = cfg.description
|
||||
no_description = cfg.no_description
|
||||
uti = cfg.uti
|
||||
ignore_case = cfg.ignore_case
|
||||
edited = cfg.edited
|
||||
external_edit = cfg.external_edit
|
||||
favorite = cfg.favorite
|
||||
not_favorite = cfg.not_favorite
|
||||
hidden = cfg.hidden
|
||||
not_hidden = cfg.not_hidden
|
||||
shared = cfg.shared
|
||||
not_shared = cfg.not_shared
|
||||
from_date = cfg.from_date
|
||||
to_date = cfg.to_date
|
||||
verbose = cfg.verbose
|
||||
missing = cfg.missing
|
||||
update = cfg.update
|
||||
dry_run = cfg.dry_run
|
||||
export_as_hardlink = cfg.export_as_hardlink
|
||||
touch_file = cfg.touch_file
|
||||
overwrite = cfg.overwrite
|
||||
export_by_date = cfg.export_by_date
|
||||
skip_edited = cfg.skip_edited
|
||||
skip_original_if_edited = cfg.skip_original_if_edited
|
||||
skip_bursts = cfg.skip_bursts
|
||||
skip_live = cfg.skip_live
|
||||
skip_raw = cfg.skip_raw
|
||||
person_keyword = cfg.person_keyword
|
||||
album_keyword = cfg.album_keyword
|
||||
keyword_template = cfg.keyword_template
|
||||
description_template = cfg.description_template
|
||||
current_name = cfg.current_name
|
||||
convert_to_jpeg = cfg.convert_to_jpeg
|
||||
jpeg_quality = cfg.jpeg_quality
|
||||
sidecar = cfg.sidecar
|
||||
only_photos = cfg.only_photos
|
||||
only_movies = cfg.only_movies
|
||||
burst = cfg.burst
|
||||
not_burst = cfg.not_burst
|
||||
live = cfg.live
|
||||
not_live = cfg.not_live
|
||||
download_missing = cfg.download_missing
|
||||
exiftool = cfg.exiftool
|
||||
ignore_date_modified = cfg.ignore_date_modified
|
||||
portrait = cfg.portrait
|
||||
not_portrait = cfg.not_portrait
|
||||
screenshot = cfg.screenshot
|
||||
not_screenshot = cfg.not_screenshot
|
||||
slow_mo = cfg.slow_mo
|
||||
not_slow_mo = cfg.not_slow_mo
|
||||
time_lapse = cfg.time_lapse
|
||||
not_time_lapse = cfg.not_time_lapse
|
||||
hdr = cfg.hdr
|
||||
not_hdr = cfg.not_hdr
|
||||
selfie = cfg.selfie
|
||||
not_selfie = cfg.not_selfie
|
||||
panorama = cfg.panorama
|
||||
not_panorama = cfg.not_panorama
|
||||
has_raw = cfg.has_raw
|
||||
directory = cfg.directory
|
||||
filename_template = cfg.filename_template
|
||||
edited_suffix = cfg.edited_suffix
|
||||
original_suffix = cfg.original_suffix
|
||||
place = cfg.place
|
||||
no_place = cfg.no_place
|
||||
has_comment = cfg.has_comment
|
||||
no_comment = cfg.no_comment
|
||||
has_likes = cfg.has_likes
|
||||
no_likes = cfg.no_likes
|
||||
label = cfg.label
|
||||
deleted = cfg.deleted
|
||||
deleted_only = cfg.deleted_only
|
||||
use_photos_export = cfg.use_photos_export
|
||||
use_photokit = cfg.use_photokit
|
||||
report = cfg.report
|
||||
cleanup = cfg.cleanup
|
||||
|
||||
# config file might have changed verbose
|
||||
VERBOSE = bool(verbose)
|
||||
verbose_(f"Loaded options from file {load_config}")
|
||||
|
||||
exclusive_options = [
|
||||
("favorite", "not_favorite"),
|
||||
("hidden", "not_hidden"),
|
||||
("title", "no_title"),
|
||||
("description", "no_description"),
|
||||
("only_photos", "only_movies"),
|
||||
("burst", "not_burst"),
|
||||
("live", "not_live"),
|
||||
("portrait", "not_portrait"),
|
||||
("screenshot", "not_screenshot"),
|
||||
("slow_mo", "not_slow_mo"),
|
||||
("time_lapse", "not_time_lapse"),
|
||||
("hdr", "not_hdr"),
|
||||
("selfie", "not_selfie"),
|
||||
("panorama", "not_panorama"),
|
||||
("export_by_date", "directory"),
|
||||
("export_as_hardlink", "exiftool"),
|
||||
("place", "no_place"),
|
||||
("deleted", "deleted_only"),
|
||||
("skip_edited", "skip_original_if_edited"),
|
||||
("export_as_hardlink", "convert_to_jpeg"),
|
||||
("export_as_hardlink", "download_missing"),
|
||||
("shared", "not_shared"),
|
||||
("has_comment", "no_comment"),
|
||||
("has_likes", "no_likes"),
|
||||
]
|
||||
dependent_options = [
|
||||
("missing", ("download_missing", "use_photos_export")),
|
||||
("jpeg_quality", ("convert_to_jpeg")),
|
||||
]
|
||||
try:
|
||||
cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True)
|
||||
except ConfigOptionsInvalidError as e:
|
||||
click.echo(f"Incompatible export options: {e.message}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
if save_config:
|
||||
verbose_(f"Saving options to file {save_config}")
|
||||
cfg.write_to_file(save_config)
|
||||
|
||||
# set defaults for options that need them
|
||||
jpeg_quality = DEFAULT_JPEG_QUALITY if jpeg_quality is None else jpeg_quality
|
||||
edited_suffix = DEFAULT_EDITED_SUFFIX if edited_suffix is None else edited_suffix
|
||||
original_suffix = (
|
||||
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
|
||||
)
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
click.echo(f"DEST {dest} must be valid path", err=True)
|
||||
@@ -1554,51 +1742,6 @@ def export(
|
||||
click.echo(f"report is a directory, must be file name", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
# sanity check input args
|
||||
exclusive = [
|
||||
(favorite, not_favorite),
|
||||
(hidden, not_hidden),
|
||||
(any(title), no_title),
|
||||
(any(description), no_description),
|
||||
(only_photos, only_movies),
|
||||
(burst, not_burst),
|
||||
(live, not_live),
|
||||
(portrait, not_portrait),
|
||||
(screenshot, not_screenshot),
|
||||
(slow_mo, not_slow_mo),
|
||||
(time_lapse, not_time_lapse),
|
||||
(hdr, not_hdr),
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
(export_by_date, directory),
|
||||
(export_as_hardlink, exiftool),
|
||||
(any(place), no_place),
|
||||
(deleted, deleted_only),
|
||||
(skip_edited, skip_original_if_edited),
|
||||
(export_as_hardlink, convert_to_jpeg),
|
||||
(shared, not_shared),
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
]
|
||||
if any(all(bb) for bb in exclusive):
|
||||
click.echo("Incompatible export options", err=True)
|
||||
click.echo(cli.commands["export"].get_help(ctx), err=True)
|
||||
return
|
||||
|
||||
if export_as_hardlink and download_missing:
|
||||
click.echo(
|
||||
"Incompatible export options: --export-as-hardlink is not compatible with --download-missing",
|
||||
err=True,
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
if missing and not download_missing:
|
||||
click.echo(
|
||||
"Incompatible export options: --missing must be used with --download-missing",
|
||||
err=True,
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
# if use_photokit and not check_photokit_authorization():
|
||||
# click.echo(
|
||||
# "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
|
||||
@@ -1679,12 +1822,12 @@ def export(
|
||||
|
||||
if verbose_:
|
||||
if export_db.was_created:
|
||||
verbose(f"Created export database {export_db_path}")
|
||||
verbose_(f"Created export database {export_db_path}")
|
||||
else:
|
||||
verbose(f"Using export database {export_db_path}")
|
||||
verbose_(f"Using export database {export_db_path}")
|
||||
upgraded = export_db.was_upgraded
|
||||
if upgraded:
|
||||
verbose(
|
||||
verbose_(
|
||||
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
|
||||
)
|
||||
|
||||
@@ -1780,12 +1923,12 @@ def export(
|
||||
results_sidecar_xmp_skipped = []
|
||||
results_missing = []
|
||||
results_error = []
|
||||
if verbose_:
|
||||
if verbose:
|
||||
for p in photos:
|
||||
results = export_photo(
|
||||
photo=p,
|
||||
dest=dest,
|
||||
verbose_=verbose_,
|
||||
verbose=verbose,
|
||||
export_by_date=export_by_date,
|
||||
sidecar=sidecar,
|
||||
update=update,
|
||||
@@ -1834,7 +1977,7 @@ def export(
|
||||
# for photo_file in set(
|
||||
# results.exported + results.updated + results.exif_updated
|
||||
# ):
|
||||
# verbose(f"Converting {photo_file} to jpeg")
|
||||
# verbose_(f"Converting {photo_file} to jpeg")
|
||||
|
||||
else:
|
||||
# show progress bar
|
||||
@@ -1843,7 +1986,7 @@ def export(
|
||||
results = export_photo(
|
||||
photo=p,
|
||||
dest=dest,
|
||||
verbose_=verbose_,
|
||||
verbose=verbose,
|
||||
export_by_date=export_by_date,
|
||||
sidecar=sidecar,
|
||||
update=update,
|
||||
@@ -1927,7 +2070,7 @@ def export(
|
||||
click.echo(f"Deleted: {cleaned_files} {file_str}, {cleaned_dirs} {dir_str}")
|
||||
|
||||
if report:
|
||||
verbose(f"Writing export report to {report}")
|
||||
verbose_(f"Writing export report to {report}")
|
||||
write_export_report(
|
||||
report,
|
||||
results_exported=results_exported,
|
||||
@@ -2156,7 +2299,7 @@ def _query(
|
||||
arguments must be passed in same order as query and export
|
||||
if either is modified, need to ensure all three functions are updated"""
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(
|
||||
uuid=uuid,
|
||||
@@ -2413,7 +2556,7 @@ def get_photos_by_attribute(photos, attribute, values, ignore_case):
|
||||
def export_photo(
|
||||
photo=None,
|
||||
dest=None,
|
||||
verbose_=None,
|
||||
verbose=None,
|
||||
export_by_date=None,
|
||||
sidecar=None,
|
||||
update=None,
|
||||
@@ -2449,7 +2592,7 @@ def export_photo(
|
||||
Args:
|
||||
photo: PhotoInfo object
|
||||
dest: destination path as string
|
||||
verbose_: boolean; print verbose output
|
||||
verbose: boolean; print verbose output
|
||||
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
|
||||
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
|
||||
export_as_hardlink: boolean; hardlink files instead of copying them
|
||||
@@ -2484,7 +2627,7 @@ def export_photo(
|
||||
ValueError on invalid filename_template
|
||||
"""
|
||||
global VERBOSE
|
||||
VERBOSE = bool(verbose_)
|
||||
VERBOSE = bool(verbose)
|
||||
|
||||
results_exported = []
|
||||
results_new = []
|
||||
@@ -2514,7 +2657,7 @@ def export_photo(
|
||||
# requested edited version but it's missing, download original
|
||||
export_original = True
|
||||
export_edited = False
|
||||
verbose(
|
||||
verbose_(
|
||||
f"Edited file for {photo.original_filename} is missing, exporting original"
|
||||
)
|
||||
|
||||
@@ -2544,16 +2687,31 @@ def export_photo(
|
||||
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
||||
for filename in filenames:
|
||||
if original_suffix:
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
original_suffix, filename=True
|
||||
)
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unmatched={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
"original_suffix",
|
||||
f"Invalid template for --original-suffix: may not use multi-valued templates: '{original_suffix}': results={rendered_suffix}",
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
|
||||
original_filename = pathlib.Path(filename)
|
||||
original_filename = (
|
||||
original_filename.parent
|
||||
/ f"{original_filename.stem}{original_suffix}{original_filename.suffix}"
|
||||
/ f"{original_filename.stem}{rendered_suffix}{original_filename.suffix}"
|
||||
)
|
||||
original_filename = str(original_filename)
|
||||
else:
|
||||
original_filename = filename
|
||||
|
||||
verbose(
|
||||
verbose_(
|
||||
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}"
|
||||
)
|
||||
|
||||
@@ -2585,8 +2743,8 @@ def export_photo(
|
||||
# original is missing, in which case we should download the edited version
|
||||
if export_original:
|
||||
if missing_original:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(f"{space}Skipping missing photo {photo.original_filename}")
|
||||
space = " " if not verbose else ""
|
||||
verbose_(f"{space}Skipping missing photo {photo.original_filename}")
|
||||
results_missing.append(
|
||||
str(pathlib.Path(dest_path) / original_filename)
|
||||
)
|
||||
@@ -2616,7 +2774,7 @@ def export_photo(
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
use_photokit=use_photokit,
|
||||
verbose=verbose,
|
||||
verbose=verbose_,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results.exported)
|
||||
@@ -2648,7 +2806,7 @@ def export_photo(
|
||||
str(pathlib.Path(dest) / original_filename)
|
||||
)
|
||||
else:
|
||||
verbose(f"Skipping original version of {photo.original_filename}")
|
||||
verbose_(f"Skipping original version of {photo.original_filename}")
|
||||
|
||||
# if export-edited, also export the edited version
|
||||
# verify the photo has adjustments and valid path to avoid raising an exception
|
||||
@@ -2664,13 +2822,36 @@ def export_photo(
|
||||
# use filename suffix which might be wrong,
|
||||
# will be corrected by use_photos_export
|
||||
edited_ext = pathlib.Path(photo.filename).suffix
|
||||
edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}"
|
||||
verbose(
|
||||
|
||||
if edited_suffix:
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
edited_suffix, filename=True
|
||||
)
|
||||
|
||||
if not rendered_suffix or unmatched:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix '{edited_suffix}': results={rendered_suffix} unmatched={unmatched}",
|
||||
)
|
||||
if len(rendered_suffix) > 1:
|
||||
raise click.BadOptionUsage(
|
||||
"edited_suffix",
|
||||
f"Invalid template for --edited-suffix: may not use multi-valued templates: '{edited_suffix}': results={rendered_suffix}",
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
|
||||
edited_filename = (
|
||||
f"{edited_filename.stem}{rendered_suffix}{edited_ext}"
|
||||
)
|
||||
else:
|
||||
edited_filename = f"{edited_filename.stem}{edited_ext}"
|
||||
|
||||
verbose_(
|
||||
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
|
||||
)
|
||||
if missing_edited:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(f"{space}Skipping missing edited photo for {filename}")
|
||||
space = " " if not verbose else ""
|
||||
verbose_(f"{space}Skipping missing edited photo for {filename}")
|
||||
results_missing.append(
|
||||
str(pathlib.Path(dest_path) / edited_filename)
|
||||
)
|
||||
@@ -2699,7 +2880,7 @@ def export_photo(
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
use_photokit=use_photokit,
|
||||
verbose=verbose,
|
||||
verbose=verbose_,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results_edited.exported)
|
||||
@@ -2731,19 +2912,19 @@ def export_photo(
|
||||
)
|
||||
results_error.extend(str(pathlib.Path(dest) / edited_filename))
|
||||
|
||||
if verbose_:
|
||||
if verbose:
|
||||
if update:
|
||||
for new in results_new:
|
||||
verbose(f"Exported new file {new}")
|
||||
verbose_(f"Exported new file {new}")
|
||||
for updated in results_updated:
|
||||
verbose(f"Exported updated file {updated}")
|
||||
verbose_(f"Exported updated file {updated}")
|
||||
for skipped in results_skipped:
|
||||
verbose(f"Skipped up to date file {skipped}")
|
||||
verbose_(f"Skipped up to date file {skipped}")
|
||||
else:
|
||||
for exported in results_exported:
|
||||
verbose(f"Exported {exported}")
|
||||
verbose_(f"Exported {exported}")
|
||||
for touched in results_touched:
|
||||
verbose(f"Touched date on file {touched}")
|
||||
verbose_(f"Touched date on file {touched}")
|
||||
|
||||
return ExportResults(
|
||||
exported=results_exported,
|
||||
@@ -3048,7 +3229,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
|
||||
for p in pathlib.Path(dest_path).rglob("*"):
|
||||
path = str(p).lower()
|
||||
if p.is_file() and path not in keepers:
|
||||
verbose(f"Deleting {p}")
|
||||
verbose_(f"Deleting {p}")
|
||||
fileutil.unlink(p)
|
||||
deleted_files += 1
|
||||
|
||||
@@ -3058,7 +3239,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
|
||||
path = str(p).lower()
|
||||
# if directory and directory is empty
|
||||
if p.is_dir() and not next(p.iterdir(), False):
|
||||
verbose(f"Deleting empty directory {p}")
|
||||
verbose_(f"Deleting empty directory {p}")
|
||||
fileutil.rmdir(p)
|
||||
deleted_dirs += 1
|
||||
|
||||
|
||||
@@ -109,3 +109,13 @@ MAX_FILENAME_LEN = 255
|
||||
# Max directory name length on MacOS
|
||||
MAX_DIRNAME_LEN = 255
|
||||
|
||||
# Default JPEG quality when converting to JPEG
|
||||
DEFAULT_JPEG_QUALITY = 1.0
|
||||
|
||||
# Default suffix to add to edited images
|
||||
DEFAULT_EDITED_SUFFIX = "_edited"
|
||||
|
||||
# Default suffix to add to original images
|
||||
DEFAULT_ORIGINAL_SUFFIX = ""
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.38.0"
|
||||
__version__ = "0.38.4"
|
||||
|
||||
|
||||
|
||||
173
osxphotos/configoptions.py
Normal file
173
osxphotos/configoptions.py
Normal file
@@ -0,0 +1,173 @@
|
||||
""" ConfigOptions class to load/save config settings for osxphotos CLI """
|
||||
import toml
|
||||
|
||||
|
||||
class ConfigOptionsException(Exception):
|
||||
""" Invalid combination of options. """
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ConfigOptionsInvalidError(ConfigOptionsException):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigOptionsLoadError(ConfigOptionsException):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigOptions:
|
||||
""" data class to store and load options for osxphotos commands """
|
||||
|
||||
def __init__(self, name, attrs, ignore=None):
|
||||
""" init ConfigOptions class
|
||||
|
||||
Args:
|
||||
name: name for these options, will be used for section heading in TOML file when saving/loading from file
|
||||
attrs: dict with name and default value for all allowed attributes
|
||||
ignore: optional list of strings of keys to ignore from attrs dict
|
||||
"""
|
||||
self._name = name
|
||||
self._attrs = attrs.copy()
|
||||
if ignore:
|
||||
for attrname in ignore:
|
||||
self._attrs.pop(attrname, None)
|
||||
|
||||
self.set_attributes(attrs)
|
||||
|
||||
def set_attributes(self, args):
|
||||
for attr in self._attrs:
|
||||
try:
|
||||
arg = args[attr]
|
||||
# don't test 'not arg'; need to handle empty strings as valid values
|
||||
if arg is None or arg == False:
|
||||
if type(self._attrs[attr]) == tuple:
|
||||
setattr(self, attr, ())
|
||||
else:
|
||||
setattr(self, attr, self._attrs[attr])
|
||||
else:
|
||||
setattr(self, attr, arg)
|
||||
except KeyError:
|
||||
raise KeyError(f"Missing argument: {attr}")
|
||||
|
||||
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
|
||||
""" validate combinations of otions
|
||||
|
||||
Args:
|
||||
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
|
||||
ie. either option_1 can be set or option_2 but not both;
|
||||
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
|
||||
ie. if either option_1 or option_2 is set, the other must be set
|
||||
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
|
||||
where if option_1 is set, then at least one of the options in the second tuple must also be set
|
||||
cli: bool, set to True if called to validate CLI options;
|
||||
will prepend '--' to option names in InvalidOptions.message and change _ to - in option names
|
||||
|
||||
Returns:
|
||||
True if all options valid
|
||||
|
||||
Raises:
|
||||
InvalidOption if any combination of options is invalid
|
||||
InvalidOption.message will be descriptive message of invalid options
|
||||
"""
|
||||
if not any([exclusive, inclusive, dependent]):
|
||||
return True
|
||||
|
||||
prefix = "--" if cli else ""
|
||||
if exclusive:
|
||||
for a, b in exclusive:
|
||||
vala = getattr(self, a)
|
||||
valb = getattr(self, b)
|
||||
vala = any(vala) if isinstance(vala, tuple) else vala
|
||||
valb = any(valb) if isinstance(valb, tuple) else valb
|
||||
if vala and valb:
|
||||
stra = a.replace("_", "-") if cli else a
|
||||
strb = b.replace("_", "-") if cli else b
|
||||
raise ConfigOptionsInvalidError(
|
||||
f"{prefix}{stra} and {prefix}{strb} options cannot be used together."
|
||||
)
|
||||
if inclusive:
|
||||
for a, b in inclusive:
|
||||
vala = getattr(self, a)
|
||||
valb = getattr(self, b)
|
||||
vala = any(vala) if isinstance(vala, tuple) else vala
|
||||
valb = any(valb) if isinstance(valb, tuple) else valb
|
||||
if any([vala, valb]) and not all([vala, valb]):
|
||||
stra = a.replace("_", "-") if cli else a
|
||||
strb = b.replace("_", "-") if cli else b
|
||||
raise ConfigOptionsInvalidError(
|
||||
f"{prefix}{stra} and {prefix}{strb} options must be used together."
|
||||
)
|
||||
if dependent:
|
||||
for a, b in dependent:
|
||||
vala = getattr(self, a)
|
||||
if not isinstance(b, tuple):
|
||||
# python unrolls the tuple if there's a single element
|
||||
b = (b,)
|
||||
valb = [getattr(self, x) for x in b]
|
||||
valb = [any(x) if isinstance(x, tuple) else x for x in valb]
|
||||
if vala and not any(valb):
|
||||
if cli:
|
||||
stra = prefix + a.replace("_", "-")
|
||||
strb = ", ".join(prefix + x.replace("_", "-") for x in b)
|
||||
else:
|
||||
stra = a
|
||||
strb = ", ".join(b)
|
||||
raise ConfigOptionsInvalidError(
|
||||
f"{stra} must be used with at least one of: {strb}."
|
||||
)
|
||||
return True
|
||||
|
||||
def write_to_file(self, filename):
|
||||
""" Write self to TOML file
|
||||
|
||||
Args:
|
||||
filename: full path to TOML file to write; filename will be overwritten if it exists
|
||||
"""
|
||||
# todo: add overwrite and option to merge contents already in TOML file (under different [section] with new content)
|
||||
data = {}
|
||||
for attr in sorted(self._attrs.keys()):
|
||||
val = getattr(self, attr)
|
||||
if val in [False, ()]:
|
||||
val = None
|
||||
else:
|
||||
val = list(val) if type(val) == tuple else val
|
||||
|
||||
data[attr] = val
|
||||
|
||||
with open(filename, "w") as fd:
|
||||
toml.dump({self._name: data}, fd)
|
||||
|
||||
def load_from_file(self, filename, override=False):
|
||||
""" Load options from a TOML file.
|
||||
|
||||
Args:
|
||||
filename: full path to TOML file
|
||||
override: bool; if True, values in the TOML file will override values already set in the instance
|
||||
|
||||
Raises:
|
||||
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
|
||||
"""
|
||||
loaded = toml.load(filename)
|
||||
name = self._name
|
||||
if name not in loaded:
|
||||
raise ConfigOptionsLoadError(f"[{name}] section missing from {filename}")
|
||||
|
||||
for attr in loaded[name]:
|
||||
if attr not in self._attrs:
|
||||
raise ConfigOptionsLoadError(
|
||||
f"Unknown option: {attr} = {loaded[name][attr]}"
|
||||
)
|
||||
val = loaded[name][attr]
|
||||
if not override:
|
||||
# use value from self if set
|
||||
val = getattr(self, attr) or val
|
||||
if type(self._attrs[attr]) == tuple:
|
||||
val = tuple(val)
|
||||
setattr(self, attr, val)
|
||||
return self
|
||||
|
||||
def asdict(self):
|
||||
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}
|
||||
@@ -1197,7 +1197,8 @@ def _exiftool_dict(
|
||||
EXIF:ModifyDate
|
||||
IPTC:DateCreated
|
||||
IPTC:TimeCreated
|
||||
QuickTime:CreationDate (UTC)
|
||||
QuickTime:CreationDate
|
||||
QuickTime:CreateDate (UTC)
|
||||
QuickTime:ModifyDate (UTC)
|
||||
QuickTime:GPSCoordinates
|
||||
UserData:GPSCoordinates
|
||||
@@ -1300,22 +1301,26 @@ def _exiftool_dict(
|
||||
# [IPTC] Digital Creation Date : 2020:10:30
|
||||
# [IPTC] Date Created : 2020:10:30
|
||||
#
|
||||
# for videos:
|
||||
# [QuickTime] CreateDate : 2020:12:11 06:10:10
|
||||
# [QuickTime] ModifyDate : 2020:12:11 06:10:10
|
||||
# [Keys] CreationDate : 2020:12:10 22:10:10-08:00
|
||||
# This code deviates from Photos in one regard:
|
||||
# if photo has modification date, use it otherwise use creation date
|
||||
|
||||
if self.isphoto:
|
||||
date = self.date
|
||||
# exiftool expects format to "2015:01:18 12:00:00"
|
||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
date = self.date
|
||||
offsettime = date.strftime("%z")
|
||||
# find timezone offset in format "-04:00"
|
||||
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
|
||||
offset = offset[0] # findall returns list of tuples
|
||||
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
|
||||
|
||||
# exiftool expects format to "2015:01:18 12:00:00"
|
||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
|
||||
if self.isphoto:
|
||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||
exif["EXIF:CreateDate"] = datetimeoriginal
|
||||
|
||||
offsettime = date.strftime("%z")
|
||||
# find timezone offset in format "-04:00"
|
||||
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
|
||||
offset = offset[0] # findall returns list of tuples
|
||||
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
|
||||
exif["EXIF:OffsetTimeOriginal"] = offsettime
|
||||
|
||||
dateoriginal = date.strftime("%Y:%m:%d")
|
||||
@@ -1330,10 +1335,14 @@ def _exiftool_dict(
|
||||
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
elif self.ismovie:
|
||||
# QuickTime spec specifies times in UTC
|
||||
# QuickTime:CreateDate and ModifyDate are in UTC w/ no timezone
|
||||
# QuickTime:CreationDate must include time offset or Photos shows invalid values
|
||||
# reference: https://exiftool.org/TagNames/QuickTime.html#Keys
|
||||
date_utc = datetime_tz_to_utc(self.date)
|
||||
# https://exiftool.org/forum/index.php?topic=11927.msg64369#msg64369
|
||||
exif["QuickTime:CreationDate"] = f"{datetimeoriginal}{offsettime}"
|
||||
|
||||
date_utc = datetime_tz_to_utc(date)
|
||||
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
|
||||
exif["QuickTime:CreationDate"] = creationdate
|
||||
exif["QuickTime:CreateDate"] = creationdate
|
||||
if self.date_modified is not None and not ignore_date_modified:
|
||||
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
|
||||
@@ -1381,7 +1390,8 @@ def _exiftool_json_sidecar(
|
||||
EXIF:ModifyDate
|
||||
IPTC:DigitalCreationDate
|
||||
IPTC:DateCreated
|
||||
QuickTime:CreationDate (UTC)
|
||||
QuickTime:CreationDate
|
||||
QuickTime:CreateDate (UTC)
|
||||
QuickTime:ModifyDate (UTC)
|
||||
QuickTime:GPSCoordinates
|
||||
UserData:GPSCoordinates
|
||||
|
||||
@@ -52,6 +52,7 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
),
|
||||
"{photo_or_video}": "'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'",
|
||||
"{hdr}": "Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
|
||||
"{edited}": "Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
|
||||
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
|
||||
"{created.year}": "4-digit year of photo creation time",
|
||||
"{created.yy}": "2-digit year of photo creation time",
|
||||
@@ -632,7 +633,9 @@ class PhotoTemplate:
|
||||
elif field == "photo_or_video":
|
||||
value = self.get_photo_video_type(default)
|
||||
elif field == "hdr":
|
||||
value = self.get_photo_hdr(default, bool_val)
|
||||
value = self.get_photo_bool_attribute("hdr", default, bool_val)
|
||||
elif field == "edited":
|
||||
value = self.get_photo_bool_attribute("hasadjustments", default, bool_val)
|
||||
elif field == "created.date":
|
||||
value = DateTimeFormatter(self.photo.date).date
|
||||
elif field == "created.year":
|
||||
@@ -962,8 +965,10 @@ class PhotoTemplate:
|
||||
else:
|
||||
return default_dict["photo"]
|
||||
|
||||
def get_photo_hdr(self, default, bool_val):
|
||||
if self.photo.hdr:
|
||||
def get_photo_bool_attribute(self, attr, default, bool_val):
|
||||
# get value for a PhotoInfo bool attribute
|
||||
val = getattr(self.photo, attr)
|
||||
if val:
|
||||
return bool_val
|
||||
else:
|
||||
return default
|
||||
|
||||
1
setup.py
1
setup.py
@@ -80,6 +80,7 @@ setup(
|
||||
"dataclasses==0.7;python_version<'3.7'",
|
||||
"wurlitzer>=2.0.1",
|
||||
"photoscript>=0.1.0",
|
||||
"toml>=0.10.0",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
@@ -66,7 +66,9 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
|
||||
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
|
||||
|
||||
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
|
||||
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
|
||||
|
||||
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
|
||||
"Pumkins1.jpg",
|
||||
@@ -79,6 +81,17 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
|
||||
"wedding_bearbeiten.jpeg",
|
||||
]
|
||||
|
||||
CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [
|
||||
"Pumkins1.jpg",
|
||||
"Pumkins2.jpg",
|
||||
"Pumpkins3.jpg",
|
||||
"St James Park.jpg",
|
||||
"St James Park_edited.jpeg",
|
||||
"Tulips.jpg",
|
||||
"wedding.jpg",
|
||||
"wedding_edited.jpeg",
|
||||
]
|
||||
|
||||
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
|
||||
"Pumkins1_original.jpg",
|
||||
"Pumkins2_original.jpg",
|
||||
@@ -90,6 +103,17 @@ CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
|
||||
"wedding_edited.jpeg",
|
||||
]
|
||||
|
||||
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [
|
||||
"Pumkins1.jpg",
|
||||
"Pumkins2.jpg",
|
||||
"Pumpkins3.jpg",
|
||||
"St James Park_original.jpg",
|
||||
"St James Park_edited.jpeg",
|
||||
"Tulips.jpg",
|
||||
"wedding_original.jpg",
|
||||
"wedding_edited.jpeg",
|
||||
]
|
||||
|
||||
CLI_EXPORT_FILENAMES_CURRENT = [
|
||||
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
|
||||
"3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg",
|
||||
@@ -344,7 +368,7 @@ CLI_EXIFTOOL_QUICKTIME = {
|
||||
"XMP:TagsList": "Travel",
|
||||
"XMP:Subject": "Travel",
|
||||
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||
"QuickTime:CreationDate": "2020:01:05 22:13:13",
|
||||
"QuickTime:CreationDate": "2020:01:05 14:13:13-08:00",
|
||||
"QuickTime:CreateDate": "2020:01:05 22:13:13",
|
||||
"QuickTime:ModifyDate": "2020:01:05 22:13:13",
|
||||
},
|
||||
@@ -355,7 +379,7 @@ CLI_EXIFTOOL_QUICKTIME = {
|
||||
"XMP:TagsList": "Travel",
|
||||
"XMP:Subject": "Travel",
|
||||
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||
"QuickTime:CreationDate": "2020:12:05 05:21:52",
|
||||
"QuickTime:CreationDate": "2020:12:04 21:21:52-08:00",
|
||||
"QuickTime:CreateDate": "2020:12:05 05:21:52",
|
||||
"QuickTime:ModifyDate": "2020:12:05 05:21:52",
|
||||
},
|
||||
@@ -875,7 +899,7 @@ def test_export_using_hardlinks_incompat_options():
|
||||
"-V",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code == 1
|
||||
assert "Incompatible export options" in result.output
|
||||
|
||||
|
||||
@@ -1087,6 +1111,33 @@ def test_export_edited_suffix():
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
|
||||
|
||||
|
||||
def test_export_edited_suffix_template():
|
||||
""" test export with --edited-suffix template """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--edited-suffix",
|
||||
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE,
|
||||
"-V",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE)
|
||||
|
||||
|
||||
def test_export_original_suffix():
|
||||
""" test export with --original-suffix """
|
||||
import glob
|
||||
@@ -1114,6 +1165,33 @@ def test_export_original_suffix():
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX)
|
||||
|
||||
|
||||
def test_export_original_suffix_template():
|
||||
""" test export with --original-suffix template """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--original-suffix",
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE,
|
||||
"-V",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||
reason="Skip if running in Github actions, no GPU.",
|
||||
@@ -3961,3 +4039,103 @@ def test_export_cleanup():
|
||||
assert not pathlib.Path("./delete_me.txt").is_file()
|
||||
assert not pathlib.Path("./foo/delete_me_too.txt").is_file()
|
||||
|
||||
|
||||
def test_save_load_config():
|
||||
""" test --save-config, --load-config """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
# test save config file
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--sidecar",
|
||||
"XMP",
|
||||
"--touch-file",
|
||||
"--update",
|
||||
"--save-config",
|
||||
"config.toml",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Saving options to file" in result.output
|
||||
files = glob.glob("*")
|
||||
assert "config.toml" in files
|
||||
|
||||
# test load config file
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--load-config",
|
||||
"config.toml",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Loaded options from file" in result.output
|
||||
assert "Skipped up to date XMP sidecar" in result.output
|
||||
|
||||
# test overwrite existing config file
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--sidecar",
|
||||
"XMP",
|
||||
"--touch-file",
|
||||
"--not-live",
|
||||
"--update",
|
||||
"--save-config",
|
||||
"config.toml",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Saving options to file" in result.output
|
||||
files = glob.glob("*")
|
||||
assert "config.toml" in files
|
||||
|
||||
# test load config file with incompat command line option
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--load-config",
|
||||
"config.toml",
|
||||
"--live",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Incompatible export options" in result.output
|
||||
|
||||
# test load config file with command line override
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--load-config",
|
||||
"config.toml",
|
||||
"--sidecar",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing exiftool JSON sidecar" in result.output
|
||||
assert "Writing XMP sidecar" not in result.output
|
||||
|
||||
86
tests/test_configoptions.py
Normal file
86
tests/test_configoptions.py
Normal file
@@ -0,0 +1,86 @@
|
||||
""" test ConfigOptions class """
|
||||
|
||||
import pathlib
|
||||
import pytest
|
||||
import toml
|
||||
|
||||
from osxphotos.configoptions import (
|
||||
ConfigOptions,
|
||||
ConfigOptionsInvalidError,
|
||||
ConfigOptionsLoadError,
|
||||
)
|
||||
|
||||
VARS = {"foo": "bar", "bar": False, "test1": (), "test2": None, "test2_setting": False}
|
||||
|
||||
|
||||
def test_init():
|
||||
cfg = ConfigOptions("test", VARS)
|
||||
assert isinstance(cfg, ConfigOptions)
|
||||
assert cfg.foo is "bar"
|
||||
assert cfg.bar == False
|
||||
assert type(cfg.test1) == tuple
|
||||
|
||||
|
||||
def test_init_with_ignore():
|
||||
cfg = ConfigOptions("test", VARS, ignore=["test2"])
|
||||
assert isinstance(cfg, ConfigOptions)
|
||||
assert hasattr(cfg, "test1")
|
||||
assert not hasattr(cfg, "test2")
|
||||
|
||||
|
||||
def test_write_to_file_load_from_file(tmpdir):
|
||||
cfg = ConfigOptions("test", VARS)
|
||||
cfg.bar = True
|
||||
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
|
||||
cfg.write_to_file(str(cfg_file))
|
||||
assert cfg_file.is_file()
|
||||
|
||||
cfg_dict = toml.load(str(cfg_file))
|
||||
assert cfg_dict["test"]["foo"] == "bar"
|
||||
|
||||
cfg2 = ConfigOptions("test", VARS).load_from_file(str(cfg_file))
|
||||
assert cfg2.foo == "bar"
|
||||
assert cfg2.bar
|
||||
|
||||
|
||||
def test_load_from_file_error(tmpdir):
|
||||
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
|
||||
cfg = ConfigOptions("test", VARS)
|
||||
cfg.write_to_file(str(cfg_file))
|
||||
# try to load with a section that doesn't exist in the TOML file
|
||||
with pytest.raises(ConfigOptionsLoadError):
|
||||
cfg2 = ConfigOptions("FOO", VARS).load_from_file(str(cfg_file))
|
||||
|
||||
|
||||
def test_asdict():
|
||||
cfg = ConfigOptions("test", VARS)
|
||||
cfg_dict = cfg.asdict()
|
||||
assert cfg_dict["foo"] == "bar"
|
||||
assert cfg_dict["bar"] == False
|
||||
assert cfg_dict["test1"] == ()
|
||||
|
||||
|
||||
def test_validate():
|
||||
cfg = ConfigOptions("test", VARS)
|
||||
|
||||
# test exclusive
|
||||
assert cfg.validate(exclusive=[("foo", "bar")])
|
||||
cfg.bar = True
|
||||
with pytest.raises(ConfigOptionsInvalidError):
|
||||
assert cfg.validate(exclusive=[("foo", "bar")])
|
||||
|
||||
# test dependent
|
||||
cfg.test2 = True
|
||||
cfg.test2_setting = 1.0
|
||||
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
|
||||
cfg.test2 = False
|
||||
with pytest.raises(ConfigOptionsInvalidError):
|
||||
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
|
||||
|
||||
# test inclusive
|
||||
cfg.foo = "foo"
|
||||
cfg.bar = True
|
||||
assert cfg.validate(inclusive=[("foo", "bar")])
|
||||
cfg.foo = None
|
||||
with pytest.raises(ConfigOptionsInvalidError):
|
||||
assert cfg.validate(inclusive=[("foo", "bar")])
|
||||
@@ -59,10 +59,16 @@ TEMPLATE_VALUES_TITLE = {
|
||||
}
|
||||
|
||||
# Boolean type values that render to True
|
||||
UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
|
||||
UUID_BOOL_VALUES = {
|
||||
"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15",
|
||||
"edited": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
|
||||
}
|
||||
|
||||
# Boolean type values that render to False
|
||||
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
|
||||
UUID_BOOL_VALUES_NOT = {
|
||||
"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
|
||||
"edited": "CCBE0EB9-AE9F-4479-BFFD-107042C75227",
|
||||
}
|
||||
|
||||
# for exiftool template
|
||||
UUID_EXIFTOOL = {
|
||||
|
||||
Reference in New Issue
Block a user