diff --git a/dev_requirements.txt b/dev_requirements.txt index 61cd4a24..185fafa2 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -4,6 +4,7 @@ furo m2r2 pdbpp pyinstaller==4.10 +pytest-cov==3.0.0 pytest-mock pytest==7.0.1 Sphinx diff --git a/osxphotos/cli/cli.py b/osxphotos/cli/cli.py index 21ea3085..3fdc8bfc 100644 --- a/osxphotos/cli/cli.py +++ b/osxphotos/cli/cli.py @@ -15,6 +15,7 @@ from .export import export from .exportdb import exportdb from .grep import grep from .help import help +from .import_cli import import_cli from .info import info from .install_uninstall_run import install, run, uninstall from .keywords import keywords @@ -75,6 +76,7 @@ for command in [ exportdb, grep, help, + import_cli, info, install, keywords, diff --git a/osxphotos/cli/import_cli.py b/osxphotos/cli/import_cli.py new file mode 100644 index 00000000..06be90f4 --- /dev/null +++ b/osxphotos/cli/import_cli.py @@ -0,0 +1,1072 @@ +"""import command for osxphotos CLI to import photos into Photos""" + +import datetime +import fnmatch +import logging +import os +import os.path +import sys +import uuid +from collections import namedtuple +from pathlib import Path +from textwrap import dedent +from typing import Callable, List, Optional, Tuple, Union + +import click +from photoscript import Photo, PhotosLibrary +from rich.console import Console +from rich.markdown import Markdown + +from osxphotos._constants import _OSXPHOTOS_NONE_SENTINEL +from osxphotos.cli.help import HELP_WIDTH +from osxphotos.datetime_utils import datetime_naive_to_local +from osxphotos.exiftool import ExifToolCaching, get_exiftool_path +from osxphotos.photosalbum import PhotosAlbumPhotoScript +from osxphotos.phototemplate import PhotoTemplate, RenderOptions +from osxphotos.utils import pluralize +from osxphotos.cli.param_types import TemplateString + +from .click_rich_echo import ( + rich_click_echo, + set_rich_console, + set_rich_theme, + set_rich_timestamp, +) +from .color_themes import get_theme +from .common import THEME_OPTION +from .rich_progress import rich_progress +from .verbose import get_verbose_console, verbose_print + +MetaData = namedtuple("MetaData", ["title", "description", "keywords", "location"]) + + +def echo(message, emoji=True, **kwargs): + """Echo text with rich""" + if emoji: + if "[error]" in message: + message = f":cross_mark-emoji: {message}" + elif "[warning]" in message: + message = f":warning-emoji: {message}" + rich_click_echo(message, **kwargs) + + +class PhotoInfoFromFile: + """Mock PhotoInfo class for a file to be imported + + Returns None for most attributes but allows some templates like exiftool and created to work correctly""" + + def __init__(self, filepath: Union[str, Path], exiftool: Optional[str] = None): + self._path = str(filepath) + self._exiftool_path = exiftool + self._uuid = str(uuid.uuid1()).upper() + + @property + def uuid(self): + return self._uuid + + @property + def original_filename(self): + return Path(self._path).name + + @property + def date(self): + """Use file creation date and local timezone""" + ctime = os.path.getctime(self._path) + dt = datetime.datetime.fromtimestamp(ctime) + return datetime_naive_to_local(dt) + + @property + def path(self): + """Path to photo file""" + return self._path + + @property + def exiftool(self): + """Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo. + Requires that exiftool (https://exiftool.org/) be installed + If exiftool not installed, logs warning and returns None + If photo path is missing, returns None + """ + try: + # return the memoized instance if it exists + return self._exiftool + except AttributeError: + try: + exiftool_path = self._exiftool_path or get_exiftool_path() + if self._path is not None and os.path.isfile(self._path): + exiftool = ExifToolCaching(self._path, exiftool=exiftool_path) + else: + exiftool = None + except FileNotFoundError: + # get_exiftool_path raises FileNotFoundError if exiftool not found + exiftool = None + logging.warning( + "exiftool not in path; download and install from https://exiftool.org/" + ) + + self._exiftool = exiftool + return self._exiftool + + def render_template( + self, template_str: str, options: Optional[RenderOptions] = None + ): + """Renders a template string for PhotoInfo instance using PhotoTemplate + + Args: + template_str: a template string with fields to render + options: a RenderOptions instance + + Returns: + ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values + """ + options = options or RenderOptions() + template = PhotoTemplate(self, exiftool_path=self._exiftool_path) + return template.render(template_str, options) + + def __getattr__(self, name): + """Return None for any other non-private attribute""" + if not name.startswith("_"): + return None + raise AttributeError() + + +def import_photo( + filepath: Path, dup_check: bool, verbose: Callable[..., None] +) -> Tuple[Optional[Photo], Optional[str]]: + """Import a photo and return Photo object and error string if any + + Args: + filepath: path to the file to import + dup_check: enable or disable Photo's duplicate check on import + verbose: Callable""" + if imported := PhotosLibrary().import_photos( + [filepath], skip_duplicate_check=not dup_check + ): + verbose( + f"Imported [filename]{filepath.name}[/] with UUID [uuid]{imported[0].uuid}[/]" + ) + photo = imported[0] + return photo, None + else: + error_str = f"[error]Error importing file [filepath]{filepath}[/][/]" + echo(error_str, err=True) + return None, error_str + + +def render_photo_template( + filepath: Path, + relative_filepath: Path, + template: str, + exiftool_path: Optional[str], +): + """Render template string for a photo""" + + photoinfo = PhotoInfoFromFile(filepath, exiftool=exiftool_path) + options = RenderOptions( + none_str=_OSXPHOTOS_NONE_SENTINEL, filepath=relative_filepath + ) + template_values, _ = photoinfo.render_template(template, options=options) + # filter out empty strings + template_values = [v.replace(_OSXPHOTOS_NONE_SENTINEL, "") for v in template_values] + template_values = [v for v in template_values if v] + return template_values + + +def add_photo_to_albums( + photo: Photo, + filepath: Path, + relative_filepath: Path, + album: Tuple[str], + split_folder: str, + exiftool_path: Path, + verbose: Callable[..., None], +): + """Add photo to one or more albums""" + albums = [] + for a in album: + albums.extend( + render_photo_template(filepath, relative_filepath, a, exiftool_path) + ) + verbose( + f"Adding photo [filename]{filepath.name}[/filename] to {len(albums)} {pluralize(len(albums), 'album', 'albums')}" + ) + + # add photo to albums + for a in albums: + verbose(f"Adding photo [filename]{filepath.name}[/] to album [filepath]{a}[/]") + photos_album = PhotosAlbumPhotoScript( + a, verbose=verbose, split_folder=split_folder + ) + photos_album.add(photo) + + +def clear_photo_metadata(photo: Photo, filepath: Path, verbose: Callable[..., None]): + """Clear any metadata (title, description, keywords) associated with Photo in the Photos Library""" + verbose(f"Clearing metadata for [filename]{filepath.name}[/]") + photo.title = "" + photo.description = "" + photo.keywords = [] + + +def clear_photo_location(photo: Photo, filepath: Path, verbose: Callable[..., None]): + """Clear any location (latitude, longitude) associated with Photo in the Photos Library""" + verbose(f"Clearing location for [filename]{filepath.name}[/]") + photo.location = (None, None) + + +def metadata_from_file(filepath: Path, exiftool_path: str) -> MetaData: + """Get metadata from file with exiftool + + Returns the following metadata from EXIF/XMP/IPTC fields as a MetaData named tuple + title: str, XMP:Title, IPTC:ObjectName, QuickTime:DisplayName + description: str, XMP:Description, IPTC:Caption-Abstract, EXIF:ImageDescription, QuickTime:Description + keywords: str, XMP:Subject, XMP:TagsList, IPTC:Keywords (QuickTime:Keywords not supported) + location: Tuple[lat, lon], EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef, EXIF:GPSLongitude, QuickTime:GPSCoordinates, UserData:GPSCoordinates + """ + exiftool = ExifToolCaching(filepath, exiftool_path) + metadata = exiftool.asdict() + title = ( + metadata.get("XMP:Title") + or metadata.get("IPTC:ObjectName") + or metadata.get("QuickTime:DisplayName") + ) + description = ( + metadata.get("XMP:Description") + or metadata.get("IPTC:Caption-Abstract") + or metadata.get("EXIF:ImageDescription") + or metadata.get("QuickTime:Description") + ) + keywords = ( + metadata.get("XMP:Subject") + or metadata.get("XMP:TagsList") + or metadata.get("IPTC:Keywords") + ) + + title = title or "" + description = description or "" + keywords = keywords or [] + if not isinstance(keywords, (tuple, list)): + keywords = [keywords] + + location = location_from_file(filepath, exiftool_path) + return MetaData(title, description, keywords, location) + + +def location_from_file( + filepath: Path, exiftool_path: str +) -> Tuple[Optional[float], Optional[float]]: + """Get location from file with exiftool + + Returns: + Tuple of lat, long or None, None if not set + + Note: + Attempts to get location from the following EXIF fields: + EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef + EXIF:GPSLatitude, EXIF:GPSLongitude + QuickTime:GPSCoordinates + UserData:GPSCoordinates + """ + exiftool = ExifToolCaching(filepath, exiftool_path) + metadata = exiftool.asdict() + + # photos and videos store location data differently + # for photos, location in EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef, EXIF:GPSLatitude, EXIF:GPSLongitude + # the GPSLatitudeRef and GPSLongitudeRef are needed to determine N/S, E/W respectively + # for example: + # EXIF:GPSLatitudeRef N + # EXIF:GPSLongitudeRef W + # EXIF:GPSLatitude 33.7198027777778 + # EXIF:GPSLongitude 118.285491666667 + # for video, location in QuickTime:GPSCoordinates or UserData:GPSCoordinates as a + # pair of positive/negative numbers thus no ref needed + # for example: + # QuickTime:GPSCoordinates 34.0533 -118.2423 + + latitude, longitude = None, None + try: + if latitude := metadata.get("EXIF:GPSLatitude"): + latitude = float(latitude) + latitude_ref = metadata.get("EXIF:GPSLatitudeRef") + if latitude_ref == "S": + latitude = -latitude + elif latitude_ref != "N": + latitude = None + if longitude := metadata.get("EXIF:GPSLongitude"): + longitude = float(longitude) + longitude_ref = metadata.get("EXIF:GPSLongitudeRef") + if longitude_ref == "W": + longitude = -longitude + elif longitude_ref != "E": + longitude = None + if latitude is None or longitude is None: + # maybe it's a video + if lat_lon := metadata.get("QuickTime:GPSCoordinates") or metadata.get( + "UserData:GPSCoordinates" + ): + lat_lon = lat_lon.split() + if len(lat_lon) != 2: + latitude = None + longitude = None + else: + latitude = float(lat_lon[0]) + longitude = float(lat_lon[1]) + except ValueError: + # couldn't convert one of the numbers to float + return None, None + return latitude, longitude + + +def set_photo_metadata(photo: Photo, metadata: MetaData, merge_keywords: bool): + """Set metadata (title, description, keywords) for a Photo object""" + photo.title = metadata.title + photo.description = metadata.description + keywords = metadata.keywords.copy() + if merge_keywords: + if old_keywords := photo.keywords: + keywords.extend(old_keywords) + keywords = list(set(keywords)) + photo.keywords = keywords + + +def set_photo_metadata_from_exiftool( + photo: Photo, + filepath: Path, + exiftool_path: str, + merge_keywords: bool, + verbose: Callable[..., None], +): + """Set photo's metadata by reading metadata form file with exiftool""" + verbose(f"Setting metadata and location from EXIF for [filename]{filepath.name}[/]") + metadata = metadata_from_file(filepath, exiftool_path) + if any([metadata.title, metadata.description, metadata.keywords]): + set_photo_metadata(photo, metadata, merge_keywords) + verbose(f"Set metadata for [filename]{filepath.name}[/]:") + verbose( + f"title='{metadata.title}', description='{metadata.description}', keywords={metadata.keywords}" + ) + else: + verbose(f"No metadata to set for [filename]{filepath.name}[/]") + if metadata.location[0] is not None and metadata.location[1] is not None: + # location will be set to None, None if latitude or longitude is missing + photo.location = metadata.location + verbose( + f"Set location for [filename]{filepath.name}[/]: " + f"[num]{metadata.location[0]}[/], [num]{metadata.location[1]}[/]" + ) + else: + verbose(f"No location to set for [filename]{filepath.name}[/]") + + +def set_photo_title( + photo: Photo, + filepath: Path, + relative_filepath: Path, + title_template: str, + exiftool_path: str, + verbose: Callable[..., None], +): + """Set title of photo""" + title_text = render_photo_template( + filepath, relative_filepath, title_template, exiftool_path + ) + if len(title_text) > 1: + echo( + f"photo can have only a single title: '{title_template}' = {title_text}", + err=True, + ) + raise click.Abort() + if title_text: + verbose( + f"Setting title of photo [filename]{filepath.name}[/] to '{title_text[0]}'" + ) + photo.title = title_text[0] + + +def set_photo_description( + photo: Photo, + filepath: Path, + relative_filepath: Path, + description_template: str, + exiftool_path: str, + verbose: Callable[..., None], +): + """Set description of photo""" + description_text = render_photo_template( + filepath, relative_filepath, description_template, exiftool_path + ) + if len(description_text) > 1: + echo( + f"photo can have only a single description: '{description_template}' = {description_text}", + err=True, + ) + raise click.Abort() + if description_text: + verbose( + f"Setting description of photo [filename]{filepath.name}[/] to '{description_text[0]}'" + ) + photo.description = description_text[0] + + +def set_photo_keywords( + photo: Photo, + filepath: Path, + relative_filepath: Path, + keyword_template: str, + exiftool_path: str, + merge: bool, + verbose: Callable[..., None], +): + """Set keywords of photo""" + keywords = [] + for keyword in keyword_template: + kw = render_photo_template(filepath, relative_filepath, keyword, exiftool_path) + keywords.extend(kw) + if keywords: + if merge: + if old_keywords := photo.keywords: + keywords.extend(old_keywords) + keywords = list(set(keywords)) + verbose(f"Setting keywords of photo [filename]{filepath.name}[/] to {keywords}") + photo.keywords = keywords + + +def set_photo_location( + photo: Photo, + filepath: Path, + location: Tuple[float, float], + verbose: Callable[..., None], +): + """Set location of photo""" + verbose( + f"Setting location of photo [filename]{filepath.name}[/] to {location[0]}, {location[1]}" + ) + photo.location = location + + +def get_relative_filepath(filepath: Path, relative_to: Optional[str]) -> Path: + """Get relative filepath of file relative to relative_to or return filepath if relative_to is None + + Args: + filepath: path to file + relative_to: path to directory to which filepath is relative + + Returns: relative filepath or filepath if relative_to is None + + Raises: click.Abort if relative_to is not in the same path as filepath + """ + relative_filepath = filepath + + # check relative_to here so we abort before import if relative_to is bad + if relative_to: + try: + relative_filepath = relative_filepath.relative_to(relative_to) + except ValueError as e: + echo( + f"--relative-to value of '{relative_to}' is not in the same path as '{relative_filepath}'", + err=True, + ) + raise click.Abort() from e + + return relative_filepath + + +def check_templates_and_exit( + files: List[str], + relative_to: Optional[Path], + title: Optional[str], + description: Optional[str], + keyword: Tuple[str], + album: Tuple[str], + split_folder: Optional[str], + exiftool_path: Optional[str], + exiftool: bool, +): + """Renders templates against each file so user can verify correctness""" + for file in files: + file = Path(file).absolute().resolve() + relative_filepath = get_relative_filepath(file, relative_to) + echo(f"[filepath]{file}[/]:") + if exiftool: + metadata = metadata_from_file(file, exiftool_path) + echo(f"exiftool title: {metadata.title}") + echo(f"exiftool description: {metadata.description}") + echo(f"exiftool keywords: {metadata.keywords}") + echo(f"exiftool location: {metadata.location}") + if title: + rendered_title = render_photo_template( + file, relative_filepath, title, exiftool_path + ) + rendered_title = rendered_title[0] if rendered_title else "None" + echo(f"title: [italic]{title}[/]: {rendered_title}") + if description: + rendered_description = render_photo_template( + file, relative_filepath, description, exiftool_path + ) + rendered_description = ( + rendered_description[0] if rendered_description else "None" + ) + echo(f"description: [italic]{description}[/]: {rendered_description}") + if keyword: + for kw in keyword: + rendered_keywords = render_photo_template( + file, relative_filepath, kw, exiftool_path + ) + rendered_keywords = rendered_keywords or "None" + echo(f"keyword: [italic]{kw}[/]: {rendered_keywords}") + if album: + for al in album: + rendered_album = render_photo_template( + file, relative_filepath, al, exiftool_path + ) + rendered_album = rendered_album[0] if rendered_album else "None" + echo(f"album: [italic]{al}[/]: {rendered_album}") + sys.exit(0) + + +def filename_matches_patterns(filename: str, patterns: Tuple[str]) -> bool: + """Return True if filename matches any pattern in patterns""" + return any(fnmatch.fnmatch(filename, pattern) for pattern in patterns) + + +def collect_files_to_import( + files: Tuple[str], walk: bool, glob: Tuple[str] +) -> List[str]: + """Collect files to import, recursively if necessary + + Args: + files: list of initial files or directories to import + walk: whether to walk directories + glob: optional glob patterns to match files + """ + files_to_import = [] + for file in files: + if os.path.isfile(file): + if glob and filename_matches_patterns(os.path.basename(file), glob): + files_to_import.append(file) + elif not glob: + files_to_import.append(file) + elif os.path.isdir(file): + if walk: + for root, dirs, files in os.walk(file): + for file in files: + if glob and filename_matches_patterns( + os.path.basename(file), glob + ): + files_to_import.append(os.path.join(root, file)) + elif not glob: + files_to_import.append(os.path.join(root, file)) + else: + continue + return files_to_import + + +class ImportCommand(click.Command): + """Custom click.Command that overrides get_help() to show additional help info for import""" + + def get_help(self, ctx): + help_text = super().get_help(ctx) + formatter = click.HelpFormatter(width=HELP_WIDTH) + extra_help = dedent( + """ + ## Examples + + Import a file into Photos: + `osxphotos import /Volumes/photos/img_1234.jpg` + + Import multiple jpg files into Photos: + + `osxphotos import /Volumes/photos/*.jpg` + + Import files into Photos and add to album: + + `osxphotos import /Volumes/photos/*.jpg --album "My Album"` + + Import files into Photos and add to album named for 4-digit year of file creation date: + + `oxphotos import /Volumes/photos/*.jpg --album "{created.year}"` + + Import files into Photos and add to album named for month of the year in folder named + for the 4-digit year of the file creation date: + + `osxphotos import /Volumes/photos/*.jpg --album "{created.year}/{created.month}" --split-folder "/"` + + ## Albums + + The imported files may be added to one or more albums using the `--album` option. + The value passed to `--album` may be a literal string or an osxphotos template + (see Template System below). For example: + + `osxphotos import /Volumes/photos/*.jpg --album "Vacation"` + + adds all photos to the album "Vacation". The album will be created if it does not + already exist. + + `osxphotos import /Volumes/photos/Madrid/*.jpg --album "{filepath.parent.name}"` + + adds all photos to the album "Madrid" (the name of the file's parent folder). + + ## Folders + + If you want to organize the imported photos into both folders and albums, you can + use the `--split-folder` option. For example, if your photos are organized into + folders as follows: + + . + ├── 2021 + │ ├── Family + │ └── Travel + └── 2022 + ├── Family + └── Travel + + You can recreate this hierarchal structure on import using + + `--album "{filepath.parent}" --split-folder "/"` + + In this example, `{filepath.parent}` renders to '2021/Family', '2021/Travel', etc. + and `--split-folder "/"` instructs osxphotos to split the album name into separate + parts '2021' and 'Family'. + + If your photos are organized in a set of folders but you want to exclude one or more parent + folders from the list of folders and album, you can use the `--relative-to` option to specify + the parent path that all subsequent paths should be relative to. For example, if your photos + are organized into photos as follows: + + / + └── Volumes + └── Photos + ├── 2021 + │ ├── Family + │ └── Travel + └── 2022 + ├── Family + └── Travel + + and you want to exclude /Volumes/Photos from the folder/album path, you can do this: + + `osxphotos import /Volumes/Photos/* --walk --album "{filepath}" --relative-to "/Volumes/Photos"` + + Note: in Photos, only albums can contain photos and folders + may contain albums or other folders. + + ## Metadata + + `osxphotos import` can set metadata (title, description, keywords, and location) for + imported photos/videos using several options. + + If you have exiftool (https://exiftool.org/) installed, osxphotos can use + exiftool to extract metadata from the imported file and use this to update + the metadata in Photos. + + The `--exiftool` option will automatically attempt to update title, + description, keywords, and location from the file's metadata: + + `osxphotos import *.jpg --exiftool` + + The following metadata fields are read (in priority order) and used to set + the metadata of the imported photo: + + - Title: XMP:Title, IPTC:ObjectName, QuickTime:DisplayName + - Description: XMP:Description, IPTC:Caption-Abstract, EXIF:ImageDescription, QuickTime:Description + - Keywords: XMP:Subject, XMP:TagsList, IPTC:Keywords (QuickTime:Keywords not supported) + - Location: EXIF:GPSLatitude/EXIF:GPSLatitudeRef, EXIF:GPSLongitude/EXIF:GPSLongitudeRef, QuickTime:GPSCoordinates, UserData:GPSCoordinates + + When importing photos, Photos itself will usually read most of these same fields + and set the metadata but when importing via AppleScript (which is how `osxphotos + import` interacts with Photos), Photos does not always reliably do this. It is + recommended you use `--exiftool` to ensure metadata gets correctly imported. + + You can also use `--clear-metadata` to remove any metadata automatically set by + Photos upon import. + + In addition to `--exiftool`, you can specify a template (see Template System below) + for setting title (`--title`), description (`--description`), and keywords (`--keywords`). + Location can be set using `--location`. The album(s) of the imported file can likewise + be specified with `--album`. + + `--title`, `--description`, `--keyword`, and `--album` all take a literal string or an + osxphotos template string. If a template string is used, the template is rendered + using the osxphotos template language to produce the final value. + + For example: + + `--title "{exiftool:XMP:Title}"` sets the title of the imported file to whatever value + is in the `XMP:Title` metadata field (as read by `exiftool`). + + `--keyword "Vacation"` sets the keyword for the imported file to the literal string "Vacation". + + ## Template System + + As mentioned above, the `--title`, `--description`, `--keyword`, and `--album` options + all take an osxphotos template language template string that is further rendered to + produce the final value. The template system used by `osxphotos import` is a subset + of the template system used by `osxphotos export`. For a complete description of the + template system, see `osxphotos help export`. + + Most fields in the osxphotos template system are not available to `osxphotos import` as + they are derived from data in the Photos library and the photos will obviously not be + imported yet. The following fields are available: + + #### {exiftool} + - `{exiftool}`: Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org) + to extract metadata, in form GROUP:TAGNAME, from image. + E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract + keywords. See https://exiftool.org/TagNames/ for list of valid tag names. + You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. + exiftool must be installed in the path to use this template (alternatively, you can use + `--exiftool-path` to specify the path to exiftool.) + + #### {filepath} + + - `{filepath}`: The full path to the file being imported. + For example, `/Volumes/photos/img_1234.jpg`. + + `{filepath}` has several subfields that + allow you to access various parts of the path using the following subfield modifiers: + + - `{filepath.parent}`: the parent directory + - `{filepath.name}`: the name of the file or final sub-directory + - `{filepath.stem}`: the name of the file without the extension + - `{filepath.suffix}`: the suffix of the file including the leading '.' + + For example, if the field `{filepath}` is '/Shared/Backup/Photos/IMG_1234.JPG': + - `{filepath.parent}` is '/Shared/Backup/Photos' + - `{filepath.name}` is 'IMG_1234.JPG' + - `{filepath.stem}` is 'IMG_1234' + - `{filepath.suffix}` is '.JPG' + + Subfields may be chained, for example, `{filepath.parent.parent}` in the above + example would be `/Shared/Backup` and `{filepath.parent.name}` would be `Photos`. + + `{filepath}` may be modified using the `--relative-to` option. For example, + if the path to the imported photo is `/Volumes/Photos/Folder1/Album1/IMG_1234.jpg` + and you specify `--relative-to "/Volumes/Photos"` then `{filepath}` will be set + to `Folder1/Album1/IMG_1234.jpg` + (a subset of the path relative to the value of `--relative-to`). + + #### {created} + + - `{created}`: The date the file was created. `{created}` must be used with a subfield to + specify the format of the date. + + - `{created.date}`: Photo's creation date in ISO format, e.g. '2020-03-22' + - `{created.year}`: 4-digit year of photo creation time + - `{created.yy}`: 2-digit year of photo creation time + - `{created.mm}`: 2-digit month of the photo creation time (zero padded) + - `{created.month}`: Month name in user's locale of the photo creation time + - `{created.mon}`: Month abbreviation in the user's locale of the photo creation time + - `{created.dd}`: 2-digit day of the month (zero padded) of photo creation time + - `{created.dow}`: Day of week in user's locale of the photo creation time + - `{created.doy}`: 3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded) + - `{created.hour}`: 2-digit hour of the photo creation time + - `{created.min}`: 2-digit minute of the photo creation time + - `{created.sec}`: 2-digit second of the photo creation time + - `{created.strftime}`: Apply strftime template to file creation date/time. Should be used in form + `{created.strftime,TEMPLATE}` where TEMPLATE is a valid strftime template, e.g. + `{created.strftime,%Y-%U}` would result in year-week number of year: '2020-23'. + If used with no template will return null value. + See https://strftime.org/ for help on strftime templates. + + You may find the `--check-templates` option useful for testing templates. + When run with `--check-templates` osxphotos will not actually import anything + but will instead print out the rendered value for each `--title`, `--description`, + `--keyword`, and `--album` option. It will also print out the values extracted by + the `--exiftool` option. + """ + ) + console = Console() + with console.capture() as capture: + console.print(Markdown(extra_help), width=min(HELP_WIDTH, console.width)) + formatter.write(capture.get()) + help_text += "\n\n" + formatter.getvalue() + return help_text + + +@click.command(name="import", cls=ImportCommand) +@click.option( + "--album", + "-a", + metavar="ALBUM_TEMPLATE", + multiple=True, + type=TemplateString(), + help="Import photos into album ALBUM_TEMPLATE. " + "ALBUM_TEMPLATE is an osxphotos template string. " + "Photos may be imported into more than one album by repeating --album. " + "See Template System in help for additional information.", +) +@click.option( + "--title", + "-t", + metavar="TITLE_TEMPLATE", + type=TemplateString(), + help="Set title of imported photos to TITLE_TEMPLATE. " + "TITLE_TEMPLATE is a an osxphotos template string. " + "See Template System in help for additional information.", +) +@click.option( + "--description", + "-d", + metavar="DESCRIPTION_TEMPLATE", + type=TemplateString(), + help="Set description of imported photos to DESCRIPTION_TEMPLATE. " + "DESCRIPTION_TEMPLATE is a an osxphotos template string. " + "See Template System in help for additional information.", +) +@click.option( + "--keyword", + "-k", + metavar="KEYWORD_TEMPLATE", + multiple=True, + type=TemplateString(), + help="Set keywords of imported photos to KEYWORD_TEMPLATE. " + "KEYWORD_TEMPLATE is a an osxphotos template string. " + "More than one keyword may be set by repeating --keyword. " + "See Template System in help for additional information.", +) +@click.option( + "--merge-keywords", + "-m", + is_flag=True, + help="Merge keywords created by --exiftool or --keyword " + "with any keywords already associated with the photo. " + "Without --merge-keywords, existing keywords will be overwritten.", +) +@click.option( + "--location", + "-l", + metavar="LATITUDE LONGITUDE", + nargs=2, + type=click.Tuple([click.FloatRange(-90.0, 90.0), click.FloatRange(-180.0, 180.0)]), + help="Set location of imported photo to LATITUDE LONGITUDE. " + "Latitude is a number in the range -90.0 to 90.0; " + "positive latitudes are north of the equator, negative latitudes are south of the equator. " + "Longitude is a number in the range -180.0 to 180.0; " + "positive longitudes are east of the Prime Meridian; negative longitudes are west of the Prime Meridian.", +) +@click.option( + "--clear-metadata", + "-C", + is_flag=True, + help="Clear any metadata set automatically " + "by Photos upon import. Normally, Photos will set title, description, and keywords " + "from XMP metadata in the imported file. If you specify --clear-metadata, any metadata " + "set by Photos will be cleared after import.", +) +@click.option( + "--clear-location", + "-L", + is_flag=True, + help="Clear any location data automatically imported by Photos. " + "Normally, Photos will set location of the photo to the location data found in the " + "metadata in the imported file. If you specify --clear-location, " + "this data will be cleared after import.", +) +@click.option( + "--exiftool", + "-e", + is_flag=True, + help="Use third party tool exiftool (https://exiftool.org/) to automatically " + "update metadata (title, description, keywords, location) in imported photos from " + "the imported file's metadata. " + "Note: importing keywords from video files is not currently supported.", +) +@click.option( + "--exiftool-path", + "-p", + metavar="EXIFTOOL_PATH", + type=click.Path(exists=True, dir_okay=False), + help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.", +) +@click.option( + "--relative-to", + "-r", + metavar="RELATIVE_TO_PATH", + type=click.Path(exists=True, file_okay=False, resolve_path=True), + help="If set, the '{filepath}' template " + "will be computed relative to RELATIVE_TO_PATH. " + "For example, if path to import is '/Volumes/photos/import/album/img_1234.jpg' " + "then '{filepath}' will be this same value. " + "If you set '--relative-to /Volumes/photos/import' " + "then '{filepath}' will be set to 'album/img_1234.jpg'", +) +@click.option("--dup-check", "-D", is_flag=True, help="Check for duplicates on import.") +@click.option( + "--split-folder", + "-f", + help="Automatically create hierarchal folders for albums as needed by splitting album name " + "into folders and album. You must specify the character used to split folders and " + "albums. For example, '--split-folder \"/\"' will split the album name 'Folder/Album' " + "into folder 'Folder' and album 'Album'. ", +) +@click.option( + "--walk", "-w", is_flag=True, help="Recursively walk through directories." +) +@click.option( + "--glob", + "-g", + metavar="GLOB", + multiple=True, + help="Only import files matching GLOB. " + "GLOB is a Unix shell-style glob pattern, for example: '--glob \"*.jpg\"'. " + "GLOB may be repeated to import multiple patterns.", +) +@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") +@click.option( + "--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output" +) +@click.option( + "--no-progress", is_flag=True, help="Do not display progress bar during import." +) +@click.option( + "--check-templates", + is_flag=True, + help="Don't actually import anything; " + "renders template strings so you can verify they are correct.", +) +@THEME_OPTION +@click.argument("files", nargs=-1) +@click.pass_obj +@click.pass_context +def import_cli( + ctx, + cli_obj, + album, + check_templates, + clear_location, + clear_metadata, + description, + dup_check, + exiftool, + exiftool_path, + files, + glob, + keyword, + location, + merge_keywords, + no_progress, + relative_to, + split_folder, + theme, + timestamp, + title, + verbose_, + walk, +): + """Import photos and videos into Photos.""" + + color_theme = get_theme(theme) + verbose = verbose_print( + verbose_, timestamp, rich=True, theme=color_theme, highlight=False + ) + # set console for rich_echo to be same as for verbose_ + set_rich_console(get_verbose_console()) + set_rich_theme(color_theme) + set_rich_timestamp(timestamp) + + if not files: + echo("Nothing to import", err=True) + return + + # 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(db, cli_db) + # if not db: + # echo(get_help_msg(import_cli), err=True) + # echo("\n\nLocated the following Photos library databases: ", err=True) + # _list_libraries() + # return + + relative_to = Path(relative_to) if relative_to else None + + imported_count = 0 + error_count = 0 + files = collect_files_to_import(files, walk, glob) + if check_templates: + check_templates_and_exit( + files, + relative_to, + title, + description, + keyword, + album, + split_folder, + exiftool_path, + exiftool, + ) + + filecount = len(files) + with rich_progress(console=get_verbose_console(), mock=no_progress) as progress: + task = progress.add_task( + f"Importing [num]{filecount}[/] {pluralize(filecount, 'file', 'files')}", + total=filecount, + ) + for filepath in files: + filepath = Path(filepath).resolve().absolute() + relative_filepath = get_relative_filepath(filepath, relative_to) + + verbose(f"Importing [filepath]{filepath}[/]") + photo, error = import_photo(filepath, dup_check, verbose) + if error: + error_count += 1 + continue + imported_count += 1 + + if clear_metadata: + clear_photo_metadata(photo, filepath, verbose) + + if clear_location: + clear_photo_location(photo, filepath, verbose) + + if exiftool: + set_photo_metadata_from_exiftool( + photo, filepath, exiftool_path, merge_keywords, verbose + ) + + if title: + set_photo_title( + photo, filepath, relative_filepath, title, exiftool_path, verbose + ) + + if description: + set_photo_description( + photo, + filepath, + relative_filepath, + description, + exiftool_path, + verbose, + ) + + if keyword: + set_photo_keywords( + photo, + filepath, + relative_filepath, + keyword, + exiftool_path, + merge_keywords, + verbose, + ) + + if location: + set_photo_location(photo, filepath, location, verbose) + + if album: + add_photo_to_albums( + photo, + filepath, + relative_filepath, + album, + split_folder, + exiftool_path, + verbose, + ) + + progress.advance(task) + + echo( + f"Done: imported [num]{imported_count}[/] {pluralize(imported_count, 'file', 'files')}, " + f"[num]{error_count}[/] {pluralize(error_count, 'error', 'errors')}", + emoji=False, + ) diff --git a/osxphotos/photosalbum.py b/osxphotos/photosalbum.py index 9a266513..8f568d4d 100644 --- a/osxphotos/photosalbum.py +++ b/osxphotos/photosalbum.py @@ -4,7 +4,7 @@ from typing import List, Optional import photoscript from more_itertools import chunked -from photoscript import Photo, PhotosLibrary +from photoscript import Album, Folder, Photo, PhotosLibrary from .photoinfo import PhotoInfo from .utils import noop, pluralize @@ -51,19 +51,72 @@ class PhotosAlbum: return self.album.photos() +def folder_by_path(folders: List[str], verbose: Optional[callable] = None) -> Folder: + """Get (and create if necessary) a Photos Folder by path (passed as list of folder names)""" + library = PhotosLibrary() + verbose = verbose or noop + top_folder_name = folders.pop(0) + top_folder = library.folder(top_folder_name, top_level=True) + if not top_folder: + verbose(f"Creating folder '{top_folder_name}'") + top_folder = library.create_folder(top_folder_name) + current_folder = top_folder + for folder_name in folders: + folder = current_folder.folder(folder_name) + if not folder: + verbose(f"Creating folder '{folder_name}'") + folder = current_folder.create_folder(folder_name) + current_folder = folder + return current_folder + + +def album_by_path( + folders_album: List[str], verbose: Optional[callable] = None +) -> Album: + """Get (and create if necessary) a Photos Album by path (pass as list of folders, album name)""" + library = PhotosLibrary() + verbose = verbose or noop + if len(folders_album) > 1: + # have folders + album_name = folders_album.pop() + folder = folder_by_path(folders_album, verbose) + album = folder.album(album_name) + if not album: + verbose(f"Creating album '{album_name}'") + album = folder.create_album(album_name) + else: + # only have album name + album_name = folders_album[0] + album = library.album(album_name, top_level=True) + if not album: + verbose(f"Creating album '{album_name}'") + album = library.create_album(album_name) + + return album + + class PhotosAlbumPhotoScript: """Add photoscript.Photo objects to album""" - def __init__(self, name: str, verbose: Optional[callable] = None): - self.name = name + def __init__( + self, name: str, verbose: Optional[callable] = None, split_folder: Optional[str] = None + ): + """Return a PhotosAlbumPhotoScript object, creating the album if necessary + + Args: + name: Name of album + verbose: optional callable to print verbose output + split_folder: if set, split album name on value of split_folder to create folders if necessary, + e.g. if name = 'folder1/folder2/album' and split_folder='/', + then folders 'folder1' and 'folder2' will be created and album 'album' will be created in 'folder2'; + if not set, album 'folder1/folder2/album' will be created + """ self.verbose = verbose or noop self.library = PhotosLibrary() - album = self.library.album(name) - if album is None: - self.verbose(f"Creating Photos album '{self.name}'") - album = self.library.create_album(name) - self.album = album + folders_album = name.split(split_folder) if split_folder else [name] + self.album = album_by_path(folders_album, verbose=verbose) + self.name = name def add(self, photo: Photo): self.album.add([photo]) diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index c4426fd1..95c5524e 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -1629,20 +1629,18 @@ def _get_pathlib_value(field, value, quote): if len(parts) == 1: return shlex.quote(value) if quote else value - if len(parts) > 2: - raise ValueError(f"Illegal value for path template: {field}") - - path = parts[0] - attribute = parts[1] path = pathlib.Path(value) - try: - val = getattr(path, attribute) - val_str = str(val) - if quote: - val_str = shlex.quote(val_str) - return val_str - except AttributeError: - raise ValueError("Illegal value for path template: {attribute}") + for attribute in parts[1:]: + try: + val = getattr(path, attribute) + path = pathlib.Path(val) + except AttributeError as e: + raise ValueError(f"Illegal value for filepath template: {attribute}") from e + + val_str = str(val) + if quote: + val_str = shlex.quote(val_str) + return val_str def format_str_value(value, format_str): diff --git a/tests/README.md b/tests/README.md index 47c6bcf6..6bd24a31 100644 --- a/tests/README.md +++ b/tests/README.md @@ -21,6 +21,12 @@ Some of the export tests rely on photos in my local library and will look for `O One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable. +A couple of tests require interaction with Photos and configuring a specific test library. Currently these run only on Catalina. The tests must be specified by using a pytest flag. Only one of these interactive tests can be run at a time. The current flags are: + +--addalbum: test --add-to-album options +--timewarp: test `osxphotos timewarp` +--test-import: test `osxphotos import` + ## Test Photo Libraries **Important**: The test code uses several test photo libraries created on various version of MacOS. If you need to inspect one of these or modify one for a test, make a copy of the library (for example, copy it to your ~/Pictures folder) then open the copy in Photos. Once done, copy the revised library back to the tests/ folder. If you do not do this, the Photos background process photoanalysisd will forever try to process the library resulting in updates to the database which will cause git to see changes to the file you didn't intend. I'm not aware of any way to disassociate photoanalysisd from the library once you've opened it in Photos. diff --git a/tests/conftest.py b/tests/conftest.py index d0fed118..f0f3cfd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,9 @@ from .test_catalina_10_15_7 import UUID_DICT_LOCAL # run timewarp tests (configured with --timewarp) TEST_TIMEWARP = False +# run import tests (configured with --import) +TEST_IMPORT = False + # don't clean up crash logs (configured with --no-cleanup) NO_CLEANUP = False @@ -42,6 +45,7 @@ def get_os_version(): OS_VER = get_os_version()[1] if OS_VER == "15": TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary" + TEST_LIBRARY_IMPORT = TEST_LIBRARY from tests.config_timewarp_catalina import TEST_LIBRARY_TIMEWARP else: TEST_LIBRARY = None @@ -56,6 +60,13 @@ def setup_photos_timewarp(): copy_photos_library(TEST_LIBRARY_TIMEWARP, delay=10) +@pytest.fixture(scope="session", autouse=True) +def setup_photos_import(): + if not TEST_IMPORT: + return + copy_photos_library(TEST_LIBRARY_IMPORT, delay=10) + + @pytest.fixture(autouse=True) def reset_singletons(): """Need to clean up any ExifTool singletons between tests""" @@ -72,6 +83,12 @@ def pytest_addoption(parser): parser.addoption( "--timewarp", action="store_true", default=False, help="run --timewarp tests" ) + parser.addoption( + "--test-import", + action="store_true", + default=False, + help="run `osxphotos import` tests", + ) parser.addoption( "--no-cleanup", action="store_true", @@ -81,8 +98,18 @@ def pytest_addoption(parser): def pytest_configure(config): - if config.getoption("--addalbum") and config.getoption("--timewarp"): - pytest.exit("--addalbum and --timewarp are mutually exclusive") + if ( + sum( + bool(x) + for x in [ + config.getoption("--addalbum"), + config.getoption("--timewarp"), + config.getoption("--test-import"), + ] + ) + > 1 + ): + pytest.exit("--addalbum, --timewarp, --test-import are mutually exclusive") config.addinivalue_line( "markers", "addalbum: mark test as requiring --addalbum to run" @@ -90,12 +117,19 @@ def pytest_configure(config): config.addinivalue_line( "markers", "timewarp: mark test as requiring --timewarp to run" ) + config.addinivalue_line( + "markers", "test_import: mark test as requiring --test-import to run" + ) # this is hacky but I can't figure out how to check config options in other fixtures if config.getoption("--timewarp"): global TEST_TIMEWARP TEST_TIMEWARP = True + if config.getoption("--test-import"): + global TEST_IMPORT + TEST_IMPORT = True + if config.getoption("--no-cleanup"): global NO_CLEANUP NO_CLEANUP = True @@ -118,6 +152,14 @@ def pytest_collection_modifyitems(config, items): if "timewarp" in item.keywords: item.add_marker(skip_timewarp) + if not (config.getoption("--test-import") and TEST_LIBRARY_IMPORT is not None): + skip_test_import = pytest.mark.skip( + reason="need --test-import option and MacOS Catalina to run" + ) + for item in items: + if "test_import" in item.keywords: + item.add_marker(skip_test_import) + def copy_photos_library(photos_library, delay=0): """copy the test library and open Photos, returns path to copied library""" diff --git a/tests/test-images/IMG_4179.jpeg b/tests/test-images/IMG_4179.jpeg new file mode 100644 index 00000000..6b7bc717 Binary files /dev/null and b/tests/test-images/IMG_4179.jpeg differ diff --git a/tests/test-images/Jellyfish.mov b/tests/test-images/Jellyfish.mov new file mode 100644 index 00000000..beea5e41 Binary files /dev/null and b/tests/test-images/Jellyfish.mov differ diff --git a/tests/test_cli_import.py b/tests/test_cli_import.py new file mode 100644 index 00000000..25e791ef --- /dev/null +++ b/tests/test_cli_import.py @@ -0,0 +1,646 @@ +""" Tests which require user interaction to run for osxphotos import command; run with pytest --test-import """ + +import os +import os.path +import pathlib +import re +import time +from typing import Dict + +import pytest +from click.testing import CliRunner +from photoscript import Photo +from pytest import approx + +from osxphotos.cli.import_cli import import_cli +from osxphotos.exiftool import ExifTool, get_exiftool_path +from tests.conftest import get_os_version + +TERMINAL_WIDTH = 250 + +TEST_IMAGES_DIR = "tests/test-images" +TEST_IMAGE_1 = "tests/test-images/IMG_4179.jpeg" +TEST_IMAGE_2 = "tests/test-images/faceinfo/exif1.jpg" +TEST_VIDEO_1 = "tests/test-images/Jellyfish.mov" +TEST_VIDEO_2 = "tests/test-images/IMG_0670B_NOGPS.MOV" + +TEST_DATA = { + TEST_IMAGE_1: { + "title": "Waves crashing on rocks", + "description": "Used for testing osxphotos", + "keywords": ["osxphotos"], + "lat": 33.7150638888889, + "lon": -118.319672222222, + "check_templates": [ + "exiftool title: Waves crashing on rocks", + "exiftool description: Used for testing osxphotos", + "exiftool keywords: ['osxphotos']", + "exiftool location: (33.7150638888889, -118.319672222222)", + "title: {exiftool:XMP:Title}: Waves crashing on rocks", + "description: {exiftool:IPTC:Caption-Abstract}: Used for testing osxphotos", + "keyword: {exiftool:IPTC:Keywords}: ['osxphotos']", + "album: {filepath.parent}: test-images", + ], + }, + TEST_VIDEO_1: { + "title": "Jellyfish", + "description": "Jellyfish Video", + # "keywords": ["Travel"], # exiftool doesn't seem to support the binary QuickTime:Keywords + "keywords": [], + "lat": 34.0533, + "lon": -118.2423, + }, + TEST_VIDEO_2: { + "title": "", + "description": "", + "lat": None, + "lon": None, + }, + TEST_IMAGE_2: { + "albums": ["faceinfo"], + }, +} + +# set timezone to avoid issues with comparing dates +os.environ["TZ"] = "US/Pacific" +time.tzset() + + +# determine if exiftool installed so exiftool tests can be skipped +try: + exiftool_path = get_exiftool_path() +except FileNotFoundError: + exiftool_path = None + +OS_VER = get_os_version()[1] +if OS_VER != "15": + pytest.skip(allow_module_level=True) + + +def prompt(message): + """Helper function for tests that require user input""" + message = f"\n{message}\nPlease answer y/n: " + answer = input(message) + return answer.lower() == "y" + + +def say(msg: str) -> None: + """Say message with text to speech""" + os.system(f"say {msg}") + + +def parse_import_output(output: str) -> Dict[str, str]: + """Parse output of osxphotos import command and return dict of {image name: uuid} for imported photos""" + # look for lines that look like this: + # Imported IMG_4179.jpeg with UUID A62792F0-4524-4529-9931-56E52C95E873 + + results = {} + for line in output.split("\n"): + pattern = re.compile( + r"Imported ([\w\.]+)\s.*UUID\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})" + ) + if match := re.match(pattern, line): + file = match[1] + uuid = match[2] + results[file] = uuid + return results + + +########## Interactive tests run first ########## + + +@pytest.mark.test_import +def test_import(): + """Test basic import""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + ["--verbose", test_image_1], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + + +@pytest.mark.test_import +def test_import_dup_check(): + """Test basic import with --dup-check""" + say("Please click Import when prompted by Photos to import duplicate photo.") + + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + ["--verbose", "--dup-check", test_image_1], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + + +@pytest.mark.test_import +def test_import_album(): + """Test basic import to an album""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + ["--verbose", "--album", "My New Album", test_image_1], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + albums = photo_1.albums + assert len(albums) == 1 + assert albums[0].title == "My New Album" + + +@pytest.mark.test_import +def test_import_album_2(): + """Test basic import to an album with a "/" in it""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + ["--verbose", "--album", "Folder/My New Album", test_image_1], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + albums = photo_1.albums + assert len(albums) == 1 + assert albums[0].title == "Folder/My New Album" + assert albums[0].path_str() == "Folder/My New Album" + + +@pytest.mark.test_import +def test_import_album_slit_folder(): + """Test basic import to an album with a "/" in it and --split-folder""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--album", + "Folder/My New Album", + "--split-folder", + "/", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + albums = photo_1.albums + assert len(albums) == 1 + assert albums[0].title == "My New Album" + assert albums[0].path_str() == "Folder/My New Album" + + +@pytest.mark.test_import +def test_import_album_relative_to(): + """Test import with --relative-to""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--album", + "{filepath.parent}", + "--split-folder", + "/", + "--relative-to", + cwd, + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + albums = photo_1.albums + assert len(albums) == 1 + assert albums[0].title == "test-images" + assert albums[0].path_str() == "tests/test-images" + + +@pytest.mark.test_import +def test_import_clear_metadata(): + """Test import with --clear-metadata""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert not photo_1.title + assert not photo_1.description + assert not photo_1.keywords + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_exiftool(): + """Test import file with --exiftool""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--exiftool", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert photo_1.title == TEST_DATA[TEST_IMAGE_1]["title"] + assert photo_1.description == TEST_DATA[TEST_IMAGE_1]["description"] + assert photo_1.keywords == TEST_DATA[TEST_IMAGE_1]["keywords"] + lat, lon = photo_1.location + assert lat == approx(TEST_DATA[TEST_IMAGE_1]["lat"]) + assert lon == approx(TEST_DATA[TEST_IMAGE_1]["lon"]) + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_exiftool_video(): + """Test import video file with --exiftool""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_VIDEO_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--exiftool", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert photo_1.title == TEST_DATA[TEST_VIDEO_1]["title"] + assert photo_1.description == TEST_DATA[TEST_VIDEO_1]["description"] + assert photo_1.keywords == TEST_DATA[TEST_VIDEO_1]["keywords"] + lat, lon = photo_1.location + assert lat == approx(TEST_DATA[TEST_VIDEO_1]["lat"]) + assert lon == approx(TEST_DATA[TEST_VIDEO_1]["lon"]) + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_exiftool_video_no_metadata(): + """Test import video file with --exiftool that has no metadata""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_VIDEO_2) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--exiftool", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert photo_1.title == "" + assert photo_1.description == "" + assert photo_1.keywords == [] + lat, lon = photo_1.location + assert lat is None + assert lon is None + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_title(): + """Test import with --title""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--title", + "{exiftool:XMP:Title|upper}", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert photo_1.title == TEST_DATA[TEST_IMAGE_1]["title"].upper() + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_description(): + """Test import with --description""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--description", + "{exiftool:XMP:Description|upper}", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert photo_1.description == TEST_DATA[TEST_IMAGE_1]["description"].upper() + + +@pytest.mark.test_import +def test_import_keyword(): + """Test import with --keyword""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--keyword", + "Bar", + "--keyword", + "Foo", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert sorted(photo_1.keywords) == ["Bar", "Foo"] + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_keyword_merge(): + """Test import with --keyword and --merge-keywords""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--exiftool", + "--keyword", + "Bar", + "--keyword", + "Foo", + "--merge-keywords", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert sorted(photo_1.keywords) == ["Bar", "Foo", "osxphotos"] + + +@pytest.mark.test_import +def test_import_location(): + """Test import file with --location""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--clear-metadata", + "--location", + "-45.0", + "-45.0", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + lat, lon = photo_1.location + assert lat == approx(-45.0) + assert lon == approx(-45.0) + + +@pytest.mark.test_import +def test_import_glob(): + """Test import with --glob""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + ["--verbose", f"{cwd}/{TEST_IMAGES_DIR}/", "--walk", "--glob", "Pumpk*.jpg"], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + assert "imported 2 files" in result.output + + +@pytest.mark.test_import +def test_import_glob_walk(): + """Test import with --walk --glob""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + f"{cwd}/{TEST_IMAGES_DIR}/", + "--walk", + "--glob", + "exif*.jpg", + "--album", + "{filepath.parent.name}", + "--relative-to", + f"{cwd}/{TEST_IMAGES_DIR}", + ], + terminal_width=TERMINAL_WIDTH, + ) + assert result.exit_code == 0 + assert "imported 4 files" in result.output + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(TEST_IMAGE_2).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert [a.title for a in photo_1.albums] == TEST_DATA[TEST_IMAGE_2]["albums"] + + +@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed") +@pytest.mark.test_import +def test_import_check_templates(): + """Test import file with --check-templates""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--exiftool", + "--title", + "{exiftool:XMP:Title}", + "--description", + "{exiftool:IPTC:Caption-Abstract}", + "--keyword", + "{exiftool:IPTC:Keywords}", + "--album", + "{filepath.parent}", + "--relative-to", + f"{cwd}/tests", + "--check-templates", + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + # assert result.output == "foo" + assert result.exit_code == 0 + output = result.output.splitlines() + output.pop(0) + + for idx, line in enumerate(output): + assert line == TEST_DATA[TEST_IMAGE_1]["check_templates"][idx] diff --git a/tests/test_template.py b/tests/test_template.py index e47a401a..4e22d54e 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1228,6 +1228,9 @@ def test_filepath(): rendered, _ = template.render("{filepath.parent}", options) assert rendered[0] == "/foo" + rendered, _ = template.render("{filepath.parent.name}", options) + assert rendered[0] == "foo" + rendered, _ = template.render("{filepath.stem}", options) assert rendered[0] == "bar"