Source code for osxphotos.sidecars

"""Write sidecars for PhotoInfo objects"""

from __future__ import annotations

import dataclasses
import logging
import os
import pathlib
from functools import cache
from typing import TYPE_CHECKING, Callable

from mako.template import Template

from ._constants import (
    _OSXPHOTOS_NONE_SENTINEL,
    _TEMPLATE_DIR,
    _UNKNOWN_PERSON,
    _XMP_TEMPLATE_NAME,
    _XMP_TEMPLATE_NAME_BETA,
    SIDECAR_EXIFTOOL,
    SIDECAR_JSON,
    SIDECAR_XMP,
)
from ._version import __version__
from .exifwriter import ExifOptions, ExifWriter, _ExifMixin, exif_options_from_options
from .export_db import ExportDBTemp
from .exportoptions import ExportOptions, ExportResults
from .fileutil import FileUtilMacOS, FileUtilShUtil
from .metadata_reader import get_sidecar_for_file
from .photoinfo_file import render_photo_template_from_filepath, strip_edited_suffix
from .phototemplate import PhotoTemplate, RenderOptions
from .platform import is_macos
from .rich_utils import add_rich_markup_tag
from .touch_files import touch_files
from .utils import hexdigest

if TYPE_CHECKING:
    from .photoinfo import PhotoInfo

# Global to hold the compiled XMP template
# This is expensive to compile so we only want to do it once
_global_xmp_template: Template | None = None

logger = logging.getLogger("osxphotos")

__all__ = [
    "SidecarWriter",
    "exiftool_json_sidecar",
    "xmp_sidecar",
    "get_sidecar_file_with_template",
]


class UserSidecarError(Exception):
    """Generated if there's an error in user sidecar template so it can be handled by export CLI"""

    pass


@dataclasses.dataclass
class SidecarVars:
    description: str | None = None
    extension: str | None = None
    keywords: list[str] = dataclasses.field(default_factory=list)
    persons: list[str] = dataclasses.field(default_factory=list)
    subjects: list[str] = dataclasses.field(default_factory=list)
    location: tuple[float | None, float | None] = dataclasses.field(
        default_factory=lambda: (None, None)
    )
    rating: int | None = None


@cache
def _get_template(template: str) -> Template:
    """Get template from cache or load from file"""
    return Template(filename=template)


[docs] class SidecarWriter(_ExifMixin): """Write sidecars for PhotoInfo objects Can write XMP, JSON, and exiftool sidecars Args: photo: PhotoInfo object Note: Sidecars are written by calling write_sidecar_files() which returns an ExportResults object """ def __init__(self, photo: PhotoInfo): super().__init__(photo) self._verbose = photo._verbose
[docs] def write_sidecar_files( self, dest: pathlib.Path, options: ExportOptions, export_results: ExportResults, ) -> ExportResults: """Write sidecar files for the photo. Args: dest: destination path for photo that sidecars are being written for options: ExportOptions object that configures the sidecars export_results: ExportResults object containing information about the exported photo Returns: An ExportResults object containing information about the exported sidecar files Note: dest is the path to the the sidecar belongs to. THe sidecar filename will be dest.ext where ext is the extension for sidecar (e.g. xmp or json) If dest is "img_1234.jpg", XMP sidecar would be "img_1234.jpg.xmp" Use ExportOptions(sidecar_drop_ext) to drop the image extension from the sidecar filename (e.g. "img_1234.xmp") """ # if ExportDB isn't provided, use a temporary in-memory database # this allows SidecarWriter to be used independently of PhotoExporter export_db = options.export_db or ExportDBTemp() # likewise, if FileUtil isn't provided, use default fileutil = options.fileutil or FileUtilMacOS() if is_macos else FileUtilShUtil() verbose = options.verbose or self._verbose # define functions for adding markup _filepath = add_rich_markup_tag("filepath", rich=options.rich) # export metadata sidecars = [] sidecar_json_files_skipped = [] sidecar_json_files_written = [] sidecar_exiftool_files_skipped = [] sidecar_exiftool_files_written = [] sidecar_xmp_files_skipped = [] sidecar_xmp_files_written = [] dest_suffix = "" if options.sidecar_drop_ext else dest.suffix exif_options = exif_options_from_options(options) if options.sidecar & SIDECAR_JSON: sidecar_filename = dest.parent / pathlib.Path( f"{dest.stem}{dest_suffix}.json" ) sidecar_str = exiftool_json_sidecar( photo=self.photo, filename=dest.name, options=exif_options, ) sidecars.append( ( sidecar_filename, sidecar_str, sidecar_json_files_written, sidecar_json_files_skipped, "JSON", ) ) if options.sidecar & SIDECAR_EXIFTOOL: sidecar_filename = dest.parent / pathlib.Path( f"{dest.stem}{dest_suffix}.json" ) sidecar_str = exiftool_json_sidecar( photo=self.photo, tag_groups=False, filename=dest.name, options=exif_options, ) sidecars.append( ( sidecar_filename, sidecar_str, sidecar_exiftool_files_written, sidecar_exiftool_files_skipped, "exiftool", ) ) if options.sidecar & SIDECAR_XMP: sidecar_filename = dest.parent / pathlib.Path( f"{dest.stem}{dest_suffix}.xmp" ) sidecar_str = self.xmp_sidecar( extension=dest.suffix[1:] if dest.suffix else None, options=options ) sidecars.append( ( sidecar_filename, sidecar_str, sidecar_xmp_files_written, sidecar_xmp_files_skipped, "XMP", ) ) for data in sidecars: sidecar_filename = data[0] sidecar_str = data[1] files_written = data[2] files_skipped = data[3] sidecar_type = data[4] sidecar_digest = hexdigest(sidecar_str) sidecar_record = export_db.create_or_get_file_record( sidecar_filename, self.photo.uuid ) write_sidecar = ( not (options.update or options.force_update) or ( (options.update or options.force_update) and not sidecar_filename.exists() ) or ( (options.update or options.force_update) and (sidecar_digest != sidecar_record.digest) or not fileutil.cmp_file_sig( sidecar_filename, sidecar_record.dest_sig ) ) ) if write_sidecar: verbose(f"Writing {sidecar_type} sidecar {_filepath(sidecar_filename)}") files_written.append(str(sidecar_filename)) if not options.dry_run: self._write_sidecar(sidecar_filename, sidecar_str) sidecar_record.digest = sidecar_digest sidecar_record.dest_sig = fileutil.file_sig(sidecar_filename) else: verbose( f"Skipped up to date {sidecar_type} sidecar {_filepath(sidecar_filename)}" ) files_skipped.append(str(sidecar_filename)) results = ExportResults( sidecar_json_written=sidecar_json_files_written, sidecar_json_skipped=sidecar_json_files_skipped, sidecar_exiftool_written=sidecar_exiftool_files_written, sidecar_exiftool_skipped=sidecar_exiftool_files_skipped, sidecar_xmp_written=sidecar_xmp_files_written, sidecar_xmp_skipped=sidecar_xmp_files_skipped, ) # write user sidecar files if specified if options.sidecar_template: results += self.write_user_sidecar_files( dest=dest, options=options, export_results=export_results ) if options.touch_file: all_sidecars = ( sidecar_json_files_written + sidecar_exiftool_files_written + sidecar_xmp_files_written + sidecar_json_files_skipped + sidecar_exiftool_files_skipped + sidecar_xmp_files_skipped + results.sidecar_user_written + results.sidecar_user_skipped ) results += touch_files(self.photo, all_sidecars, options) # update destination signatures in database for sidecar_filename in all_sidecars: sidecar_record = export_db.create_or_get_file_record( sidecar_filename, self.photo.uuid ) sidecar_record.dest_sig = fileutil.file_sig(sidecar_filename) return results
[docs] def write_user_sidecar_files( self, dest: pathlib.Path, options: ExportOptions, export_results: ExportResults, ) -> ExportResults: """Write user sidecar files for the photo. Args: dest: destination path for photo that sidecars are being written for options: ExportOptions object that configures the sidecars export_results: ExportResults object with information about the exorted photos Returns: An ExportResults object containing information about the exported sidecar files """ verbose = options.verbose or self._verbose # define functions for adding markup _filepath = add_rich_markup_tag("filepath", rich=options.rich) sidecar_user_written = [] sidecar_user_skipped = [] sidecar_user_error = [] exif_options = exif_options_from_options(options) for ( template_file, filename_template, template_options, ) in options.sidecar_template: strip_whitespace = "strip_whitespace" in template_options strip_lines = "strip_lines" in template_options write_skipped = "write_skipped" in template_options skip_zero = "skip_zero" in template_options catch_errors = "catch_errors" in template_options # Render the sidecar filename template_filename = self._render_sidecar_filename( filepath=str(dest), filename_template=filename_template, export_dir=str(dest.parent), exiftool_path=options.exiftool_path, ) if not template_filename: logger.error( f"Invalid SIDECAR_FILENAME_TEMPLATE for --sidecar-template '{filename_template}'" ) continue sidecar_path = pathlib.Path(template_filename) if not write_skipped and str(dest) in export_results.skipped: sidecar_user_skipped.append(str(sidecar_path)) verbose(f"Skipping existing sidecar file [filepath]{sidecar_path}[/]") continue try: result = self._render_user_sidecar( template_file=template_file, sidecar_path=sidecar_path, photo_path=dest, strip_whitespace=strip_whitespace, strip_lines=strip_lines, skip_zero=skip_zero, catch_errors=catch_errors, options=options, exif_options=exif_options, ) except ValueError as e: logger.warning(f"Error writing sidecar {sidecar_path}: {e}") sidecar_user_error.append((str(sidecar_path), str(e))) continue if result is None: # skip_zero triggered, skip this sidecar continue sidecar_str = result verbose(f"Writing sidecar file {_filepath(sidecar_path)}") sidecar_user_written.append(str(sidecar_path)) if not options.dry_run: try: with open(sidecar_path, "w") as f: f.write(sidecar_str) except Exception as e: sidecar_user_error.append(str(e)) results = ExportResults( sidecar_user_written=sidecar_user_written, sidecar_user_skipped=sidecar_user_skipped, sidecar_user_error=sidecar_user_error, ) return results
def _render_sidecar_filename( self, filepath: str, filename_template: str, export_dir: str, exiftool_path: str | None, ) -> str | None: """Render sidecar filename template""" render_options = RenderOptions(export_dir=export_dir, filepath=filepath) photo_template = PhotoTemplate(self.photo, exiftool_path=exiftool_path) template_filename, _ = photo_template.render( filename_template, options=render_options ) template_filename = template_filename[0] if template_filename else None return template_filename def _render_user_sidecar( self, template_file: str, sidecar_path: pathlib.Path, photo_path: pathlib.Path, strip_whitespace: bool, strip_lines: bool, skip_zero: bool, catch_errors: bool, options: ExportOptions, exif_options: ExifOptions, ) -> str | None: """Render user sidecar template and return data Returns: str: rendered sidecar data None: if skip_zero is True and sidecar is empty Exception: if catch_errors is True and an error occurred Raises: Raises ValueError if error and catch_errors is False """ vars = self._sidecar_variables(options, None) # Render the template try: sidecar_template = _get_template(template_file) sidecar_data = sidecar_template.render( photo=self.photo, sidecar_path=sidecar_path, photo_path=photo_path, description=vars.description, keywords=vars.keywords, persons=vars.persons, subjects=vars.subjects, extension=vars.extension, location=vars.location, version=__version__, rating=vars.rating, ) except Exception as e: if catch_errors: raise ValueError(f"Error rendering sidecar template: {e}") from e raise UserSidecarError(e) from e if strip_whitespace: # strip whitespace sidecar_data = "\n".join(line.strip() for line in sidecar_data.split("\n")) if strip_lines: # strip blank lines sidecar_data = "\n".join( line for line in sidecar_data.split("\n") if line.strip() ) if skip_zero and not sidecar_data: verbose = options.verbose or self._verbose _filepath = add_rich_markup_tag("filepath", rich=options.rich) verbose(f"Skipping empty sidecar file {_filepath(sidecar_path)}") return None return sidecar_data
[docs] def xmp_sidecar( self, options: ExportOptions | None = None, extension: str | None = None, ): """returns string for XMP sidecar Args: options (ExportOptions): options for export extension (Optional[str]): which extension to use for SidecarForExtension property """ options = options or ExportOptions() xmp_template = self._xmp_template() vars = self._sidecar_variables(options, extension) xmp_str = xmp_template.render( photo=self.photo, description=vars.description, keywords=vars.keywords, persons=vars.persons, subjects=vars.subjects, extension=vars.extension, location=vars.location, version=__version__, rating=vars.rating, ) # remove extra lines that mako inserts from template xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "") return xmp_str
def _xmp_template(self): """Return the mako template for XMP sidecar, creating it if necessary""" global _global_xmp_template if _global_xmp_template is not None: return _global_xmp_template xmp_template_file = ( _XMP_TEMPLATE_NAME_BETA if self.photo._db._beta else _XMP_TEMPLATE_NAME ) _global_xmp_template = Template( filename=os.path.join(_TEMPLATE_DIR, xmp_template_file) ) return _global_xmp_template def _sidecar_variables( self, options: ExportOptions | None = None, extension: str | None = None, ) -> SidecarVars: """Render sidecar variables""" render_options = options.render_options or RenderOptions() if extension is None: extension_path = pathlib.Path(self.photo.original_filename) extension = extension_path.suffix[1:] if extension_path.suffix else None if options.description_template is not None: render_options_description = dataclasses.replace( render_options, expand_inplace=True, inplace_sep=", " ) rendered = self.photo.render_template( options.description_template, render_options_description )[0] description = " ".join(rendered) if rendered else "" if options.strip: description = description.strip() else: description = ( self.photo.description if self.photo.description is not None else "" ) keyword_list = [] if options.merge_exif_keywords: keyword_list.extend(self._get_exif_keywords()) if self.photo.keywords and not options.replace_keywords: keyword_list.extend(self.photo.keywords) person_list = [] if options.persons: if options.merge_exif_persons: person_list.extend(self._get_exif_persons()) if self.photo.persons: # filter out _UNKNOWN_PERSON person_list.extend( [p for p in self.photo.persons if p != _UNKNOWN_PERSON] ) if options.use_persons_as_keywords and person_list: keyword_list.extend(person_list) if options.use_albums_as_keywords and self.photo.albums: keyword_list.extend(self.photo.albums) if options.keyword_template: rendered_keywords = [] render_options_keywords = dataclasses.replace( render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/" ) for template_str in options.keyword_template: rendered, unmatched = self.photo.render_template( template_str, render_options_keywords ) if unmatched: logger.warning( f"Unmatched template substitution for template: {template_str} {unmatched}" ) rendered_keywords.extend(rendered) if options.strip: rendered_keywords = [keyword.strip() for keyword in rendered_keywords] # filter out any template values that didn't match by looking for sentinel rendered_keywords = [ keyword for keyword in rendered_keywords if _OSXPHOTOS_NONE_SENTINEL not in keyword ] keyword_list.extend(rendered_keywords) # remove duplicates # sorted mainly to make testing the XMP file easier if keyword_list: keyword_list = sorted(list(set(keyword_list))) if options.persons and person_list: person_list = sorted(list(set(person_list))) subject_list = keyword_list latlon = self.photo.location if options.location else (None, None) if options.favorite_rating: rating = 5 if self.photo.favorite else 0 elif self.photo._db._source == "iPhoto": rating = self.photo.rating else: rating = None return SidecarVars( description=description, extension=extension, keywords=keyword_list, persons=person_list, subjects=subject_list, location=latlon, rating=rating, ) def _write_sidecar(self, filename, sidecar_str): """write sidecar_str to filename used for exporting sidecar info""" if not (filename or sidecar_str): raise ( ValueError( f"filename {filename} and sidecar_str {sidecar_str} must not be None" ) ) with open(filename, "w") as f: f.write(sidecar_str)
def xmp_sidecar( photo: PhotoInfo, options: ExportOptions | None = None, extension: str | None = None, ) -> str: """Returns string for XMP sidecar Args: photo: PhotoInfo object to generate sidecar for options (ExportOptions): options for export extension (Optional[str]): which extension to use for SidecarForExtension property Returns: str: string containing XMP sidecar """ writer = SidecarWriter(photo) return writer.xmp_sidecar(options=options, extension=extension) def exiftool_json_sidecar( photo: PhotoInfo, options: ExportOptions | ExifOptions = None, tag_groups: bool = True, filename: str | None = None, ) -> str: """Return JSON string for EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. Does not include all the EXIF fields as those are likely already in the image. Args: options (ExportOptions or ExifOptions): options for export tag_groups (bool, default=True): if True, include tag groups in the output filename (str): name of target image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included Returns: JSON str for dict of exiftool tags / values """ exif_options = ( exif_options_from_options(options) if isinstance(options, ExportOptions) else options ) return ExifWriter(photo).exiftool_json_sidecar( options=exif_options, tag_groups=tag_groups, filename=filename, ) def get_sidecar_file_with_template( filepath: pathlib.Path, sidecar: bool, sidecar_filename_template: str | None, edited_suffix: str | None, exiftool_path: str | None, ) -> pathlib.Path | None: """Find sidecar file for photo with optional template for the sidecar and/or edited suffix""" if not (sidecar or sidecar_filename_template): return None sidecar_file = None if sidecar_filename_template: if sidecars := render_photo_template_from_filepath( filepath, None, sidecar_filename_template, exiftool_path, None, ): # allow multiple values to be rendered and checked # but only one will be used if more than one is valid for f in sidecars: sidecar_file = pathlib.Path(f) if sidecar_file.exists(): break else: sidecar_file = None else: logger.warning( f"Could not render sidecar template '{sidecar_filename_template}' for '{filepath}'" ) else: sidecar_file = get_sidecar_for_file(filepath) if not sidecar_file or not sidecar_file.exists(): if edited_suffix: # try again with the edited suffix removed filepath = strip_edited_suffix(filepath, edited_suffix, exiftool_path) return get_sidecar_file_with_template( filepath, sidecar, sidecar_filename_template, None, exiftool_path, ) return None return sidecar_file