From 391815dd9401fb0b47c0f935cf844bbb85e5ae55 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 14 May 2022 09:04:04 -0700 Subject: [PATCH] --report can now accept a template, #339 --- osxphotos/cli/export.py | 45 +++++++++--- osxphotos/exiftool.py | 3 +- osxphotos/export_db.py | 6 +- osxphotos/phototemplate.py | 140 +++++++++++++++++++------------------ tests/test_cli.py | 28 +++++++- 5 files changed, 139 insertions(+), 83 deletions(-) diff --git a/osxphotos/cli/export.py b/osxphotos/cli/export.py index 3dcfe65b..de6df90d 100644 --- a/osxphotos/cli/export.py +++ b/osxphotos/cli/export.py @@ -47,6 +47,7 @@ from osxphotos.export_db import ExportDB, ExportDBInMemory from osxphotos.fileutil import FileUtil, FileUtilNoOp from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter +from osxphotos.photoinfo import PhotoInfoNone from osxphotos.photokit import ( check_photokit_authorization, request_photokit_authorization, @@ -513,8 +514,10 @@ from .verbose import get_verbose_console, time_stamp, verbose_print @click.option( "--report", metavar="REPORT_FILE", - help="Write a CSV formatted report of all files that were exported.", - type=click.Path(), + help="Write a CSV formatted report of all files that were exported. " + "REPORT_FILE may be a template string (see Templating System), for example, " + "--report 'export_{today.date}.csv' will write a report file named with today's date.", + type=TemplateString(), ) @click.option( "--cleanup", @@ -1149,12 +1152,8 @@ def export( dest = str(pathlib.Path(dest).resolve()) - if report and os.path.isdir(report): - rich_click_echo( - f"[error]report is a directory, must be file name", - err=True, - ) - sys.exit(1) + if report: + report = render_and_validate_report(report, exiftool_path, dest) # if use_photokit and not check_photokit_authorization(): # click.echo( @@ -2826,3 +2825,33 @@ def run_post_command( rich_echo_error( f'[error]Error running command "{command}": {run_error}' ) + + +def render_and_validate_report(report: str, exiftool_path: str, export_dir: str) -> str: + """Render a report file template and validate the filename + + Args: + report: the template string + exiftool_path: the path to the exiftool binary + export_dir: the export directory + + Returns: + the rendered report filename + + Note: + Exits with error if the report filename is invalid + """ + # render report template and validate the filename + template = PhotoTemplate(PhotoInfoNone(), exiftool_path=exiftool_path) + render_options = RenderOptions(export_dir=export_dir) + report_file, _ = template.render(report, options=render_options) + report = report_file[0] + + if os.path.isdir(report): + rich_click_echo( + f"[error]Report '{report}' is a directory, must be file name", + err=True, + ) + sys.exit(1) + + return report diff --git a/osxphotos/exiftool.py b/osxphotos/exiftool.py index 2074ab75..ffa375ff 100644 --- a/osxphotos/exiftool.py +++ b/osxphotos/exiftool.py @@ -84,8 +84,7 @@ def terminate_exiftool(): @lru_cache(maxsize=1) def get_exiftool_path(): """return path of exiftool, cache result""" - exiftool_path = shutil.which("exiftool") - if exiftool_path: + if exiftool_path := shutil.which("exiftool"): return exiftool_path.rstrip() else: raise FileNotFoundError( diff --git a/osxphotos/export_db.py b/osxphotos/export_db.py index aa451b2b..7dc1135a 100644 --- a/osxphotos/export_db.py +++ b/osxphotos/export_db.py @@ -1,7 +1,6 @@ """ Helper class for managing database used by PhotoExporter for tracking state of exports and updates """ -import contextlib import datetime import json import logging @@ -9,6 +8,7 @@ import os import pathlib import sqlite3 import sys +from contextlib import suppress from io import StringIO from sqlite3 import Error from tempfile import TemporaryDirectory @@ -367,7 +367,7 @@ class ExportDB: def __del__(self): """ensure the database connection is closed""" - with contextlib.suppress(Exception): + with suppress(Exception): self._conn.close() def _insert_run_info(self): @@ -681,7 +681,7 @@ class ExportDBInMemory(ExportDB): def __del__(self): """close the database connection""" - with contextlib.suppress(Error): + with suppress(Error): self.close() diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index b540ae3c..cf8ee011 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -1,13 +1,13 @@ """ Custom template system for osxphotos, implements metadata template language (MTL) """ + import datetime -import json import locale -import logging import os import pathlib import shlex import sys +from contextlib import suppress from dataclasses import dataclass from typing import Optional @@ -419,7 +419,7 @@ class PhotoTemplate: try: model = self.parser.parse(template) except TextXSyntaxError as e: - raise ValueError(f"SyntaxError: {e}") + raise ValueError(f"SyntaxError: {e}") from e if not model: # empty string @@ -612,10 +612,12 @@ class PhotoTemplate: break if match: break - if (match and not negation) or (negation and not match): - return ["True"] - else: - return [] + + return ( + ["True"] + if (match and not negation) or (negation and not match) + else [] + ) def comparison_test(test_function): """Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation""" @@ -627,14 +629,16 @@ class PhotoTemplate: match = bool( test_function(float(vals[0]), float(conditional_value[0])) ) - if (match and not negation) or (negation and not match): - return ["True"] - else: - return [] + + return ( + ["True"] + if (match and not negation) or (negation and not match) + else [] + ) except ValueError as e: raise ValueError( f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}" - ) + ) from e if operator in ["contains", "matches", "startswith", "endswith"]: # process any "or" values separated by "|" @@ -722,9 +726,6 @@ class PhotoTemplate: ValueError if no rule exists for field. """ - if self.photo.uuid is None: - return [] - # initialize today with current date/time if needed if self.today is None: self.today = datetime.datetime.now() @@ -732,7 +733,50 @@ class PhotoTemplate: value = None # wouldn't a switch/case statement be nice... - if field == "name": + # handle the fields that don't require a PhotoInfo object first + if field == "today.date": + value = DateTimeFormatter(self.today).date + elif field == "today.year": + value = DateTimeFormatter(self.today).year + elif field == "today.yy": + value = DateTimeFormatter(self.today).yy + elif field == "today.mm": + value = DateTimeFormatter(self.today).mm + elif field == "today.month": + value = DateTimeFormatter(self.today).month + elif field == "today.mon": + value = DateTimeFormatter(self.today).mon + elif field == "today.dd": + value = DateTimeFormatter(self.today).dd + elif field == "today.dow": + value = DateTimeFormatter(self.today).dow + elif field == "today.doy": + value = DateTimeFormatter(self.today).doy + elif field == "today.hour": + value = DateTimeFormatter(self.today).hour + elif field == "today.min": + value = DateTimeFormatter(self.today).min + elif field == "today.sec": + value = DateTimeFormatter(self.today).sec + elif field == "today.strftime": + if default: + try: + value = self.today.strftime(default[0]) + except: + raise ValueError(f"Invalid strftime template: '{default}'") + else: + value = None + elif field in PUNCTUATION: + value = PUNCTUATION[field] + elif field == "osxphotos_version": + value = __version__ + elif field == "osxphotos_cmd_line": + value = " ".join(sys.argv) + elif self.photo.uuid is None: + # if no uuid, don't have a PhotoInfo object (could be PhotoInfoNone) + # so don't try to handle any of the photo fields + return [] + elif field == "name": value = pathlib.Path(self.photo.filename).stem elif field == "original_name": value = pathlib.Path(self.photo.original_filename).stem @@ -865,38 +909,6 @@ class PhotoTemplate: raise ValueError(f"Invalid strftime template: '{default}'") else: value = None - elif field == "today.date": - value = DateTimeFormatter(self.today).date - elif field == "today.year": - value = DateTimeFormatter(self.today).year - elif field == "today.yy": - value = DateTimeFormatter(self.today).yy - elif field == "today.mm": - value = DateTimeFormatter(self.today).mm - elif field == "today.month": - value = DateTimeFormatter(self.today).month - elif field == "today.mon": - value = DateTimeFormatter(self.today).mon - elif field == "today.dd": - value = DateTimeFormatter(self.today).dd - elif field == "today.dow": - value = DateTimeFormatter(self.today).dow - elif field == "today.doy": - value = DateTimeFormatter(self.today).doy - elif field == "today.hour": - value = DateTimeFormatter(self.today).hour - elif field == "today.min": - value = DateTimeFormatter(self.today).min - elif field == "today.sec": - value = DateTimeFormatter(self.today).sec - elif field == "today.strftime": - if default: - try: - value = self.today.strftime(default[0]) - except: - raise ValueError(f"Invalid strftime template: '{default}'") - else: - value = None elif field == "place.name": value = self.photo.place.name if self.photo.place else None elif field == "place.country_code": @@ -982,33 +994,25 @@ class PhotoTemplate: elif field == "id": value = format_str_value(self.photo._info["pk"], subfield) elif field.startswith("album_seq") or field.startswith("folder_album_seq"): - dest_path = self.dest_path - if not dest_path: - value = None - else: + if dest_path := self.dest_path: if field.startswith("album_seq"): album = pathlib.Path(dest_path).name album_info = _get_album_by_name(self.photo, album) else: album_info = _get_album_by_path(self.photo, dest_path) value = album_info.photo_index(self.photo) if album_info else None + else: + value = None if value is not None: - try: + with suppress(IndexError): start_id = field.split(".", 1) value = int(value) + int(start_id[1]) - except IndexError: - pass value = format_str_value(value, subfield) - elif field in PUNCTUATION: - value = PUNCTUATION[field] - elif field == "osxphotos_version": - value = __version__ - elif field == "osxphotos_cmd_line": - value = " ".join(sys.argv) else: # if here, didn't get a match raise ValueError(f"Unhandled template value: {field}") + # sanitize filename or directory name if needed if self.filename: value = sanitize_pathpart(value) elif self.dirname: @@ -1038,8 +1042,8 @@ class PhotoTemplate: field_value = None try: field_value = getattr(self, field_stem) - except AttributeError: - raise ValueError(f"Unknown path-like field: {field_stem}") + except AttributeError as e: + raise ValueError(f"Unknown path-like field: {field_stem}") from e value = _get_pathlib_value(field, field_value, self.quote) @@ -1083,14 +1087,14 @@ class PhotoTemplate: value = ["{" + values + "}"] if values else [] elif filter_ == "parens": if values and type(values) == list: - value = ["(" + v + ")" for v in values] + value = [f"({v})" for v in values] else: - value = ["(" + values + ")"] if values else [] + value = [f"({values})"] if values else [] elif filter_ == "brackets": if values and type(values) == list: - value = ["[" + v + "]" for v in values] + value = [f"[{v}]" for v in values] else: - value = ["[" + values + "]"] if values else [] + value = [f"[{values}]"] if values else [] elif filter_ == "shell_quote": if values and type(values) == list: value = [shlex.quote(v) for v in values] @@ -1406,7 +1410,7 @@ def get_template_field_table(): *TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items(), ]: # replace '|' with '\|' to avoid markdown parsing issues (e.g. in {pipe} description) - descr = descr.replace("'|'", "'\|'") + descr = descr.replace("'|'", r"'\|'") template_table += f"\n|{subst}|{descr}|" return template_table diff --git a/tests/test_cli.py b/tests/test_cli.py index 10ca86ea..78f06593 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,7 @@ from click.testing import CliRunner from osxmetadata import OSXMetaData, Tag import osxphotos +from osxphotos._version import __version__ from osxphotos._constants import OSXPHOTOS_EXPORT_DB from osxphotos.cli import ( about, @@ -5481,6 +5482,28 @@ def test_export_report(): assert os.path.exists("report.csv") +def test_export_report_template(): + """test export with --report option with a template for report name""" + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--report", + "report_{osxphotos_version}.csv", + ], + ) + assert result.exit_code == 0 + assert "Writing export report" in result.output + assert os.path.exists(f"report_{__version__}.csv") + + def test_export_report_not_a_file(): """test export with --report option and bad report value""" @@ -5492,7 +5515,7 @@ def test_export_report_not_a_file(): export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."] ) assert result.exit_code != 0 - assert "report is a directory, must be file name" in result.output + assert "is a directory, must be file name" in result.output def test_export_as_hardlink_download_missing(): @@ -7794,6 +7817,7 @@ def test_export_limit(): assert result.exit_code == 0 assert "limit: 0/20 exported" in result.output + def test_export_no_keyword(): """test export --no-keyword""" @@ -7812,4 +7836,4 @@ def test_export_no_keyword(): ], ) assert result.exit_code == 0 - assert "Exporting 11" in result.output \ No newline at end of file + assert "Exporting 11" in result.output