2022-02-22 06:40:25 -08:00

5109 lines
173 KiB
Python

"""Command line interface for osxphotos """
import atexit
import code
import cProfile
import csv
import dataclasses
import datetime
import io
import json
import os
import os.path
import pathlib
import pprint
import pstats
import shlex
import shutil
import subprocess
import sys
import time
from runpy import run_module, run_path
from typing import Dict, List
import bitmath
import click
import osxmetadata
import photoscript
import rich.traceback
import yaml
from rich import pretty, print
from rich.console import Console
from rich.syntax import Syntax
import osxphotos
from ._constants import (
_EXIF_TOOL_URL,
_OSXPHOTOS_NONE_SENTINEL,
_PHOTOS_4_VERSION,
_UNKNOWN_PLACE,
CLI_COLOR_ERROR,
CLI_COLOR_WARNING,
DEFAULT_EDITED_SUFFIX,
DEFAULT_JPEG_QUALITY,
DEFAULT_ORIGINAL_SUFFIX,
DEFAULT_PREVIEW_SUFFIX,
EXTENDED_ATTRIBUTE_NAMES,
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
OSXPHOTOS_URL,
POST_COMMAND_CATEGORIES,
PROFILE_SORT_KEYS,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
)
from ._version import __version__
from .cli_help import ExportCommand, tutorial_help
from .configoptions import (
ConfigOptions,
ConfigOptionsInvalidError,
ConfigOptionsLoadError,
)
from .crash_reporter import crash_reporter
from .datetime_formatter import DateTimeFormatter
from .exiftool import get_exiftool_path
from .export_db import ExportDB, ExportDBInMemory
from .export_db_utils import (
OSXPHOTOS_EXPORTDB_VERSION,
export_db_check_signatures,
export_db_get_last_run,
export_db_get_version,
export_db_save_config_to_file,
export_db_touch_files,
export_db_update_signatures,
export_db_vacuum,
)
from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoexporter import ExportOptions, ExportResults, PhotoExporter
from .photoinfo import PhotoInfo
from .photokit import check_photokit_authorization, request_photokit_authorization
from .photosalbum import PhotosAlbum
from .photosdb import PhotosDB
from .photosdb.photosdb_utils import get_photos_library_version
from .phototemplate import PhotoTemplate, RenderOptions
from .pyrepl import embed_repl
from .queryoptions import QueryOptions
from .sqlgrep import sqlgrep
from .uti import get_preferred_uti_extension
from .utils import (
expand_and_validate_filepath,
format_sec_to_hhmmss,
load_function,
normalize_fs_path,
)
__all__ = [
"verbose_",
"get_photos_db",
"DateTimeISO8601",
"BitMathSize",
"TimeISO8601",
"FunctionCall",
"CLI_Obj",
"DELETED_OPTIONS",
"QUERY_OPTIONS",
"cli",
"export",
"help",
"query",
"print_photo_info",
"export_photo",
"export_photo_to_directory",
"get_filenames_from_template",
"get_dirnames_from_template",
"find_files_in_branch",
"load_uuid_from_file",
"write_export_report",
"cleanup_files",
"write_finder_tags",
"write_extended_attributes",
"run_post_command",
"install",
"uninstall",
"keywords",
"albums",
"persons",
"labels",
"info",
"places",
"dump",
"list_libraries",
"uuid",
"about",
"tutorial",
"repl",
"grep",
"debug_dump",
"snap",
"diff",
]
# global variable to control verbose output
# set via --verbose/-V
VERBOSE = False
VERBOSE_TIMESTAMP = False
# global variable to control debug output
# set via --debug
DEBUG = False
# used to show/hide hidden commands
OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False))
# used by snap and diff commands
OSXPHOTOS_SNAPSHOT_DIR = "/private/tmp/osxphotos_snapshots"
# where to write the crash report if osxphotos crashes
OSXPHOTOS_CRASH_LOG = os.getcwd() + "/osxphotos_crash.log"
rich.traceback.install()
def verbose_(*args, **kwargs):
"""print output if verbose flag set"""
if VERBOSE:
styled_args = []
timestamp = str(datetime.datetime.now()) + " -- " if VERBOSE_TIMESTAMP else ""
for arg in args:
if type(arg) == str:
arg = timestamp + arg
if "error" in arg.lower():
arg = click.style(arg, fg=CLI_COLOR_ERROR)
elif "warning" in arg.lower():
arg = click.style(arg, fg=CLI_COLOR_WARNING)
styled_args.append(arg)
click.echo(*styled_args, **kwargs)
def get_photos_db(*db_options):
"""Return path to photos db, select first non-None db_options
If no db_options are non-None, try to find library to use in
the following order:
- last library opened
- system library
- ~/Pictures/Photos Library.photoslibrary
- failing above, returns None
"""
if db_options:
for db in db_options:
if db is not None:
return db
# if get here, no valid database paths passed, so try to figure out which to use
db = osxphotos.utils.get_last_library_path()
if db is not None:
click.echo(f"Using last opened Photos library: {db}", err=True)
return db
db = osxphotos.utils.get_system_library_path()
if db is not None:
click.echo(f"Using system Photos library: {db}", err=True)
return db
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
if os.path.isdir(db):
click.echo(f"Using Photos library: {db}", err=True)
return db
else:
return None
class DateTimeISO8601(click.ParamType):
name = "DATETIME"
def convert(self, value, param, ctx):
try:
return datetime.datetime.fromisoformat(value)
except Exception:
self.fail(
f"Invalid datetime format {value}. "
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
)
class BitMathSize(click.ParamType):
name = "BITMATH"
def convert(self, value, param, ctx):
try:
value = bitmath.parse_string(value)
except ValueError:
# no units specified
try:
value = int(value)
value = bitmath.Byte(value)
except ValueError as e:
self.fail(
f"{value} must be specified as bytes or using SI/NIST units. "
+ "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'."
)
return value
class TimeISO8601(click.ParamType):
name = "TIME"
def convert(self, value, param, ctx):
try:
return datetime.time.fromisoformat(value).replace(tzinfo=None)
except Exception:
self.fail(
f"Invalid time format {value}. "
"Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] "
"however, note that timezone will be ignored."
)
class FunctionCall(click.ParamType):
name = "FUNCTION"
def convert(self, value, param, ctx):
if "::" not in value:
self.fail(
f"Could not parse function name from '{value}'. "
"Valid format filename.py::function"
)
filename, funcname = value.split("::")
filename_validated = expand_and_validate_filepath(filename)
if not filename_validated:
self.fail(f"'{filename}' does not appear to be a file")
try:
function = load_function(filename_validated, funcname)
except Exception as e:
self.fail(f"Could not load function {funcname} from {filename_validated}")
return (function, value)
class ExportDBType(click.ParamType):
name = "EXPORTDB"
def convert(self, value, param, ctx):
try:
export_db_name = pathlib.Path(value)
if export_db_name.is_dir():
raise click.BadParameter(f"{value} is a directory")
if export_db_name.is_file():
# verify it's actually an osxphotos export_db
# export_db_get_version will raise an error if it's not valid
osxphotos_ver, export_db_ver = export_db_get_version(value)
return value
except Exception:
self.fail(f"{value} exists but is not a valid osxphotos export database. ")
class IncompatibleQueryOptions(Exception):
pass
# Click CLI object & context settings
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
if debug:
osxphotos._set_debug(True)
self.db = db
self.json = json
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
DB_OPTION = click.option(
"--db",
required=False,
metavar="<Photos database path>",
default=None,
help=(
"Specify Photos database path. "
"Path to Photos library/database can be specified using either --db "
"or directly as PHOTOS_LIBRARY positional argument. "
"If neither --db or PHOTOS_LIBRARY provided, will attempt to find the library "
"to use in the following order: 1. last opened library, 2. system library, 3. ~/Pictures/Photos Library.photoslibrary"
),
type=click.Path(exists=True),
)
DB_ARGUMENT = click.argument("photos_library", nargs=-1, type=click.Path(exists=True))
JSON_OPTION = click.option(
"--json",
"json_",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format.",
)
def DELETED_OPTIONS(f):
o = click.option
options = [
o(
"--deleted",
is_flag=True,
help="Include photos from the 'Recently Deleted' folder.",
),
o(
"--deleted-only",
is_flag=True,
help="Include only photos from the 'Recently Deleted' folder.",
),
]
for o in options[::-1]:
f = o(f)
return f
def QUERY_OPTIONS(f):
o = click.option
options = [
o(
"--keyword",
metavar="KEYWORD",
default=None,
multiple=True,
help="Search for photos with keyword KEYWORD. "
'If more than one keyword, treated as "OR", e.g. find photos matching any keyword',
),
o(
"--person",
metavar="PERSON",
default=None,
multiple=True,
help="Search for photos with person PERSON. "
'If more than one person, treated as "OR", e.g. find photos matching any person',
),
o(
"--album",
metavar="ALBUM",
default=None,
multiple=True,
help="Search for photos in album ALBUM. "
'If more than one album, treated as "OR", e.g. find photos matching any album',
),
o(
"--folder",
metavar="FOLDER",
default=None,
multiple=True,
help="Search for photos in an album in folder FOLDER. "
'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. '
"Only searches top level folders (e.g. does not look at subfolders)",
),
o(
"--name",
metavar="FILENAME",
default=None,
multiple=True,
help="Search for photos with filename matching FILENAME. "
'If more than one --name options is specified, they are treated as "OR", '
"e.g. find photos matching any FILENAME. ",
),
o(
"--uuid",
metavar="UUID",
default=None,
multiple=True,
help="Search for photos with UUID(s). "
"May be repeated to include multiple UUIDs.",
),
o(
"--uuid-from-file",
metavar="FILE",
default=None,
multiple=False,
help="Search for photos with UUID(s) loaded from FILE. "
"Format is a single UUID per line. Lines preceded with # are ignored.",
type=click.Path(exists=True),
),
o(
"--title",
metavar="TITLE",
default=None,
multiple=True,
help="Search for TITLE in title of photo.",
),
o("--no-title", is_flag=True, help="Search for photos with no title."),
o(
"--description",
metavar="DESC",
default=None,
multiple=True,
help="Search for DESC in description of photo.",
),
o(
"--no-description",
is_flag=True,
help="Search for photos with no description.",
),
o(
"--place",
metavar="PLACE",
default=None,
multiple=True,
help="Search for PLACE in photo's reverse geolocation info",
),
o(
"--no-place",
is_flag=True,
help="Search for photos with no associated place name info (no reverse geolocation info)",
),
o(
"--location",
is_flag=True,
help="Search for photos with associated location info (e.g. GPS coordinates)",
),
o(
"--no-location",
is_flag=True,
help="Search for photos with no associated location info (e.g. no GPS coordinates)",
),
o(
"--label",
metavar="LABEL",
multiple=True,
help="Search for photos with image classification label LABEL (Photos 5 only). "
'If more than one label, treated as "OR", e.g. find photos matching any label',
),
o(
"--uti",
metavar="UTI",
default=None,
multiple=False,
help="Search for photos whose uniform type identifier (UTI) matches UTI",
),
o(
"-i",
"--ignore-case",
is_flag=True,
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(
"--external-edit",
is_flag=True,
help="Search for photos edited in external editor.",
),
o("--favorite", is_flag=True, help="Search for photos marked favorite."),
o(
"--not-favorite",
is_flag=True,
help="Search for photos not marked favorite.",
),
o("--hidden", is_flag=True, help="Search for photos marked hidden."),
o("--not-hidden", is_flag=True, help="Search for photos not marked hidden."),
o(
"--shared",
is_flag=True,
help="Search for photos in shared iCloud album (Photos 5 only).",
),
o(
"--not-shared",
is_flag=True,
help="Search for photos not in shared iCloud album (Photos 5 only).",
),
o(
"--burst",
is_flag=True,
help="Search for photos that were taken in a burst.",
),
o(
"--not-burst",
is_flag=True,
help="Search for photos that are not part of a burst.",
),
o("--live", is_flag=True, help="Search for Apple live photos"),
o(
"--not-live",
is_flag=True,
help="Search for photos that are not Apple live photos.",
),
o("--portrait", is_flag=True, help="Search for Apple portrait mode photos."),
o(
"--not-portrait",
is_flag=True,
help="Search for photos that are not Apple portrait mode photos.",
),
o("--screenshot", is_flag=True, help="Search for screenshot photos."),
o(
"--not-screenshot",
is_flag=True,
help="Search for photos that are not screenshot photos.",
),
o("--slow-mo", is_flag=True, help="Search for slow motion videos."),
o(
"--not-slow-mo",
is_flag=True,
help="Search for photos that are not slow motion videos.",
),
o("--time-lapse", is_flag=True, help="Search for time lapse videos."),
o(
"--not-time-lapse",
is_flag=True,
help="Search for photos that are not time lapse videos.",
),
o("--hdr", is_flag=True, help="Search for high dynamic range (HDR) photos."),
o("--not-hdr", is_flag=True, help="Search for photos that are not HDR photos."),
o(
"--selfie",
is_flag=True,
help="Search for selfies (photos taken with front-facing cameras).",
),
o("--not-selfie", is_flag=True, help="Search for photos that are not selfies."),
o("--panorama", is_flag=True, help="Search for panorama photos."),
o(
"--not-panorama",
is_flag=True,
help="Search for photos that are not panoramas.",
),
o(
"--has-raw",
is_flag=True,
help="Search for photos with both a jpeg and raw version",
),
o(
"--only-movies",
is_flag=True,
help="Search only for movies (default searches both images and movies).",
),
o(
"--only-photos",
is_flag=True,
help="Search only for photos/images (default searches both images and movies).",
),
o(
"--from-date",
help="Search by item start date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
type=DateTimeISO8601(),
),
o(
"--to-date",
help="Search by item end date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
type=DateTimeISO8601(),
),
o(
"--from-time",
help="Search by item start time of day, e.g. 12:00, or 12:00:00.",
type=TimeISO8601(),
),
o(
"--to-time",
help="Search by item end time of day, e.g. 12:00 or 12:00:00.",
type=TimeISO8601(),
),
o("--has-comment", is_flag=True, help="Search for photos that have comments."),
o("--no-comment", is_flag=True, help="Search for photos with no comments."),
o("--has-likes", is_flag=True, help="Search for photos that have likes."),
o("--no-likes", is_flag=True, help="Search for photos with no likes."),
o(
"--is-reference",
is_flag=True,
help="Search for photos that were imported as referenced files (not copied into Photos library).",
),
o(
"--in-album",
is_flag=True,
help="Search for photos that are in one or more albums.",
),
o(
"--not-in-album",
is_flag=True,
help="Search for photos that are not in any albums.",
),
o(
"--duplicate",
is_flag=True,
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
"times or duplicated within Photos.",
),
o(
"--min-size",
metavar="SIZE",
type=BitMathSize(),
help="Search for photos with size >= SIZE bytes. "
"The size evaluated is the photo's original size (when imported to Photos). "
"Size may be specified as integer bytes or using SI or NIST units. "
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
),
o(
"--max-size",
metavar="SIZE",
type=BitMathSize(),
help="Search for photos with size <= SIZE bytes. "
"The size evaluated is the photo's original size (when imported to Photos). "
"Size may be specified as integer bytes or using SI or NIST units. "
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
),
o(
"--regex",
metavar="REGEX TEMPLATE",
nargs=2,
multiple=True,
help="Search for photos where TEMPLATE matches regular expression REGEX. "
"For example, to find photos in an album that begins with 'Beach': '--regex \"^Beach\" \"{album}\"'. "
"You may specify more than one regular expression match by repeating '--regex' with different arguments.",
),
o(
"--selected",
is_flag=True,
help="Filter for photos that are currently selected in Photos.",
),
o(
"--exif",
metavar="EXIF_TAG VALUE",
nargs=2,
multiple=True,
help="Search for photos where EXIF_TAG exists in photo's EXIF data and contains VALUE. "
"For example, to find photos created by Adobe Photoshop: `--exif Software 'Adobe Photoshop' `"
"or to find all photos shot on a Canon camera: `--exif Make Canon`. "
"EXIF_TAG can be any valid exiftool tag, with or without group name, e.g. `EXIF:Make` or `Make`. "
"To use --exif, exiftool must be installed and in the path.",
),
o(
"--query-eval",
metavar="CRITERIA",
multiple=True,
help="Evaluate CRITERIA to filter photos. "
"CRITERIA will be evaluated in context of the following python list comprehension: "
"`photos = [photo for photo in photos if CRITERIA]` "
"where photo represents a PhotoInfo object. "
"For example: `--query-eval photo.favorite` returns all photos that have been "
"favorited and is equivalent to --favorite. "
"You may specify more than one CRITERIA by using --query-eval multiple times. "
"CRITERIA must be a valid python expression. "
"See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
),
o(
"--query-function",
metavar="filename.py::function",
multiple=True,
type=FunctionCall(),
help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python "
+ "file you've created and function is the name of the function in the python file you want to call. "
+ "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. "
+ "You may use more than one function by repeating the --query-function option with a different value. "
+ "Your query function will be called after all other query options have been evaluated. "
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.",
),
]
for o in options[::-1]:
f = o(f)
return f
@click.group(context_settings=CTX_SETTINGS)
@DB_OPTION
@JSON_OPTION
@click.option(
"--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN
)
@click.version_option(__version__, "--version", "-v")
@click.pass_context
def cli(ctx, db, json_, debug):
ctx.obj = CLI_Obj(db=db, json=json_)
@cli.command(cls=ExportCommand)
@DB_OPTION
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output")
@QUERY_OPTIONS
@click.option(
"--missing",
is_flag=True,
help="Export only photos missing from the Photos library; must be used with --download-missing.",
)
@DELETED_OPTIONS
@click.option(
"--update",
is_flag=True,
help="Only export new or updated files. "
"See also --force-update and notes below on export and --update.",
)
@click.option(
"--force-update",
is_flag=True,
help="Only export new or updated files. Unlike --update, --force-update will re-export photos "
"if their metadata has changed even if this would not otherwise trigger an export. "
"See also --update and notes below on export and --update.",
)
@click.option(
"--ignore-signature",
is_flag=True,
help="When used with '--update', ignores file signature when updating files. "
"This is useful if you have processed or edited exported photos changing the "
"file signature (size & modification date). In this case, '--update' would normally "
"re-export the processed files but with '--ignore-signature', files which exist "
"in the export directory will not be re-exported. "
"If used with '--sidecar', '--ignore-signature' has the following behavior: "
"1) if the metadata (in Photos) that went into the sidecar did not change, "
"the sidecar will not be updated; "
"2) if the metadata (in Photos) that went into the sidecar did change, "
"a new sidecar is written but a new image file is not; "
"3) if a sidecar does not exist for the photo, a sidecar will be written "
"whether or not the photo file was written or updated.",
)
@click.option(
"--only-new",
is_flag=True,
help="If used with --update, ignores any previously exported files, even if missing from "
"the export folder and only exports new files that haven't previously been exported.",
)
@click.option(
"--dry-run",
is_flag=True,
help="Dry run (test) the export but don't actually export any files; most useful with --verbose.",
)
@click.option(
"--export-as-hardlink",
is_flag=True,
help="Hardlink files instead of copying them. "
"Cannot be used with --exiftool which creates copies of the files with embedded EXIF data. "
"Note: on APFS volumes, files are cloned when exporting giving many of the same "
"advantages as hardlinks without having to use --export-as-hardlink.",
)
@click.option(
"--touch-file",
is_flag=True,
help="Sets the file's modification time to match photo date.",
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files. "
"Default behavior is to add (1), (2), etc to filename if file already exists. "
"Use this with caution as it may create name collisions on export. "
"(e.g. if two files happen to have the same name)",
)
@click.option(
"--retry",
metavar="RETRY",
type=click.INT,
help="Automatically retry export up to RETRY times if an error occurs during export. "
"This may be useful with network drives that experience intermittent errors.",
)
@click.option(
"--export-by-date",
is_flag=True,
help="Automatically create output folders to organize photos by date created "
"(e.g. DEST/2019/12/20/photoname.jpg).",
)
@click.option(
"--skip-edited",
is_flag=True,
help="Do not export edited version of photo if an edited version exists.",
)
@click.option(
"--skip-original-if-edited",
is_flag=True,
help="Do not export original if there is an edited version (exports only the edited version).",
)
@click.option(
"--skip-bursts",
is_flag=True,
help="Do not export all associated burst images in the library if a photo is a burst photo. ",
)
@click.option(
"--skip-live",
is_flag=True,
help="Do not export the associated live video component of a live photo.",
)
@click.option(
"--skip-raw",
is_flag=True,
help="Do not export associated RAW image of a RAW+JPEG pair. "
"Note: this does not skip RAW photos if the RAW photo does not have an associated JPEG image "
"(e.g. the RAW file was imported to Photos without a JPEG preview).",
)
@click.option(
"--skip-uuid",
metavar="UUID",
default=None,
multiple=True,
help="Skip photos with UUID(s) during export. "
"May be repeated to include multiple UUIDs.",
)
@click.option(
"--skip-uuid-from-file",
metavar="FILE",
default=None,
multiple=False,
help="Skip photos with UUID(s) loaded from FILE. "
"Format is a single UUID per line. Lines preceded with # are ignored.",
type=click.Path(exists=True),
)
@click.option(
"--current-name",
is_flag=True,
help="Use photo's current filename instead of original filename for export. "
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
"photos are exported with the the original name they had before import.",
)
@click.option(
"--convert-to-jpeg",
is_flag=True,
help="Convert all non-JPEG images (e.g. RAW, HEIC, PNG, etc) to JPEG upon export. "
"Note: does not convert the RAW component of a RAW+JPEG pair as the associated JPEG image "
"will be exported. You can use --skip-raw to skip exporting the associated RAW image of "
"a RAW+JPEG pair. See also --jpeg-quality and --jpeg-ext. "
"Only works if your Mac has a GPU (thus may not work on virtual machines).",
)
@click.option(
"--jpeg-quality",
type=click.FloatRange(0.0, 1.0),
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
"A value of 1.0 specifies best quality, "
"a value of 0.0 specifies maximum compression. "
f"Defaults to {DEFAULT_JPEG_QUALITY}",
)
@click.option(
"--preview",
is_flag=True,
help="Export preview image generated by Photos. "
"This is a lower-resolution image used by Photos to quickly preview the image. "
"See also --preview-suffix and --preview-if-missing.",
)
@click.option(
"--preview-if-missing",
is_flag=True,
help="Export preview image generated by Photos if the actual photo file is missing from the library. "
"This may be helpful if photos were not copied to the Photos library and the original photo is missing. "
"See also --preview-suffix and --preview.",
)
@click.option(
"--preview-suffix",
metavar="SUFFIX",
help="Optional suffix template for naming preview photos. Default name for preview photos is in form "
f"'photoname{DEFAULT_PREVIEW_SUFFIX}.ext'. For example, with '--preview-suffix _low_res', the preview photo "
f"would be named 'photoname_low_res.ext'. The default suffix is '{DEFAULT_PREVIEW_SUFFIX}'. "
"Multi-value templates (see Templating System) are not permitted with --preview-suffix. "
"See also --preview and --preview-if-missing.",
)
@click.option(
"--download-missing",
is_flag=True,
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
"the photo does not exist on disk. This will be slow and will require internet connection. "
"This obviously only works if the Photos library is synched to iCloud. "
"Note: --download-missing does not currently export all burst images; "
"only the primary photo will be exported--associated burst images will be skipped.",
)
@click.option(
"--sidecar",
default=None,
multiple=True,
metavar="FORMAT",
type=click.Choice(["xmp", "json", "exiftool"], case_sensitive=False),
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json, exiftool; "
"--sidecar xmp: create XMP sidecar used by Digikam, Adobe Lightroom, etc. "
"The sidecar file is named in format photoname.ext.xmp "
"The XMP sidecar exports the following tags: Description, Title, Keywords/Tags, "
"Subject (set to Keywords + PersonInImage), PersonInImage, CreateDate, ModifyDate, "
"GPSLongitude, Face Regions (Metadata Working Group and Microsoft Photo)."
f"\n--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" '
"The sidecar file is named in format photoname.ext.json; "
"format includes tag groups (equivalent to running 'exiftool -G -j'). "
"\n--sidecar exiftool: create JSON sidecar compatible with output of 'exiftool -j'. "
"Unlike '--sidecar json', '--sidecar exiftool' does not export tag groups. "
"Sidecar filename is in format photoname.ext.json; "
"For a list of tags exported in the JSON and exiftool sidecar, see '--exiftool'. "
"See also '--ignore-signature'.",
)
@click.option(
"--sidecar-drop-ext",
is_flag=True,
help="Drop the photo's extension when naming sidecar files. "
"By default, sidecar files are named in format 'photo_filename.photo_ext.sidecar_ext', "
"e.g. 'IMG_1234.JPG.xmp'. Use '--sidecar-drop-ext' to ignore the photo extension. "
"Resulting sidecar files will have name in format 'IMG_1234.xmp'. "
"Warning: this may result in sidecar filename collisions if there are files of different "
"types but the same name in the output directory, e.g. 'IMG_1234.JPG' and 'IMG_1234.MOV'.",
)
@click.option(
"--exiftool",
is_flag=True,
help="Use exiftool to write metadata directly to exported photos. "
"To use this option, exiftool must be installed and in the path. "
"exiftool may be installed from https://exiftool.org/. "
"Cannot be used with --export-as-hardlink. Writes the following metadata: "
"EXIF:ImageDescription, XMP:Description (see also --description-template); "
"XMP:Title; XMP:TagsList, IPTC:Keywords, XMP:Subject "
"(see also --keyword-template, --person-keyword, --album-keyword); "
"XMP:PersonInImage; EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef; EXIF:GPSLatitude; EXIF:GPSLongitude; "
"EXIF:GPSPosition; EXIF:DateTimeOriginal; EXIF:OffsetTimeOriginal; "
"EXIF:ModifyDate (see --ignore-date-modified); IPTC:DateCreated; IPTC:TimeCreated; "
"(video files only): QuickTime:CreationDate; QuickTime:CreateDate; QuickTime:ModifyDate (see also --ignore-date-modified); "
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
)
@click.option(
"--exiftool-path",
metavar="EXIFTOOL_PATH",
type=click.Path(exists=True),
help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.",
)
@click.option(
"--exiftool-option",
multiple=True,
metavar="OPTION",
help="Optional flag/option to pass to exiftool when using --exiftool. "
"For example, --exiftool-option '-m' to ignore minor warnings. "
"Specify these as you would on the exiftool command line. "
"See exiftool docs at https://exiftool.org/exiftool_pod.html for full list of options. "
"More than one option may be specified by repeating the option, e.g. "
"--exiftool-option '-m' --exiftool-option '-F'. ",
)
@click.option(
"--exiftool-merge-keywords",
is_flag=True,
help="Merge any keywords found in the original file with keywords used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--exiftool-merge-persons",
is_flag=True,
help="Merge any persons found in the original file with persons used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--ignore-date-modified",
is_flag=True,
help="If used with --exiftool or --sidecar, will ignore the photo "
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
)
@click.option(
"--person-keyword",
is_flag=True,
help="Use person in image as keyword/tag when exporting metadata.",
)
@click.option(
"--album-keyword",
is_flag=True,
help="Use album name as keyword/tag when exporting metadata.",
)
@click.option(
"--keyword-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"keyword in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to add "
"the full path to the folder and album photo is contained in as a keyword when exporting "
'you could specify --keyword-template "{folder_album}" '
'You may specify more than one template, for example --keyword-template "{folder_album}" '
'--keyword-template "{created.year}". '
"See '--replace-keywords' and Templating System below.",
)
@click.option(
"--replace-keywords",
is_flag=True,
help="Replace keywords with any values specified with --keyword-template. "
"By default, --keyword-template will add keywords to any keywords already associated "
"with the photo. If --replace-keywords is specified, values from --keyword-template "
"will replace any existing keywords instead of adding additional keywords.",
)
@click.option(
"--description-template",
metavar="TEMPLATE",
multiple=False,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"description in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to append "
"'exported with osxphotos on [today's date]' to the description, you could specify "
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
)
@click.option(
"--finder-tag-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="Set MacOS Finder tags to TEMPLATE. These tags can be searched in the Finder or Spotlight with "
"'tag:tagname' format. For example, '--finder-tag-template \"{label}\"' to set Finder tags to photo labels. "
"You may specify multiple TEMPLATE values by using '--finder-tag-template' multiple times. "
"See also '--finder-tag-keywords and Extended Attributes below.'.",
)
@click.option(
"--finder-tag-keywords",
is_flag=True,
help="Set MacOS Finder tags to keywords; any keywords specified via '--keyword-template', '--person-keyword', etc. "
"will also be used as Finder tags. See also '--finder-tag-template and Extended Attributes below.'.",
)
@click.option(
"--xattr-template",
nargs=2,
metavar="ATTRIBUTE TEMPLATE",
multiple=True,
help="Set extended attribute ATTRIBUTE to TEMPLATE value. Valid attributes are: "
f"{', '.join(EXTENDED_ATTRIBUTE_NAMES_QUOTED)}. "
"For example, to set Finder comment to the photo's title and description: "
'\'--xattr-template findercomment "{title}; {descr}" '
"See Extended Attributes below for additional details on this option.",
)
@click.option(
"--directory",
metavar="DIRECTORY",
default=None,
help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. "
"See below for additional details on templating system.",
)
@click.option(
"--filename",
"filename_template",
metavar="FILENAME",
default=None,
help="Optional template for specifying name of output file in the form '{name,DEFAULT}'. "
"File extension will be added automatically--do not include an extension in the FILENAME template. "
"See below for additional details on templating system.",
)
@click.option(
"--jpeg-ext",
multiple=False,
metavar="EXTENSION",
type=click.Choice(["jpeg", "jpg", "JPEG", "JPG"], case_sensitive=True),
help="Specify file extension for JPEG files. Photos uses .jpeg for edited images but many images "
"are imported with .jpg or .JPG which can result in multiple different extensions used for JPEG files "
"upon export. Use --jpeg-ext to specify a single extension to use for all exported JPEG images. "
"Valid values are jpeg, jpg, JPEG, JPG; e.g. '--jpeg-ext jpg' to use '.jpg' for all JPEGs.",
)
@click.option(
"--strip",
is_flag=True,
help="Optionally strip leading and trailing whitespace from any rendered templates. "
'For example, if --filename template is "{title,} {original_name}" and image has no '
"title, resulting file would have a leading space but if used with --strip, this will "
"be removed.",
)
@click.option(
"--edited-suffix",
metavar="SUFFIX",
help="Optional suffix template for naming edited photos. Default name for edited photos is in form "
"'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo "
f"would be named 'photoname_bearbeiten.ext'. The default suffix is '{DEFAULT_EDITED_SUFFIX}'. "
"Multi-value templates (see Templating System) are not permitted with --edited-suffix.",
)
@click.option(
"--original-suffix",
metavar="SUFFIX",
help="Optional suffix template for naming original photos. Default name for original photos is in form "
"'filename.ext'. For example, with '--original-suffix _original', the original photo "
"would be named 'filename_original.ext'. The default suffix is '' (no suffix). "
"Multi-value templates (see Templating System) are not permitted with --original-suffix.",
)
@click.option(
"--use-photos-export",
is_flag=True,
help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').",
)
@click.option(
"--use-photokit",
is_flag=True,
help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. "
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
"This is faster and more reliable than the default AppleScript interface.",
)
@click.option(
"--report",
metavar="<path to export report>",
help="Write a CSV formatted report of all files that were exported.",
type=click.Path(),
)
@click.option(
"--cleanup",
is_flag=True,
help="Cleanup export directory by deleting any files which were not included in this export set. "
"For example, photos which had previously been exported and were subsequently deleted in Photos. "
"WARNING: --cleanup will delete *any* files in the export directory that were not exported by osxphotos, "
"for example, your own scripts or other files. Be sure this is what you intend before using "
"--cleanup. Use --dry-run with --cleanup first if you're not certain.",
)
@click.option(
"--add-exported-to-album",
metavar="ALBUM",
help="Add all exported photos to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All exported photos will be added to this album. "
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large export sets.",
)
@click.option(
"--add-skipped-to-album",
metavar="ALBUM",
help="Add all skipped photos to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All skipped photos will be added to this album. "
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large export sets.",
)
@click.option(
"--add-missing-to-album",
metavar="ALBUM",
help="Add all missing photos to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All missing photos will be added to this album. "
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large export sets.",
)
@click.option(
"--post-command",
metavar="CATEGORY COMMAND",
nargs=2,
type=(click.Choice(POST_COMMAND_CATEGORIES, case_sensitive=False), str),
multiple=True,
help="Run COMMAND on exported files of category CATEGORY. CATEGORY can be one of: "
f"{', '.join(list(POST_COMMAND_CATEGORIES.keys()))}. "
"COMMAND is an osxphotos template string, for example: '--post-command exported \"echo {filepath|shell_quote} >> {export_dir}/exported.txt\"', "
"which appends the full path of all exported files to the file 'exported.txt'. "
"You can run more than one command by repeating the '--post-command' option with different arguments. "
"See Post Command below.",
)
@click.option(
"--post-function",
metavar="filename.py::function",
nargs=1,
type=FunctionCall(),
multiple=True,
help="Run function on exported files. Use this in format: --post-function filename.py::function where filename.py is a python "
"file you've created and function is the name of the function in the python file you want to call. The function will be "
"passed information about the photo that's been exported and a list of all exported files associated with the photo. "
"You can run more than one function by repeating the '--post-function' option with different arguments. "
"See Post Function below.",
)
@click.option(
"--exportdb",
metavar="EXPORTDB_FILE",
default=None,
help=(
"Specify alternate path for database file which stores state information for export and --update. "
f"If --exportdb is not specified, export database will be saved to '{OSXPHOTOS_EXPORT_DB}' "
"in the export directory. If --exportdb is specified, it will be saved to the specified file. "
),
type=ExportDBType(),
)
@click.option(
"--ramdb",
is_flag=True,
help="Copy export database to memory during export; "
"may improve performance when exporting over a network or slow disk but could result in "
"losing update state information if the program is interrupted or crashes.",
)
@click.option(
"--load-config",
required=False,
metavar="<config file path>",
default=None,
help=(
"Load options from file as written with --save-config. "
"This allows you to save a complex export command to file for later reuse. "
"For example: 'osxphotos export <lots of options here> --save-config osxphotos.toml' then "
" 'osxphotos export /path/to/export --load-config osxphotos.toml'. "
"If any other command line options are used in conjunction with --load-config, "
"they will override the corresponding values in the config file."
),
type=click.Path(exists=True),
)
@click.option(
"--save-config",
required=False,
metavar="<config file path>",
default=None,
help="Save options to file for use with --load-config. File format is TOML. "
"See also --config-only.",
type=click.Path(),
)
@click.option(
"--config-only",
is_flag=True,
help="If specified, saves the config file but does not export any files; must be used with --save-config.",
)
@click.option(
"--beta",
is_flag=True,
default=False,
hidden=OSXPHOTOS_HIDDEN,
help="Enable beta options.",
)
@click.option(
"--profile",
is_flag=True,
default=False,
hidden=OSXPHOTOS_HIDDEN,
help="Run export with code profiler.",
)
@click.option(
"--profile-sort",
default=None,
hidden=OSXPHOTOS_HIDDEN,
multiple=True,
metavar="SORT_KEY",
type=click.Choice(
PROFILE_SORT_KEYS,
case_sensitive=True,
),
help="Sort profiler output by SORT_KEY as specified at https://docs.python.org/3/library/profile.html#pstats.Stats.sort_stats. "
f"Can be specified multiple times. Valid options are: {PROFILE_SORT_KEYS}. "
"Default = 'cumulative'.",
)
@click.option(
"--debug",
required=False,
is_flag=True,
default=False,
hidden=OSXPHOTOS_HIDDEN,
help="Enable debug output.",
)
@DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True))
@click.pass_obj
@click.pass_context
@crash_reporter(
OSXPHOTOS_CRASH_LOG,
"[red]Something went wrong and osxphotos encountered an error:[/red]",
"osxphotos crash log",
"Please file a bug report at https://github.com/RhetTbull/osxphotos/issues with the crash log attached.",
f"osxphotos version: {__version__}",
)
def export(
ctx,
cli_obj,
db,
photos_library,
keyword,
person,
album,
folder,
uuid,
name,
uuid_from_file,
title,
no_title,
description,
no_description,
uti,
ignore_case,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
shared,
not_shared,
from_date,
to_date,
from_time,
to_time,
verbose,
timestamp,
missing,
update,
force_update,
ignore_signature,
only_new,
dry_run,
export_as_hardlink,
touch_file,
overwrite,
retry,
export_by_date,
skip_edited,
skip_original_if_edited,
skip_bursts,
skip_live,
skip_raw,
skip_uuid,
skip_uuid_from_file,
person_keyword,
album_keyword,
keyword_template,
replace_keywords,
description_template,
finder_tag_template,
finder_tag_keywords,
xattr_template,
current_name,
convert_to_jpeg,
jpeg_quality,
sidecar,
sidecar_drop_ext,
only_photos,
only_movies,
burst,
not_burst,
live,
not_live,
download_missing,
dest,
exiftool,
exiftool_path,
exiftool_option,
exiftool_merge_keywords,
exiftool_merge_persons,
ignore_date_modified,
portrait,
not_portrait,
screenshot,
not_screenshot,
slow_mo,
not_slow_mo,
time_lapse,
not_time_lapse,
hdr,
not_hdr,
selfie,
not_selfie,
panorama,
not_panorama,
has_raw,
directory,
filename_template,
jpeg_ext,
strip,
edited_suffix,
original_suffix,
place,
no_place,
location,
no_location,
has_comment,
no_comment,
has_likes,
no_likes,
label,
deleted,
deleted_only,
use_photos_export,
use_photokit,
report,
cleanup,
add_exported_to_album,
add_skipped_to_album,
add_missing_to_album,
exportdb,
ramdb,
load_config,
save_config,
config_only,
is_reference,
beta,
in_album,
not_in_album,
min_size,
max_size,
regex,
selected,
exif,
query_eval,
query_function,
duplicate,
post_command,
post_function,
preview,
preview_suffix,
preview_if_missing,
profile,
profile_sort,
debug,
):
"""Export photos from the Photos database.
Export path DEST is required.
Optionally, query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options).
If no query options are provided, all photos will be exported.
By default, all versions of all photos will be exported including edited
versions, live photo movies, burst photos, and associated raw images.
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
to modify this behavior.
"""
global DEBUG
if debug:
DEBUG = True
osxphotos._set_debug(True)
if profile:
click.echo("Profiling...")
profile_sort = profile_sort or ["cumulative"]
click.echo(f"Profile sort_stats order: {profile_sort}")
pr = cProfile.Profile()
pr.enable()
def at_exit():
pr.disable()
click.echo("Profiling completed")
s = io.StringIO()
pstats.Stats(pr, stream=s).strip_dirs().sort_stats(
*profile_sort
).print_stats()
click.echo(s.getvalue())
atexit.register(at_exit)
# NOTE: because of the way ConfigOptions works, Click options must not
# set defaults which are not None or False. If defaults need to be set
# do so below after load_config and save_config are handled.
cfg = ConfigOptions(
"export",
locals(),
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
)
global VERBOSE
global VERBOSE_TIMESTAMP
VERBOSE = bool(verbose)
VERBOSE_TIMESTAMP = timestamp
if load_config:
try:
cfg.load_from_file(load_config)
except ConfigOptionsLoadError as e:
click.echo(
click.style(
f"Error parsing {load_config} config file: {e.message}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
sys.exit(1)
# re-set the local vars to the corresponding config value
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
add_exported_to_album = cfg.add_exported_to_album
add_missing_to_album = cfg.add_missing_to_album
add_skipped_to_album = cfg.add_skipped_to_album
album = cfg.album
album_keyword = cfg.album_keyword
beta = cfg.beta
burst = cfg.burst
cleanup = cfg.cleanup
convert_to_jpeg = cfg.convert_to_jpeg
current_name = cfg.current_name
db = cfg.db
deleted = cfg.deleted
deleted_only = cfg.deleted_only
description = cfg.description
description_template = cfg.description_template
directory = cfg.directory
download_missing = cfg.download_missing
dry_run = cfg.dry_run
duplicate = cfg.duplicate
edited = cfg.edited
edited_suffix = cfg.edited_suffix
exif = cfg.exif
exiftool = cfg.exiftool
exiftool_merge_keywords = cfg.exiftool_merge_keywords
exiftool_merge_persons = cfg.exiftool_merge_persons
exiftool_option = cfg.exiftool_option
exiftool_path = cfg.exiftool_path
export_as_hardlink = cfg.export_as_hardlink
export_by_date = cfg.export_by_date
exportdb = cfg.exportdb
ramdb = cfg.ramdb
external_edit = cfg.external_edit
favorite = cfg.favorite
filename_template = cfg.filename_template
finder_tag_keywords = cfg.finder_tag_keywords
finder_tag_template = cfg.finder_tag_template
folder = cfg.folder
force_update = cfg.force_update
from_date = cfg.from_date
from_time = cfg.from_time
has_comment = cfg.has_comment
has_likes = cfg.has_likes
has_raw = cfg.has_raw
hdr = cfg.hdr
hidden = cfg.hidden
ignore_case = cfg.ignore_case
ignore_date_modified = cfg.ignore_date_modified
ignore_signature = cfg.ignore_signature
in_album = cfg.in_album
jpeg_ext = cfg.jpeg_ext
jpeg_quality = cfg.jpeg_quality
keyword = cfg.keyword
keyword_template = cfg.keyword_template
label = cfg.label
live = cfg.live
location = cfg.location
max_size = cfg.max_size
min_size = cfg.min_size
missing = cfg.missing
name = cfg.name
no_comment = cfg.no_comment
no_description = cfg.no_description
no_likes = cfg.no_likes
no_location = cfg.no_location
no_place = cfg.no_place
no_title = cfg.no_title
not_burst = cfg.not_burst
not_favorite = cfg.not_favorite
not_hdr = cfg.not_hdr
not_hidden = cfg.not_hidden
not_in_album = cfg.not_in_album
not_live = cfg.not_live
not_panorama = cfg.not_panorama
not_portrait = cfg.not_portrait
not_screenshot = cfg.not_screenshot
not_selfie = cfg.not_selfie
not_shared = cfg.not_shared
not_slow_mo = cfg.not_slow_mo
not_time_lapse = cfg.not_time_lapse
only_movies = cfg.only_movies
only_new = cfg.only_new
only_photos = cfg.only_photos
original_suffix = cfg.original_suffix
overwrite = cfg.overwrite
panorama = cfg.panorama
person = cfg.person
person_keyword = cfg.person_keyword
photos_library = cfg.photos_library
place = cfg.place
portrait = cfg.portrait
post_command = cfg.post_command
post_function = cfg.post_function
preview = cfg.preview
preview_if_missing = cfg.preview_if_missing
preview_suffix = cfg.preview_suffix
query_eval = cfg.query_eval
query_function = cfg.query_function
regex = cfg.regex
replace_keywords = cfg.replace_keywords
report = cfg.report
retry = cfg.retry
screenshot = cfg.screenshot
selected = cfg.selected
selfie = cfg.selfie
shared = cfg.shared
sidecar = cfg.sidecar
sidecar_drop_ext = cfg.sidecar_drop_ext
skip_bursts = cfg.skip_bursts
skip_edited = cfg.skip_edited
skip_live = cfg.skip_live
skip_original_if_edited = cfg.skip_original_if_edited
skip_raw = cfg.skip_raw
skip_uuid = cfg.skip_uuid
skip_uuid_from_file = cfg.skip_uuid_from_file
slow_mo = cfg.slow_mo
strip = cfg.strip
time_lapse = cfg.time_lapse
title = cfg.title
to_date = cfg.to_date
to_time = cfg.to_time
touch_file = cfg.touch_file
update = cfg.update
use_photokit = cfg.use_photokit
use_photos_export = cfg.use_photos_export
uti = cfg.uti
uuid = cfg.uuid
uuid_from_file = cfg.uuid_from_file
verbose = cfg.verbose
xattr_template = cfg.xattr_template
# config file might have changed verbose
VERBOSE = bool(verbose)
verbose_(f"Loaded options from file {load_config}")
verbose_(f"osxphotos version {__version__}")
# validate options
exclusive_options = [
("favorite", "not_favorite"),
("hidden", "not_hidden"),
("title", "no_title"),
("description", "no_description"),
("only_photos", "only_movies"),
("burst", "not_burst"),
("live", "not_live"),
("portrait", "not_portrait"),
("screenshot", "not_screenshot"),
("slow_mo", "not_slow_mo"),
("time_lapse", "not_time_lapse"),
("hdr", "not_hdr"),
("selfie", "not_selfie"),
("panorama", "not_panorama"),
("export_by_date", "directory"),
("export_as_hardlink", "exiftool"),
("place", "no_place"),
("deleted", "deleted_only"),
("skip_edited", "skip_original_if_edited"),
("export_as_hardlink", "convert_to_jpeg"),
("export_as_hardlink", "download_missing"),
("shared", "not_shared"),
("has_comment", "no_comment"),
("has_likes", "no_likes"),
("in_album", "not_in_album"),
("location", "no_location"),
]
dependent_options = [
("missing", ("download_missing", "use_photos_export")),
("jpeg_quality", ("convert_to_jpeg")),
("ignore_signature", ("update", "force_update")),
("only_new", ("update", "force_update")),
("exiftool_option", ("exiftool")),
("exiftool_merge_keywords", ("exiftool", "sidecar")),
("exiftool_merge_persons", ("exiftool", "sidecar")),
]
try:
cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True)
except ConfigOptionsInvalidError as e:
click.echo(
click.style(
f"Incompatible export options: {e.message}", fg=CLI_COLOR_ERROR
),
err=True,
)
sys.exit(1)
if config_only and not save_config:
click.secho(
"--config-only must be used with --save-config",
fg=CLI_COLOR_ERROR,
err=True,
)
sys.exit(1)
if all(x in [s.lower() for s in sidecar] for x in ["json", "exiftool"]):
click.echo(
click.style(
"Cannot use --sidecar json with --sidecar exiftool due to name collisions",
fg=CLI_COLOR_ERROR,
),
err=True,
)
sys.exit(1)
if xattr_template:
for attr, _ in xattr_template:
if attr not in EXTENDED_ATTRIBUTE_NAMES:
click.echo(
click.style(
f"Invalid attribute '{attr}' for --xattr-template; "
f"valid values are {', '.join(EXTENDED_ATTRIBUTE_NAMES_QUOTED)}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
sys.exit(1)
if save_config:
verbose_(f"Saving options to config file '{save_config}'")
cfg.write_to_file(save_config)
if config_only:
click.echo(f"Saved config file to '{save_config}'")
sys.exit(0)
# set defaults for options that need them
jpeg_quality = DEFAULT_JPEG_QUALITY if jpeg_quality is None else jpeg_quality
edited_suffix = DEFAULT_EDITED_SUFFIX if edited_suffix is None else edited_suffix
original_suffix = (
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
)
preview_suffix = (
DEFAULT_PREVIEW_SUFFIX if preview_suffix is None else preview_suffix
)
retry = 0 if not retry else retry
if not os.path.isdir(dest):
click.echo(
click.style(f"DEST {dest} must be valid path", fg=CLI_COLOR_ERROR), err=True
)
sys.exit(1)
dest = str(pathlib.Path(dest).resolve())
if report and os.path.isdir(report):
click.echo(
click.style(
f"report is a directory, must be file name", fg=CLI_COLOR_ERROR
),
err=True,
)
sys.exit(1)
# if use_photokit and not check_photokit_authorization():
# click.echo(
# "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
# )
# request_photokit_authorization()
# click.confirm("Have you granted access?")
# if not check_photokit_authorization():
# click.echo(
# "Failed to get access to the Photos library which is needed with `--use-photokit`."
# )
# return
# initialize export flags
# by default, will export all versions of photos unless skip flag is set
(export_edited, export_bursts, export_live, export_raw) = [
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
]
# verify exiftool installed and in path if path not provided and exiftool will be used
# NOTE: this won't catch use of {exiftool:} in a template
# but those will raise error during template eval if exiftool path not set
if (
any([exiftool, exiftool_merge_keywords, exiftool_merge_persons])
and not exiftool_path
):
try:
exiftool_path = get_exiftool_path()
except FileNotFoundError:
click.echo(
click.style(
"Could not find exiftool. Please download and install"
" from https://exiftool.org/",
fg=CLI_COLOR_ERROR,
),
err=True,
)
ctx.exit(2)
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
verbose_(f"exiftool path: {exiftool_path}")
# default searches for everything
photos = True
movies = True
if only_movies:
photos = False
if only_photos:
movies = False
# load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file:
uuid_list = list(uuid) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list)
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["export"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
# sanity check exportdb
if exportdb and exportdb != OSXPHOTOS_EXPORT_DB:
if pathlib.Path(pathlib.Path(dest) / OSXPHOTOS_EXPORT_DB).exists():
click.echo(
click.style(
f"Warning: export database is '{exportdb}' but found '{OSXPHOTOS_EXPORT_DB}' in {dest}; using '{exportdb}'",
fg=CLI_COLOR_WARNING,
)
)
if pathlib.Path(exportdb).resolve().parent != pathlib.Path(dest):
click.echo(
click.style(
f"Warning: export database '{pathlib.Path(exportdb).resolve()}' is in a different directory than export destination '{dest}'",
fg=CLI_COLOR_WARNING,
)
)
# open export database
export_db_path = exportdb or os.path.join(dest, OSXPHOTOS_EXPORT_DB)
# check that export isn't in the parent or child of a previously exported library
other_db_files = find_files_in_branch(dest, OSXPHOTOS_EXPORT_DB)
if other_db_files:
click.echo(
click.style(
"WARNING: found other export database files in this destination directory branch. "
+ "This likely means you are attempting to export files into a directory "
+ "that is either the parent or a child directory of a previous export. "
+ "Proceeding may cause your exported files to be overwritten.",
fg=CLI_COLOR_WARNING,
),
err=True,
)
click.echo(
f"You are exporting to {dest}, found {OSXPHOTOS_EXPORT_DB} files in:"
)
for other_db in other_db_files:
click.echo(f"{other_db}")
click.confirm("Do you want to continue?", abort=True)
if dry_run:
export_db = ExportDBInMemory(dbfile=export_db_path, export_dir=dest)
fileutil = FileUtilNoOp
else:
export_db = (
ExportDBInMemory(dbfile=export_db_path, export_dir=dest)
if ramdb
else ExportDB(dbfile=export_db_path, export_dir=dest)
)
fileutil = FileUtil
if verbose_:
if export_db.was_created:
verbose_(f"Created export database {export_db_path}")
else:
verbose_(f"Using export database {export_db_path}")
upgraded = export_db.was_upgraded
if upgraded:
verbose_(
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
)
# save config to export_db
export_db.set_config(cfg.write_to_str())
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_, exiftool=exiftool_path)
# enable beta features if requested
photosdb._beta = beta
query_options = QueryOptions(
keyword=keyword,
person=person,
album=album,
folder=folder,
uuid=uuid,
title=title,
no_title=no_title,
description=description,
no_description=no_description,
ignore_case=ignore_case,
edited=edited,
external_edit=external_edit,
favorite=favorite,
not_favorite=not_favorite,
hidden=hidden,
not_hidden=not_hidden,
missing=missing,
not_missing=None,
shared=shared,
not_shared=not_shared,
photos=photos,
movies=movies,
uti=uti,
burst=burst,
not_burst=not_burst,
live=live,
not_live=not_live,
cloudasset=False,
not_cloudasset=False,
incloud=False,
not_incloud=False,
from_date=from_date,
to_date=to_date,
from_time=from_time,
to_time=to_time,
portrait=portrait,
not_portrait=not_portrait,
screenshot=screenshot,
not_screenshot=not_screenshot,
slow_mo=slow_mo,
not_slow_mo=not_slow_mo,
time_lapse=time_lapse,
not_time_lapse=not_time_lapse,
hdr=hdr,
not_hdr=not_hdr,
selfie=selfie,
not_selfie=not_selfie,
panorama=panorama,
not_panorama=not_panorama,
has_raw=has_raw,
place=place,
no_place=no_place,
location=location,
no_location=no_location,
label=label,
deleted=deleted,
deleted_only=deleted_only,
has_comment=has_comment,
no_comment=no_comment,
has_likes=has_likes,
no_likes=no_likes,
is_reference=is_reference,
in_album=in_album,
not_in_album=not_in_album,
burst_photos=export_bursts,
# skip missing bursts if using --download-missing by itself as AppleScript otherwise causes errors
missing_bursts=(download_missing and use_photokit) or not download_missing,
name=name,
min_size=min_size,
max_size=max_size,
regex=regex,
selected=selected,
exif=exif,
query_eval=query_eval,
function=query_function,
duplicate=duplicate,
)
try:
photos = photosdb.query(query_options)
except ValueError as e:
if "Invalid query_eval CRITERIA:" in str(e):
msg = str(e).split(":")[1]
raise click.BadOptionUsage(
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
)
else:
raise ValueError(e)
if skip_uuid:
photos = [p for p in photos if p.uuid not in skip_uuid]
if skip_uuid_from_file:
skip_uuid_list = load_uuid_from_file(skip_uuid_from_file)
photos = [p for p in photos if p.uuid not in skip_uuid_list]
if photos and only_new:
# ignore previously exported files
previous_uuids = {uuid: 1 for uuid in export_db.get_previous_uuids()}
photos = [p for p in photos if p.uuid not in previous_uuids]
# store results of export
results = ExportResults()
if photos:
num_photos = len(photos)
# TODO: photos or photo appears several times, pull into a separate function
photo_str = "photos" if num_photos > 1 else "photo"
click.echo(f"Exporting {num_photos} {photo_str} to {dest}...")
start_time = time.perf_counter()
# though the command line option is current_name, internally all processing
# logic uses original_name which is the boolean inverse of current_name
# because the original code used --original-name as an option
original_name = not current_name
# set up for --add-export-to-album if needed
album_export = (
PhotosAlbum(add_exported_to_album, verbose=verbose_)
if add_exported_to_album
else None
)
album_skipped = (
PhotosAlbum(add_skipped_to_album, verbose=verbose_)
if add_skipped_to_album
else None
)
album_missing = (
PhotosAlbum(add_missing_to_album, verbose=verbose_)
if add_missing_to_album
else None
)
photo_num = 0
# send progress bar output to /dev/null if verbose to hide the progress bar
fp = open(os.devnull, "w") if verbose else None
with click.progressbar(photos, show_pos=True, file=fp) as bar:
for p in bar:
photo_num += 1
export_results = export_photo(
photo=p,
dest=dest,
album_keyword=album_keyword,
convert_to_jpeg=convert_to_jpeg,
description_template=description_template,
directory=directory,
download_missing=download_missing,
dry_run=dry_run,
edited_suffix=edited_suffix,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
exiftool_option=exiftool_option,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
export_by_date=export_by_date,
export_db=export_db,
export_dir=dest,
export_edited=export_edited,
export_live=export_live,
export_preview=preview,
export_raw=export_raw,
filename_template=filename_template,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
jpeg_quality=jpeg_quality,
keyword_template=keyword_template,
num_photos=num_photos,
original_name=original_name,
original_suffix=original_suffix,
overwrite=overwrite,
person_keyword=person_keyword,
photo_num=photo_num,
preview_if_missing=preview_if_missing,
preview_suffix=preview_suffix,
replace_keywords=replace_keywords,
retry=retry,
sidecar_drop_ext=sidecar_drop_ext,
sidecar=sidecar,
skip_original_if_edited=skip_original_if_edited,
strip=strip,
touch_file=touch_file,
update=update,
use_photokit=use_photokit,
use_photos_export=use_photos_export,
verbose=verbose,
)
if post_function:
for function in post_function:
# post function is tuple of (function, filename.py::function_name)
verbose_(f"Calling post-function {function[1]}")
if not dry_run:
try:
function[0](p, export_results, verbose_)
except Exception as e:
click.secho(
f"Error running post-function {function[1]}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
run_post_command(
photo=p,
post_command=post_command,
export_results=export_results,
export_dir=dest,
dry_run=dry_run,
exiftool_path=exiftool_path,
export_db=export_db,
)
if album_export and export_results.exported:
try:
album_export.add(p)
export_results.exported_album = [
(filename, album_export.name)
for filename in export_results.exported
]
except Exception as e:
click.secho(
f"Error adding photo {p.original_filename} ({p.uuid}) to album {album_export.name}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
if album_skipped and export_results.skipped:
try:
album_skipped.add(p)
export_results.skipped_album = [
(filename, album_skipped.name)
for filename in export_results.skipped
]
except Exception as e:
click.secho(
f"Error adding photo {p.original_filename} ({p.uuid}) to album {album_skipped.name}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
if album_missing and export_results.missing:
try:
album_missing.add(p)
export_results.missing_album = [
(filename, album_missing.name)
for filename in export_results.missing
]
except Exception as e:
click.secho(
f"Error adding photo {p.original_filename} ({p.uuid}) to album {album_missing.name}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
results += export_results
# all photo files (not including sidecars) that are part of this export set
# used below for applying Finder tags, etc.
photo_files = set(
export_results.exported
+ export_results.new
+ export_results.updated
+ export_results.exif_updated
+ export_results.converted_to_jpeg
+ export_results.skipped
)
if finder_tag_keywords or finder_tag_template:
tags_written, tags_skipped = write_finder_tags(
p,
photo_files,
keywords=finder_tag_keywords,
keyword_template=keyword_template,
album_keyword=album_keyword,
person_keyword=person_keyword,
exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template,
strip=strip,
export_dir=dest,
)
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
if xattr_template:
xattr_written, xattr_skipped = write_extended_attributes(
p,
photo_files,
xattr_template,
strip=strip,
export_dir=dest,
)
results.xattr_written.extend(xattr_written)
results.xattr_skipped.extend(xattr_skipped)
if fp is not None:
fp.close()
photo_str_total = "photos" if len(photos) != 1 else "photo"
if update or force_update:
summary = (
f"Processed: {len(photos)} {photo_str_total}, "
f"exported: {len(results.new)}, "
f"updated: {len(results.updated)}, "
f"skipped: {len(results.skipped)}, "
f"updated EXIF data: {len(results.exif_updated)}, "
)
else:
summary = (
f"Processed: {len(photos)} {photo_str_total}, "
f"exported: {len(results.exported)}, "
)
summary += f"missing: {len(results.missing)}, "
summary += f"error: {len(results.error)}"
if touch_file:
summary += f", touched date: {len(results.touched)}"
click.echo(summary)
stop_time = time.perf_counter()
click.echo(f"Elapsed time: {format_sec_to_hhmmss(stop_time-start_time)}")
else:
click.echo("Did not find any photos to export")
# cleanup files and do report if needed
if cleanup:
db_file = str(pathlib.Path(export_db_path).resolve())
db_files = [db_file, db_file + "-wal", db_file + "-shm"]
all_files = (
results.exported
+ results.skipped
+ results.exif_updated
+ results.touched
+ results.converted_to_jpeg
+ results.sidecar_json_written
+ results.sidecar_json_skipped
+ results.sidecar_exiftool_written
+ results.sidecar_exiftool_skipped
+ results.sidecar_xmp_written
+ results.sidecar_xmp_skipped
# include missing so a file that was already in export directory
# but was missing on --update doesn't get deleted
# (better to have old version than none)
+ results.missing
# include files that have error in case they exist from previous export
+ [r[0] for r in results.error]
+ db_files
)
click.echo(f"Cleaning up {dest}")
cleaned_files, cleaned_dirs = cleanup_files(dest, all_files, fileutil)
file_str = "files" if len(cleaned_files) != 1 else "file"
dir_str = "directories" if len(cleaned_dirs) != 1 else "directory"
click.echo(
f"Deleted: {len(cleaned_files)} {file_str}, {len(cleaned_dirs)} {dir_str}"
)
results.deleted_files = cleaned_files
results.deleted_directories = cleaned_dirs
if report:
verbose_(f"Writing export report to {report}")
write_export_report(report, results)
# close export_db and write changes if needed
if ramdb and not dry_run:
verbose_(f"Writing export database changes back to {export_db.path}")
export_db.write_to_disk()
export_db.close()
def _export_with_profiler(args: Dict):
""" "Run export with cProfile"""
try:
args.pop("profile")
except KeyError:
pass
cProfile.runctx(
"_export(**args)", globals=globals(), locals=locals(), sort="tottime"
)
@cli.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
"""Print help; for help on commands: help <command>."""
if topic is None:
click.echo(ctx.parent.get_help())
elif topic in cli.commands:
ctx.info_name = topic
click.echo_via_pager(cli.commands[topic].get_help(ctx))
else:
click.echo(f"Invalid command: {topic}", err=True)
click.echo(ctx.parent.get_help())
@cli.command()
@DB_OPTION
@JSON_OPTION
@QUERY_OPTIONS
@DELETED_OPTIONS
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
@click.option(
"--not-missing",
is_flag=True,
help="Search for photos present on disk (e.g. not missing).",
)
@click.option(
"--cloudasset",
is_flag=True,
help="Search for photos that are part of an iCloud library",
)
@click.option(
"--not-cloudasset",
is_flag=True,
help="Search for photos that are not part of an iCloud library",
)
@click.option(
"--incloud",
is_flag=True,
help="Search for photos that are in iCloud (have been synched)",
)
@click.option(
"--not-incloud",
is_flag=True,
help="Search for photos that are not in iCloud (have not been synched)",
)
@click.option(
"--add-to-album",
metavar="ALBUM",
help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All photos in the query results will be added to this album. "
"This only works if the Photos library being queried is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large query sets.",
)
@click.option(
"--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN
)
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def query(
ctx,
cli_obj,
db,
photos_library,
keyword,
person,
album,
folder,
name,
uuid,
uuid_from_file,
title,
no_title,
description,
no_description,
ignore_case,
json_,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
shared,
not_shared,
only_movies,
only_photos,
uti,
burst,
not_burst,
live,
not_live,
cloudasset,
not_cloudasset,
incloud,
not_incloud,
from_date,
to_date,
from_time,
to_time,
portrait,
not_portrait,
screenshot,
not_screenshot,
slow_mo,
not_slow_mo,
time_lapse,
not_time_lapse,
hdr,
not_hdr,
selfie,
not_selfie,
panorama,
not_panorama,
has_raw,
place,
no_place,
location,
no_location,
label,
deleted,
deleted_only,
has_comment,
no_comment,
has_likes,
no_likes,
is_reference,
in_album,
not_in_album,
duplicate,
min_size,
max_size,
regex,
selected,
exif,
query_eval,
query_function,
add_to_album,
debug,
):
"""Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options).
"""
global DEBUG
if debug:
DEBUG = True
osxphotos._set_debug(True)
# if no query terms, show help and return
# sanity check input args
nonexclusive = [
keyword,
person,
album,
folder,
name,
uuid,
uuid_from_file,
edited,
external_edit,
uti,
has_raw,
from_date,
to_date,
from_time,
to_time,
label,
is_reference,
query_eval,
query_function,
min_size,
max_size,
regex,
selected,
exif,
duplicate,
]
exclusive = [
(favorite, not_favorite),
(hidden, not_hidden),
(missing, not_missing),
(any(title), no_title),
(any(description), no_description),
(only_photos, only_movies),
(burst, not_burst),
(live, not_live),
(cloudasset, not_cloudasset),
(incloud, not_incloud),
(portrait, not_portrait),
(screenshot, not_screenshot),
(slow_mo, not_slow_mo),
(time_lapse, not_time_lapse),
(hdr, not_hdr),
(selfie, not_selfie),
(panorama, not_panorama),
(any(place), no_place),
(deleted, deleted_only),
(shared, not_shared),
(has_comment, no_comment),
(has_likes, no_likes),
(in_album, not_in_album),
(location, no_location),
]
# print help if no non-exclusive term or a double exclusive term is given
if any(all(bb) for bb in exclusive) or not any(
nonexclusive + [b ^ n for b, n in exclusive]
):
click.echo("Incompatible query options", err=True)
click.echo(cli.commands["query"].get_help(ctx), err=True)
return
# actually have something to query
# default searches for everything
photos = True
movies = True
if only_movies:
photos = False
if only_photos:
movies = False
# load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file:
uuid_list = list(uuid) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list)
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["query"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
query_options = QueryOptions(
keyword=keyword,
person=person,
album=album,
folder=folder,
uuid=uuid,
title=title,
no_title=no_title,
description=description,
no_description=no_description,
ignore_case=ignore_case,
edited=edited,
external_edit=external_edit,
favorite=favorite,
not_favorite=not_favorite,
hidden=hidden,
not_hidden=not_hidden,
missing=missing,
not_missing=not_missing,
shared=shared,
not_shared=not_shared,
photos=photos,
movies=movies,
uti=uti,
burst=burst,
not_burst=not_burst,
live=live,
not_live=not_live,
cloudasset=cloudasset,
not_cloudasset=not_cloudasset,
incloud=incloud,
not_incloud=not_incloud,
from_date=from_date,
to_date=to_date,
from_time=from_time,
to_time=to_time,
portrait=portrait,
not_portrait=not_portrait,
screenshot=screenshot,
not_screenshot=not_screenshot,
slow_mo=slow_mo,
not_slow_mo=not_slow_mo,
time_lapse=time_lapse,
not_time_lapse=not_time_lapse,
hdr=hdr,
not_hdr=not_hdr,
selfie=selfie,
not_selfie=not_selfie,
panorama=panorama,
not_panorama=not_panorama,
has_raw=has_raw,
place=place,
no_place=no_place,
location=location,
no_location=no_location,
label=label,
deleted=deleted,
deleted_only=deleted_only,
has_comment=has_comment,
no_comment=no_comment,
has_likes=has_likes,
no_likes=no_likes,
is_reference=is_reference,
in_album=in_album,
not_in_album=not_in_album,
name=name,
min_size=min_size,
max_size=max_size,
query_eval=query_eval,
function=query_function,
regex=regex,
selected=selected,
exif=exif,
duplicate=duplicate,
)
try:
photos = photosdb.query(query_options)
except ValueError as e:
if "Invalid query_eval CRITERIA:" in str(e):
msg = str(e).split(":")[1]
raise click.BadOptionUsage(
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
)
else:
raise ValueError(e)
# below needed for to make CliRunner work for testing
cli_json = cli_obj.json if cli_obj is not None else None
if add_to_album and photos:
album_query = PhotosAlbum(add_to_album, verbose=None)
photo_len = len(photos)
photo_word = "photos" if photo_len > 1 else "photo"
click.echo(
f"Adding {photo_len} {photo_word} to album '{album_query.name}'. Note: Photos may prompt you to confirm this action.",
err=True,
)
try:
album_query.add_list(photos)
except Exception as e:
click.secho(
f"Error adding photos to album {add_to_album}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
print_photo_info(photos, cli_json or json_)
def print_photo_info(photos, json=False):
dump = []
if json:
for p in photos:
dump.append(p.json())
click.echo(f"[{', '.join(dump)}]")
else:
# dump as CSV
csv_writer = csv.writer(
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
)
# add headers
dump.append(
[
"uuid",
"filename",
"original_filename",
"date",
"description",
"title",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
"external_edit",
"favorite",
"hidden",
"shared",
"latitude",
"longitude",
"path_edited",
"isphoto",
"ismovie",
"uti",
"burst",
"live_photo",
"path_live_photo",
"iscloudasset",
"incloud",
"date_modified",
"portrait",
"screenshot",
"slow_mo",
"time_lapse",
"hdr",
"selfie",
"panorama",
"has_raw",
"uti_raw",
"path_raw",
"intrash",
]
)
for p in photos:
date_modified_iso = p.date_modified.isoformat() if p.date_modified else None
dump.append(
[
p.uuid,
p.filename,
p.original_filename,
p.date.isoformat(),
p.description,
p.title,
", ".join(p.keywords),
", ".join(p.albums),
", ".join(p.persons),
p.path,
p.ismissing,
p.hasadjustments,
p.external_edit,
p.favorite,
p.hidden,
p.shared,
p._latitude,
p._longitude,
p.path_edited,
p.isphoto,
p.ismovie,
p.uti,
p.burst,
p.live_photo,
p.path_live_photo,
p.iscloudasset,
p.incloud,
date_modified_iso,
p.portrait,
p.screenshot,
p.slow_mo,
p.time_lapse,
p.hdr,
p.selfie,
p.panorama,
p.has_raw,
p.uti_raw,
p.path_raw,
p.intrash,
]
)
for row in dump:
csv_writer.writerow(row)
def export_photo(
photo=None,
dest=None,
verbose=None,
export_by_date=None,
sidecar=None,
sidecar_drop_ext=False,
update=None,
force_update=None,
ignore_signature=None,
export_as_hardlink=None,
overwrite=None,
export_edited=None,
skip_original_if_edited=None,
original_name=None,
export_live=None,
download_missing=None,
exiftool=None,
exiftool_merge_keywords=False,
exiftool_merge_persons=False,
directory=None,
filename_template=None,
export_raw=None,
album_keyword=None,
person_keyword=None,
keyword_template=None,
description_template=None,
export_db=None,
fileutil=FileUtil,
dry_run=None,
touch_file=None,
edited_suffix="_edited",
original_suffix="",
use_photos_export=False,
convert_to_jpeg=False,
jpeg_quality=1.0,
ignore_date_modified=False,
use_photokit=False,
exiftool_option=None,
strip=False,
jpeg_ext=None,
replace_keywords=False,
retry=0,
export_dir=None,
export_preview=False,
preview_suffix=None,
preview_if_missing=False,
photo_num=1,
num_photos=1,
):
"""Helper function for export that does the actual export
Args:
photo: PhotoInfo object
dest: destination path as string
album_keyword: bool; if True, exports album names as keywords in metadata
convert_to_jpeg: bool; if True, converts non-jpeg images to jpeg
description_template: str; optional template string that will be rendered for use as photo description
directory: template used to determine output directory
download_missing: attempt download of missing iCloud photos
dry_run: bool; if True, doesn't actually export or update any files
exiftool_merge_keywords: bool; if True, merged keywords found in file's exif data (requires exiftool)
exiftool_merge_persons: bool; if True, merged persons found in file's exif data (requires exiftool)
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to exiftool
exiftool: bool; use exiftool to write EXIF metadata directly to exported photo
export_as_hardlink: bool; hardlink files instead of copying them
export_by_date: bool; create export folder in form dest/YYYY/MM/DD
export_db: export database instance compatible with ExportDB_ABC
export_dir: top-level export directory for {export_dir} template
export_edited: bool; if True exports edited version of photo if there is one
export_live: bool; also export live video component if photo is a live photo; live video will have same name as photo but with .mov extension
export_preview: export the preview image generated by Photos
export_raw: bool; if True exports raw image associate with the photo
filename_template: template use to determine output file
fileutil: file util class compatible with FileUtilABC
force_update: bool, only export updated photos but trigger export even if only metadata has changed
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
keyword_template: list of strings; if provided use rendered template strings as keywords
num_photos: int, total number of photos that will be exported
original_name: bool; use original filename instead of current filename
overwrite: bool; overwrite dest file if it already exists
person_keyword: bool; if True, exports person names as keywords in metadata
photo_num: int, which number photo in total of num_photos is being exported
preview_if_missing: bool, export preview if original is missing
preview_suffix: str, template to use as suffix for preview images
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error
sidecar_drop_ext: bool; if True, drops photo extension from sidecar name
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
skip_original_if_edited: bool; if True does not export original if photo has been edited
touch_file: bool; sets file's modification time to match photo date
update: bool, only export updated photos
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
verbose: bool; print verbose output
Returns:
list of path(s) of exported photo or None if photo was missing
Raises:
ValueError on invalid filename_template
"""
global VERBOSE
VERBOSE = bool(verbose)
export_original = not (skip_original_if_edited and photo.hasadjustments)
# can't export edited if photo doesn't have edited versions
export_edited = export_edited if photo.hasadjustments else False
# slow_mo photos will always have hasadjustments=True even if not edited
if photo.hasadjustments and photo.path_edited is None:
if photo.slow_mo:
export_original = True
export_edited = False
elif not download_missing:
# requested edited version but it's missing, download original
export_original = True
export_edited = False
verbose_(
f"Edited file for {photo.original_filename} is missing, exporting original"
)
# check for missing photos before downloading
missing_original = False
missing_edited = False
if download_missing:
if (
(photo.ismissing or photo.path is None)
and not photo.iscloudasset
and not photo.incloud
):
missing_original = True
if (
photo.hasadjustments
and photo.path_edited is None
and not photo.iscloudasset
and not photo.incloud
):
missing_edited = True
else:
if photo.ismissing or photo.path is None:
missing_original = True
if photo.hasadjustments and photo.path_edited is None:
missing_edited = True
sidecar = [s.lower() for s in sidecar]
sidecar_flags = 0
if "json" in sidecar:
sidecar_flags |= SIDECAR_JSON
if "xmp" in sidecar:
sidecar_flags |= SIDECAR_XMP
if "exiftool" in sidecar:
sidecar_flags |= SIDECAR_EXIFTOOL
rendered_suffix = _render_suffix_template(
original_suffix,
"original_suffix",
"--original-suffix",
strip,
dest,
photo,
export_db,
)
rendered_preview_suffix = _render_suffix_template(
preview_suffix,
"preview_suffix",
"--preview-suffix",
strip,
dest,
photo,
export_db,
)
results = ExportResults()
dest_paths = get_dirnames_from_template(
photo,
directory,
export_by_date,
dest,
dry_run,
strip=strip,
edited=False,
export_db=export_db,
)
for dest_path in dest_paths:
filenames = get_filenames_from_template(
photo,
filename_template,
dest,
dest_path,
original_name,
strip=strip,
export_db=export_db,
)
for filename in filenames:
original_filename = pathlib.Path(filename)
file_ext = original_filename.suffix
if photo.isphoto and (jpeg_ext or convert_to_jpeg):
# change the file extension to correct jpeg extension if needed
file_ext = (
"." + jpeg_ext
if jpeg_ext
and (photo.uti_original == "public.jpeg" or convert_to_jpeg)
else ".jpeg"
if convert_to_jpeg and photo.uti_original != "public.jpeg"
else original_filename.suffix
)
original_filename = (
original_filename.parent
/ f"{original_filename.stem}{rendered_suffix}{file_ext}"
)
original_filename = str(original_filename)
verbose_(
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename} ({photo_num}/{num_photos})"
)
results += export_photo_to_directory(
album_keyword=album_keyword,
convert_to_jpeg=convert_to_jpeg,
description_template=description_template,
dest_path=dest_path,
dest=dest,
download_missing=download_missing,
dry_run=dry_run,
edited=False,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
exiftool_option=exiftool_option,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
export_db=export_db,
export_dir=export_dir,
export_live=export_live,
export_original=export_original,
export_preview=export_preview,
export_raw=export_raw,
filename=original_filename,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
jpeg_quality=jpeg_quality,
keyword_template=keyword_template,
missing=missing_original,
overwrite=overwrite,
person_keyword=person_keyword,
photo=photo,
preview_if_missing=preview_if_missing,
preview_suffix=rendered_preview_suffix,
replace_keywords=replace_keywords,
retry=retry,
sidecar_drop_ext=sidecar_drop_ext,
sidecar_flags=sidecar_flags,
touch_file=touch_file,
update=update,
use_photos_export=use_photos_export,
use_photokit=use_photokit,
verbose=verbose,
)
if export_edited and photo.hasadjustments:
dest_paths = get_dirnames_from_template(
photo,
directory,
export_by_date,
dest,
dry_run,
strip=strip,
edited=True,
export_db=export_db,
)
for dest_path in dest_paths:
# if export-edited, also export the edited version
edited_filenames = get_filenames_from_template(
photo,
filename_template,
dest,
dest_path,
original_name,
strip=strip,
edited=True,
export_db=export_db,
)
for edited_filename in edited_filenames:
edited_filename = pathlib.Path(edited_filename)
# verify the photo has adjustments and valid path to avoid raising an exception
edited_ext = (
# rare cases on Photos <= 4 that uti_edited is None
"." + get_preferred_uti_extension(photo.uti_edited)
if photo.uti_edited
else pathlib.Path(photo.path_edited).suffix
if photo.path_edited
else pathlib.Path(photo.filename).suffix
)
if (
photo.isphoto
and jpeg_ext
and edited_ext.lower() in [".jpg", ".jpeg"]
):
edited_ext = "." + jpeg_ext
# Big Sur uses .heic for some edited photos so need to check
# if extension isn't jpeg/jpg and using --convert-to-jpeg
if (
photo.isphoto
and convert_to_jpeg
and edited_ext.lower() not in [".jpg", ".jpeg"]
):
edited_ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
rendered_edited_suffix = _render_suffix_template(
edited_suffix,
"edited_suffix",
"--edited-suffix",
strip,
dest,
photo,
export_db,
)
edited_filename = (
f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}"
)
verbose_(
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
)
results += export_photo_to_directory(
album_keyword=album_keyword,
convert_to_jpeg=convert_to_jpeg,
description_template=description_template,
dest_path=dest_path,
dest=dest,
download_missing=download_missing,
dry_run=dry_run,
edited=True,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
exiftool_option=exiftool_option,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
export_db=export_db,
export_dir=export_dir,
export_live=export_live,
export_original=False,
export_preview=not export_original and export_preview,
export_raw=not export_original and export_raw,
filename=edited_filename,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
jpeg_quality=jpeg_quality,
keyword_template=keyword_template,
missing=missing_edited,
overwrite=overwrite,
person_keyword=person_keyword,
photo=photo,
preview_if_missing=preview_if_missing,
preview_suffix=rendered_preview_suffix,
replace_keywords=replace_keywords,
retry=retry,
sidecar_drop_ext=sidecar_drop_ext,
sidecar_flags=sidecar_flags if not export_original else 0,
touch_file=touch_file,
update=update,
use_photos_export=use_photos_export,
use_photokit=use_photokit,
verbose=verbose,
)
return results
def _render_suffix_template(
suffix_template, var_name, option_name, strip, dest, photo, export_db
):
"""render suffix template
Returns:
rendered template
"""
if not suffix_template:
return ""
try:
options = RenderOptions(filename=True, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(suffix_template, options)
except ValueError as e:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name} '{suffix_template}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name} '{suffix_template}': results={rendered_suffix} unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name}: may not use multi-valued templates: '{suffix_template}': results={rendered_suffix}",
)
if strip:
rendered_suffix[0] = rendered_suffix[0].strip()
return rendered_suffix[0]
def export_photo_to_directory(
album_keyword,
convert_to_jpeg,
description_template,
dest_path,
dest,
download_missing,
dry_run,
edited,
exiftool_merge_keywords,
exiftool_merge_persons,
exiftool_option,
exiftool,
export_as_hardlink,
export_db,
export_dir,
export_live,
export_original,
export_preview,
export_raw,
filename,
fileutil,
force_update,
ignore_date_modified,
ignore_signature,
jpeg_ext,
jpeg_quality,
keyword_template,
missing,
overwrite,
person_keyword,
photo,
preview_if_missing,
preview_suffix,
replace_keywords,
retry,
sidecar_drop_ext,
sidecar_flags,
touch_file,
update,
use_photos_export,
use_photokit,
verbose,
):
"""Export photo to directory dest_path"""
results = ExportResults()
# don't try to export photos in the trash if they're missing
photo_path = photo.path if export_original else photo.path_edited
if photo.intrash and not photo_path and not preview_if_missing:
# skip deleted files if they're missing
# as AppleScript/PhotoKit cannot export deleted photos
verbose_(
f"Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(str(pathlib.Path(dest_path) / filename))
return results
render_options = RenderOptions(export_dir=export_dir, dest_path=dest_path)
if not export_original and not edited:
verbose_(f"Skipping original version of {photo.original_filename}")
return results
tries = 0
while tries <= retry:
tries += 1
error = 0
try:
export_options = ExportOptions(
convert_to_jpeg=convert_to_jpeg,
description_template=description_template,
download_missing=download_missing,
dry_run=dry_run,
edited=edited,
exiftool_flags=exiftool_option,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
export_db=export_db,
fileutil=fileutil,
force_update=force_update,
ignore_date_modified=ignore_date_modified,
ignore_signature=ignore_signature,
jpeg_ext=jpeg_ext,
jpeg_quality=jpeg_quality,
keyword_template=keyword_template,
live_photo=export_live,
merge_exif_keywords=exiftool_merge_keywords,
merge_exif_persons=exiftool_merge_persons,
overwrite=overwrite,
preview_suffix=preview_suffix,
preview=export_preview or (missing and preview_if_missing),
raw_photo=export_raw,
render_options=render_options,
replace_keywords=replace_keywords,
sidecar_drop_ext=sidecar_drop_ext,
sidecar=sidecar_flags,
touch_file=touch_file,
update=update,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
use_photokit=use_photokit,
use_photos_export=use_photos_export,
verbose=verbose_,
)
exporter = PhotoExporter(photo)
export_results = exporter.export(
dest=dest_path, filename=filename, options=export_options
)
for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
for error_ in export_results.exiftool_error:
click.echo(
click.style(
f"exiftool error for file {error_[0]}: {error_[1]}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
for error_ in export_results.error:
click.echo(
click.style(
f"Error exporting photo ({photo.uuid}: {photo.original_filename}) as {error_[0]}: {error_[1]}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
error += 1
if not error or tries > retry:
results += export_results
break
else:
click.echo(
f"Retrying export for photo ({photo.uuid}: {photo.original_filename})"
)
except Exception as e:
if DEBUG:
# if debug mode, don't swallow the exceptions
raise e
click.echo(
click.style(
f"Error exporting photo ({photo.uuid}: {photo.original_filename}) as {filename}: {e}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
if tries > retry:
results.error.append((str(pathlib.Path(dest) / filename), e))
break
else:
click.echo(
f"Retrying export for photo ({photo.uuid}: {photo.original_filename})"
)
if verbose:
if update or force_update:
for new in results.new:
verbose_(f"Exported new file {new}")
for updated in results.updated:
verbose_(f"Exported updated file {updated}")
for skipped in results.skipped:
verbose_(f"Skipped up to date file {skipped}")
else:
for exported in results.exported:
verbose_(f"Exported {exported}")
for touched in results.touched:
verbose_(f"Touched date on file {touched}")
return results
def get_filenames_from_template(
photo,
filename_template,
export_dir,
dest_path,
original_name,
strip=False,
edited=False,
export_db=None,
):
"""get list of export filenames for a photo
Args:
photo: a PhotoInfo instance
filename_template: a PhotoTemplate template string, may be None
original_name: bool; if True, use photo's original filename instead of current filename
dest_path: the path the photo will be exported to
strip: if True, strips leading/trailing white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
Returns:
list of filenames
Raises:
click.BadOptionUsage if template is invalid
"""
if filename_template:
photo_ext = pathlib.Path(photo.original_filename).suffix
try:
options = RenderOptions(
path_sep="_",
filename=True,
edited_version=edited,
export_dir=export_dir,
dest_path=dest_path,
)
filenames, unmatched = photo.render_template(filename_template, options)
except ValueError as e:
raise click.BadOptionUsage(
"filename_template", f"Invalid template '{filename_template}': {e}"
)
if not filenames or unmatched:
raise click.BadOptionUsage(
"filename_template",
f"Invalid template '{filename_template}': unknown field={unmatched}",
)
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
else:
filenames = (
[photo.original_filename]
if (original_name and (photo.original_filename is not None))
else [photo.filename]
)
if strip:
filenames = [filename.strip() for filename in filenames]
filenames = [sanitize_filename(filename) for filename in filenames]
return filenames
def get_dirnames_from_template(
photo,
directory,
export_by_date,
dest,
dry_run,
strip=False,
edited=False,
export_db=None,
):
"""get list of directories to export a photo into, creates directories if they don't exist
Args:
photo: a PhotoInstance object
directory: a PhotoTemplate template string, may be None
export_by_date: bool; if True, creates output directories in form YYYY-MM-DD
dest: top-level destination directory
dry_run: bool; if True, runs in dry-run mode and does not create output directories
strip: if True, strips leading/trailing white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
Returns:
list of export directories
Raises:
click.BadOptionUsage if template is invalid
"""
if export_by_date:
date_created = DateTimeFormatter(photo.date)
dest_path = os.path.join(
dest, date_created.year, date_created.mm, date_created.dd
)
if not (dry_run or os.path.isdir(dest_path)):
os.makedirs(dest_path)
dest_paths = [dest_path]
elif directory:
# got a directory template, render it and check results are valid
try:
options = RenderOptions(dirname=True, edited_version=edited)
dirnames, unmatched = photo.render_template(directory, options)
except ValueError as e:
raise click.BadOptionUsage(
"directory", f"Invalid template '{directory}': {e}"
)
if not dirnames or unmatched:
raise click.BadOptionUsage(
"directory",
f"Invalid template '{directory}': unknown field={unmatched}",
)
dest_paths = []
for dirname in dirnames:
if strip:
dirname = dirname.strip()
dirname = sanitize_filepath(dirname)
dest_path = os.path.join(dest, dirname)
if not is_valid_filepath(dest_path):
raise ValueError(f"Invalid file path: '{dest_path}'")
if not dry_run and not os.path.isdir(dest_path):
os.makedirs(dest_path)
dest_paths.append(dest_path)
else:
dest_paths = [dest]
return dest_paths
def find_files_in_branch(pathname, filename):
"""Search a directory branch to find file(s) named filename
The branch searched includes all folders below pathname and
the parent tree of pathname but not pathname itself.
e.g. find filename in children folders and parent folders
Args:
pathname: str, full path of directory to search
filename: str, filename to search for
Returns: list of full paths to any matching files
"""
pathname = pathlib.Path(pathname).resolve()
files = []
# walk down the tree
for root, _, filenames in os.walk(pathname):
# for directory in directories:
# print(os.path.join(root, directory))
for fname in filenames:
if fname == filename and pathlib.Path(root) != pathname:
files.append(os.path.join(root, fname))
# walk up the tree
path = pathlib.Path(pathname)
for root in path.parents:
filenames = os.listdir(root)
for fname in filenames:
filepath = os.path.join(root, fname)
if fname == filename and os.path.isfile(filepath):
files.append(os.path.join(root, fname))
return files
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
def write_export_report(report_file, results):
"""write CSV report with results from export
Args:
report_file: path to report file
results: ExportResults object
"""
# Collect results for reporting
all_results = {
result: {
"filename": result,
"exported": 0,
"new": 0,
"updated": 0,
"skipped": 0,
"exif_updated": 0,
"touched": 0,
"converted_to_jpeg": 0,
"sidecar_xmp": 0,
"sidecar_json": 0,
"sidecar_exiftool": 0,
"missing": 0,
"error": "",
"exiftool_warning": "",
"exiftool_error": "",
"extended_attributes_written": 0,
"extended_attributes_skipped": 0,
"cleanup_deleted_file": 0,
"cleanup_deleted_directory": 0,
"exported_album": "",
}
for result in results.all_files()
+ results.deleted_files
+ results.deleted_directories
}
for result in results.exported:
all_results[result]["exported"] = 1
for result in results.new:
all_results[result]["new"] = 1
for result in results.updated:
all_results[result]["updated"] = 1
for result in results.skipped:
all_results[result]["skipped"] = 1
for result in results.exif_updated:
all_results[result]["exif_updated"] = 1
for result in results.touched:
all_results[result]["touched"] = 1
for result in results.converted_to_jpeg:
all_results[result]["converted_to_jpeg"] = 1
for result in results.sidecar_xmp_written:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["exported"] = 1
for result in results.sidecar_xmp_skipped:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["skipped"] = 1
for result in results.sidecar_json_written:
all_results[result]["sidecar_json"] = 1
all_results[result]["exported"] = 1
for result in results.sidecar_json_skipped:
all_results[result]["sidecar_json"] = 1
all_results[result]["skipped"] = 1
for result in results.sidecar_exiftool_written:
all_results[result]["sidecar_exiftool"] = 1
all_results[result]["exported"] = 1
for result in results.sidecar_exiftool_skipped:
all_results[result]["sidecar_exiftool"] = 1
all_results[result]["skipped"] = 1
for result in results.missing:
all_results[result]["missing"] = 1
for result in results.error:
all_results[result[0]]["error"] = result[1]
for result in results.exiftool_warning:
all_results[result[0]]["exiftool_warning"] = result[1]
for result in results.exiftool_error:
all_results[result[0]]["exiftool_error"] = result[1]
for result in results.xattr_written:
all_results[result]["extended_attributes_written"] = 1
for result in results.xattr_skipped:
all_results[result]["extended_attributes_skipped"] = 1
for result in results.deleted_files:
all_results[result]["cleanup_deleted_file"] = 1
for result in results.deleted_directories:
all_results[result]["cleanup_deleted_directory"] = 1
for result, album in results.exported_album:
all_results[result]["exported_album"] = album
report_columns = [
"filename",
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_xmp",
"sidecar_json",
"sidecar_exiftool",
"missing",
"error",
"exiftool_warning",
"exiftool_error",
"extended_attributes_written",
"extended_attributes_skipped",
"cleanup_deleted_file",
"cleanup_deleted_directory",
"exported_album",
]
try:
with open(report_file, "w") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=report_columns)
writer.writeheader()
for data in [result for result in all_results.values()]:
writer.writerow(data)
except IOError:
click.echo(
click.style("Could not open output file for writing", fg=CLI_COLOR_ERROR),
err=True,
)
sys.exit(1)
def cleanup_files(dest_path, files_to_keep, fileutil):
"""cleanup dest_path by deleting and files and empty directories
not in files_to_keep
Args:
dest_path: path to directory to clean
files_to_keep: list of full file paths to keep (not delete)
fileutile: FileUtil object
Returns:
tuple of (list of files deleted, list of directories deleted)
"""
keepers = {
normalize_fs_path(str(filename).lower()): 1 for filename in files_to_keep
}
deleted_files = []
for p in pathlib.Path(dest_path).rglob("*"):
if p.is_file() and normalize_fs_path(str(p).lower()) not in keepers:
verbose_(f"Deleting {p}")
fileutil.unlink(p)
deleted_files.append(str(p))
# delete empty directories
deleted_dirs = []
# walk directory tree bottom up and verify contents are empty
for dirpath, _, _ in os.walk(dest_path, topdown=False):
if not list(pathlib.Path(dirpath).glob("*")):
# directory and directory is empty
verbose_(f"Deleting empty directory {dirpath}")
fileutil.rmdir(dirpath)
deleted_dirs.append(str(dirpath))
return (deleted_files, deleted_dirs)
def write_finder_tags(
photo,
files,
keywords=False,
keyword_template=None,
album_keyword=None,
person_keyword=None,
exiftool_merge_keywords=None,
finder_tag_template=None,
strip=False,
export_dir=None,
):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
Args:
photo: a PhotoInfo object
files: list of file paths to write Finder tags to
keywords: if True, sets Finder tags to all keywords including any evaluated from keyword_template, album_keyword, person_keyword, exiftool_merge_keywords
keyword_template: list of keyword templates to evaluate for determining keywords
album_keyword: if True, use album names as keywords
person_keyword: if True, use person in 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
export_dir: value to use for {export_dir} template
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
"""
tags = []
written = []
skipped = []
if keywords:
# match whatever keywords would've been used in --exiftool or --sidecar
export_options = ExportOptions(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
merge_exif_keywords=exiftool_merge_keywords,
)
exif = PhotoExporter(photo)._exiftool_dict(options=export_options)
try:
if exif["IPTC:Keywords"]:
tags.extend(exif["IPTC:Keywords"])
except KeyError:
pass
if finder_tag_template:
rendered_tags = []
for template_str in finder_tag_template:
try:
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
export_dir=export_dir,
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
raise click.BadOptionUsage(
"finder_tag_template",
f"Invalid template for --finder-tag-template '{template_str}': {e}",
)
if unmatched:
click.echo(
click.style(
f"Warning: unknown field for template: {template_str} unknown field = {unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,
)
rendered_tags.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
if strip:
rendered_tags = [value.strip() for value in rendered_tags]
rendered_tags = [
value.replace(_OSXPHOTOS_NONE_SENTINEL, "") for value in rendered_tags
]
tags.extend(rendered_tags)
tags = [osxmetadata.Tag(tag) for tag in set(tags)]
for f in files:
md = osxmetadata.OSXMetaData(f)
if sorted(md.tags) != sorted(tags):
verbose_(f"Writing Finder tags to {f}")
md.tags = tags
written.append(f)
else:
verbose_(f"Skipping Finder tags for {f}: nothing to do")
skipped.append(f)
return (written, skipped)
def write_extended_attributes(
photo,
files,
xattr_template,
strip=False,
export_dir=None,
):
"""Writes extended attributes to exported files
Args:
photo: a PhotoInfo object
strip: xattr_template: list of tuples: (attribute name, attribute template)
export_dir: value to use for {export_dir} template
Returns:
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
"""
attributes = {}
for xattr, template_str in xattr_template:
try:
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/", export_dir=export_dir
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
raise click.BadOptionUsage(
"xattr_template",
f"Invalid template for --xattr-template '{template_str}': {e}",
)
if unmatched:
click.echo(
click.style(
f"Warning: unmatched template substitution for template: {template_str} unknown field={unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,
)
# filter out any template values that didn't match by looking for sentinel
if strip:
rendered = [value.strip() for value in rendered]
rendered = [value.replace(_OSXPHOTOS_NONE_SENTINEL, "") for value in rendered]
try:
attributes[xattr].extend(rendered)
except KeyError:
attributes[xattr] = rendered
written = set()
skipped = set()
for f in files:
md = osxmetadata.OSXMetaData(f)
for attr, value in attributes.items():
islist = osxmetadata.ATTRIBUTES[attr].list
if value:
value = ", ".join(value) if not islist else sorted(value)
file_value = md.get_attribute(attr)
if file_value and islist:
file_value = sorted(file_value)
if (not file_value and not value) or file_value == value:
# if both not set or both equal, nothing to do
# get_attribute 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")
skipped.add(f)
else:
verbose_(f"Writing extended attribute {attr} to {f}")
md.set_attribute(attr, value)
written.add(f)
return list(written), [f for f in skipped if f not in written]
def run_post_command(
photo, post_command, export_results, export_dir, dry_run, exiftool_path, export_db
):
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
# todo: need a shell_quote template type:
# {shell_quote,{filepath}/foo/bar}
# that quotes everything in the default value
for category, command_template in post_command:
files = getattr(export_results, category)
for f in files:
# some categories, like error, return a tuple of (file, error str)
if isinstance(f, tuple):
f = f[0]
render_options = RenderOptions(export_dir=export_dir, filepath=f)
template = PhotoTemplate(photo, exiftool_path=exiftool_path)
command, _ = template.render(command_template, options=render_options)
command = command[0] if command else None
if command:
verbose_(f'Running command: "{command}"')
if not dry_run:
args = shlex.split(command)
cwd = pathlib.Path(f).parent
run_error = None
run_results = None
try:
run_results = subprocess.run(command, shell=True, cwd=cwd)
except Exception as e:
run_error = e
finally:
run_error = run_error or run_results.returncode
if run_error:
click.echo(
click.style(
f'Error running command "{command}": {run_error}',
fg=CLI_COLOR_ERROR,
),
err=True,
)
@cli.command()
@click.argument("packages", nargs=-1, required=True)
@click.option(
"-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version"
)
def install(packages, upgrade):
"""Install Python packages into the same environment as osxphotos"""
args = ["pip", "install"]
if upgrade:
args += ["--upgrade"]
args += list(packages)
sys.argv = args
run_module("pip", run_name="__main__")
@cli.command()
@click.argument("packages", nargs=-1, required=True)
@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation")
def uninstall(packages, yes):
"""Uninstall Python packages from the osxphotos environment"""
sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else [])
run_module("pip", run_name="__main__")
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def keywords(ctx, cli_obj, db, json_, photos_library):
"""Print out keywords found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["keywords"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
keywords = {"keywords": photosdb.keywords_as_dict}
if json_ or cli_obj.json:
click.echo(json.dumps(keywords, ensure_ascii=False))
else:
click.echo(yaml.dump(keywords, sort_keys=False, allow_unicode=True))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def albums(ctx, cli_obj, db, json_, photos_library):
"""Print out albums found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["albums"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
albums = {"albums": photosdb.albums_as_dict}
if photosdb.db_version > _PHOTOS_4_VERSION:
albums["shared albums"] = photosdb.albums_shared_as_dict
if json_ or cli_obj.json:
click.echo(json.dumps(albums, ensure_ascii=False))
else:
click.echo(yaml.dump(albums, sort_keys=False, allow_unicode=True))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def persons(ctx, cli_obj, db, json_, photos_library):
"""Print out persons (faces) found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["persons"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
persons = {"persons": photosdb.persons_as_dict}
if json_ or cli_obj.json:
click.echo(json.dumps(persons, ensure_ascii=False))
else:
click.echo(yaml.dump(persons, sort_keys=False, allow_unicode=True))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def labels(ctx, cli_obj, db, json_, photos_library):
"""Print out image classification labels found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["labels"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
labels = {"labels": photosdb.labels_as_dict}
if json_ or cli_obj.json:
click.echo(json.dumps(labels, ensure_ascii=False))
else:
click.echo(yaml.dump(labels, sort_keys=False, allow_unicode=True))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def info(ctx, cli_obj, db, json_, photos_library):
"""Print out descriptive info of the Photos library database."""
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
click.echo(cli.commands["info"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
info = {"database_path": photosdb.db_path, "database_version": photosdb.db_version}
photos = photosdb.photos(movies=False)
not_shared_photos = [p for p in photos if not p.shared]
info["photo_count"] = len(not_shared_photos)
hidden = [p for p in photos if p.hidden]
info["hidden_photo_count"] = len(hidden)
movies = photosdb.photos(images=False, movies=True)
not_shared_movies = [p for p in movies if not p.shared]
info["movie_count"] = len(not_shared_movies)
if photosdb.db_version > _PHOTOS_4_VERSION:
shared_photos = [p for p in photos if p.shared]
info["shared_photo_count"] = len(shared_photos)
shared_movies = [p for p in movies if p.shared]
info["shared_movie_count"] = len(shared_movies)
keywords = photosdb.keywords_as_dict
info["keywords_count"] = len(keywords)
info["keywords"] = keywords
albums = photosdb.albums_as_dict
info["albums_count"] = len(albums)
info["albums"] = albums
if photosdb.db_version > _PHOTOS_4_VERSION:
albums_shared = photosdb.albums_shared_as_dict
info["shared_albums_count"] = len(albums_shared)
info["shared_albums"] = albums_shared
persons = photosdb.persons_as_dict
info["persons_count"] = len(persons)
info["persons"] = persons
if cli_obj.json or json_:
click.echo(json.dumps(info, ensure_ascii=False))
else:
click.echo(yaml.dump(info, sort_keys=False, allow_unicode=True))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def places(ctx, cli_obj, db, json_, photos_library):
"""Print out places found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["places"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
place_names = {}
for photo in photosdb.photos(movies=True):
if photo.place:
try:
place_names[photo.place.name] += 1
except Exception:
place_names[photo.place.name] = 1
else:
try:
place_names[_UNKNOWN_PLACE] += 1
except Exception:
place_names[_UNKNOWN_PLACE] = 1
# sort by place count
places = {
"places": {
name: place_names[name]
for name in sorted(
place_names.keys(), key=lambda key: place_names[key], reverse=True
)
}
}
# below needed for to make CliRunner work for testing
cli_json = cli_obj.json if cli_obj is not None else None
if json_ or cli_json:
click.echo(json.dumps(places, ensure_ascii=False))
else:
click.echo(yaml.dump(places, sort_keys=False, allow_unicode=True))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DELETED_OPTIONS
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
"""Print list of all photos & associated info from the Photos library."""
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
click.echo(cli.commands["dump"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
# check exclusive options
if deleted and deleted_only:
click.echo("Incompatible dump options", err=True)
click.echo(cli.commands["dump"].get_help(ctx), err=True)
return
photosdb = osxphotos.PhotosDB(dbfile=db)
if deleted or deleted_only:
photos = photosdb.photos(movies=True, intrash=True)
else:
photos = []
if not deleted_only:
photos += photosdb.photos(movies=True)
print_photo_info(photos, json_ or cli_obj.json)
@cli.command(name="list")
@JSON_OPTION
@click.pass_obj
@click.pass_context
def list_libraries(ctx, cli_obj, json_):
"""Print list of Photos libraries found on the system."""
# implemented in _list_libraries so it can be called by other CLI functions
# without errors due to passing ctx and cli_obj
_list_libraries(json_=json_ or cli_obj.json, error=False)
def _list_libraries(json_=False, error=True):
"""Print list of Photos libraries found on the system.
If json_ == True, print output as JSON (default = False)"""
photo_libs = osxphotos.utils.list_photo_libraries()
sys_lib = osxphotos.utils.get_system_library_path()
last_lib = osxphotos.utils.get_last_library_path()
if json_:
libs = {
"photo_libraries": photo_libs,
"system_library": sys_lib,
"last_library": last_lib,
}
click.echo(json.dumps(libs, ensure_ascii=False))
else:
last_lib_flag = sys_lib_flag = False
for lib in photo_libs:
if lib == sys_lib:
click.echo(f"(*)\t{lib}", err=error)
sys_lib_flag = True
elif lib == last_lib:
click.echo(f"(#)\t{lib}", err=error)
last_lib_flag = True
else:
click.echo(f"\t{lib}", err=error)
if sys_lib_flag or last_lib_flag:
click.echo("\n", err=error)
if sys_lib_flag:
click.echo("(*)\tSystem Photos Library", err=error)
if last_lib_flag:
click.echo("(#)\tLast opened Photos Library", err=error)
@cli.command(name="uuid")
@click.pass_obj
@click.pass_context
@click.option(
"--filename",
"-f",
required=False,
is_flag=True,
default=False,
help="Include filename of selected photos in output",
)
def uuid(ctx, cli_obj, filename):
"""Print out unique IDs (UUID) of photos selected in Photos
Prints outs UUIDs in form suitable for --uuid-from-file and --skip-uuid-from-file
"""
for photo in photoscript.PhotosLibrary().selection:
if filename:
print(f"# {photo.filename}")
print(photo.uuid)
@cli.command(name="about")
@click.pass_obj
@click.pass_context
def about(ctx, cli_obj):
"""Print information about osxphotos including license."""
license = """
MIT License
Copyright (c) 2019-2021 Rhet Turnbull
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
osxphotos uses the following 3rd party software licensed under the BSD-3-Clause License:
Click (Copyright 2014 Pallets), ptpython (Copyright (c) 2015, Jonathan Slenders)
Redistribution and use in source and binary forms, with or without modification, are
permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list
of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list
of conditions and the following disclaimer in the documentation and/or other materials
provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be
used to endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
click.echo(f"osxphotos, version {__version__}")
click.echo("")
click.echo(f"Source code available at: {OSXPHOTOS_URL}")
click.echo(license)
@cli.command(name="tutorial")
@click.argument(
"WIDTH",
nargs=-1,
type=click.INT,
)
@click.pass_obj
@click.pass_context
def tutorial(ctx, cli_obj, width):
"""Display osxphotos tutorial."""
width = width[0] if width else 100
click.echo_via_pager(tutorial_help(width=width))
def _show_photo(photo: PhotoInfo):
"""open image with default image viewer
Note: This is for debugging only -- it will actually open any filetype which could
be very, very bad.
Args:
photo: PhotoInfo object or a path to a photo on disk
"""
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
if not os.path.isfile(photopath):
return f"'{photopath}' does not appear to be a valid photo path"
os.system(f"open '{photopath}'")
def _load_photos_db(dbpath):
print("Loading database")
tic = time.perf_counter()
photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print)
toc = time.perf_counter()
tictoc = toc - tic
print(f"Done: took {tictoc:0.2f} seconds")
return photosdb
def _get_all_photos(photosdb):
"""get list of all photos in photosdb"""
photos = photosdb.photos(images=True, movies=True)
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
return photos
def _get_selected(photosdb):
"""get list of PhotoInfo objects for photos selected in Photos"""
def get_selected():
selected = photoscript.PhotosLibrary().selection
if not selected:
return []
return photosdb.photos(uuid=[p.uuid for p in selected])
return get_selected
def _spotlight_photo(photo: PhotoInfo):
photo_ = photoscript.Photo(photo.uuid)
photo_.spotlight()
@cli.command(name="repl")
@DB_OPTION
@click.pass_obj
@click.pass_context
@click.option(
"--emacs",
required=False,
is_flag=True,
default=False,
help="Launch REPL with Emacs keybindings (default is vi bindings)",
)
@click.option(
"--beta",
is_flag=True,
default=False,
hidden=True,
help="Enable beta options.",
)
@QUERY_OPTIONS
@DELETED_OPTIONS
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
@click.option(
"--not-missing",
is_flag=True,
help="Search for photos present on disk (e.g. not missing).",
)
@click.option(
"--cloudasset",
is_flag=True,
help="Search for photos that are part of an iCloud library",
)
@click.option(
"--not-cloudasset",
is_flag=True,
help="Search for photos that are not part of an iCloud library",
)
@click.option(
"--incloud",
is_flag=True,
help="Search for photos that are in iCloud (have been synched)",
)
@click.option(
"--not-incloud",
is_flag=True,
help="Search for photos that are not in iCloud (have not been synched)",
)
def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
"""Run interactive osxphotos REPL shell (useful for debugging, prototyping, and inspecting your Photos library)"""
import logging
from objexplore import explore
from photoscript import Album, Photo, PhotosLibrary
from rich import inspect as _inspect
from osxphotos import ExifTool, PhotoInfo, PhotosDB
from osxphotos.albuminfo import AlbumInfo
from osxphotos.momentinfo import MomentInfo
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
from osxphotos.placeinfo import PlaceInfo
from osxphotos.queryoptions import QueryOptions
from osxphotos.scoreinfo import ScoreInfo
from osxphotos.searchinfo import SearchInfo
logger = logging.getLogger()
logger.disabled = True
pretty.install()
print(f"python version: {sys.version}")
print(f"osxphotos version: {osxphotos._version.__version__}")
db = db or get_photos_db()
photosdb = _load_photos_db(db)
# enable beta features if requested
if beta:
photosdb._beta = beta
print("Beta mode enabled")
print("Getting photos")
tic = time.perf_counter()
try:
query_options = _query_options_from_kwargs(**kwargs)
except IncompatibleQueryOptions:
click.echo("Incompatible query options", err=True)
click.echo(cli.commands["repl"].get_help(ctx), err=True)
sys.exit(1)
photos = _query_photos(photosdb, query_options)
all_photos = _get_all_photos(photosdb)
toc = time.perf_counter()
tictoc = toc - tic
# shortcut for helper functions
get_photo = photosdb.get_photo
show = _show_photo
spotlight = _spotlight_photo
get_selected = _get_selected(photosdb)
try:
selected = get_selected()
except Exception:
# get_selected sometimes fails
selected = []
def inspect(obj):
"""inspect object"""
return _inspect(obj, methods=True)
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n")
print("The following classes have been imported from osxphotos:")
print(
"- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportOptions, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n"
)
print("The following variables are defined:")
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
print(
f"- photos: list of PhotoInfo objects for all photos filtered with any query options passed on command line (len={len(photos)})"
)
print(
f"- all_photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash (len={len(all_photos)})"
)
print(
f"- selected: list of PhotoInfo objects for any photos selected in Photos (len={len(selected)})"
)
print(f"\nThe following functions may be helpful:")
print(
f"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')"
)
print(
f"- get_selected(); return list of PhotoInfo objects for photos selected in Photos"
)
print(
f"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])"
)
print(
f"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')"
)
print(f"- spotlight(photo): open a photo and spotlight it in Photos")
# print(
# f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
# )
print(
f"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)"
)
print(
f"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)"
)
print(f"- q, quit, quit(), exit, exit(): exit this interactive shell\n")
embed_repl(
globals=globals(),
locals=locals(),
history_filename=str(pathlib.Path.home() / ".osxphotos_repl_history"),
quit_words=["q", "quit", "exit"],
vi_mode=not emacs,
)
@cli.command(name="grep", hidden=OSXPHOTOS_HIDDEN)
@DB_OPTION
@click.pass_obj
@click.pass_context
@click.option(
"--ignore-case",
"-i",
required=False,
is_flag=True,
default=False,
help="Ignore case when searching (default is case-sensitive).",
)
@click.option(
"--print-filename",
"-p",
required=False,
is_flag=True,
default=False,
help="Print name of database file when printing results.",
)
@click.argument("pattern", metavar="PATTERN", required=True)
def grep(ctx, cli_obj, db, ignore_case, print_filename, pattern):
"""Search for PATTERN in the Photos sqlite database file"""
db = db or get_photos_db()
db = pathlib.Path(db)
if db.is_file():
# if passed the actual database, really want the parent of the database directory
db = db.parent.parent
photos_ver = get_photos_library_version(str(db))
if photos_ver < 5:
db_file = db / "database" / "photos.db"
else:
db_file = db / "database" / "Photos.sqlite"
if not db_file.is_file():
click.secho(f"Could not find database file {db_file}", fg="red")
ctx.exit(2)
db_file = str(db_file)
for table, column, row_id, value in sqlgrep(
db_file, pattern, ignore_case, print_filename, rich_markup=True
):
print(", ".join([table, column, row_id, value]))
@cli.command(hidden=OSXPHOTOS_HIDDEN)
@DB_OPTION
@DB_ARGUMENT
@click.option(
"--dump",
metavar="ATTR",
help="Name of PhotosDB attribute to print; "
+ "can also use albums, persons, keywords, photos to dump related attributes.",
multiple=True,
)
@click.option(
"--uuid",
metavar="UUID",
help="Use with '--dump photos' to dump only certain UUIDs. "
"May be repeated to include multiple UUIDs.",
multiple=True,
)
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
@click.pass_obj
@click.pass_context
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
"""Print out debug info"""
global VERBOSE
VERBOSE = bool(verbose)
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
click.echo(cli.commands["debug-dump"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
start_t = time.perf_counter()
print(f"Opening database: {db}")
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
stop_t = time.perf_counter()
print(f"Done; took {(stop_t-start_t):.2f} seconds")
for attr in dump:
if attr == "albums":
print("_dbalbums_album:")
pprint.pprint(photosdb._dbalbums_album)
print("_dbalbums_uuid:")
pprint.pprint(photosdb._dbalbums_uuid)
print("_dbalbum_details:")
pprint.pprint(photosdb._dbalbum_details)
print("_dbalbum_folders:")
pprint.pprint(photosdb._dbalbum_folders)
print("_dbfolder_details:")
pprint.pprint(photosdb._dbfolder_details)
elif attr == "keywords":
print("_dbkeywords_keyword:")
pprint.pprint(photosdb._dbkeywords_keyword)
print("_dbkeywords_uuid:")
pprint.pprint(photosdb._dbkeywords_uuid)
elif attr == "persons":
print("_dbfaces_uuid:")
pprint.pprint(photosdb._dbfaces_uuid)
print("_dbfaces_pk:")
pprint.pprint(photosdb._dbfaces_pk)
print("_dbpersons_pk:")
pprint.pprint(photosdb._dbpersons_pk)
print("_dbpersons_fullname:")
pprint.pprint(photosdb._dbpersons_fullname)
elif attr == "photos":
if uuid:
for uuid_ in uuid:
print(f"_dbphotos['{uuid_}']:")
try:
pprint.pprint(photosdb._dbphotos[uuid_])
except KeyError:
print(f"Did not find uuid {uuid_} in _dbphotos")
else:
print("_dbphotos:")
pprint.pprint(photosdb._dbphotos)
else:
try:
val = getattr(photosdb, attr)
print(f"{attr}:")
pprint.pprint(val)
except Exception:
print(f"Did not find attribute {attr} in PhotosDB")
@cli.command(name="snap")
@click.pass_obj
@click.pass_context
@DB_OPTION
def snap(ctx, cli_obj, db):
"""Create snapshot of Photos database to use with diff command
Snapshots only the database files, not the entire library. If OSXPHOTOS_SNAPSHOT
environment variable is defined, will use that as snapshot directory, otherwise
uses '/private/tmp/osxphotos_snapshots'
Works only on Photos library versions since Catalina (10.15) or newer.
"""
db = get_photos_db(db, cli_obj.db)
db_path = pathlib.Path(db)
if db_path.is_file():
# assume it's the sqlite file
db_path = db_path.parent.parent
db_path = db_path / "database"
db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR)
if not os.path.isdir(db_folder):
click.echo(f"Creating snapshot folder: '{db_folder}'")
os.mkdir(db_folder)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
destination_path = pathlib.Path(db_folder) / timestamp
# get all the sqlite files including the write ahead log if any
files = db_path.glob("*.sqlite*")
os.makedirs(destination_path)
fu = osxphotos.fileutil.FileUtil()
count = 0
for file in files:
if file.is_file():
fu.copy(file, destination_path)
count += 1
print(f"Copied {count} files from {db_path} to {destination_path}")
@cli.command(name="diff")
@click.pass_obj
@click.pass_context
@DB_OPTION
@click.option(
"--raw-output",
"-r",
is_flag=True,
default=False,
help="Print raw output (don't use syntax highlighting).",
)
@click.option(
"--style",
"-s",
metavar="STYLE",
nargs=1,
default="monokai",
help="Specify style/theme for syntax highlighting. "
"Theme may be any valid pygments style (https://pygments.org/styles/). "
"Default is 'monokai'.",
)
@click.argument("db2", nargs=-1, type=click.Path(exists=True))
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
def diff(ctx, cli_obj, db, raw_output, style, db2, verbose):
"""Compare two Photos databases and print out differences
To use the diff command, you'll need to install sqldiff via homebrew:
- Install homebrew (https://brew.sh/) if not already installed
- Install sqldiff: `brew install sqldiff`
When run with no arguments, compares the current Photos library to the
most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory.
If run with the --db option, compares the library specified by --db to the
most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory.
If run with just the DB2 argument, compares the current Photos library to
the database specified by the DB2 argument.
If run with both the --db option and the DB2 argument, compares the
library specified by --db to the database specified by DB2
See also `osxphotos snap`
If the OSXPHOTOS_SNAPSHOT environment variable is not set, will use
'/private/tmp/osxphotos_snapshots'
Works only on Photos library versions since Catalina (10.15) or newer.
"""
global VERBOSE
VERBOSE = bool(verbose)
sqldiff = shutil.which("sqldiff")
if not sqldiff:
click.echo(
"sqldiff not found; install via homebrew (https://brew.sh/): `brew install sqldiff`"
)
ctx.exit(2)
verbose_(f"sqldiff found at '{sqldiff}'")
db = get_photos_db(db, cli_obj.db)
db_path = pathlib.Path(db)
if db_path.is_file():
# assume it's the sqlite file
db_path = db_path.parent.parent
db_path = db_path / "database"
db_1 = db_path / "photos.sqlite"
if db2:
db_2 = pathlib.Path(db2[0])
else:
# get most recent snapshot
db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR)
verbose_(f"Using snapshot folder: '{db_folder}'")
folders = sorted([f for f in pathlib.Path(db_folder).glob("*") if f.is_dir()])
folder_2 = folders[-1]
db_2 = folder_2 / "Photos.sqlite"
if not db_1.exists():
print(f"database file {db_1} missing")
if not db_2.exists():
print(f"database file {db_2} missing")
verbose_(f"Comparing databases {db_1} and {db_2}")
diff_proc = subprocess.Popen([sqldiff, db_2, db_1], stdout=subprocess.PIPE)
console = Console()
for line in iter(diff_proc.stdout.readline, b""):
line = line.decode("UTF-8").rstrip()
if raw_output:
print(line)
else:
syntax = Syntax(
line, "sql", theme=style, line_numbers=False, code_width=1000
)
console.print(syntax)
@cli.command(name="run")
@click.argument("python_file", nargs=1, type=click.Path(exists=True))
def run(python_file):
"""Run a python file using same environment as osxphotos"""
run_path(python_file, run_name="__main__")
@cli.command(name="exportdb", hidden=OSXPHOTOS_HIDDEN)
@click.option("--version", is_flag=True, help="Print export database version and exit.")
@click.option("--vacuum", is_flag=True, help="Run VACUUM to defragment the database.")
@click.option(
"--check-signatures",
is_flag=True,
help="Check signatures for all exported photos in the database to find signatures that don't match.",
)
@click.option(
"--update-signatures",
is_flag=True,
help="Update signatures for all exported photos in the database to match on-disk signatures.",
)
@click.option(
"--touch-file",
is_flag=True,
help="Touch files on disk to match created date in Photos library and update export database signatures",
)
@click.option(
"--last-run",
is_flag=True,
help="Show last run osxphotos commands used with this database.",
)
@click.option(
"--save-config",
metavar="CONFIG_FILE",
help="Save last run configuration to TOML file for use by --load-config.",
)
@click.option(
"--info",
metavar="FILE_PATH",
nargs=1,
help="Print information about FILE_PATH contained in the database.",
)
@click.option(
"--migrate",
is_flag=True,
help="Migrate (if needed) export database to current version.",
)
@click.option(
"--sql",
metavar="SQL_STATEMENT",
help="Execute SQL_STATEMENT against export database and print results.",
)
@click.option(
"--export-dir",
help="Optional path to export directory (if not parent of export database).",
type=click.Path(exists=True, file_okay=False, dir_okay=True),
)
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
@click.option(
"--dry-run",
is_flag=True,
help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.",
)
@click.argument("export_db", metavar="EXPORT_DATABASE", type=click.Path(exists=True))
def exportdb(
version,
vacuum,
check_signatures,
update_signatures,
touch_file,
last_run,
save_config,
info,
migrate,
sql,
export_dir,
verbose,
dry_run,
export_db,
):
"""Utilities for working with the osxphotos export database"""
export_db = pathlib.Path(export_db)
if export_db.is_dir():
# assume it's the export folder
export_db = export_db / OSXPHOTOS_EXPORT_DB
if not export_db.is_file():
print(
f"[red]Error: {OSXPHOTOS_EXPORT_DB} missing from {export_db.parent}[/red]"
)
sys.exit(1)
export_dir = export_dir or export_db.parent
sub_commands = [
version,
check_signatures,
update_signatures,
touch_file,
last_run,
bool(save_config),
bool(info),
migrate,
bool(sql),
]
if sum(sub_commands) > 1:
print(f"[red]Only a single sub-command may be specified at a time[/red]")
sys.exit(1)
if version:
try:
osxphotos_ver, export_db_ver = export_db_get_version(export_db)
except Exception as e:
print(f"[red]Error: could not read version from {export_db}: {e}[/red]")
sys.exit(1)
else:
print(
f"osxphotos version: {osxphotos_ver}, export database version: {export_db_ver}"
)
sys.exit(0)
if vacuum:
try:
start_size = pathlib.Path(export_db).stat().st_size
export_db_vacuum(export_db)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
print(
f"Vacuumed {export_db}! {start_size} bytes -> {pathlib.Path(export_db).stat().st_size} bytes"
)
sys.exit(0)
if update_signatures:
try:
updated, skipped = export_db_update_signatures(
export_db, export_dir, verbose, dry_run
)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
print(f"Done. Updated {updated} files, skipped {skipped} files.")
sys.exit(0)
if last_run:
try:
last_run_info = export_db_get_last_run(export_db)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
print(f"last run at {last_run_info[0]}:")
print(f"osxphotos {last_run_info[1]}")
sys.exit(0)
if save_config:
try:
export_db_save_config_to_file(export_db, save_config)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
print(f"Saved configuration to {save_config}")
sys.exit(0)
if check_signatures:
try:
matched, notmatched, skipped = export_db_check_signatures(
export_db, export_dir, verbose=verbose
)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
print(
f"Done. Found {matched} matching signatures and {notmatched} signatures that don't match. Skipped {skipped} missing files."
)
sys.exit(0)
if touch_file:
try:
touched, not_touched, skipped = export_db_touch_files(
export_db, export_dir, verbose=verbose, dry_run=dry_run
)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
print(
f"Done. Touched {touched} files, skipped {not_touched} up to date files, skipped {skipped} missing files."
)
sys.exit(0)
if info:
exportdb = ExportDB(export_db, export_dir)
try:
info_rec = exportdb.get_file_record(info)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
if info_rec:
print(info_rec.asdict())
else:
print(f"[red]File '{info}' not found in export database[/red]")
sys.exit(0)
if migrate:
exportdb = ExportDB(export_db, export_dir)
upgraded = exportdb.was_upgraded
if upgraded:
print(
f"Migrated export database {export_db} from version {upgraded[0]} to {upgraded[1]}"
)
else:
print(
f"Export database {export_db} is already at latest version {OSXPHOTOS_EXPORTDB_VERSION}"
)
sys.exit(0)
if sql:
exportdb = ExportDB(export_db, export_dir)
try:
c = exportdb._conn.cursor()
results = c.execute(sql)
except Exception as e:
print(f"[red]Error: {e}[/red]")
sys.exit(1)
else:
for row in results:
print(row)
sys.exit(0)
def _query_options_from_kwargs(**kwargs) -> QueryOptions:
"""Validate query options and create a QueryOptions instance"""
# sanity check input args
nonexclusive = [
"keyword",
"person",
"album",
"folder",
"name",
"uuid",
"uuid_from_file",
"edited",
"external_edit",
"uti",
"has_raw",
"from_date",
"to_date",
"from_time",
"to_time",
"label",
"is_reference",
"query_eval",
"query_function",
"min_size",
"max_size",
"regex",
"selected",
"exif",
"duplicate",
]
exclusive = [
("favorite", "not_favorite"),
("hidden", "not_hidden"),
("missing", "not_missing"),
("only_photos", "only_movies"),
("burst", "not_burst"),
("live", "not_live"),
("cloudasset", "not_cloudasset"),
("incloud", "not_incloud"),
("portrait", "not_portrait"),
("screenshot", "not_screenshot"),
("slow_mo", "not_slow_mo"),
("time_lapse", "not_time_lapse"),
("hdr", "not_hdr"),
("selfie", "not_selfie"),
("panorama", "not_panorama"),
("deleted", "deleted_only"),
("shared", "not_shared"),
("has_comment", "no_comment"),
("has_likes", "no_likes"),
("in_album", "not_in_album"),
("location", "no_location"),
]
# 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"]]),
]
):
raise IncompatibleQueryOptions
# 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)
def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List:
"""Query photos given a QueryOptions instance"""
try:
photos = photosdb.query(query_options)
except ValueError as e:
if "Invalid query_eval CRITERIA:" in str(e):
msg = str(e).split(":")[1]
raise click.BadOptionUsage(
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
)
else:
raise ValueError(e)
return photos