Compare commits

..

17 Commits

Author SHA1 Message Date
Rhet Turnbull
d5730dd8ae Fix for issue #263 2020-12-13 22:18:39 -08:00
Rhet Turnbull
5c1c0c5c5a Updated CHANGELOG.md 2020-12-13 22:05:33 -08:00
Rhet Turnbull
d8593a01e2 Fix for QuickTime date/time, issue #282 2020-12-12 22:13:01 -08:00
Rhet Turnbull
1dffe894ff Updated CHANGELOG.md 2020-12-12 08:08:44 -08:00
Rhet Turnbull
29721dd4f0 Merge pull request #290 from RhetTbull/save_config
Added --save-config, --load-config
2020-12-12 07:58:04 -08:00
Rhet Turnbull
6559c4d8f6 removed extended_attributes reference 2020-12-12 07:53:18 -08:00
Rhet Turnbull
baf45ccd2a This is why I never use branches 2020-12-12 07:51:36 -08:00
Rhet Turnbull
aca85ee2aa Version bump 2020-12-12 07:45:24 -08:00
Rhet Turnbull
9584a9ccc5 Merge branch 'master' into save_config 2020-12-12 07:38:35 -08:00
Rhet Turnbull
182b816e34 Updated README.md for --save-config, --load-config 2020-12-12 07:29:16 -08:00
Rhet Turnbull
0262e0d97e Added tests for configoptions.py 2020-12-12 07:25:50 -08:00
Rhet Turnbull
73f936e061 Added link to discussions 2020-12-11 06:19:05 -08:00
Rhet Turnbull
09687cfca4 Initial test for --save-config, --load-config 2020-12-11 06:12:32 -08:00
Rhet Turnbull
e17ee0e388 Updated CHANGELOG.md 2020-12-10 20:48:19 -08:00
Rhet Turnbull
d7c81adae8 Updated validate code 2020-12-09 20:17:49 -08:00
Rhet Turnbull
37b1e5ca47 Refactoring of save-config/load-config code 2020-12-08 08:32:37 -08:00
Rhet Turnbull
22355fd446 Initial implementation of configoptions for --save-config, --load-config 2020-12-08 07:22:40 -08:00
12 changed files with 840 additions and 139 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
""" version info """
__version__ = "0.38.0"
__version__ = "0.38.4"

173
osxphotos/configoptions.py Normal file
View 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())}

View File

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

View File

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

View File

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

View File

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

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

View File

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