Refactor verbose 931 (#960)

* Began refactoring verbose code for #931

* Fixed tests for timewarp due to verbose refactor

* Updated test data

* More refactoring for #931

* Refactored queryoptions.py

* Refactored queryoptions.py

* Refactored queryoptions.py

* Refactored echo_error

* Refactored debug

* Refactored debug

* Refactored use of verbose in export

* Refactored use of verbose in export

* Refactred --verbose in add-locations and debug-dump

* Refactored --verbose for

* Refactored --verbose for osxphotos exportdb

* Refactored --verbose for osxphotos import

* Refactored --verbose for osxphotos orphans

* Refactored --verbose for osxphotos snap-diff

* Refactored --verbose for osxphotos sync

* Refactored --verbose for osxphotos timewarp

* Added default verbose() function to verbose
This commit is contained in:
Rhet Turnbull 2023-01-28 17:44:20 -08:00 committed by GitHub
parent 770d85759d
commit 0c293d0bf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 919 additions and 735 deletions

View File

@ -1,3 +1,7 @@
"""__init__.py for osxphotos"""
from __future__ import annotations
import logging import logging
from ._constants import AlbumSortOrder from ._constants import AlbumSortOrder
@ -20,7 +24,13 @@ from .placeinfo import PlaceInfo
from .queryoptions import QueryOptions from .queryoptions import QueryOptions
from .scoreinfo import ScoreInfo from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo from .searchinfo import SearchInfo
from .utils import _get_logger
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s",
)
logger: logging.Logger = logging.getLogger("osxphotos")
if not is_debug(): if not is_debug():
logging.disable(logging.DEBUG) logging.disable(logging.DEBUG)
@ -37,6 +47,7 @@ __all__ = [
"ExportResults", "ExportResults",
"FileUtil", "FileUtil",
"FileUtilNoOp", "FileUtilNoOp",
"FolderInfo",
"ImportInfo", "ImportInfo",
"LikeInfo", "LikeInfo",
"MomentInfo", "MomentInfo",
@ -53,8 +64,7 @@ __all__ = [
"ScoreInfo", "ScoreInfo",
"SearchInfo", "SearchInfo",
"__version__", "__version__",
"_get_logger",
"is_debug", "is_debug",
"logger",
"set_debug", "set_debug",
"FolderInfo",
] ]

View File

@ -47,7 +47,7 @@ from .about import about
from .add_locations import add_locations from .add_locations import add_locations
from .albums import albums from .albums import albums
from .cli import cli_main from .cli import cli_main
from .common import get_photos_db, load_uuid_from_file from .common import get_photos_db
from .debug_dump import debug_dump from .debug_dump import debug_dump
from .dump import dump from .dump import dump
from .exiftool_cli import exiftool from .exiftool_cli import exiftool
@ -91,7 +91,6 @@ __all__ = [
"labels", "labels",
"list_libraries", "list_libraries",
"list_libraries", "list_libraries",
"load_uuid_from_file",
"orphans", "orphans",
"persons", "persons",
"photo_inspect", "photo_inspect",

View File

@ -8,17 +8,12 @@ import click
import photoscript import photoscript
import osxphotos import osxphotos
from osxphotos.queryoptions import IncompatibleQueryOptions, query_options_from_kwargs
from osxphotos.utils import pluralize from osxphotos.utils import pluralize
from .click_rich_echo import ( from .click_rich_echo import rich_click_echo as echo
rich_click_echo, from .click_rich_echo import rich_echo_error as echo_error
rich_echo_error, from .common import QUERY_OPTIONS, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION
set_rich_console,
set_rich_theme,
set_rich_timestamp,
)
from .color_themes import get_theme
from .common import QUERY_OPTIONS, THEME_OPTION, query_options_from_kwargs
from .param_types import TimeOffset from .param_types import TimeOffset
from .rich_progress import rich_progress from .rich_progress import rich_progress
from .verbose import get_verbose_console, verbose_print from .verbose import get_verbose_console, verbose_print
@ -94,15 +89,15 @@ def get_location(
help="Don't actually add location, just print what would be done. " help="Don't actually add location, just print what would be done. "
"Most useful with --verbose.", "Most useful with --verbose.",
) )
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@click.option( @TIMESTAMP_OPTION
"--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output."
)
@QUERY_OPTIONS @QUERY_OPTIONS
@THEME_OPTION @THEME_OPTION
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def add_locations(ctx, cli_ob, window, dry_run, verbose_, timestamp, theme, **kwargs): def add_locations(
ctx, cli_ob, window, dry_run, verbose_flag, timestamp, theme, **kwargs
):
"""Add missing location data to photos in Photos.app using nearest neighbor. """Add missing location data to photos in Photos.app using nearest neighbor.
This command will search for photos that are missing location data and look This command will search for photos that are missing location data and look
@ -136,20 +131,19 @@ def add_locations(ctx, cli_ob, window, dry_run, verbose_, timestamp, theme, **kw
use `osxphotos add-locations` to add location information. use `osxphotos add-locations` to add location information.
See `osxphotos help timewarp` for more information. See `osxphotos help timewarp` for more information.
""" """
color_theme = get_theme(theme) verbose = verbose_print(verbose_flag, timestamp, theme=theme)
verbose = verbose_print(
verbose_, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
verbose("Searching for photos with missing location data...") verbose("Searching for photos with missing location data...")
# load photos database # load photos database
photosdb = osxphotos.PhotosDB(verbose=verbose) photosdb = osxphotos.PhotosDB(verbose=verbose)
try:
query_options = query_options_from_kwargs(**kwargs) query_options = query_options_from_kwargs(**kwargs)
except IncompatibleQueryOptions as e:
echo_error("Incompatible query options")
echo_error(ctx.obj.group.commands["repl"].get_help(ctx))
ctx.exit(1)
photos = photosdb.query(query_options) photos = photosdb.query(query_options)
# sort photos by date # sort photos by date
@ -159,7 +153,7 @@ def add_locations(ctx, cli_ob, window, dry_run, verbose_, timestamp, theme, **kw
missing_location = 0 missing_location = 0
found_location = 0 found_location = 0
verbose(f"Processing {len(photos)} photos, window = ±{window}...") verbose(f"Processing {len(photos)} photos, window = ±{window}...")
with rich_progress(console=get_verbose_console(), mock=verbose_) as progress: with rich_progress(console=get_verbose_console(), mock=verbose_flag) as progress:
task = progress.add_task( task = progress.add_task(
f"Processing [num]{num_photos}[/] {pluralize(len(photos), 'photo', 'photos')}, window = ±{window}", f"Processing [num]{num_photos}[/] {pluralize(len(photos), 'photo', 'photos')}, window = ±{window}",
total=num_photos, total=num_photos,
@ -183,7 +177,7 @@ def add_locations(ctx, cli_ob, window, dry_run, verbose_, timestamp, theme, **kw
f"No location found for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])" f"No location found for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
) )
progress.advance(task) progress.advance(task)
rich_click_echo( echo(
f"Done. Processed: [num]{num_photos}[/] photos, " f"Done. Processed: [num]{num_photos}[/] photos, "
f"missing location: [num]{missing_location}[/], " f"missing location: [num]{missing_location}[/], "
f"found location: [num]{found_location}[/] " f"found location: [num]{found_location}[/] "

View File

@ -1,7 +1,6 @@
"""Globals and constants use by the CLI commands""" """Globals and constants use by the CLI commands"""
import dataclasses
import os import os
import pathlib import pathlib
from datetime import datetime from datetime import datetime
@ -11,7 +10,6 @@ from packaging import version
from xdg import xdg_config_home, xdg_data_home from xdg import xdg_config_home, xdg_data_home
import osxphotos import osxphotos
from osxphotos import QueryOptions
from osxphotos._constants import APP_NAME from osxphotos._constants import APP_NAME
from osxphotos._version import __version__ from osxphotos._version import __version__
from osxphotos.utils import get_latest_version from osxphotos.utils import get_latest_version
@ -41,20 +39,14 @@ __all__ = [
"JSON_OPTION", "JSON_OPTION",
"QUERY_OPTIONS", "QUERY_OPTIONS",
"THEME_OPTION", "THEME_OPTION",
"VERBOSE_OPTION",
"TIMESTAMP_OPTION",
"get_photos_db", "get_photos_db",
"load_uuid_from_file",
"noop", "noop",
"query_options_from_kwargs",
"time_stamp", "time_stamp",
] ]
class IncompatibleQueryOptions(Exception):
"""Incompatible query options"""
pass
def noop(*args, **kwargs): def noop(*args, **kwargs):
"""no-op function""" """no-op function"""
pass pass
@ -289,7 +281,11 @@ def QUERY_OPTIONS(f):
help="Case insensitive search for title, description, place, keyword, person, or album.", help="Case insensitive search for title, description, place, keyword, person, or album.",
), ),
o("--edited", is_flag=True, help="Search for photos that have been edited."), o("--edited", is_flag=True, help="Search for photos that have been edited."),
o("--not-edited", is_flag=True, help="Search for photos that have not been edited."), o(
"--not-edited",
is_flag=True,
help="Search for photos that have not been edited.",
),
o( o(
"--external-edit", "--external-edit",
is_flag=True, is_flag=True,
@ -610,37 +606,22 @@ THEME_OPTION = click.option(
"--theme", "--theme",
metavar="THEME", metavar="THEME",
type=click.Choice(["dark", "light", "mono", "plain"], case_sensitive=False), type=click.Choice(["dark", "light", "mono", "plain"], case_sensitive=False),
help="Specify the color theme to use for --verbose output. " help="Specify the color theme to use for output. "
"Valid themes are 'dark', 'light', 'mono', and 'plain'. " "Valid themes are 'dark', 'light', 'mono', and 'plain'. "
"Defaults to 'dark' or 'light' depending on system dark mode setting.", "Defaults to 'dark' or 'light' depending on system dark mode setting.",
) )
VERBOSE_OPTION = click.option(
"--verbose",
"-V",
"verbose_flag",
count=True,
help="Print verbose output; may be specified multiple times for more verbose output.",
)
def load_uuid_from_file(filename): TIMESTAMP_OPTION = click.option(
"""Load UUIDs from file. Does not validate UUIDs. "--timestamp", is_flag=True, help="Add time stamp to verbose output"
Format is 1 UUID per line, any line beginning with # is ignored. )
Whitespace is stripped.
Arguments:
filename: file name of the file containing UUIDs
Returns:
list of UUIDs or empty list of no UUIDs in file
Raises:
FileNotFoundError if file does not exist
"""
if not pathlib.Path(filename).is_file():
raise FileNotFoundError(f"Could not find file {filename}")
uuid = []
with open(filename, "r") as uuid_file:
for line in uuid_file:
line = line.strip()
if len(line) and line[0] != "#":
uuid.append(line)
return uuid
def get_config_dir() -> pathlib.Path: def get_config_dir() -> pathlib.Path:
@ -670,103 +651,3 @@ def check_version():
"to suppress this message and prevent osxphotos from checking for latest version.", "to suppress this message and prevent osxphotos from checking for latest version.",
err=True, err=True,
) )
def query_options_from_kwargs(**kwargs) -> QueryOptions:
"""Validate query options and create a QueryOptions instance"""
# sanity check input args
nonexclusive = [
"added_after",
"added_before",
"added_in_last",
"album",
"duplicate",
"edited",
"exif",
"external_edit",
"folder",
"from_date",
"from_time",
"has_raw",
"keyword",
"label",
"max_size",
"min_size",
"name",
"person",
"query_eval",
"query_function",
"regex",
"selected",
"to_date",
"to_time",
"uti",
"uuid_from_file",
"uuid",
"year",
]
exclusive = [
("burst", "not_burst"),
("cloudasset", "not_cloudasset"),
("favorite", "not_favorite"),
("has_comment", "no_comment"),
("has_likes", "no_likes"),
("hdr", "not_hdr"),
("hidden", "not_hidden"),
("in_album", "not_in_album"),
("incloud", "not_incloud"),
("live", "not_live"),
("location", "no_location"),
("keyword", "no_keyword"),
("missing", "not_missing"),
("only_photos", "only_movies"),
("panorama", "not_panorama"),
("portrait", "not_portrait"),
("screenshot", "not_screenshot"),
("selfie", "not_selfie"),
("shared", "not_shared"),
("slow_mo", "not_slow_mo"),
("time_lapse", "not_time_lapse"),
("is_reference", "not_reference"),
]
# print help if no non-exclusive term or a double exclusive term is given
# TODO: add option to validate requiring at least one query arg
if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any(
[
all([any(kwargs["title"]), kwargs["no_title"]]),
all([any(kwargs["description"]), kwargs["no_description"]]),
all([any(kwargs["place"]), kwargs["no_place"]]),
all([any(kwargs["keyword"]), kwargs["no_keyword"]]),
]
):
raise IncompatibleQueryOptions
# can also be used with --deleted/--not-deleted which are not part of
# standard query options
try:
if kwargs["deleted"] and kwargs["not_deleted"]:
raise IncompatibleQueryOptions
except KeyError:
pass
# actually have something to query
include_photos = True
include_movies = True # default searches for everything
if kwargs["only_movies"]:
include_photos = False
if kwargs["only_photos"]:
include_movies = False
# load UUIDs if necessary and append to any uuids passed with --uuid
uuid = None
if kwargs["uuid_from_file"]:
uuid_list = list(kwargs["uuid"]) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"]))
uuid = tuple(uuid_list)
query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
query_dict = {field: kwargs.get(field) for field in query_fields}
query_dict["photos"] = include_photos
query_dict["movies"] = include_movies
query_dict["uuid"] = uuid
return QueryOptions(**query_dict)

View File

@ -9,7 +9,15 @@ from rich import print
import osxphotos import osxphotos
from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN, get_photos_db from .common import (
DB_ARGUMENT,
DB_OPTION,
JSON_OPTION,
OSXPHOTOS_HIDDEN,
TIMESTAMP_OPTION,
VERBOSE_OPTION,
get_photos_db,
)
from .list import _list_libraries from .list import _list_libraries
from .verbose import verbose_print from .verbose import verbose_print
@ -31,13 +39,14 @@ from .verbose import verbose_print
"May be repeated to include multiple UUIDs.", "May be repeated to include multiple UUIDs.",
multiple=True, multiple=True,
) )
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@TIMESTAMP_OPTION
@click.pass_obj @click.pass_obj
@click.pass_context @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_flag, timestamp):
"""Print out debug info""" """Print out debug info"""
verbose_ = verbose_print(verbose, rich=True) verbose = verbose_print(verbose_flag, timestamp)
db = get_photos_db(*photos_library, db, cli_obj.db) db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None: if db is None:
click.echo(ctx.obj.group.commands["debug-dump"].get_help(ctx), err=True) click.echo(ctx.obj.group.commands["debug-dump"].get_help(ctx), err=True)
@ -47,7 +56,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
start_t = time.perf_counter() start_t = time.perf_counter()
print(f"Opening database: {db}") print(f"Opening database: {db}")
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_) photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
stop_t = time.perf_counter() stop_t = time.perf_counter()
print(f"Done; took {(stop_t-start_t):.2f} seconds") print(f"Done; took {(stop_t-start_t):.2f} seconds")

View File

@ -17,15 +17,14 @@ from osxphotos.fileutil import FileUtil, FileUtilNoOp
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
from osxphotos.utils import pluralize from osxphotos.utils import pluralize
from .click_rich_echo import ( from .click_rich_echo import rich_click_echo, rich_echo_error
rich_click_echo, from .common import (
rich_echo_error, DB_OPTION,
set_rich_console, THEME_OPTION,
set_rich_theme, TIMESTAMP_OPTION,
set_rich_timestamp, VERBOSE_OPTION,
get_photos_db,
) )
from .color_themes import get_theme
from .common import DB_OPTION, THEME_OPTION, get_photos_db
from .export import export, render_and_validate_report from .export import export, render_and_validate_report
from .param_types import ExportDBType, TemplateString from .param_types import ExportDBType, TemplateString
from .report_writer import ReportWriterNoOp, export_report_writer_factory from .report_writer import ReportWriterNoOp, export_report_writer_factory
@ -166,8 +165,8 @@ from .verbose import get_verbose_console, verbose_print
help="If used with --report, add data to existing report file instead of overwriting it. " help="If used with --report, add data to existing report file instead of overwriting it. "
"See also --report.", "See also --report.",
) )
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output") @TIMESTAMP_OPTION
@click.option( @click.option(
"--dry-run", "--dry-run",
is_flag=True, is_flag=True,
@ -203,7 +202,7 @@ def exiftool(
save_config, save_config,
theme, theme,
timestamp, timestamp,
verbose, verbose_flag,
): ):
"""Run exiftool on previously exported files to update metadata. """Run exiftool on previously exported files to update metadata.
@ -235,6 +234,7 @@ def exiftool(
# need to ensure --exiftool is true in the config options # need to ensure --exiftool is true in the config options
locals_["exiftool"] = True locals_["exiftool"] = True
locals_["verbose"] = verbose_flag
config = ConfigOptions( config = ConfigOptions(
"export", "export",
locals_, locals_,
@ -249,14 +249,7 @@ def exiftool(
"save_config", "save_config",
], ],
) )
color_theme = get_theme(theme) verbose = verbose_print(verbose_flag, timestamp, theme=theme)
verbose_ = verbose_print(
verbose, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
# load config options from either file or export database # load config options from either file or export database
# values already set in config will take precedence over any values # values already set in config will take precedence over any values
@ -269,26 +262,16 @@ def exiftool(
f"[error]Error parsing {load_config} config file: {e.message}", err=True f"[error]Error parsing {load_config} config file: {e.message}", err=True
) )
sys.exit(1) sys.exit(1)
verbose_(f"Loaded options from file [filepath]{load_config}") verbose(f"Loaded options from file [filepath]{load_config}")
elif db_config: elif db_config:
config = export_db_get_config(exportdb, config) config = export_db_get_config(exportdb, config)
verbose_("Loaded options from export database") verbose("Loaded options from export database")
# from here on out, use config.param_name instead of using the params passed into the function # from here on out, use config.param_name instead of using the params passed into the function
# as the values may have been updated from config file or database # as the values may have been updated from config file or database
if load_config or db_config: if load_config or db_config:
# config file might have changed verbose # config file might have changed verbose
color_theme = get_theme(config.theme) verbose = verbose_print(config.verbose, config.timestamp, theme=theme)
verbose_ = verbose_print(
config.verbose,
config.timestamp,
rich=True,
theme=color_theme,
highlight=False,
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_timestamp(config.timestamp)
# validate options # validate options
if append and not report: if append and not report:
@ -298,14 +281,14 @@ def exiftool(
config.db = get_photos_db(config.db) config.db = get_photos_db(config.db)
if save_config: if save_config:
verbose_(f"Saving options to config file '[filepath]{save_config}'") verbose(f"Saving options to config file '[filepath]{save_config}'")
config.write_to_file(save_config) config.write_to_file(save_config)
process_files(exportdb, export_dir, verbose=verbose_, options=config) process_files(exportdb, export_dir, verbose=verbose, options=config)
def process_files( def process_files(
exportdb: str, export_dir: str, verbose: Callable, options: ConfigOptions exportdb: str, export_dir: str, verbose: Callable[..., None], options: ConfigOptions
): ):
"""Process files in the export database. """Process files in the export database.
@ -362,6 +345,12 @@ def process_files(
hardlink_ok = True hardlink_ok = True
verbose(f"Processing file [filepath]{file}[/] ([num]{count}/{total}[/num])") verbose(f"Processing file [filepath]{file}[/] ([num]{count}/{total}[/num])")
photo = photosdb.get_photo(uuid) photo = photosdb.get_photo(uuid)
if not photo:
verbose(
f"Could not find photo for [filepath]{file}[/] ([uuid]{uuid}[/])"
)
report_writer.write(ExportResults(missing=[file]))
continue
export_options = ExportOptions( export_options = ExportOptions(
description_template=options.description_template, description_template=options.description_template,
dry_run=options.dry_run, dry_run=options.dry_run,

View File

@ -2,6 +2,7 @@
import os import os
import pathlib import pathlib
import platform
import shlex import shlex
import subprocess import subprocess
import sys import sys
@ -54,19 +55,16 @@ from osxphotos.photokit import (
) )
from osxphotos.photosalbum import PhotosAlbum from osxphotos.photosalbum import PhotosAlbum
from osxphotos.phototemplate import PhotoTemplate, RenderOptions from osxphotos.phototemplate import PhotoTemplate, RenderOptions
from osxphotos.queryoptions import QueryOptions from osxphotos.queryoptions import QueryOptions, load_uuid_from_file
from osxphotos.uti import get_preferred_uti_extension from osxphotos.uti import get_preferred_uti_extension
from osxphotos.utils import format_sec_to_hhmmss, normalize_fs_path, pluralize from osxphotos.utils import (
get_macos_version,
from .click_rich_echo import ( format_sec_to_hhmmss,
rich_click_echo, normalize_fs_path,
rich_echo, pluralize,
rich_echo_error,
set_rich_console,
set_rich_theme,
set_rich_timestamp,
) )
from .color_themes import get_theme
from .click_rich_echo import rich_click_echo, rich_echo, rich_echo_error
from .common import ( from .common import (
CLI_COLOR_ERROR, CLI_COLOR_ERROR,
CLI_COLOR_WARNING, CLI_COLOR_WARNING,
@ -78,8 +76,9 @@ from .common import (
OSXPHOTOS_HIDDEN, OSXPHOTOS_HIDDEN,
QUERY_OPTIONS, QUERY_OPTIONS,
THEME_OPTION, THEME_OPTION,
TIMESTAMP_OPTION,
VERBOSE_OPTION,
get_photos_db, get_photos_db,
load_uuid_from_file,
noop, noop,
) )
from .help import ExportCommand, get_help_msg from .help import ExportCommand, get_help_msg
@ -87,13 +86,13 @@ from .list import _list_libraries
from .param_types import ExportDBType, FunctionCall, TemplateString from .param_types import ExportDBType, FunctionCall, TemplateString
from .report_writer import ReportWriterNoOp, export_report_writer_factory from .report_writer import ReportWriterNoOp, export_report_writer_factory
from .rich_progress import rich_progress from .rich_progress import rich_progress
from .verbose import get_verbose_console, time_stamp, verbose_print from .verbose import get_verbose_console, verbose_print
@click.command(cls=ExportCommand) @click.command(cls=ExportCommand)
@DB_OPTION @DB_OPTION
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output") @TIMESTAMP_OPTION
@click.option( @click.option(
"--no-progress", is_flag=True, help="Do not display progress bar during export." "--no-progress", is_flag=True, help="Do not display progress bar during export."
) )
@ -875,7 +874,7 @@ def export(
uti, uti,
uuid, uuid,
uuid_from_file, uuid_from_file,
verbose, verbose_flag,
xattr_template, xattr_template,
year, year,
# debug, # debug, watch, breakpoint handled in cli/__init__.py # debug, # debug, watch, breakpoint handled in cli/__init__.py
@ -908,6 +907,10 @@ def export(
locals_ = locals() locals_ = locals()
set_crash_data("locals", locals_) set_crash_data("locals", locals_)
# config expects --verbose to be named "verbose" not "verbose_flag"
locals_["verbose"] = verbose_flag
del locals_["verbose_flag"]
# NOTE: because of the way ConfigOptions works, Click options must not # 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 # 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. # do so below after load_config and save_config are handled.
@ -917,14 +920,7 @@ def export(
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"], ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
) )
color_theme = get_theme(theme) verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
verbose_ = verbose_print(
verbose, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
if load_config: if load_config:
try: try:
@ -1095,23 +1091,20 @@ def export(
uti = cfg.uti uti = cfg.uti
uuid = cfg.uuid uuid = cfg.uuid
uuid_from_file = cfg.uuid_from_file uuid_from_file = cfg.uuid_from_file
verbose = cfg.verbose # this is the one option that is named differently in the config file than the variable passed by --verbose (verbose_flag)
verbose_flag = cfg.verbose
xattr_template = cfg.xattr_template xattr_template = cfg.xattr_template
year = cfg.year year = cfg.year
# config file might have changed verbose # config file might have changed verbose
color_theme = get_theme(theme) verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
verbose_ = verbose_print( verbose(f"Loaded options from file [filepath]{load_config}")
verbose, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_timestamp(timestamp)
verbose_(f"Loaded options from file [filepath]{load_config}")
set_crash_data("cfg", cfg.asdict()) set_crash_data("cfg", cfg.asdict())
verbose_(f"osxphotos version {__version__}") verbose(f"osxphotos version: {__version__}")
verbose(f"Python version: {sys.version}")
verbose(f"Platform: {platform.platform()}, {'.'.join(get_macos_version())}")
verbose(f"Verbose level: {verbose_flag}")
# validate options # validate options
exclusive_options = [ exclusive_options = [
@ -1195,7 +1188,7 @@ def export(
sys.exit(1) sys.exit(1)
if save_config: if save_config:
verbose_(f"Saving options to config file '[filepath]{save_config}'") verbose(f"Saving options to config file '[filepath]{save_config}'")
cfg.write_to_file(save_config) cfg.write_to_file(save_config)
if config_only: if config_only:
rich_echo(f"Saved config file to '[filepath]{save_config}'") rich_echo(f"Saved config file to '[filepath]{save_config}'")
@ -1260,7 +1253,7 @@ def export(
ctx.exit(1) ctx.exit(1)
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]): if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
verbose_(f"exiftool path: [filepath]{exiftool_path}") verbose(f"exiftool path: [filepath]{exiftool_path}")
# default searches for everything # default searches for everything
photos = True photos = True
@ -1331,14 +1324,14 @@ def export(
) )
fileutil = FileUtilShUtil if alt_copy else FileUtil fileutil = FileUtilShUtil if alt_copy else FileUtil
if verbose_: if verbose:
if export_db.was_created: if export_db.was_created:
verbose_(f"Created export database [filepath]{export_db_path}") verbose(f"Created export database [filepath]{export_db_path}")
else: else:
verbose_(f"Using export database [filepath]{export_db_path}") verbose(f"Using export database [filepath]{export_db_path}")
upgraded = export_db.was_upgraded upgraded = export_db.was_upgraded
if upgraded: if upgraded:
verbose_( verbose(
f"Upgraded export database [filepath]{export_db_path}[/] from version [num]{upgraded[0]}[/] to [num]{upgraded[1]}[/]" f"Upgraded export database [filepath]{export_db_path}[/] from version [num]{upgraded[0]}[/] to [num]{upgraded[1]}[/]"
) )
@ -1346,7 +1339,7 @@ def export(
export_db.set_config(cfg.write_to_str()) export_db.set_config(cfg.write_to_str())
photosdb = osxphotos.PhotosDB( photosdb = osxphotos.PhotosDB(
dbfile=db, verbose=verbose_, exiftool=exiftool_path, rich=True dbfile=db, verbose=verbose, exiftool=exiftool_path, rich=True
) )
# enable beta features if requested # enable beta features if requested
@ -1401,7 +1394,7 @@ def export(
no_title=no_title, no_title=no_title,
not_burst=not_burst, not_burst=not_burst,
not_cloudasset=not_cloudasset, not_cloudasset=not_cloudasset,
not_edited = not_edited, not_edited=not_edited,
not_favorite=not_favorite, not_favorite=not_favorite,
not_hdr=not_hdr, not_hdr=not_hdr,
not_hidden=not_hidden, not_hidden=not_hidden,
@ -1478,17 +1471,17 @@ def export(
# set up for --add-export-to-album if needed # set up for --add-export-to-album if needed
album_export = ( album_export = (
PhotosAlbum(add_exported_to_album, verbose=verbose_) PhotosAlbum(add_exported_to_album, verbose=verbose)
if add_exported_to_album if add_exported_to_album
else None else None
) )
album_skipped = ( album_skipped = (
PhotosAlbum(add_skipped_to_album, verbose=verbose_) PhotosAlbum(add_skipped_to_album, verbose=verbose)
if add_skipped_to_album if add_skipped_to_album
else None else None
) )
album_missing = ( album_missing = (
PhotosAlbum(add_missing_to_album, verbose=verbose_) PhotosAlbum(add_missing_to_album, verbose=verbose)
if add_missing_to_album if add_missing_to_album
else None else None
) )
@ -1552,17 +1545,17 @@ def export(
update_errors=update_errors, update_errors=update_errors,
use_photokit=use_photokit, use_photokit=use_photokit,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
verbose_=verbose_, verbose=verbose,
tmpdir=tmpdir, tmpdir=tmpdir,
) )
if post_function: if post_function:
for function in post_function: for function in post_function:
# post function is tuple of (function, filename.py::function_name) # post function is tuple of (function, filename.py::function_name)
verbose_(f"Calling post-function [bold]{function[1]}") verbose(f"Calling post-function [bold]{function[1]}")
if not dry_run: if not dry_run:
try: try:
function[0](p, export_results, verbose_) function[0](p, export_results, verbose)
except Exception as e: except Exception as e:
rich_echo_error( rich_echo_error(
f"[error]Error running post-function [italic]{function[1]}[/italic]: {e}" f"[error]Error running post-function [italic]{function[1]}[/italic]: {e}"
@ -1576,7 +1569,7 @@ def export(
dry_run=dry_run, dry_run=dry_run,
exiftool_path=exiftool_path, exiftool_path=exiftool_path,
export_db=export_db, export_db=export_db,
verbose_=verbose_, verbose=verbose,
) )
if album_export and export_results.exported: if album_export and export_results.exported:
@ -1646,7 +1639,7 @@ def export(
finder_tag_template=finder_tag_template, finder_tag_template=finder_tag_template,
strip=strip, strip=strip,
export_dir=dest, export_dir=dest,
verbose_=verbose_, verbose=verbose,
) )
export_results.xattr_written.extend(tags_written) export_results.xattr_written.extend(tags_written)
export_results.xattr_skipped.extend(tags_skipped) export_results.xattr_skipped.extend(tags_skipped)
@ -1660,7 +1653,7 @@ def export(
xattr_template, xattr_template,
strip=strip, strip=strip,
export_dir=dest, export_dir=dest,
verbose_=verbose_, verbose=verbose,
) )
export_results.xattr_written.extend(xattr_written) export_results.xattr_written.extend(xattr_written)
export_results.xattr_skipped.extend(xattr_skipped) export_results.xattr_skipped.extend(xattr_skipped)
@ -1758,7 +1751,7 @@ def export(
all_files += files_to_keep all_files += files_to_keep
rich_echo(f"Cleaning up [filepath]{dest}") rich_echo(f"Cleaning up [filepath]{dest}")
cleaned_files, cleaned_dirs = cleanup_files( cleaned_files, cleaned_dirs = cleanup_files(
dest, all_files, dirs_to_keep, fileutil, verbose_=verbose_ dest, all_files, dirs_to_keep, fileutil, verbose=verbose
) )
file_str = "files" if len(cleaned_files) != 1 else "file" file_str = "files" if len(cleaned_files) != 1 else "file"
dir_str = "directories" if len(cleaned_dirs) != 1 else "directory" dir_str = "directories" if len(cleaned_dirs) != 1 else "directory"
@ -1775,12 +1768,12 @@ def export(
export_db.set_export_results(results) export_db.set_export_results(results)
if report: if report:
verbose_(f"Wrote export report to [filepath]{report}") verbose(f"Wrote export report to [filepath]{report}")
report_writer.close() report_writer.close()
# close export_db and write changes if needed # close export_db and write changes if needed
if ramdb and not dry_run: if ramdb and not dry_run:
verbose_(f"Writing export database changes back to [filepath]{export_db.path}") verbose(f"Writing export database changes back to [filepath]{export_db.path}")
export_db.write_to_disk() export_db.write_to_disk()
export_db.close() export_db.close()
@ -1788,7 +1781,7 @@ def export(
def export_photo( def export_photo(
photo=None, photo=None,
dest=None, dest=None,
verbose_=None, verbose=None,
export_by_date=None, export_by_date=None,
sidecar=None, sidecar=None,
sidecar_drop_ext=False, sidecar_drop_ext=False,
@ -1885,7 +1878,7 @@ def export_photo(
update: bool, only export updated photos update: bool, only export updated photos
update_errors: bool, attempt to re-export photos that previously produced errors even if they otherwise would not be exported update_errors: bool, attempt to re-export photos that previously produced errors even if they otherwise would not be exported
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
verbose_: callable for verbose output verbose: callable for verbose output
tmpdir: optional str; temporary directory to use for export tmpdir: optional str; temporary directory to use for export
Returns: Returns:
list of path(s) of exported photo or None if photo was missing list of path(s) of exported photo or None if photo was missing
@ -1908,7 +1901,7 @@ def export_photo(
# requested edited version but it's missing, download original # requested edited version but it's missing, download original
export_original = True export_original = True
export_edited = False export_edited = False
verbose_( verbose(
f"Edited file for [filename]{photo.original_filename}[/] is missing, exporting original" f"Edited file for [filename]{photo.original_filename}[/] is missing, exporting original"
) )
@ -2004,7 +1997,7 @@ def export_photo(
) )
original_filename = str(original_filename) original_filename = str(original_filename)
verbose_( verbose(
f"Exporting [filename]{photo.original_filename}[/] ([filename]{photo.filename}[/]) ([count]{photo_num}/{num_photos}[/])" f"Exporting [filename]{photo.original_filename}[/] ([filename]{photo.filename}[/]) ([count]{photo_num}/{num_photos}[/])"
) )
@ -2052,7 +2045,7 @@ def export_photo(
update_errors=update_errors, update_errors=update_errors,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
use_photokit=use_photokit, use_photokit=use_photokit,
verbose_=verbose_, verbose=verbose,
tmpdir=tmpdir, tmpdir=tmpdir,
) )
@ -2120,7 +2113,7 @@ def export_photo(
f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}" f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}"
) )
verbose_( verbose(
f"Exporting edited version of [filename]{photo.original_filename}[/filename] ([filename]{photo.filename}[/filename])" f"Exporting edited version of [filename]{photo.original_filename}[/filename] ([filename]{photo.filename}[/filename])"
) )
@ -2168,7 +2161,7 @@ def export_photo(
update_errors=update_errors, update_errors=update_errors,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
use_photokit=use_photokit, use_photokit=use_photokit,
verbose_=verbose_, verbose=verbose,
tmpdir=tmpdir, tmpdir=tmpdir,
) )
@ -2255,7 +2248,7 @@ def export_photo_to_directory(
update_errors, update_errors,
use_photos_export, use_photos_export,
use_photokit, use_photokit,
verbose_, verbose,
tmpdir, tmpdir,
): ):
"""Export photo to directory dest_path""" """Export photo to directory dest_path"""
@ -2267,7 +2260,7 @@ def export_photo_to_directory(
if photo.intrash and not photo_path and not preview_if_missing: if photo.intrash and not photo_path and not preview_if_missing:
# skip deleted files if they're missing # skip deleted files if they're missing
# as AppleScript/PhotoKit cannot export deleted photos # as AppleScript/PhotoKit cannot export deleted photos
verbose_( verbose(
f"Skipping missing deleted photo {photo.original_filename} ({photo.uuid})" f"Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
) )
results.missing.append(str(pathlib.Path(dest_path) / filename)) results.missing.append(str(pathlib.Path(dest_path) / filename))
@ -2276,7 +2269,7 @@ def export_photo_to_directory(
render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path) render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path)
if not export_original and not edited: if not export_original and not edited:
verbose_(f"Skipping original version of [filename]{photo.original_filename}") verbose(f"Skipping original version of [filename]{photo.original_filename}")
return results return results
tries = 0 tries = 0
@ -2322,14 +2315,14 @@ def export_photo_to_directory(
use_persons_as_keywords=person_keyword, use_persons_as_keywords=person_keyword,
use_photokit=use_photokit, use_photokit=use_photokit,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
verbose=verbose_, verbose=verbose,
) )
exporter = PhotoExporter(photo) exporter = PhotoExporter(photo)
export_results = exporter.export( export_results = exporter.export(
dest=dest_path, filename=filename, options=export_options dest=dest_path, filename=filename, options=export_options
) )
for warning_ in export_results.exiftool_warning: for warning_ in export_results.exiftool_warning:
verbose_( verbose(
f"[warning]exiftool warning for file {warning_[0]}: {warning_[1]}" f"[warning]exiftool warning for file {warning_[0]}: {warning_[1]}"
) )
for error_ in export_results.exiftool_error: for error_ in export_results.exiftool_error:
@ -2364,19 +2357,19 @@ def export_photo_to_directory(
f"Retrying export for photo ([uuid]{photo.uuid}[/uuid]: [filename]{photo.original_filename}[/filename])" f"Retrying export for photo ([uuid]{photo.uuid}[/uuid]: [filename]{photo.original_filename}[/filename])"
) )
if verbose_: if verbose:
if update or force_update: if update or force_update:
for new in results.new: for new in results.new:
verbose_(f"Exported new file [filepath]{new}") verbose(f"Exported new file [filepath]{new}")
for updated in results.updated: for updated in results.updated:
verbose_(f"Exported updated file [filepath]{updated}") verbose(f"Exported updated file [filepath]{updated}")
for skipped in results.skipped: for skipped in results.skipped:
verbose_(f"Skipped up to date file [filepath]{skipped}") verbose(f"Skipped up to date file [filepath]{skipped}")
else: else:
for exported in results.exported: for exported in results.exported:
verbose_(f"Exported [filepath]{exported}") verbose(f"Exported [filepath]{exported}")
for touched in results.touched: for touched in results.touched:
verbose_(f"Touched date on file [filepath]{touched}") verbose(f"Touched date on file [filepath]{touched}")
return results return results
@ -2572,7 +2565,7 @@ def collect_files_to_keep(
return files_to_keep, dirs_to_keep return files_to_keep, dirs_to_keep
def cleanup_files(dest_path, files_to_keep, dirs_to_keep, fileutil, verbose_): def cleanup_files(dest_path, files_to_keep, dirs_to_keep, fileutil, verbose):
"""cleanup dest_path by deleting and files and empty directories """cleanup dest_path by deleting and files and empty directories
not in files_to_keep not in files_to_keep
@ -2581,7 +2574,7 @@ def cleanup_files(dest_path, files_to_keep, dirs_to_keep, fileutil, verbose_):
files_to_keep: list of full file paths to keep (not delete) files_to_keep: list of full file paths to keep (not delete)
dirs_to_keep: list of full dir paths to keep (not delete if they are empty) dirs_to_keep: list of full dir paths to keep (not delete if they are empty)
fileutil: FileUtil object fileutil: FileUtil object
verbose_: verbose callable for printing verbose output verbose: verbose callable for printing verbose output
Returns: Returns:
tuple of (list of files deleted, list of directories deleted) tuple of (list of files deleted, list of directories deleted)
@ -2593,7 +2586,7 @@ def cleanup_files(dest_path, files_to_keep, dirs_to_keep, fileutil, verbose_):
deleted_files = [] deleted_files = []
for p in pathlib.Path(dest_path).rglob("*"): for p in pathlib.Path(dest_path).rglob("*"):
if p.is_file() and normalize_fs_path(str(p).lower()) not in keepers: if p.is_file() and normalize_fs_path(str(p).lower()) not in keepers:
verbose_(f"Deleting [filepath]{p}") verbose(f"Deleting [filepath]{p}")
fileutil.unlink(p) fileutil.unlink(p)
deleted_files.append(str(p)) deleted_files.append(str(p))
@ -2605,7 +2598,7 @@ def cleanup_files(dest_path, files_to_keep, dirs_to_keep, fileutil, verbose_):
continue continue
if not list(pathlib.Path(dirpath).glob("*")): if not list(pathlib.Path(dirpath).glob("*")):
# directory and directory is empty # directory and directory is empty
verbose_(f"Deleting empty directory {dirpath}") verbose(f"Deleting empty directory {dirpath}")
fileutil.rmdir(dirpath) fileutil.rmdir(dirpath)
deleted_dirs.append(str(dirpath)) deleted_dirs.append(str(dirpath))
@ -2623,7 +2616,7 @@ def write_finder_tags(
finder_tag_template=None, finder_tag_template=None,
strip=False, strip=False,
export_dir=None, export_dir=None,
verbose_=noop, verbose=noop,
): ):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written """Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
@ -2637,7 +2630,7 @@ def write_finder_tags(
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags finder_tag_template: list of templates to evaluate for determining Finder tags
export_dir: value to use for {export_dir} template export_dir: value to use for {export_dir} template
verbose_: function to call to print verbose messages verbose: function to call to print verbose messages
Returns: Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating) (list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
@ -2697,11 +2690,11 @@ def write_finder_tags(
for f in files: for f in files:
md = OSXMetaData(f) md = OSXMetaData(f)
if sorted(md.tags) != sorted(tags): if sorted(md.tags) != sorted(tags):
verbose_(f"Writing Finder tags to {f}") verbose(f"Writing Finder tags to {f}")
md.tags = tags md.tags = tags
written.append(f) written.append(f)
else: else:
verbose_(f"Skipping Finder tags for {f}: nothing to do") verbose(f"Skipping Finder tags for {f}: nothing to do")
skipped.append(f) skipped.append(f)
return (written, skipped) return (written, skipped)
@ -2713,14 +2706,17 @@ def write_extended_attributes(
xattr_template, xattr_template,
strip=False, strip=False,
export_dir=None, export_dir=None,
verbose_=noop, verbose=noop,
): ):
"""Writes extended attributes to exported files """Writes extended attributes to exported files
Args: Args:
photo: a PhotoInfo object photo: a PhotoInfo object
files: list of file paths to write extended attributes to
xattr_template: list of tuples: (attribute name, attribute template)
strip: xattr_template: list of tuples: (attribute name, attribute template) strip: xattr_template: list of tuples: (attribute name, attribute template)
export_dir: value to use for {export_dir} template export_dir: value to use for {export_dir} template
verbose: function to call to print verbose messages
Returns: Returns:
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating) tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
@ -2770,10 +2766,10 @@ def write_extended_attributes(
if (not file_value and not value) or file_value == value: if (not file_value and not value) or file_value == value:
# if both not set or both equal, nothing to do # if both not set or both equal, nothing to do
# get returns None if not set and value will be [] if not set so can't directly compare # get returns None if not set and value will be [] if not set so can't directly compare
verbose_(f"Skipping extended attribute {attr} for {f}: nothing to do") verbose(f"Skipping extended attribute {attr} for {f}: nothing to do")
skipped.add(f) skipped.add(f)
else: else:
verbose_(f"Writing extended attribute {attr} to {f}") verbose(f"Writing extended attribute {attr} to {f}")
md.set(attr, value) md.set(attr, value)
written.add(f) written.add(f)
@ -2788,7 +2784,7 @@ def run_post_command(
dry_run, dry_run,
exiftool_path, exiftool_path,
export_db, export_db,
verbose_, verbose,
): ):
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?) # todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
# todo: need a shell_quote template type: # todo: need a shell_quote template type:
@ -2805,7 +2801,7 @@ def run_post_command(
command, _ = template.render(command_template, options=render_options) command, _ = template.render(command_template, options=render_options)
command = command[0] if command else None command = command[0] if command else None
if command: if command:
verbose_(f'Running command: "{command}"') verbose(f'Running command: "{command}"')
if not dry_run: if not dry_run:
args = shlex.split(command) args = shlex.split(command)
cwd = pathlib.Path(f).parent cwd = pathlib.Path(f).parent

View File

@ -34,6 +34,7 @@ from .click_rich_echo import (
set_rich_theme, set_rich_theme,
) )
from .color_themes import get_theme from .color_themes import get_theme
from .common import TIMESTAMP_OPTION, VERBOSE_OPTION
from .export import render_and_validate_report from .export import render_and_validate_report
from .param_types import TemplateString from .param_types import TemplateString
from .report_writer import export_report_writer_factory from .report_writer import export_report_writer_factory
@ -150,7 +151,8 @@ from .verbose import get_verbose_console, verbose_print
help="If used with --report, add data to existing report file instead of overwriting it. " help="If used with --report, add data to existing report file instead of overwriting it. "
"See also --report.", "See also --report.",
) )
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@TIMESTAMP_OPTION
@click.option( @click.option(
"--dry-run", "--dry-run",
is_flag=True, is_flag=True,
@ -171,6 +173,7 @@ def exportdb(
report, report,
save_config, save_config,
sql, sql,
timestamp,
touch_file, touch_file,
update_signatures, update_signatures,
uuid_files, uuid_files,
@ -178,17 +181,11 @@ def exportdb(
delete_uuid, delete_uuid,
delete_file, delete_file,
vacuum, vacuum,
verbose, verbose_flag,
version, version,
): ):
"""Utilities for working with the osxphotos export database""" """Utilities for working with the osxphotos export database"""
color_theme = get_theme() verbose = verbose_print(verbose_flag, timestamp=timestamp)
verbose_ = verbose_print(
verbose, timestamp=False, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console(theme=color_theme))
set_rich_theme(color_theme)
# validate options and args # validate options and args
if append and not report: if append and not report:
@ -264,7 +261,7 @@ def exportdb(
if update_signatures: if update_signatures:
try: try:
updated, skipped = export_db_update_signatures( updated, skipped = export_db_update_signatures(
export_db, export_dir, verbose_, dry_run export_db, export_dir, verbose, dry_run
) )
except Exception as e: except Exception as e:
rich_echo(f"[error]Error: {e}[/error]") rich_echo(f"[error]Error: {e}[/error]")
@ -299,7 +296,7 @@ def exportdb(
if check_signatures: if check_signatures:
try: try:
matched, notmatched, skipped = export_db_check_signatures( matched, notmatched, skipped = export_db_check_signatures(
export_db, export_dir, verbose_=verbose_ export_db, export_dir, verbose_=verbose
) )
except Exception as e: except Exception as e:
rich_echo(f"[error]Error: {e}[/error]") rich_echo(f"[error]Error: {e}[/error]")
@ -314,7 +311,7 @@ def exportdb(
if touch_file: if touch_file:
try: try:
touched, not_touched, skipped = export_db_touch_files( touched, not_touched, skipped = export_db_touch_files(
export_db, export_dir, verbose_=verbose_, dry_run=dry_run export_db, export_dir, verbose_=verbose, dry_run=dry_run
) )
except Exception as e: except Exception as e:
rich_echo(f"[error]Error: {e}[/error]") rich_echo(f"[error]Error: {e}[/error]")

View File

@ -27,7 +27,7 @@ from strpdatetime import strpdatetime
from osxphotos._constants import _OSXPHOTOS_NONE_SENTINEL from osxphotos._constants import _OSXPHOTOS_NONE_SENTINEL
from osxphotos._version import __version__ from osxphotos._version import __version__
from osxphotos.cli.common import get_data_dir from osxphotos.cli.common import TIMESTAMP_OPTION, VERBOSE_OPTION, get_data_dir
from osxphotos.cli.help import HELP_WIDTH from osxphotos.cli.help import HELP_WIDTH
from osxphotos.cli.param_types import FunctionCall, StrpDateTimePattern, TemplateString from osxphotos.cli.param_types import FunctionCall, StrpDateTimePattern, TemplateString
from osxphotos.datetime_utils import ( from osxphotos.datetime_utils import (
@ -44,14 +44,7 @@ from osxphotos.phototemplate import PhotoTemplate, RenderOptions
from osxphotos.sqlitekvstore import SQLiteKVStore from osxphotos.sqlitekvstore import SQLiteKVStore
from osxphotos.utils import pluralize from osxphotos.utils import pluralize
from .click_rich_echo import ( from .click_rich_echo import rich_click_echo, rich_echo_error
rich_click_echo,
rich_echo_error,
set_rich_console,
set_rich_theme,
set_rich_timestamp,
)
from .color_themes import get_theme
from .common import THEME_OPTION from .common import THEME_OPTION
from .rich_progress import rich_progress from .rich_progress import rich_progress
from .verbose import get_verbose_console, verbose_print from .verbose import get_verbose_console, verbose_print
@ -1362,10 +1355,8 @@ class ImportCommand(click.Command):
help="If used with --report, add data to existing report file instead of overwriting it. " help="If used with --report, add data to existing report file instead of overwriting it. "
"See also --report.", "See also --report.",
) )
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@click.option( @TIMESTAMP_OPTION
"--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output"
)
@click.option( @click.option(
"--no-progress", is_flag=True, help="Do not display progress bar during import." "--no-progress", is_flag=True, help="Do not display progress bar during import."
) )
@ -1419,19 +1410,12 @@ def import_cli(
theme, theme,
timestamp, timestamp,
title, title,
verbose_, verbose_flag,
walk, walk,
): ):
"""Import photos and videos into Photos.""" """Import photos and videos into Photos."""
color_theme = get_theme(theme) verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
verbose = verbose_print(
verbose_, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
if not files: if not files:
echo("Nothing to import", err=True) echo("Nothing to import", err=True)

View File

@ -19,12 +19,16 @@ from osxphotos.fileutil import FileUtil
from osxphotos.utils import increment_filename, pluralize from osxphotos.utils import increment_filename, pluralize
from .click_rich_echo import rich_click_echo as echo from .click_rich_echo import rich_click_echo as echo
from .click_rich_echo import set_rich_console, set_rich_theme, set_rich_timestamp from .common import (
from .color_themes import get_theme DB_OPTION,
from .common import DB_OPTION, THEME_OPTION, get_photos_db THEME_OPTION,
TIMESTAMP_OPTION,
VERBOSE_OPTION,
get_photos_db,
)
from .help import get_help_msg from .help import get_help_msg
from .list import _list_libraries from .list import _list_libraries
from .verbose import get_verbose_console, verbose_print from .verbose import verbose_print
@click.command(name="orphans") @click.command(name="orphans")
@ -36,22 +40,15 @@ from .verbose import get_verbose_console, verbose_print
help="Export orphans to directory EXPORT_PATH. If --export not specified, orphans are listed but not exported.", help="Export orphans to directory EXPORT_PATH. If --export not specified, orphans are listed but not exported.",
) )
@DB_OPTION @DB_OPTION
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output") @TIMESTAMP_OPTION
@THEME_OPTION @THEME_OPTION
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def orphans(ctx, cli_obj, export, db, verbose, timestamp, theme): def orphans(ctx, cli_obj, export, db, verbose_flag, timestamp, theme):
"""Find orphaned photos in a Photos library""" """Find orphaned photos in a Photos library"""
color_theme = get_theme(theme) verbose_ = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
verbose_ = verbose_print(
verbose, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None cli_db = cli_obj.db if cli_obj is not None else None

View File

@ -11,7 +11,7 @@ from osxphotos.cli.click_rich_echo import (
from osxphotos.debug import set_debug from osxphotos.debug import set_debug
from osxphotos.photosalbum import PhotosAlbum from osxphotos.photosalbum import PhotosAlbum
from osxphotos.phototemplate import RenderOptions from osxphotos.phototemplate import RenderOptions
from osxphotos.queryoptions import QueryOptions from osxphotos.queryoptions import QueryOptions, load_uuid_from_file
from .color_themes import get_default_theme from .color_themes import get_default_theme
from .common import ( from .common import (
@ -25,7 +25,6 @@ from .common import (
OSXPHOTOS_HIDDEN, OSXPHOTOS_HIDDEN,
QUERY_OPTIONS, QUERY_OPTIONS,
get_photos_db, get_photos_db,
load_uuid_from_file,
) )
from .list import _list_libraries from .list import _list_libraries
from .print_photo_info import print_photo_fields, print_photo_info from .print_photo_info import print_photo_fields, print_photo_info

View File

@ -13,19 +13,22 @@ from rich import pretty, print
import osxphotos import osxphotos
from osxphotos._constants import _PHOTOS_4_VERSION from osxphotos._constants import _PHOTOS_4_VERSION
from osxphotos.cli.click_rich_echo import rich_echo_error as echo_error
from osxphotos.photoinfo import PhotoInfo from osxphotos.photoinfo import PhotoInfo
from osxphotos.photosdb import PhotosDB from osxphotos.photosdb import PhotosDB
from osxphotos.pyrepl import embed_repl from osxphotos.pyrepl import embed_repl
from osxphotos.queryoptions import QueryOptions from osxphotos.queryoptions import (
IncompatibleQueryOptions,
QueryOptions,
query_options_from_kwargs,
)
from .common import ( from .common import (
DB_ARGUMENT, DB_ARGUMENT,
DB_OPTION, DB_OPTION,
DELETED_OPTIONS, DELETED_OPTIONS,
IncompatibleQueryOptions,
QUERY_OPTIONS, QUERY_OPTIONS,
get_photos_db, get_photos_db,
query_options_from_kwargs,
) )
@ -70,6 +73,11 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
logger.disabled = True logger.disabled = True
pretty.install() pretty.install()
try:
query_options = query_options_from_kwargs(**kwargs)
except IncompatibleQueryOptions as e:
echo_error(f"Incompatible query options: {e}")
ctx.exit(1)
print(f"python version: {sys.version}") print(f"python version: {sys.version}")
print(f"osxphotos version: {osxphotos._version.__version__}") print(f"osxphotos version: {osxphotos._version.__version__}")
db = db or get_photos_db() db = db or get_photos_db()
@ -80,12 +88,6 @@ def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
print("Beta mode enabled") print("Beta mode enabled")
print("Getting photos") print("Getting photos")
tic = time.perf_counter() tic = time.perf_counter()
try:
query_options = query_options_from_kwargs(**kwargs)
except IncompatibleQueryOptions:
click.echo("Incompatible query options", err=True)
click.echo(ctx.obj.group.commands["repl"].get_help(ctx), err=True)
sys.exit(1)
photos = _query_photos(photosdb, query_options) photos = _query_photos(photosdb, query_options)
all_photos = _get_all_photos(photosdb) all_photos = _get_all_photos(photosdb)
toc = time.perf_counter() toc = time.perf_counter()

View File

@ -12,7 +12,13 @@ from rich.syntax import Syntax
import osxphotos import osxphotos
from .common import DB_OPTION, OSXPHOTOS_SNAPSHOT_DIR, get_photos_db from .common import (
DB_OPTION,
OSXPHOTOS_SNAPSHOT_DIR,
TIMESTAMP_OPTION,
VERBOSE_OPTION,
get_photos_db,
)
from .verbose import verbose_print from .verbose import verbose_print
@ -80,8 +86,9 @@ def snap(ctx, cli_obj, db):
"Default is 'monokai'.", "Default is 'monokai'.",
) )
@click.argument("db2", nargs=-1, type=click.Path(exists=True)) @click.argument("db2", nargs=-1, type=click.Path(exists=True))
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
def diff(ctx, cli_obj, db, raw_output, style, db2, verbose): @TIMESTAMP_OPTION
def diff(ctx, cli_obj, db, raw_output, style, db2, verbose_flag, timestamp):
"""Compare two Photos databases and print out differences """Compare two Photos databases and print out differences
To use the diff command, you'll need to install sqldiff via homebrew: To use the diff command, you'll need to install sqldiff via homebrew:
@ -110,7 +117,7 @@ def diff(ctx, cli_obj, db, raw_output, style, db2, verbose):
Works only on Photos library versions since Catalina (10.15) or newer. Works only on Photos library versions since Catalina (10.15) or newer.
""" """
verbose_ = verbose_print(verbose, rich=True) verbose = verbose_print(verbose_flag, timestamp=timestamp)
sqldiff = shutil.which("sqldiff") sqldiff = shutil.which("sqldiff")
if not sqldiff: if not sqldiff:
@ -118,7 +125,7 @@ def diff(ctx, cli_obj, db, raw_output, style, db2, verbose):
"sqldiff not found; install via homebrew (https://brew.sh/): `brew install sqldiff`" "sqldiff not found; install via homebrew (https://brew.sh/): `brew install sqldiff`"
) )
ctx.exit(2) ctx.exit(2)
verbose_(f"sqldiff found at '{sqldiff}'") verbose(f"sqldiff found at '{sqldiff}'")
db = get_photos_db(db, cli_obj.db) db = get_photos_db(db, cli_obj.db)
db_path = pathlib.Path(db) db_path = pathlib.Path(db)
@ -133,7 +140,7 @@ def diff(ctx, cli_obj, db, raw_output, style, db2, verbose):
else: else:
# get most recent snapshot # get most recent snapshot
db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR) db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR)
verbose_(f"Using snapshot folder: '{db_folder}'") verbose(f"Using snapshot folder: '{db_folder}'")
folders = sorted([f for f in pathlib.Path(db_folder).glob("*") if f.is_dir()]) folders = sorted([f for f in pathlib.Path(db_folder).glob("*") if f.is_dir()])
folder_2 = folders[-1] folder_2 = folders[-1]
db_2 = folder_2 / "Photos.sqlite" db_2 = folder_2 / "Photos.sqlite"
@ -143,7 +150,7 @@ def diff(ctx, cli_obj, db, raw_output, style, db2, verbose):
if not db_2.exists(): if not db_2.exists():
print(f"database file {db_2} missing") print(f"database file {db_2} missing")
verbose_(f"Comparing databases {db_1} and {db_2}") verbose(f"Comparing databases {db_1} and {db_2}")
diff_proc = subprocess.Popen([sqldiff, db_2, db_1], stdout=subprocess.PIPE) diff_proc = subprocess.Popen([sqldiff, db_2, db_1], stdout=subprocess.PIPE)
console = Console() console = Console()

View File

@ -16,19 +16,23 @@ from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.photosalbum import PhotosAlbum from osxphotos.photosalbum import PhotosAlbum
from osxphotos.photosdb.photosdb_utils import get_db_version from osxphotos.photosdb.photosdb_utils import get_db_version
from osxphotos.phototemplate import PhotoTemplate, RenderOptions from osxphotos.phototemplate import PhotoTemplate, RenderOptions
from osxphotos.queryoptions import QueryOptions from osxphotos.queryoptions import (
IncompatibleQueryOptions,
QueryOptions,
query_options_from_kwargs,
)
from osxphotos.sqlitekvstore import SQLiteKVStore from osxphotos.sqlitekvstore import SQLiteKVStore
from osxphotos.utils import pluralize from osxphotos.utils import pluralize
from .click_rich_echo import ( from .click_rich_echo import rich_click_echo as echo
rich_click_echo, from .click_rich_echo import rich_echo_error as echo_error
rich_echo_error, from .common import (
set_rich_console, DB_OPTION,
set_rich_theme, QUERY_OPTIONS,
set_rich_timestamp, THEME_OPTION,
TIMESTAMP_OPTION,
VERBOSE_OPTION,
) )
from .color_themes import get_theme
from .common import DB_OPTION, QUERY_OPTIONS, THEME_OPTION, query_options_from_kwargs
from .param_types import TemplateString from .param_types import TemplateString
from .report_writer import sync_report_writer_factory from .report_writer import sync_report_writer_factory
from .rich_progress import rich_progress from .rich_progress import rich_progress
@ -107,7 +111,7 @@ def render_and_validate_report(report: str) -> str:
report = report_file[0] report = report_file[0]
if os.path.isdir(report): if os.path.isdir(report):
rich_click_echo( echo(
f"[error]Report '{report}' is a directory, must be file name", f"[error]Report '{report}' is a directory, must be file name",
err=True, err=True,
) )
@ -183,7 +187,7 @@ def export_metadata(
verbose(f"Analyzing [num]{num_photos}[/] {photo_word} to export") verbose(f"Analyzing [num]{num_photos}[/] {photo_word} to export")
verbose(f"Exporting [num]{len(photos)}[/] {photo_word} to {output_path}") verbose(f"Exporting [num]{len(photos)}[/] {photo_word} to {output_path}")
export_metadata_to_db(photos, metadata_db, progress=True) export_metadata_to_db(photos, metadata_db, progress=True)
rich_click_echo( echo(
f"Done: exported metadata for [num]{len(photos)}[/] {photo_word} to [filepath]{output_path}[/]" f"Done: exported metadata for [num]{len(photos)}[/] {photo_word} to [filepath]{output_path}[/]"
) )
metadata_db.close() metadata_db.close()
@ -289,7 +293,7 @@ def import_metadata(
elif import_type == "export": elif import_type == "export":
import_db = open_metadata_db(import_path) import_db = open_metadata_db(import_path)
else: else:
rich_echo_error( echo_error(
f"Unable to determine type of import file: [filepath]{import_path}[/]" f"Unable to determine type of import file: [filepath]{import_path}[/]"
) )
raise click.Abort() raise click.Abort()
@ -309,7 +313,7 @@ def import_metadata(
elif unmatched: elif unmatched:
# unable to find metadata for photo in import_db # unable to find metadata for photo in import_db
for photo in key_photos: for photo in key_photos:
rich_click_echo( echo(
f"Unable to find metadata for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/]) in [filepath]{import_path}[/]" f"Unable to find metadata for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/]) in [filepath]{import_path}[/]"
) )
@ -317,7 +321,7 @@ def import_metadata(
# find any keys in import_db that don't match keys in photos # find any keys in import_db that don't match keys in photos
for key in import_db.keys(): for key in import_db.keys():
if key not in key_to_photo: if key not in key_to_photo:
rich_click_echo(f"Unable to find [uuid]{key}[/] in selected photos.") echo(f"Unable to find [uuid]{key}[/] in selected photos.")
return results return results
@ -368,7 +372,7 @@ def _update_albums_for_photo(
before = sorted(photo.albums) before = sorted(photo.albums)
albums_to_add = set(value) - set(before) albums_to_add = set(value) - set(before)
if not albums_to_add: if not albums_to_add:
verbose(f"\tNothing to do for albums") verbose(f"\tNothing to do for albums", level=2)
results.add_result( results.add_result(
photo.uuid, photo.uuid,
photo.original_filename, photo.original_filename,
@ -426,7 +430,7 @@ def _set_metadata_for_photo(
if not dry_run: if not dry_run:
set_photo_property(photo_, field, value) set_photo_property(photo_, field, value)
else: else:
verbose(f"\tNothing to do for {field}") verbose(f"\tNothing to do for {field}", level=2)
results.add_result( results.add_result(
photo.uuid, photo.uuid,
@ -464,7 +468,7 @@ def _merge_metadata_for_photo(
before = sorted(before) before = sorted(before)
if value == before: if value == before:
verbose(f"\tNothing to do for {field}") verbose(f"\tNothing to do for {field}", level=2)
results.add_result( results.add_result(
photo.uuid, photo.uuid,
photo.original_filename, photo.original_filename,
@ -486,7 +490,7 @@ def _merge_metadata_for_photo(
elif before is None: elif before is None:
new_value = value new_value = value
else: else:
rich_echo_error( echo_error(
f"Unable to merge {field} for [filename]{photo.original_filename}[filename]" f"Unable to merge {field} for [filename]{photo.original_filename}[filename]"
) )
raise click.Abort() raise click.Abort()
@ -498,7 +502,7 @@ def _merge_metadata_for_photo(
else: else:
# Merge'd value might still be the same as original value # Merge'd value might still be the same as original value
# (e.g. if value is str and has previously been merged) # (e.g. if value is str and has previously been merged)
verbose(f"\tNothing to do for {field}") verbose(f"\tNothing to do for {field}", level=2)
results.add_result( results.add_result(
photo.uuid, photo.uuid,
@ -534,7 +538,7 @@ def print_import_summary(results: SyncResults):
f"updated {property}: [num]{summary.get(property,0)}[/]" f"updated {property}: [num]{summary.get(property,0)}[/]"
for property in SYNC_PROPERTIES for property in SYNC_PROPERTIES
) )
rich_click_echo( echo(
f"Processed [num]{summary['total']}[/] photos, updated: [num]{summary['updated']}[/], {property_summary}" f"Processed [num]{summary['total']}[/] photos, updated: [num]{summary['updated']}[/], {property_summary}"
) )
@ -634,10 +638,8 @@ def print_import_summary(results: SyncResults):
is_flag=True, is_flag=True,
help="Dry run; " "when used with --import, don't actually update metadata.", help="Dry run; " "when used with --import, don't actually update metadata.",
) )
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @VERBOSE_OPTION
@click.option( @TIMESTAMP_OPTION
"--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output."
)
@QUERY_OPTIONS @QUERY_OPTIONS
@DB_OPTION @DB_OPTION
@THEME_OPTION @THEME_OPTION
@ -657,7 +659,7 @@ def sync(
theme, theme,
timestamp, timestamp,
unmatched, unmatched,
verbose_, verbose_flag,
**kwargs, # query options **kwargs, # query options
): ):
"""Sync metadata and albums between Photos libraries. """Sync metadata and albums between Photos libraries.
@ -711,23 +713,17 @@ def sync(
osxphotos sync --export /path/to/export/folder/computer2.db --merge all --import /path/to/export/folder/computer1.db osxphotos sync --export /path/to/export/folder/computer2.db --merge all --import /path/to/export/folder/computer1.db
""" """
color_theme = get_theme(theme)
verbose = verbose_print( verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
verbose_, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
if (set_ or merge) and not import_path: if (set_ or merge) and not import_path:
rich_echo_error("--set and --merge can only be used with --import") echo_error("--set and --merge can only be used with --import")
ctx.exit(1) ctx.exit(1)
# filter out photos in shared albums as these cannot be updated # filter out photos in shared albums as these cannot be updated
# Not elegant but works for now without completely refactoring QUERY_OPTIONS # Not elegant but works for now without completely refactoring QUERY_OPTIONS
if kwargs.get("shared"): if kwargs.get("shared"):
rich_echo_error( echo_error(
"[warning]--shared cannot be used with --import/--export " "[warning]--shared cannot be used with --import/--export "
"as photos in shared iCloud albums cannot be updated; " "as photos in shared iCloud albums cannot be updated; "
"--shared will be ignored[/]" "--shared will be ignored[/]"
@ -748,14 +744,19 @@ def sync(
set_ = set(set_) set_ = set(set_)
merge = set(merge) merge = set(merge)
if set_ & merge: if set_ & merge:
rich_echo_error( echo_error(
"--set and --merge cannot be used with the same fields: " "--set and --merge cannot be used with the same fields: "
f"set: {set_}, merge: {merge}" f"set: {set_}, merge: {merge}"
) )
ctx.exit(1) ctx.exit(1)
if import_path: if import_path:
try:
query_options = query_options_from_kwargs(**kwargs) query_options = query_options_from_kwargs(**kwargs)
except IncompatibleQueryOptions:
echo_error("Incompatible query options")
echo_error(ctx.obj.group.commands["repl"].get_help(ctx))
ctx.exit(1)
photosdb = PhotosDB(dbfile=db, verbose=verbose) photosdb = PhotosDB(dbfile=db, verbose=verbose)
photos = photosdb.query(query_options) photos = photosdb.query(query_options)
results = import_metadata( results = import_metadata(

View File

@ -25,16 +25,10 @@ from osxphotos.photosalbum import PhotosAlbumPhotoScript
from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater
from osxphotos.utils import noop, pluralize from osxphotos.utils import noop, pluralize
from .click_rich_echo import ( from .click_rich_echo import rich_click_echo as echo
rich_click_echo, from .click_rich_echo import rich_echo_error as echo_error
rich_echo,
rich_echo_error,
set_rich_console,
set_rich_theme,
set_rich_timestamp,
)
from .color_themes import get_theme from .color_themes import get_theme
from .common import THEME_OPTION from .common import THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION
from .darkmode import is_dark_mode from .darkmode import is_dark_mode
from .help import HELP_WIDTH, rich_text from .help import HELP_WIDTH, rich_text
from .param_types import ( from .param_types import (
@ -310,7 +304,8 @@ command which can be used to change the time zone of photos after import.
help="When used with --compare-exif, adds any photos with date/time/timezone differences " help="When used with --compare-exif, adds any photos with date/time/timezone differences "
"between Photos/EXIF to album ALBUM. If ALBUM does not exist, it will be created.", "between Photos/EXIF to album ALBUM. If ALBUM does not exist, it will be created.",
) )
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Show verbose output.") @VERBOSE_OPTION
@TIMESTAMP_OPTION
@click.option( @click.option(
"--library", "--library",
"-L", "-L",
@ -326,19 +321,6 @@ command which can be used to change the time zone of photos after import.
type=click.Path(exists=True), type=click.Path(exists=True),
help="Optional path to exiftool executable (will look in $PATH if not specified) for those options which require exiftool.", help="Optional path to exiftool executable (will look in $PATH if not specified) for those options which require exiftool.",
) )
@click.option(
"--output-file",
"-o",
type=click.File(mode="w", lazy=False),
help="Output file. If not specified, output is written to stdout.",
)
@click.option(
"--terminal-width",
"-w",
type=int,
help="Terminal width in characters.",
hidden=True,
)
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output") @click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output")
@THEME_OPTION @THEME_OPTION
@click.option( @click.option(
@ -366,13 +348,11 @@ def timewarp(
use_file_time, use_file_time,
add_to_album, add_to_album,
exiftool_path, exiftool_path,
verbose_, verbose_flag,
library, library,
theme, theme,
parse_date, parse_date,
plain, plain,
output_file,
terminal_width,
timestamp, timestamp,
force, force,
): ):
@ -419,33 +399,7 @@ def timewarp(
if add_to_album and not compare_exif: if add_to_album and not compare_exif:
raise click.UsageError("--add-to-album must be used with --compare-exif.") raise click.UsageError("--add-to-album must be used with --compare-exif.")
# configure colored rich output verbose = verbose_print(verbose=verbose_flag, timestamp=timestamp, theme=theme)
# TODO: this is all a little hacky, find a better way to do this
color_theme = get_theme(theme)
verbose = verbose_print(
verbose_,
timestamp,
rich=True,
theme=color_theme,
highlight=False,
file=output_file,
)
# set console for rich_echo to be same as for verbose_
terminal_width = terminal_width or (1000 if output_file else None)
if output_file:
set_rich_console(Console(file=output_file, width=terminal_width))
elif terminal_width:
set_rich_console(
Console(
file=sys.stdout,
theme=color_theme,
force_terminal=True,
width=terminal_width,
)
)
else:
set_rich_console(get_verbose_console(theme=color_theme))
set_rich_theme(color_theme)
if any([compare_exif, push_exif, pull_exif]): if any([compare_exif, push_exif, pull_exif]):
exiftool_path = exiftool_path or get_exiftool_path() exiftool_path = exiftool_path or get_exiftool_path()
@ -454,19 +408,19 @@ def timewarp(
try: try:
photos = PhotosLibrary().selection photos = PhotosLibrary().selection
if not photos: if not photos:
rich_echo_error("[warning]No photos selected[/]") echo_error("[warning]No photos selected[/]")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
# AppleScript error -1728 occurs if user attempts to get selected photos in a Smart Album # AppleScript error -1728 occurs if user attempts to get selected photos in a Smart Album
if "(-1728)" in str(e): if "(-1728)" in str(e):
rich_echo_error( echo_error(
"[error]Could not get selected photos. Ensure photos is open and photos are selected. " "[error]Could not get selected photos. Ensure photos is open and photos are selected. "
"If you have selected photos and you see this message, it may be because the selected photos are in a Photos Smart Album. " "If you have selected photos and you see this message, it may be because the selected photos are in a Photos Smart Album. "
f"{APP_NAME} cannot access photos in a Smart Album. Select the photos in a regular album or in 'All Photos' view. " f"{APP_NAME} cannot access photos in a Smart Album. Select the photos in a regular album or in 'All Photos' view. "
"Another option is to create a new album using 'File | New Album With Selection' then select the photos in the new album.[/]", "Another option is to create a new album using 'File | New Album With Selection' then select the photos in the new album.[/]",
) )
else: else:
rich_echo_error( echo_error(
f"[error]Could not get selected photos. Ensure Photos is open and photos to process are selected. {e}[/]", f"[error]Could not get selected photos. Ensure Photos is open and photos to process are selected. {e}[/]",
) )
sys.exit(1) sys.exit(1)
@ -535,7 +489,7 @@ def timewarp(
if inspect: if inspect:
tzinfo = PhotoTimeZone(library_path=library) tzinfo = PhotoTimeZone(library_path=library)
if photos: if photos:
rich_echo( echo(
"[filename]filename[/filename], [uuid]uuid[/uuid], " "[filename]filename[/filename], [uuid]uuid[/uuid], "
"[time]photo time (local)[/time], " "[time]photo time (local)[/time], "
"[time]photo time[/time], " "[time]photo time[/time], "
@ -545,7 +499,7 @@ def timewarp(
tz_seconds, tz_str, tz_name = tzinfo.get_timezone(photo) tz_seconds, tz_str, tz_name = tzinfo.get_timezone(photo)
photo_date_local = datetime_naive_to_local(photo.date) photo_date_local = datetime_naive_to_local(photo.date)
photo_date_tz = datetime_to_new_tz(photo_date_local, tz_seconds) photo_date_tz = datetime_to_new_tz(photo_date_local, tz_seconds)
rich_echo( echo(
f"[filename]{photo.filename}[/filename], [uuid]{photo.uuid}[/uuid], " f"[filename]{photo.filename}[/filename], [uuid]{photo.uuid}[/uuid], "
f"[time]{photo_date_local.strftime(DATETIME_FORMAT)}[/time], " f"[time]{photo_date_local.strftime(DATETIME_FORMAT)}[/time], "
f"[time]{photo_date_tz.strftime(DATETIME_FORMAT)}[/time], " f"[time]{photo_date_tz.strftime(DATETIME_FORMAT)}[/time], "
@ -563,7 +517,7 @@ def timewarp(
exiftool_path=exiftool_path, exiftool_path=exiftool_path,
) )
if not album: if not album:
rich_echo( echo(
"filename, uuid, photo time (Photos), photo time (EXIF), timezone offset (Photos), timezone offset (EXIF)" "filename, uuid, photo time (Photos), photo time (EXIF), timezone offset (Photos), timezone offset (EXIF)"
) )
for photo in photos: for photo in photos:
@ -592,13 +546,13 @@ def timewarp(
else: else:
verbose(f"Photo {filename} ({uuid}) has same date/time/timezone") verbose(f"Photo {filename} ({uuid}) has same date/time/timezone")
else: else:
rich_echo( echo(
f"{filename}, {uuid}, " f"{filename}, {uuid}, "
f"{diff_results.photos_date} {diff_results.photos_time}, {diff_results.exif_date} {diff_results.exif_time}, " f"{diff_results.photos_date} {diff_results.photos_time}, {diff_results.exif_date} {diff_results.exif_time}, "
f"{diff_results.photos_tz}, {diff_results.exif_tz}" f"{diff_results.photos_tz}, {diff_results.exif_tz}"
) )
if album: if album:
rich_echo( echo(
f"Compared {len(photos)} photos, found {different_photos} " f"Compared {len(photos)} photos, found {different_photos} "
f"that {pluralize(different_photos, 'is', 'are')} different and " f"that {pluralize(different_photos, 'is', 'are')} different and "
f"added {pluralize(different_photos, 'it', 'them')} to album '{album.name}'." f"added {pluralize(different_photos, 'it', 'them')} to album '{album.name}'."
@ -649,12 +603,10 @@ def timewarp(
# before exiftool is run # before exiftool is run
exif_warn, exif_error = exif_updater.update_exif_from_photos(p) exif_warn, exif_error = exif_updater.update_exif_from_photos(p)
if exif_warn: if exif_warn:
rich_echo_error( echo_error(f"[warning]Warning running exiftool: {exif_warn}[/]")
f"[warning]Warning running exiftool: {exif_warn}[/]"
)
if exif_error: if exif_error:
rich_echo_error(f"[error]Error running exiftool: {exif_error}[/]") echo_error(f"[error]Error running exiftool: {exif_error}[/]")
progress.advance(task) progress.advance(task)
rich_echo("Done.") echo("Done.")

View File

@ -1,14 +1,22 @@
"""helper functions for printing verbose output""" """helper functions for printing verbose output"""
from __future__ import annotations
import os import os
import typing as t
from datetime import datetime from datetime import datetime
from typing import IO, Any, Callable, Optional
import click import click
from rich.console import Console from rich.console import Console
from rich.theme import Theme from rich.theme import Theme
from .click_rich_echo import rich_click_echo from .click_rich_echo import (
rich_click_echo,
set_rich_console,
set_rich_theme,
set_rich_timestamp,
)
from .color_themes import get_theme
from .common import CLI_COLOR_ERROR, CLI_COLOR_WARNING, time_stamp from .common import CLI_COLOR_ERROR, CLI_COLOR_WARNING, time_stamp
# set to 1 if running tests # set to 1 if running tests
@ -17,14 +25,79 @@ OSXPHOTOS_IS_TESTING = bool(os.getenv("OSXPHOTOS_IS_TESTING", default=False))
# include error/warning emoji's in verbose output # include error/warning emoji's in verbose output
ERROR_EMOJI = True ERROR_EMOJI = True
__all__ = ["get_verbose_console", "verbose_print"] # global to store verbose level
__verbose_level = 1
# global verbose function
__verbose_function: Callable[..., None] | None = None
__all__ = [
"get_verbose_console",
"get_verbose_level",
"set_verbose_level",
"verbose_print",
"verbose",
]
def _reset_verbose_globals():
"""Reset globals for testing"""
global __verbose_level
global __verbose_function
global _console
__verbose_level = 1
__verbose_function = None
_console = _Console()
def noop(*args, **kwargs):
"""no-op function"""
pass
def verbose(*args, level: int = 1, **kwargs):
"""Print verbose output
Args:
*args: arguments to pass to verbose function for printing
level: verbose level; if level > get_verbose_level(), output is suppressed
Notes:
Normally you should use verbose_print() to get the verbose function instead of calling this directly
"""
global __verbose_function
if __verbose_function is None:
return
__verbose_function(*args, level=level, **kwargs)
def set_verbose_level(level: int):
"""Set verbose level"""
global __verbose_level
global __verbose_function
__verbose_level = level
if level > 0 and __verbose_function is None:
# if verbose level set but verbose function not set, set it to default
# verbose_print sets the global __verbose_function
__verbose_function = _verbose_print_function(level)
elif level == 0 and __verbose_function is not None:
# if verbose level set to 0 but verbose function is set, set it to no-op
__verbose_function = noop
def get_verbose_level() -> int:
"""Get verbose level"""
global __verbose_level
return __verbose_level
class _Console: class _Console:
"""Store console object for verbose output""" """Store console object for verbose output"""
def __init__(self): def __init__(self):
self._console: t.Optional[Console] = None self._console: Optional[Console] = None
@property @property
def console(self): def console(self):
@ -38,12 +111,7 @@ class _Console:
_console = _Console() _console = _Console()
def noop(*args, **kwargs): def get_verbose_console(theme: Optional[Theme] = None) -> Console:
"""no-op function"""
pass
def get_verbose_console(theme: t.Optional[Theme] = None) -> Console:
"""Get console object or create one if not already created """Get console object or create one if not already created
Args: Args:
@ -59,18 +127,68 @@ def get_verbose_console(theme: t.Optional[Theme] = None) -> Console:
def verbose_print( def verbose_print(
verbose: int = 1,
timestamp: bool = False,
rich: bool = True,
theme: str | None = None,
highlight: bool = False,
file: Optional[IO] = None,
**kwargs: Any,
) -> Callable[..., None]:
"""Configure verbose printing and create verbose function to print output
Args:
verbose: if > 0, returns verbose print function otherwise returns no-op function; the value of verbose is the verbose level
timestamp: if True, includes timestamp in verbose output
rich: use rich.print instead of click.echo
highlight: if True, use automatic rich.print highlighting
theme: optional name of theme to use for formatting (will be loaded by get_theme())
file: optional file handle to write to instead of stdout
kwargs: any extra arguments to pass to click.echo or rich.print depending on whether rich==True
Returns:
function to print output
Note: sets the console for rich_echo to be the same as the console used for verbose output
"""
set_verbose_level(verbose)
color_theme = get_theme(theme)
verbose_function = _verbose_print_function(
verbose=verbose,
timestamp=timestamp,
rich=rich,
theme=color_theme,
highlight=highlight,
file=file,
**kwargs,
)
# set console for rich_echo to be same as for verbose
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
# set global verbose function to match
global __verbose_function
__verbose_function = verbose_function
return verbose_function
def _verbose_print_function(
verbose: bool = True, verbose: bool = True,
timestamp: bool = False, timestamp: bool = False,
rich: bool = False, rich: bool = False,
highlight: bool = False, highlight: bool = False,
theme: t.Optional[Theme] = None, theme: Optional[Theme] = None,
file: t.Optional[t.IO] = None, file: Optional[IO] = None,
**kwargs: t.Any, **kwargs: Any,
) -> t.Callable: ) -> Callable[..., None]:
"""Create verbose function to print output """Create verbose function to print output
Args: Args:
verbose: if True, returns verbose print function otherwise returns no-op function verbose: if > 0, returns verbose print function otherwise returns no-op function; the value of verbose is the verbose level
timestamp: if True, includes timestamp in verbose output timestamp: if True, includes timestamp in verbose output
rich: use rich.print instead of click.echo rich: use rich.print instead of click.echo
highlight: if True, use automatic rich.print highlighting highlight: if True, use automatic rich.print highlighting
@ -91,8 +209,10 @@ def verbose_print(
_console.console = Console(theme=theme, width=10_000) _console.console = Console(theme=theme, width=10_000)
# closure to capture timestamp # closure to capture timestamp
def verbose_(*args): def verbose_(*args, level: int = 1):
"""print output if verbose flag set""" """print output if verbose flag set"""
if get_verbose_level() < level:
return
styled_args = [] styled_args = []
timestamp_str = f"{str(datetime.now())} -- " if timestamp else "" timestamp_str = f"{str(datetime.now())} -- " if timestamp else ""
for arg in args: for arg in args:
@ -103,10 +223,12 @@ def verbose_print(
elif "warning" in arg.lower(): elif "warning" in arg.lower():
arg = click.style(arg, fg=CLI_COLOR_WARNING) arg = click.style(arg, fg=CLI_COLOR_WARNING)
styled_args.append(arg) styled_args.append(arg)
click.echo(*styled_args, **kwargs) click.echo(*styled_args, **kwargs, file=file or None)
def rich_verbose_(*args): def rich_verbose_(*args, level: int = 1):
"""rich.print output if verbose flag set""" """rich.print output if verbose flag set"""
if get_verbose_level() < level:
return
global ERROR_EMOJI global ERROR_EMOJI
timestamp_str = time_stamp() if timestamp else "" timestamp_str = time_stamp() if timestamp else ""
new_args = [] new_args = []
@ -124,8 +246,10 @@ def verbose_print(
new_args.append(arg) new_args.append(arg)
_console.console.print(*new_args, highlight=highlight, **kwargs) _console.console.print(*new_args, highlight=highlight, **kwargs)
def rich_verbose_testing_(*args): def rich_verbose_testing_(*args, level: int = 1):
"""print output if verbose flag set using rich.print""" """print output if verbose flag set using rich.print"""
if get_verbose_level() < level:
return
global ERROR_EMOJI global ERROR_EMOJI
timestamp_str = time_stamp() if timestamp else "" timestamp_str = time_stamp() if timestamp else ""
new_args = [] new_args = []

View File

@ -1,5 +1,7 @@
"""Utilities for debugging""" """Utilities for debugging"""
from __future__ import annotations
import logging import logging
import sys import sys
import time import time
@ -9,21 +11,33 @@ from typing import Dict, List
import wrapt import wrapt
from rich import print from rich import print
__all__ = [
"debug_breakpoint",
"debug_watch",
"get_debug_flags",
"get_debug_options",
"is_debug",
"set_debug",
"wrap_function",
]
# global variable to control debug output # global variable to control debug output
# set via --debug # set via --debug
DEBUG = False __osxphotos_debug = False
def set_debug(debug: bool): def set_debug(debug: bool):
"""set debug flag""" """set debug flag"""
global DEBUG global __osxphotos_debug
DEBUG = debug __osxphotos_debug = debug
logging.disable(logging.NOTSET if debug else logging.DEBUG) logging.disable(logging.NOTSET if debug else logging.DEBUG)
def is_debug(): def is_debug():
"""return debug flag""" """return debug flag"""
return DEBUG global __osxphotos_debug
return __osxphotos_debug
def debug_watch(wrapped, instance, args, kwargs): def debug_watch(wrapped, instance, args, kwargs):

View File

@ -8,7 +8,6 @@ import contextlib
import dataclasses import dataclasses
import datetime import datetime
import json import json
import logging
import os import os
import os.path import os.path
import pathlib import pathlib
@ -16,6 +15,7 @@ import plistlib
from datetime import timedelta, timezone from datetime import timedelta, timezone
from functools import cached_property from functools import cached_property
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import logging
import yaml import yaml
from osxmetadata import OSXMetaData from osxmetadata import OSXMetaData
@ -68,6 +68,7 @@ from .utils import _get_resource_loc, hexdigest, list_directory
__all__ = ["PhotoInfo", "PhotoInfoNone"] __all__ = ["PhotoInfo", "PhotoInfoNone"]
logger = logging.getLogger("osxphotos")
class PhotoInfo: class PhotoInfo:
""" """
@ -245,7 +246,7 @@ class PhotoInfo:
photopath = self._path_edited_5() photopath = self._path_edited_5()
if photopath is not None and not os.path.isfile(photopath): if photopath is not None and not os.path.isfile(photopath):
logging.debug( logger.debug(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
) )
photopath = None photopath = None
@ -285,7 +286,7 @@ class PhotoInfo:
filename = f"{self._uuid}_2_0_a.mov" filename = f"{self._uuid}_2_0_a.mov"
else: else:
# don't know what it is! # don't know what it is!
logging.debug(f"WARNING: unknown type {self._info['type']}") logger.debug(f"WARNING: unknown type {self._info['type']}")
return None return None
return os.path.join(library, "resources", "renders", directory, filename) return os.path.join(library, "resources", "renders", directory, filename)
@ -332,7 +333,7 @@ class PhotoInfo:
try: try:
photopath = self._get_predicted_path_edited_4() photopath = self._get_predicted_path_edited_4()
except ValueError as e: except ValueError as e:
logging.debug(f"ERROR: {e}") logger.debug(f"ERROR: {e}")
photopath = None photopath = None
if photopath is not None and not os.path.isfile(photopath): if photopath is not None and not os.path.isfile(photopath):
@ -346,12 +347,12 @@ class PhotoInfo:
# check again to see if we found a valid file # check again to see if we found a valid file
if photopath is not None and not os.path.isfile(photopath): if photopath is not None and not os.path.isfile(photopath):
logging.debug( logger.debug(
f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
) )
photopath = None photopath = None
else: else:
logging.debug(f"{self.uuid} hasAdjustments but edit_resource_id is None") logger.debug(f"{self.uuid} hasAdjustments but edit_resource_id is None")
photopath = None photopath = None
return photopath return photopath
@ -400,7 +401,7 @@ class PhotoInfo:
None, None,
) )
if photopath is None: if photopath is None:
logging.debug( logger.debug(
f"MISSING PATH: edited live photo file for UUID {self._uuid} does not appear to exist" f"MISSING PATH: edited live photo file for UUID {self._uuid} does not appear to exist"
) )
return photopath return photopath
@ -496,7 +497,7 @@ class PhotoInfo:
self._db._masters_path, self._info["raw_info"]["imagePath"] self._db._masters_path, self._info["raw_info"]["imagePath"]
) )
if not os.path.isfile(photopath): if not os.path.isfile(photopath):
logging.debug( logger.debug(
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist" f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
) )
photopath = None photopath = None
@ -912,7 +913,7 @@ class PhotoInfo:
if self.live_photo and not self.ismissing: if self.live_photo and not self.ismissing:
live_model_id = self._info["live_model_id"] live_model_id = self._info["live_model_id"]
if live_model_id is None: if live_model_id is None:
logging.debug(f"missing live_model_id: {self._uuid}") logger.debug(f"missing live_model_id: {self._uuid}")
photopath = None photopath = None
else: else:
folder_id, file_id, nn_id = _get_resource_loc(live_model_id) folder_id, file_id, nn_id = _get_resource_loc(live_model_id)
@ -1196,7 +1197,7 @@ class PhotoInfo:
""" """
if self._db._db_version <= _PHOTOS_4_VERSION: if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"score not implemented for this database version") logger.debug(f"score not implemented for this database version")
return None return None
try: try:
@ -1342,7 +1343,7 @@ class PhotoInfo:
""" """
if self._db._db_version <= _PHOTOS_4_VERSION: if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"exif_info not implemented for this database version") logger.debug(f"exif_info not implemented for this database version")
return None return None
try: try:
@ -1369,7 +1370,7 @@ class PhotoInfo:
lens_model=exif["ZLENSMODEL"], lens_model=exif["ZLENSMODEL"],
) )
except KeyError: except KeyError:
logging.debug(f"Could not find exif record for uuid {self.uuid}") logger.debug(f"Could not find exif record for uuid {self.uuid}")
exif_info = ExifInfo( exif_info = ExifInfo(
iso=None, iso=None,
flash_fired=None, flash_fired=None,
@ -1441,7 +1442,7 @@ class PhotoInfo:
""" """
if self._db._db_version <= _PHOTOS_4_VERSION: if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"cloud_metadata not implemented for this database version") logger.debug(f"cloud_metadata not implemented for this database version")
return {} return {}
_, cursor = self._db.get_db_connection() _, cursor = self._db.get_db_connection()

View File

@ -34,7 +34,7 @@ from wurlitzer import pipes
from .fileutil import FileUtil from .fileutil import FileUtil
from .uti import get_preferred_uti_extension from .uti import get_preferred_uti_extension
from .utils import _get_os_version, increment_filename from .utils import get_macos_version, increment_filename
__all__ = [ __all__ = [
"NSURL_to_path", "NSURL_to_path",
@ -124,7 +124,7 @@ def request_photokit_authorization():
will do the actual request. will do the actual request.
""" """
(_, major, _) = _get_os_version() (_, major, _) = get_macos_version()
def handler(status): def handler(status):
pass pass

View File

@ -34,6 +34,8 @@ from ..utils import normalize_unicode
These methods only work on Photos 5 databases. Will print warning on earlier library versions. These methods only work on Photos 5 databases. Will print warning on earlier library versions.
""" """
logger = logging.getLogger("osxphotos")
def _process_searchinfo(self): def _process_searchinfo(self):
"""load machine learning/search term label info from a Photos library """load machine learning/search term label info from a Photos library
@ -55,12 +57,12 @@ def _process_searchinfo(self):
self._db_searchinfo_labels_normalized = _db_searchinfo_labels_normalized = {} self._db_searchinfo_labels_normalized = _db_searchinfo_labels_normalized = {}
if self._skip_searchinfo: if self._skip_searchinfo:
logging.debug("Skipping search info processing") logger.debug("Skipping search info processing")
return return
if self._db_version <= _PHOTOS_4_VERSION: if self._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError( raise NotImplementedError(
f"search info not implemented for this database version" "search info not implemented for this database version"
) )
search_db_path = pathlib.Path(self._dbfile).parent / "search" / "psi.sqlite" search_db_path = pathlib.Path(self._dbfile).parent / "search" / "psi.sqlite"

View File

@ -53,7 +53,6 @@ from .._constants import (
from .._version import __version__ from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo, ProjectInfo from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo, ProjectInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..debug import is_debug
from ..fileutil import FileUtil from ..fileutil import FileUtil
from ..personinfo import PersonInfo from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo from ..photoinfo import PhotoInfo
@ -63,13 +62,15 @@ from ..rich_utils import add_rich_markup_tag
from ..sqlite_utils import sqlite_db_is_locked, sqlite_open_ro from ..sqlite_utils import sqlite_db_is_locked, sqlite_open_ro
from ..utils import ( from ..utils import (
_check_file_exists, _check_file_exists,
_get_os_version, get_macos_version,
get_last_library_path, get_last_library_path,
noop, noop,
normalize_unicode, normalize_unicode,
) )
from .photosdb_utils import get_db_model_version, get_db_version from .photosdb_utils import get_db_model_version, get_db_version
logger = logging.getLogger("osxphotos")
__all__ = ["PhotosDB"] __all__ = ["PhotosDB"]
# TODO: Add test for imageTimeZoneOffsetSeconds = None # TODO: Add test for imageTimeZoneOffsetSeconds = None
@ -117,7 +118,7 @@ class PhotosDB:
# Check OS version # Check OS version
system = platform.system() system = platform.system()
(ver, major, _) = _get_os_version() (ver, major, _) = get_macos_version()
if system != "Darwin" or ((ver, major) not in _TESTED_OS_VERSIONS): if system != "Darwin" or ((ver, major) not in _TESTED_OS_VERSIONS):
logging.warning( logging.warning(
f"WARNING: This module has only been tested with macOS versions " f"WARNING: This module has only been tested with macOS versions "
@ -283,8 +284,7 @@ class PhotosDB:
# key is Z_PK of ZMOMENT table and values are the moment info # key is Z_PK of ZMOMENT table and values are the moment info
self._db_moment_pk = {} self._db_moment_pk = {}
if is_debug(): logger.debug(f"dbfile = {dbfile}")
logging.debug(f"dbfile = {dbfile}")
if dbfile is None: if dbfile is None:
dbfile = get_last_library_path() dbfile = get_last_library_path()
@ -300,8 +300,7 @@ class PhotosDB:
if not _check_file_exists(dbfile): if not _check_file_exists(dbfile):
raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile) raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
if is_debug(): logger.debug(f"dbfile = {dbfile}")
logging.debug(f"dbfile = {dbfile}")
# init database names # init database names
# _tmp_db is the file that will processed by _process_database4/5 # _tmp_db is the file that will processed by _process_database4/5
@ -352,8 +351,7 @@ class PhotosDB:
# set the photos version to actual value based on Photos.sqlite # set the photos version to actual value based on Photos.sqlite
self._photos_ver = get_db_model_version(self._tmp_db) self._photos_ver = get_db_model_version(self._tmp_db)
if is_debug(): logger.debug(
logging.debug(
f"_dbfile = {self._dbfile}, _dbfile_actual = {self._dbfile_actual}" f"_dbfile = {self._dbfile}, _dbfile_actual = {self._dbfile_actual}"
) )
@ -367,8 +365,7 @@ class PhotosDB:
masters_path = os.path.join(library_path, "originals") masters_path = os.path.join(library_path, "originals")
self._masters_path = masters_path self._masters_path = masters_path
if is_debug(): logger.debug(f"library = {library_path}, masters = {masters_path}")
logging.debug(f"library = {library_path}, masters = {masters_path}")
if int(self._db_version) <= int(_PHOTOS_4_VERSION): if int(self._db_version) <= int(_PHOTOS_4_VERSION):
self._process_database4() self._process_database4()
@ -628,38 +625,10 @@ class PhotosDB:
print(f"Error copying{fname} to {dest_path}", file=sys.stderr) print(f"Error copying{fname} to {dest_path}", file=sys.stderr)
raise Exception raise Exception
if is_debug(): logger.debug(dest_path)
logging.debug(dest_path)
return dest_path return dest_path
# NOTE: This method seems to cause problems with applescript
# Bummer...would'be been nice to avoid copying the DB
# def _link_db_file(self, fname):
# """ links the sqlite database file to a temp file """
# """ returns the name of the temp file """
# """ If sqlite shared memory and write-ahead log files exist, those are copied too """
# # required because python's sqlite3 implementation can't read a locked file
# # _, suffix = os.path.splitext(fname)
# dest_name = dest_path = ""
# try:
# dest_name = pathlib.Path(fname).name
# dest_path = os.path.join(self._tempdir_name, dest_name)
# FileUtil.hardlink(fname, dest_path)
# # link write-ahead log and shared memory files (-wal and -shm) files if they exist
# if os.path.exists(f"{fname}-wal"):
# FileUtil.hardlink(f"{fname}-wal", f"{dest_path}-wal")
# if os.path.exists(f"{fname}-shm"):
# FileUtil.hardlink(f"{fname}-shm", f"{dest_path}-shm")
# except:
# print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
# raise Exception
# if is_debug():
# logging.debug(dest_path)
# return dest_path
def _process_database4(self): def _process_database4(self):
"""process the Photos database to extract info """process the Photos database to extract info
works on Photos version <= 4.0""" works on Photos version <= 4.0"""
@ -741,7 +710,7 @@ class PhotosDB:
self._dbpersons_pk[pk]["photo_uuid"] = person[2] self._dbpersons_pk[pk]["photo_uuid"] = person[2]
self._dbpersons_pk[pk]["keyface_uuid"] = person[3] self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
except KeyError: except KeyError:
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]") logger.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
# get information on detected faces # get information on detected faces
verbose("Processing detected faces in photos.") verbose("Processing detected faces in photos.")
@ -1118,8 +1087,7 @@ class PhotosDB:
self._dbphotos[uuid]["type"] = _MOVIE_TYPE self._dbphotos[uuid]["type"] = _MOVIE_TYPE
else: else:
# unknown # unknown
if is_debug(): logger.debug(f"WARNING: {uuid} found unknown type {row[21]}")
logging.debug(f"WARNING: {uuid} found unknown type {row[21]}")
self._dbphotos[uuid]["type"] = None self._dbphotos[uuid]["type"] = None
self._dbphotos[uuid]["UTI"] = row[22] self._dbphotos[uuid]["UTI"] = row[22]
@ -1352,8 +1320,7 @@ class PhotosDB:
if resource_type == 4: if resource_type == 4:
# photo # photo
if "edit_resource_id_photo" in self._dbphotos[uuid]: if "edit_resource_id_photo" in self._dbphotos[uuid]:
if is_debug(): logger.debug(
logging.debug(
f"WARNING: found more than one edit_resource_id_photo for " f"WARNING: found more than one edit_resource_id_photo for "
f"UUID {row[0]},adjustmentUUID {row[1]}, modelID {row[2]}" f"UUID {row[0]},adjustmentUUID {row[1]}, modelID {row[2]}"
) )
@ -1362,8 +1329,7 @@ class PhotosDB:
elif resource_type == 8: elif resource_type == 8:
# video # video
if "edit_resource_id_video" in self._dbphotos[uuid]: if "edit_resource_id_video" in self._dbphotos[uuid]:
if is_debug(): logger.debug(
logging.debug(
f"WARNING: found more than one edit_resource_id_video for " f"WARNING: found more than one edit_resource_id_video for "
f"UUID {row[0]},adjustmentUUID {row[1]}, modelID {row[2]}" f"UUID {row[0]},adjustmentUUID {row[1]}, modelID {row[2]}"
) )
@ -1655,8 +1621,7 @@ class PhotosDB:
but it works so don't touch it. but it works so don't touch it.
""" """
if is_debug(): logger.debug(f"_process_database5")
logging.debug(f"_process_database5")
verbose = self._verbose verbose = self._verbose
verbose(f"Processing database.") verbose(f"Processing database.")
(conn, c) = sqlite_open_ro(self._tmp_db) (conn, c) = sqlite_open_ro(self._tmp_db)
@ -1679,8 +1644,7 @@ class PhotosDB:
hdr_type_column = _DB_TABLE_NAMES[photos_ver]["HDR_TYPE"] hdr_type_column = _DB_TABLE_NAMES[photos_ver]["HDR_TYPE"]
# Look for all combinations of persons and pictures # Look for all combinations of persons and pictures
if is_debug(): logger.debug(f"Getting information about persons")
logging.debug(f"Getting information about persons")
# get info to associate persons with photos # get info to associate persons with photos
# then get detected faces in each photo and link to persons # then get detected faces in each photo and link to persons
@ -1757,7 +1721,7 @@ class PhotosDB:
self._dbpersons_pk[pk]["photo_uuid"] = person[2] self._dbpersons_pk[pk]["photo_uuid"] = person[2]
self._dbpersons_pk[pk]["keyface_uuid"] = person[3] self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
except KeyError: except KeyError:
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]") logger.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
# get information on detected faces # get information on detected faces
verbose("Processing detected faces in photos.") verbose("Processing detected faces in photos.")
@ -2095,8 +2059,7 @@ class PhotosDB:
elif row[17] == 1: elif row[17] == 1:
info["type"] = _MOVIE_TYPE info["type"] = _MOVIE_TYPE
else: else:
if is_debug(): logger.debug(f"WARNING: {uuid} found unknown type {row[17]}")
logging.debug(f"WARNING: {uuid} found unknown type {row[17]}")
info["type"] = None info["type"] = None
info["UTI"] = row[18] info["UTI"] = row[18]
@ -2283,7 +2246,7 @@ class PhotosDB:
self._dbphotos[uuid]["fok_import_session"] = row[2] self._dbphotos[uuid]["fok_import_session"] = row[2]
self._dbphotos[uuid]["import_uuid"] = row[3] self._dbphotos[uuid]["import_uuid"] = row[3]
except KeyError: except KeyError:
logging.debug(f"No info record for uuid {uuid} for import session") logger.debug(f"No info record for uuid {uuid} for import session")
# Get extended description # Get extended description
verbose("Processing additional photo details.") verbose("Processing additional photo details.")
@ -2300,8 +2263,7 @@ class PhotosDB:
if uuid in self._dbphotos: if uuid in self._dbphotos:
self._dbphotos[uuid]["extendedDescription"] = normalize_unicode(row[1]) self._dbphotos[uuid]["extendedDescription"] = normalize_unicode(row[1])
else: else:
if is_debug(): logger.debug(
logging.debug(
f"WARNING: found description {row[1]} but no photo for {uuid}" f"WARNING: found description {row[1]} but no photo for {uuid}"
) )
@ -2319,8 +2281,7 @@ class PhotosDB:
if uuid in self._dbphotos: if uuid in self._dbphotos:
self._dbphotos[uuid]["adjustmentFormatID"] = row[2] self._dbphotos[uuid]["adjustmentFormatID"] = row[2]
else: else:
if is_debug(): logger.debug(
logging.debug(
f"WARNING: found adjustmentformatidentifier {row[2]} but no photo for uuid {row[0]}" f"WARNING: found adjustmentformatidentifier {row[2]} but no photo for uuid {row[0]}"
) )
@ -2696,7 +2657,7 @@ class PhotosDB:
try: try:
folders = self._dbalbum_folders[album_uuid] folders = self._dbalbum_folders[album_uuid]
except KeyError: except KeyError:
logging.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}") logger.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}")
return [] return []
def _recurse_folder_hierarchy(folders, hierarchy=[]): def _recurse_folder_hierarchy(folders, hierarchy=[]):
@ -2733,7 +2694,7 @@ class PhotosDB:
try: try:
folders = self._dbalbum_folders[album_uuid] folders = self._dbalbum_folders[album_uuid]
except KeyError: except KeyError:
logging.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}") logger.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}")
return [] return []
def _recurse_folder_hierarchy(folders, hierarchy=[]): def _recurse_folder_hierarchy(folders, hierarchy=[]):

View File

@ -1,12 +1,20 @@
""" QueryOptions class for PhotosDB.query """ """ QueryOptions class for PhotosDB.query """
import dataclasses
import datetime import datetime
import pathlib
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Iterable, List, Optional, Tuple from typing import Iterable, List, Optional, Tuple
import bitmath import bitmath
__all__ = ["QueryOptions"] __all__ = ["QueryOptions", "query_options_from_kwargs", "IncompatibleQueryOptions"]
class IncompatibleQueryOptions(Exception):
"""Incompatible query options"""
pass
@dataclass @dataclass
@ -182,3 +190,128 @@ class QueryOptions:
def asdict(self): def asdict(self):
return asdict(self) return asdict(self)
def query_options_from_kwargs(**kwargs) -> QueryOptions:
"""Validate query options and create a QueryOptions instance"""
# sanity check input args
nonexclusive = [
"added_after",
"added_before",
"added_in_last",
"album",
"duplicate",
"exif",
"external_edit",
"folder",
"from_date",
"from_time",
"has_raw",
"keyword",
"label",
"max_size",
"min_size",
"name",
"person",
"query_eval",
"query_function",
"regex",
"selected",
"to_date",
"to_time",
"uti",
"uuid",
"uuid_from_file",
"year",
]
exclusive = [
("burst", "not_burst"),
("cloudasset", "not_cloudasset"),
("edited", "not_edited"),
("favorite", "not_favorite"),
("has_comment", "no_comment"),
("has_likes", "no_likes"),
("hdr", "not_hdr"),
("hidden", "not_hidden"),
("in_album", "not_in_album"),
("incloud", "not_incloud"),
("is_reference", "not_reference"),
("keyword", "no_keyword"),
("live", "not_live"),
("location", "no_location"),
("missing", "not_missing"),
("only_photos", "only_movies"),
("panorama", "not_panorama"),
("portrait", "not_portrait"),
("screenshot", "not_screenshot"),
("selfie", "not_selfie"),
("shared", "not_shared"),
("slow_mo", "not_slow_mo"),
("time_lapse", "not_time_lapse"),
("deleted", "not_deleted"),
]
# TODO: add option to validate requiring at least one query arg
for arg, not_arg in exclusive:
if kwargs.get(arg) and kwargs.get(not_arg):
arg = arg.replace("_", "-")
not_arg = not_arg.replace("_", "-")
raise IncompatibleQueryOptions(
f"--{arg} and --{not_arg} are mutually exclusive"
)
# some options like title can be specified multiple times
# check if any of them are specified along with their no_ counterpart
exclusive_multi_options = ["title", "description", "place", "keyword"]
for option in exclusive_multi_options:
if kwargs.get(option) and kwargs.get("no_{option}"):
raise IncompatibleQueryOptions(
f"--{option} and --no-{option} are mutually exclusive"
)
include_photos = True
include_movies = True # default searches for everything
if kwargs.get("only_movies"):
include_photos = False
if kwargs.get("only_photos"):
include_movies = False
# load UUIDs if necessary and append to any uuids passed with --uuid
uuid = None
if uuid_from_file := kwargs.get("uuid_from_file"):
uuid_list = list(kwargs.get("uuid", [])) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list)
query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
query_dict = {field: kwargs.get(field) for field in query_fields}
query_dict["photos"] = include_photos
query_dict["movies"] = include_movies
query_dict["uuid"] = uuid
return QueryOptions(**query_dict)
def load_uuid_from_file(filename):
"""Load UUIDs from file. Does not validate UUIDs.
Format is 1 UUID per line, any line beginning with # is ignored.
Whitespace is stripped.
Arguments:
filename: file name of the file containing UUIDs
Returns:
list of UUIDs or empty list of no UUIDs in file
Raises:
FileNotFoundError if file does not exist
"""
if not pathlib.Path(filename).is_file():
raise FileNotFoundError(f"Could not find file {filename}")
uuid = []
with open(filename, "r") as uuid_file:
for line in uuid_file:
line = line.strip()
if len(line) and line[0] != "#":
uuid.append(line)
return uuid

View File

@ -5,6 +5,7 @@ import pathlib
import sqlite3 import sqlite3
from typing import List, Tuple from typing import List, Tuple
logger = logging.getLogger("osxphotos")
def sqlite_open_ro(dbname: str) -> Tuple[sqlite3.Connection, sqlite3.Cursor]: def sqlite_open_ro(dbname: str) -> Tuple[sqlite3.Connection, sqlite3.Cursor]:
"""opens sqlite file dbname in read-only mode """opens sqlite file dbname in read-only mode
@ -32,7 +33,7 @@ def sqlite_db_is_locked(dbname):
conn.close() conn.close()
locked = False locked = False
except Exception as e: except Exception as e:
logging.debug(f"sqlite_db_is_locked: {e}") logger.debug(f"sqlite_db_is_locked: {e}")
locked = True locked = True
return locked return locked

View File

@ -11,11 +11,11 @@ from Foundation import NSDictionary
# needed to capture system-level stderr # needed to capture system-level stderr
from wurlitzer import pipes from wurlitzer import pipes
from .utils import _get_os_version from .utils import get_macos_version
__all__ = ["detect_text", "make_request_handler"] __all__ = ["detect_text", "make_request_handler"]
ver, major, minor = _get_os_version() ver, major, minor = get_macos_version()
if ver == "10" and int(major) < 15: if ver == "10" and int(major) < 15:
vision = False vision = False
else: else:

View File

@ -26,7 +26,7 @@ import tempfile
import CoreServices import CoreServices
import objc import objc
from .utils import _get_os_version from .utils import get_macos_version
__all__ = ["get_preferred_uti_extension", "get_uti_for_extension"] __all__ = ["get_preferred_uti_extension", "get_uti_for_extension"]
@ -522,7 +522,7 @@ def _load_uti_dict():
_load_uti_dict() _load_uti_dict()
# OS version for determining which methods can be used # OS version for determining which methods can be used
OS_VER, OS_MAJOR, _ = (int(x) for x in _get_os_version()) OS_VER, OS_MAJOR, _ = (int(x) for x in get_macos_version())
def _get_uti_from_mdls(extension): def _get_uti_from_mdls(extension):

View File

@ -25,14 +25,16 @@ import shortuuid
from ._constants import UNICODE_FORMAT from ._constants import UNICODE_FORMAT
logger = logging.getLogger("osxphotos")
__all__ = [ __all__ = [
"dd_to_dms_str", "dd_to_dms_str",
"expand_and_validate_filepath", "expand_and_validate_filepath",
"get_last_library_path", "get_last_library_path",
"get_system_library_path", "get_system_library_path",
"hexdigest", "hexdigest",
"increment_filename_with_count",
"increment_filename", "increment_filename",
"increment_filename_with_count",
"lineno", "lineno",
"list_directory", "list_directory",
"list_photo_libraries", "list_photo_libraries",
@ -46,23 +48,9 @@ __all__ = [
] ]
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s",
)
VERSION_INFO_URL = "https://pypi.org/pypi/osxphotos/json" VERSION_INFO_URL = "https://pypi.org/pypi/osxphotos/json"
def _get_logger():
"""Used only for testing
Returns:
logging.Logger object -- logging.Logger object for osxphotos
"""
return logging.Logger(__name__)
def noop(*args, **kwargs): def noop(*args, **kwargs):
"""do nothing (no operation)""" """do nothing (no operation)"""
pass pass
@ -76,7 +64,7 @@ def lineno(filename):
return f"{filename}: {line}" return f"{filename}: {line}"
def _get_os_version(): def get_macos_version():
# returns tuple of str containing OS version # returns tuple of str containing OS version
# e.g. 10.13.6 = ("10", "13", "6") # e.g. 10.13.6 = ("10", "13", "6")
version = platform.mac_ver()[0].split(".") version = platform.mac_ver()[0].split(".")
@ -176,9 +164,9 @@ def get_system_library_path():
"""return the path to the system Photos library as string""" """return the path to the system Photos library as string"""
""" only works on MacOS 10.15 """ """ only works on MacOS 10.15 """
""" on earlier versions, returns None """ """ on earlier versions, returns None """
_, major, _ = _get_os_version() _, major, _ = get_macos_version()
if int(major) < 15: if int(major) < 15:
logging.debug( logger.debug(
f"get_system_library_path not implemented for MacOS < 10.15: you have {major}" f"get_system_library_path not implemented for MacOS < 10.15: you have {major}"
) )
return None return None
@ -191,7 +179,7 @@ def get_system_library_path():
with open(plist_file, "rb") as fp: with open(plist_file, "rb") as fp:
pl = plistload(fp) pl = plistload(fp)
else: else:
logging.debug(f"could not find plist file: {str(plist_file)}") logger.debug(f"could not find plist file: {str(plist_file)}")
return None return None
return pl.get("SystemLibraryPath") return pl.get("SystemLibraryPath")
@ -208,7 +196,7 @@ def get_last_library_path():
with open(plist_file, "rb") as fp: with open(plist_file, "rb") as fp:
pl = plistload(fp) pl = plistload(fp)
else: else:
logging.debug(f"could not find plist file: {str(plist_file)}") logger.debug(f"could not find plist file: {str(plist_file)}")
return None return None
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist # get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
@ -244,7 +232,7 @@ def get_last_library_path():
return photospath return photospath
else: else:
logging.debug("Could not get path to Photos database") logger.debug("Could not get path to Photos database")
return None return None

View File

@ -7,7 +7,7 @@
<key>hostuuid</key> <key>hostuuid</key>
<string>585B80BF-8D1F-55EF-A9E8-6CF4E5523959</string> <string>585B80BF-8D1F-55EF-A9E8-6CF4E5523959</string>
<key>pid</key> <key>pid</key>
<integer>1961</integer> <integer>508</integer>
<key>processname</key> <key>processname</key>
<string>photolibraryd</string> <string>photolibraryd</string>
<key>uid</key> <key>uid</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@ -2,12 +2,18 @@
import datetime import datetime
import pathlib import pathlib
import time
from tests.parse_timewarp_output import CompareValues, InspectValues from tests.parse_timewarp_output import CompareValues, InspectValues
TEST_LIBRARY_TIMEWARP = "tests/TestTimeWarp-10.15.7.photoslibrary" TEST_LIBRARY_TIMEWARP = "tests/TestTimeWarp-10.15.7.photoslibrary"
def is_dst() -> bool:
"""Return True if daylight savings time is in effect"""
return bool(time.localtime().tm_isdst)
def get_file_timestamp(file: str) -> str: def get_file_timestamp(file: str) -> str:
"""Get timestamp of file""" """Get timestamp of file"""
return datetime.datetime.fromtimestamp(pathlib.Path(file).stat().st_mtime).strftime( return datetime.datetime.fromtimestamp(pathlib.Path(file).stat().st_mtime).strftime(
@ -24,6 +30,7 @@ CATALINA_PHOTOS_5 = {
"marigold flowers": "IMG_6517.jpeg", "marigold flowers": "IMG_6517.jpeg",
"multi-colored zinnia flowers": "IMG_6506.jpeg", "multi-colored zinnia flowers": "IMG_6506.jpeg",
"sunset": "IMG_6551.mov", "sunset": "IMG_6551.mov",
"palm tree": "20230120_010203-0400.jpg",
}, },
"inspect": { "inspect": {
# IMG_6501.jpeg # IMG_6501.jpeg
@ -362,4 +369,28 @@ CATALINA_PHOTOS_5 = {
"GMT-0700", "GMT-0700",
), ),
}, },
"parse_date": {
# 20230120_010203-0400.jpg
"uuid": "5285C4E2-BB1A-49DF-AEF5-246AA337ACAB",
"expected": InspectValues(
"20230120_010203-0400.jpg",
"5285C4E2-BB1A-49DF-AEF5-246AA337ACAB",
"2023-01-20 01:02:03-0800" if not is_dst() else "2023-01-20 00:02:03-0700",
"2023-01-20 01:02:03-0800" if not is_dst() else "2023-01-20 00:02:03-0700",
"-0800",
"GMT-0800",
),
},
"parse_date_tz": {
# 20230120_010203-0400.jpg
"uuid": "5285C4E2-BB1A-49DF-AEF5-246AA337ACAB",
"expected": InspectValues(
"20230120_010203-0400.jpg",
"5285C4E2-BB1A-49DF-AEF5-246AA337ACAB",
"2023-01-19 21:02:03-0800" if not is_dst() else "2023-01-19 20:02:03-0700",
"2023-01-20 01:02:03-0400",
"-0400",
"GMT-0400",
),
},
} }

View File

@ -66,19 +66,19 @@ elif OS_VER[0] == "13":
TEST_LIBRARY_SYNC = TEST_LIBRARY TEST_LIBRARY_SYNC = TEST_LIBRARY
from tests.config_timewarp_ventura import TEST_LIBRARY_TIMEWARP from tests.config_timewarp_ventura import TEST_LIBRARY_TIMEWARP
TEST_LIBRARY_ADD_LOCATIONS = None TEST_LIBRARY_ADD_LOCATIONS = "tests/Test-13.0.0.photoslibrary"
else: else:
TEST_LIBRARY = None TEST_LIBRARY = None
TEST_LIBRARY_TIMEWARP = None TEST_LIBRARY_TIMEWARP = None
TEST_LIBRARY_SYNC = None TEST_LIBRARY_SYNC = None
TEST_LIBRARY_ADD_LOCATIONS = "tests/Test-13.0.0.photoslibrary" TEST_LIBRARY_ADD_LOCATIONS = None
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def setup_photos_timewarp(): def setup_photos_timewarp():
if not TEST_TIMEWARP: if not TEST_TIMEWARP:
return return
copy_photos_library(TEST_LIBRARY_TIMEWARP, delay=10) copy_photos_library(TEST_LIBRARY_TIMEWARP, delay=5)
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)

View File

@ -25,9 +25,7 @@ CompareValues = namedtuple(
def parse_inspect_output(output: str) -> List[InspectValues]: def parse_inspect_output(output: str) -> List[InspectValues]:
"""Parse output of --inspect and return list of InspectValues named tuple""" """Parse output of --inspect and return list of InspectValues named tuple"""
with open(output, "r") as f: lines = [line for line in output.split("\n") if line.strip()]
lines = f.readlines()
lines = [line for line in lines if line.strip()]
# remove header # remove header
lines.pop(0) lines.pop(0)
values = [] values = []
@ -40,9 +38,7 @@ def parse_inspect_output(output: str) -> List[InspectValues]:
def parse_compare_exif(output: str) -> List[CompareValues]: def parse_compare_exif(output: str) -> List[CompareValues]:
"""Parse output of --compare-exif and return list of CompareValues named tuple""" """Parse output of --compare-exif and return list of CompareValues named tuple"""
with open(output, "r") as f: lines = [line for line in output.split("\n") if line.strip()]
lines = f.readlines()
lines = [line for line in lines if line.strip()]
# remove header # remove header
lines.pop(0) lines.pop(0)
values = [] values = []

File diff suppressed because one or more lines are too long

View File

@ -14,9 +14,9 @@ import pytest
import osxphotos import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import _get_os_version from osxphotos.utils import get_macos_version
OS_VERSION = _get_os_version() OS_VERSION = get_macos_version()
SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15" SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15"
PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")

View File

@ -73,18 +73,18 @@ def test_select_pears(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
def test_inspect(photoslib, suspend_capture, output_file): def test_inspect(photoslib, suspend_capture):
"""Test --inspect. NOTE: this test requires user interaction""" """Test --inspect. NOTE: this test requires user interaction"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
assert result.exit_code == 0 assert result.exit_code == 0
values = parse_inspect_output(output_file) values = parse_inspect_output(result.output)
assert TEST_DATA["inspect"]["expected"] == values assert TEST_DATA["inspect"]["expected"] == values
@ -111,7 +111,7 @@ def test_date(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["date_delta"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["date_delta"]["parameters"])
def test_date_delta(photoslib, suspend_capture, input_value, expected, output_file): def test_date_delta(photoslib, suspend_capture, input_value, expected):
"""Test --date-delta""" """Test --date-delta"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -129,16 +129,16 @@ def test_date_delta(photoslib, suspend_capture, input_value, expected, output_fi
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["time"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["time"]["parameters"])
def test_time(photoslib, suspend_capture, input_value, expected, output_file): def test_time(photoslib, suspend_capture, input_value, expected):
"""Test --time""" """Test --time"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -158,16 +158,16 @@ def test_time(photoslib, suspend_capture, input_value, expected, output_file):
# don't use photo.date as it will return local time instead of the time in the timezone # don't use photo.date as it will return local time instead of the time in the timezone
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["time_delta"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["time_delta"]["parameters"])
def test_time_delta(photoslib, suspend_capture, input_value, expected, output_file): def test_time_delta(photoslib, suspend_capture, input_value, expected):
"""Test --time-delta""" """Test --time-delta"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -185,10 +185,10 @@ def test_time_delta(photoslib, suspend_capture, input_value, expected, output_fi
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@ -216,34 +216,28 @@ def test_time_zone(
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected_date assert output_values[0].date_tz == expected_date
assert output_values[0].tz_offset == expected_tz assert output_values[0].tz_offset == expected_tz
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("expected", TEST_DATA["compare_exif"]["expected"]) @pytest.mark.parametrize("expected", TEST_DATA["compare_exif"]["expected"])
def test_compare_exif(photoslib, suspend_capture, expected, output_file): def test_compare_exif(photoslib, suspend_capture, expected):
"""Test --compare-exif""" """Test --compare-exif"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
[ ["--compare-exif", "--plain", "--force"],
"--compare-exif",
"--plain",
"--force",
"-o",
output_file,
],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
assert result.exit_code == 0 assert result.exit_code == 0
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == expected assert output_values[0] == expected
@ -281,24 +275,24 @@ def test_select_sunflowers(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("expected", TEST_DATA["compare_exif_3"]["expected"]) @pytest.mark.parametrize("expected", TEST_DATA["compare_exif_3"]["expected"])
def test_compare_exif_3(photoslib, suspend_capture, expected, output_file): def test_compare_exif_3(photoslib, suspend_capture, expected):
"""Test --compare-exif""" """Test --compare-exif"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
assert result.exit_code == 0 assert result.exit_code == 0
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == expected assert output_values[0] == expected
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["match"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["match"]["parameters"])
def test_match(photoslib, suspend_capture, input_value, expected, output_file): def test_match(photoslib, suspend_capture, input_value, expected):
"""Test --timezone --match""" """Test --timezone --match"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -317,10 +311,10 @@ def test_match(photoslib, suspend_capture, input_value, expected, output_file):
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@ -380,10 +374,10 @@ def test_push_exif_1(
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected_date assert output_values[0].date_tz == expected_date
photo = photoslib.selection[0] photo = photoslib.selection[0]
@ -402,7 +396,7 @@ def test_select_pears_2(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
def test_push_exif_2(photoslib, suspend_capture, output_file): def test_push_exif_2(photoslib, suspend_capture):
"""Test --push-exif""" """Test --push-exif"""
pre_test = TEST_DATA["push_exif"]["pre"] pre_test = TEST_DATA["push_exif"]["pre"]
post_test = TEST_DATA["push_exif"]["post"] post_test = TEST_DATA["push_exif"]["post"]
@ -413,10 +407,10 @@ def test_push_exif_2(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -433,15 +427,15 @@ def test_push_exif_2(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@pytest.mark.timewarp @pytest.mark.timewarp
def test_pull_exif_1(photoslib, suspend_capture, output_file): def test_pull_exif_1(photoslib, suspend_capture):
"""Test --pull-exif""" """Test --pull-exif"""
pre_test = TEST_DATA["pull_exif_1"]["pre"] pre_test = TEST_DATA["pull_exif_1"]["pre"]
post_test = TEST_DATA["pull_exif_1"]["post"] post_test = TEST_DATA["pull_exif_1"]["post"]
@ -460,10 +454,10 @@ def test_pull_exif_1(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -480,10 +474,10 @@ def test_pull_exif_1(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@ -494,7 +488,7 @@ def test_select_apple_tree(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
def test_pull_exif_no_time(photoslib, suspend_capture, output_file): def test_pull_exif_no_time(photoslib, suspend_capture):
"""Test --pull-exif when photo has invalid date/time in EXIF""" """Test --pull-exif when photo has invalid date/time in EXIF"""
pre_test = TEST_DATA["pull_exif_no_time"]["pre"] pre_test = TEST_DATA["pull_exif_no_time"]["pre"]
post_test = TEST_DATA["pull_exif_no_time"]["post"] post_test = TEST_DATA["pull_exif_no_time"]["post"]
@ -505,10 +499,10 @@ def test_pull_exif_no_time(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -525,10 +519,10 @@ def test_pull_exif_no_time(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@ -539,7 +533,7 @@ def test_select_marigolds(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
def test_pull_exif_no_offset(photoslib, suspend_capture, output_file): def test_pull_exif_no_offset(photoslib, suspend_capture):
"""Test --pull-exif when photo has no offset in EXIF""" """Test --pull-exif when photo has no offset in EXIF"""
pre_test = TEST_DATA["pull_exif_no_offset"]["pre"] pre_test = TEST_DATA["pull_exif_no_offset"]["pre"]
post_test = TEST_DATA["pull_exif_no_offset"]["post"] post_test = TEST_DATA["pull_exif_no_offset"]["post"]
@ -550,10 +544,10 @@ def test_pull_exif_no_offset(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -570,10 +564,10 @@ def test_pull_exif_no_offset(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@ -586,7 +580,7 @@ def test_select_zinnias(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
def test_pull_exif_no_data(photoslib, suspend_capture, output_file): def test_pull_exif_no_data(photoslib, suspend_capture):
"""Test --pull-exif when photo has no data in EXIF""" """Test --pull-exif when photo has no data in EXIF"""
pre_test = TEST_DATA["pull_exif_no_data"]["pre"] pre_test = TEST_DATA["pull_exif_no_data"]["pre"]
post_test = TEST_DATA["pull_exif_no_data"]["post"] post_test = TEST_DATA["pull_exif_no_data"]["post"]
@ -597,10 +591,10 @@ def test_pull_exif_no_data(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -618,15 +612,15 @@ def test_pull_exif_no_data(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@pytest.mark.timewarp @pytest.mark.timewarp
def test_pull_exif_no_data_use_file_time(photoslib, suspend_capture, output_file): def test_pull_exif_no_data_use_file_time(photoslib, suspend_capture):
"""Test --pull-exif when photo has no data in EXIF with --use-file-time""" """Test --pull-exif when photo has no data in EXIF with --use-file-time"""
pre_test = TEST_DATA["pull_exif_no_data_use_file_time"]["pre"] pre_test = TEST_DATA["pull_exif_no_data_use_file_time"]["pre"]
post_test = TEST_DATA["pull_exif_no_data_use_file_time"]["post"] post_test = TEST_DATA["pull_exif_no_data_use_file_time"]["post"]
@ -637,10 +631,10 @@ def test_pull_exif_no_data_use_file_time(photoslib, suspend_capture, output_file
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -659,10 +653,10 @@ def test_pull_exif_no_data_use_file_time(photoslib, suspend_capture, output_file
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@ -674,7 +668,7 @@ def test_select_sunset_video(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("expected", TEST_DATA["compare_video_1"]["expected"]) @pytest.mark.parametrize("expected", TEST_DATA["compare_video_1"]["expected"])
def test_video_compare_exif(photoslib, suspend_capture, expected, output_file): def test_video_compare_exif(photoslib, suspend_capture, expected):
"""Test --compare-exif with video""" """Test --compare-exif with video"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -685,13 +679,11 @@ def test_video_compare_exif(photoslib, suspend_capture, expected, output_file):
"--compare-exif", "--compare-exif",
"--plain", "--plain",
"--force", "--force",
"-o",
output_file,
], ],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
assert result.exit_code == 0 assert result.exit_code == 0
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == expected assert output_values[0] == expected
@ -708,22 +700,17 @@ def test_video_date_delta(
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
[ ["--date-delta", input_value, "--plain", "--force"],
"--date-delta",
input_value,
"--plain",
"--force",
],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@ -751,16 +738,16 @@ def test_video_time_delta(
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["video_date"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["video_date"]["parameters"])
def test_video_date(photoslib, suspend_capture, input_value, expected, output_file): def test_video_date(photoslib, suspend_capture, input_value, expected):
"""Test --date with video""" """Test --date with video"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -780,16 +767,16 @@ def test_video_date(photoslib, suspend_capture, input_value, expected, output_fi
# don't use photo.date as it will return local time instead of the time in the timezone # don't use photo.date as it will return local time instead of the time in the timezone
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["video_time"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["video_time"]["parameters"])
def test_video_time(photoslib, suspend_capture, input_value, expected, output_file): def test_video_time(photoslib, suspend_capture, input_value, expected):
"""Test --time with video""" """Test --time with video"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -809,10 +796,10 @@ def test_video_time(photoslib, suspend_capture, input_value, expected, output_fi
# don't use photo.date as it will return local time instead of the time in the timezone # don't use photo.date as it will return local time instead of the time in the timezone
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@ -840,17 +827,17 @@ def test_video_time_zone(
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected_date assert output_values[0].date_tz == expected_date
assert output_values[0].tz_offset == expected_tz assert output_values[0].tz_offset == expected_tz
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.parametrize("input_value,expected", TEST_DATA["video_match"]["parameters"]) @pytest.mark.parametrize("input_value,expected", TEST_DATA["video_match"]["parameters"])
def test_video_match(photoslib, suspend_capture, input_value, expected, output_file): def test_video_match(photoslib, suspend_capture, input_value, expected):
"""Test --timezone --match with video""" """Test --timezone --match with video"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -869,15 +856,15 @@ def test_video_match(photoslib, suspend_capture, input_value, expected, output_f
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_tz == expected assert output_values[0].date_tz == expected
@pytest.mark.timewarp @pytest.mark.timewarp
def test_video_push_exif(photoslib, suspend_capture, output_file): def test_video_push_exif(photoslib, suspend_capture):
"""Test --push-exif with video""" """Test --push-exif with video"""
pre_test = TEST_DATA["video_push_exif"]["pre"] pre_test = TEST_DATA["video_push_exif"]["pre"]
post_test = TEST_DATA["video_push_exif"]["post"] post_test = TEST_DATA["video_push_exif"]["post"]
@ -888,10 +875,10 @@ def test_video_push_exif(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -908,15 +895,15 @@ def test_video_push_exif(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@pytest.mark.timewarp @pytest.mark.timewarp
def test_video_pull_exif(photoslib, suspend_capture, output_file): def test_video_pull_exif(photoslib, suspend_capture):
"""Test --pull-exif with video""" """Test --pull-exif with video"""
pre_test = TEST_DATA["video_pull_exif"]["pre"] pre_test = TEST_DATA["video_pull_exif"]["pre"]
post_test = TEST_DATA["video_pull_exif"]["post"] post_test = TEST_DATA["video_pull_exif"]["post"]
@ -946,10 +933,10 @@ def test_video_pull_exif(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == pre_test assert output_values[0] == pre_test
result = runner.invoke( result = runner.invoke(
@ -966,10 +953,10 @@ def test_video_pull_exif(photoslib, suspend_capture, output_file):
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--compare-exif", "--plain", "--force", "-o", output_file], ["--compare-exif", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(result.output)
assert output_values[0] == post_test assert output_values[0] == post_test
@ -980,7 +967,7 @@ def test_select_pears_3(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
def test_function(photoslib, suspend_capture, output_file): def test_function(photoslib, suspend_capture):
"""Test timewarp function""" """Test timewarp function"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -999,10 +986,10 @@ def test_function(photoslib, suspend_capture, output_file):
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0] == expected assert output_values[0] == expected
@ -1013,8 +1000,7 @@ def test_select_palm_tree_1(photoslib, suspend_capture):
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.skipif(get_os_version()[0] != "13", reason="test requires macOS 13") def test_parse_date(photoslib, suspend_capture):
def test_parse_date(photoslib, suspend_capture, output_file):
"""Test --parse-date""" """Test --parse-date"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -1033,18 +1019,17 @@ def test_parse_date(photoslib, suspend_capture, output_file):
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_local == expected.date_local assert output_values[0].date_local == expected.date_local
assert output_values[0].date_tz == expected.date_tz assert output_values[0].date_tz == expected.date_tz
assert output_values[0].tz_offset == expected.tz_offset assert output_values[0].tz_offset == expected.tz_offset
@pytest.mark.timewarp @pytest.mark.timewarp
@pytest.mark.skipif(get_os_version()[0] != "13", reason="test requires macOS 13") def test_parse_date_tz(photoslib, suspend_capture):
def test_parse_date_tz(photoslib, suspend_capture, output_file):
"""Test --parse-date with a timezone""" """Test --parse-date with a timezone"""
from osxphotos.cli.timewarp import timewarp from osxphotos.cli.timewarp import timewarp
@ -1063,10 +1048,10 @@ def test_parse_date_tz(photoslib, suspend_capture, output_file):
assert result.exit_code == 0 assert result.exit_code == 0
result = runner.invoke( result = runner.invoke(
timewarp, timewarp,
["--inspect", "--plain", "--force", "-o", output_file], ["--inspect", "--plain", "--force"],
terminal_width=TERMINAL_WIDTH, terminal_width=TERMINAL_WIDTH,
) )
output_values = parse_inspect_output(output_file) output_values = parse_inspect_output(result.output)
assert output_values[0].date_local == expected.date_local assert output_values[0].date_local == expected.date_local
assert output_values[0].date_tz == expected.date_tz assert output_values[0].date_tz == expected.date_tz
assert output_values[0].tz_offset == expected.tz_offset assert output_values[0].tz_offset == expected.tz_offset

View File

@ -2,6 +2,8 @@
import pytest import pytest
from osxphotos.queryoptions import load_uuid_from_file
UUID_FILE = "tests/uuid_from_file.txt" UUID_FILE = "tests/uuid_from_file.txt"
MISSING_UUID_FILE = "tests/uuid_not_found.txt" MISSING_UUID_FILE = "tests/uuid_not_found.txt"
@ -13,8 +15,7 @@ UUID_EXPECTED_FROM_FILE = [
def test_load_uuid_from_file(): def test_load_uuid_from_file():
"""Test load_uuid_from_file function """ """Test load_uuid_from_file function"""
from osxphotos.cli import load_uuid_from_file
uuid_got = load_uuid_from_file(UUID_FILE) uuid_got = load_uuid_from_file(UUID_FILE)
assert uuid_got == UUID_EXPECTED_FROM_FILE assert uuid_got == UUID_EXPECTED_FROM_FILE
@ -22,7 +23,6 @@ def test_load_uuid_from_file():
def test_load_uuid_from_file_filenotfound(): def test_load_uuid_from_file_filenotfound():
"""Test load_uuid_from_file function raises error if file not found""" """Test load_uuid_from_file function raises error if file not found"""
from osxphotos.cli import load_uuid_from_file
with pytest.raises(FileNotFoundError) as err: with pytest.raises(FileNotFoundError) as err:
uuid_got = load_uuid_from_file(MISSING_UUID_FILE) uuid_got = load_uuid_from_file(MISSING_UUID_FILE)

116
tests/test_cli_verbose.py Normal file
View File

@ -0,0 +1,116 @@
"""Test verbose functions"""
import re
from io import StringIO
from osxphotos.cli.verbose import (
get_verbose_level,
set_verbose_level,
verbose,
verbose_print,
_reset_verbose_globals,
)
def test_set_get_verbose_level(capsys):
"""Test verbose_print"""
set_verbose_level(2)
assert get_verbose_level() == 2
def test_verbose_print_no_rich(capsys):
"""Test verbose_print"""
set_verbose_level(1)
verbose = verbose_print(1, False, False)
verbose("test")
captured = capsys.readouterr()
assert captured.out.strip() == "test"
verbose("test2", level=1)
captured = capsys.readouterr()
assert captured.out.strip() == "test2"
verbose("test3", level=2)
captured = capsys.readouterr()
assert captured.out.strip() == ""
def test_verbose_print_rich(capsys):
"""Test verbose with rich"""
set_verbose_level(1)
verbose = verbose_print(1, False, True)
verbose("test")
captured = capsys.readouterr()
assert captured.out.strip() == "test"
verbose("test2", level=1)
captured = capsys.readouterr()
assert captured.out.strip() == "test2"
verbose("test3", level=2)
captured = capsys.readouterr()
assert captured.out.strip() == ""
def test_verbose_print_timestamp(capsys):
"""Test verbose with timestamp"""
set_verbose_level(1)
verbose = verbose_print(1, True, False)
verbose("test")
captured = capsys.readouterr()
# regex to match timestamp in this format: 2023-01-25 06:40:18.216297
assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}", captured.out.strip())
assert captured.out.strip().endswith("test")
verbose("test2", level=1)
captured = capsys.readouterr()
assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}", captured.out.strip())
assert captured.out.strip().endswith("test2")
verbose("test3", level=2)
captured = capsys.readouterr()
assert captured.out.strip() == ""
def test_verbose_print_file():
"""Test verbose with file"""
set_verbose_level(1)
stream = StringIO()
verbose = verbose_print(1, False, False, file=stream)
verbose("test")
assert stream.getvalue().strip() == "test"
def test_verbose_print_noop(capsys):
"""Test verbose with noop"""
set_verbose_level(1)
verbose = verbose_print(0, False, False)
verbose("test")
captured = capsys.readouterr()
assert captured.out.strip() == ""
def test_verbose(capsys):
""" "Test verbose()"""
# reset verbose module globals for testing
_reset_verbose_globals()
set_verbose_level(0)
verbose("test")
captured = capsys.readouterr()
assert captured.out.strip() == ""
set_verbose_level(1)
verbose("test")
captured = capsys.readouterr()
assert captured.out.strip() == "test"
verbose("test2", level=2)
captured = capsys.readouterr()
assert captured.out.strip() == ""
set_verbose_level(2)
verbose("test2", level=2)
captured = capsys.readouterr()
assert captured.out.strip() == "test2"

View File

@ -7,14 +7,29 @@ from osxphotos.debug import is_debug, set_debug
def test_debug_enable(): def test_debug_enable():
"""test set_debug()"""
set_debug(True) set_debug(True)
logger = osxphotos._get_logger() assert osxphotos.logger.isEnabledFor(logging.DEBUG)
assert logger.isEnabledFor(logging.DEBUG)
assert is_debug() assert is_debug()
def test_debug_disable(): def test_debug_disable():
"""test set_debug()"""
set_debug(False) set_debug(False)
logger = osxphotos._get_logger() assert not osxphotos.logger.isEnabledFor(logging.DEBUG)
assert not logger.isEnabledFor(logging.DEBUG)
assert not is_debug() assert not is_debug()
def test_debug_print_true(caplog):
"""test debug()"""
set_debug(True)
logger = osxphotos.logger
logger.debug("test debug")
assert "test debug" in caplog.text
def test_debug_print_false(caplog):
set_debug(False)
logger = osxphotos.logger
logger.debug("test debug")
assert caplog.text == ""

View File

@ -2,9 +2,9 @@ import os
import pytest import pytest
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.utils import _get_os_version from osxphotos.utils import get_macos_version
OS_VERSION = _get_os_version() OS_VERSION = get_macos_version()
SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15" SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15"
PHOTOS_DB = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") PHOTOS_DB = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
pytestmark = pytest.mark.skipif( pytestmark = pytest.mark.skipif(

View File

@ -14,9 +14,9 @@ import pytest
import osxphotos import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import _get_os_version from osxphotos.utils import get_macos_version
OS_VERSION = _get_os_version() OS_VERSION = get_macos_version()
# SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "17" # SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "17"
SKIP_TEST = True # don't run any of the local library tests SKIP_TEST = True # don't run any of the local library tests
PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")