--report can now accept a template, #339

This commit is contained in:
Rhet Turnbull 2022-05-14 09:04:04 -07:00
parent b4dc7cfcf6
commit 391815dd94
5 changed files with 139 additions and 83 deletions

View File

@ -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

View File

@ -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(

View File

@ -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()

View File

@ -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

View File

@ -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
assert "Exporting 11" in result.output