--report can now accept a template, #339
This commit is contained in:
parent
b4dc7cfcf6
commit
391815dd94
@ -47,6 +47,7 @@ from osxphotos.export_db import ExportDB, ExportDBInMemory
|
|||||||
from osxphotos.fileutil import FileUtil, FileUtilNoOp
|
from osxphotos.fileutil import FileUtil, FileUtilNoOp
|
||||||
from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||||
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
|
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
|
||||||
|
from osxphotos.photoinfo import PhotoInfoNone
|
||||||
from osxphotos.photokit import (
|
from osxphotos.photokit import (
|
||||||
check_photokit_authorization,
|
check_photokit_authorization,
|
||||||
request_photokit_authorization,
|
request_photokit_authorization,
|
||||||
@ -513,8 +514,10 @@ from .verbose import get_verbose_console, time_stamp, verbose_print
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--report",
|
"--report",
|
||||||
metavar="REPORT_FILE",
|
metavar="REPORT_FILE",
|
||||||
help="Write a CSV formatted report of all files that were exported.",
|
help="Write a CSV formatted report of all files that were exported. "
|
||||||
type=click.Path(),
|
"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(
|
@click.option(
|
||||||
"--cleanup",
|
"--cleanup",
|
||||||
@ -1149,12 +1152,8 @@ def export(
|
|||||||
|
|
||||||
dest = str(pathlib.Path(dest).resolve())
|
dest = str(pathlib.Path(dest).resolve())
|
||||||
|
|
||||||
if report and os.path.isdir(report):
|
if report:
|
||||||
rich_click_echo(
|
report = render_and_validate_report(report, exiftool_path, dest)
|
||||||
f"[error]report is a directory, must be file name",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# if use_photokit and not check_photokit_authorization():
|
# if use_photokit and not check_photokit_authorization():
|
||||||
# click.echo(
|
# click.echo(
|
||||||
@ -2826,3 +2825,33 @@ def run_post_command(
|
|||||||
rich_echo_error(
|
rich_echo_error(
|
||||||
f'[error]Error running command "{command}": {run_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
|
||||||
|
|||||||
@ -84,8 +84,7 @@ def terminate_exiftool():
|
|||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_exiftool_path():
|
def get_exiftool_path():
|
||||||
"""return path of exiftool, cache result"""
|
"""return path of exiftool, cache result"""
|
||||||
exiftool_path = shutil.which("exiftool")
|
if exiftool_path := shutil.which("exiftool"):
|
||||||
if exiftool_path:
|
|
||||||
return exiftool_path.rstrip()
|
return exiftool_path.rstrip()
|
||||||
else:
|
else:
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
""" Helper class for managing database used by PhotoExporter for tracking state of exports and updates """
|
""" Helper class for managing database used by PhotoExporter for tracking state of exports and updates """
|
||||||
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@ -9,6 +8,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from sqlite3 import Error
|
from sqlite3 import Error
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
@ -367,7 +367,7 @@ class ExportDB:
|
|||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""ensure the database connection is closed"""
|
"""ensure the database connection is closed"""
|
||||||
with contextlib.suppress(Exception):
|
with suppress(Exception):
|
||||||
self._conn.close()
|
self._conn.close()
|
||||||
|
|
||||||
def _insert_run_info(self):
|
def _insert_run_info(self):
|
||||||
@ -681,7 +681,7 @@ class ExportDBInMemory(ExportDB):
|
|||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""close the database connection"""
|
"""close the database connection"""
|
||||||
with contextlib.suppress(Error):
|
with suppress(Error):
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
""" Custom template system for osxphotos, implements metadata template language (MTL) """
|
""" Custom template system for osxphotos, implements metadata template language (MTL) """
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
import locale
|
import locale
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -419,7 +419,7 @@ class PhotoTemplate:
|
|||||||
try:
|
try:
|
||||||
model = self.parser.parse(template)
|
model = self.parser.parse(template)
|
||||||
except TextXSyntaxError as e:
|
except TextXSyntaxError as e:
|
||||||
raise ValueError(f"SyntaxError: {e}")
|
raise ValueError(f"SyntaxError: {e}") from e
|
||||||
|
|
||||||
if not model:
|
if not model:
|
||||||
# empty string
|
# empty string
|
||||||
@ -612,10 +612,12 @@ class PhotoTemplate:
|
|||||||
break
|
break
|
||||||
if match:
|
if match:
|
||||||
break
|
break
|
||||||
if (match and not negation) or (negation and not match):
|
|
||||||
return ["True"]
|
return (
|
||||||
else:
|
["True"]
|
||||||
return []
|
if (match and not negation) or (negation and not match)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
def comparison_test(test_function):
|
def comparison_test(test_function):
|
||||||
"""Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
|
"""Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
|
||||||
@ -627,14 +629,16 @@ class PhotoTemplate:
|
|||||||
match = bool(
|
match = bool(
|
||||||
test_function(float(vals[0]), float(conditional_value[0]))
|
test_function(float(vals[0]), float(conditional_value[0]))
|
||||||
)
|
)
|
||||||
if (match and not negation) or (negation and not match):
|
|
||||||
return ["True"]
|
return (
|
||||||
else:
|
["True"]
|
||||||
return []
|
if (match and not negation) or (negation and not match)
|
||||||
|
else []
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}"
|
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"]:
|
if operator in ["contains", "matches", "startswith", "endswith"]:
|
||||||
# process any "or" values separated by "|"
|
# process any "or" values separated by "|"
|
||||||
@ -722,9 +726,6 @@ class PhotoTemplate:
|
|||||||
ValueError if no rule exists for field.
|
ValueError if no rule exists for field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.photo.uuid is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# initialize today with current date/time if needed
|
# initialize today with current date/time if needed
|
||||||
if self.today is None:
|
if self.today is None:
|
||||||
self.today = datetime.datetime.now()
|
self.today = datetime.datetime.now()
|
||||||
@ -732,7 +733,50 @@ class PhotoTemplate:
|
|||||||
value = None
|
value = None
|
||||||
|
|
||||||
# wouldn't a switch/case statement be nice...
|
# 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
|
value = pathlib.Path(self.photo.filename).stem
|
||||||
elif field == "original_name":
|
elif field == "original_name":
|
||||||
value = pathlib.Path(self.photo.original_filename).stem
|
value = pathlib.Path(self.photo.original_filename).stem
|
||||||
@ -865,38 +909,6 @@ class PhotoTemplate:
|
|||||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||||
else:
|
else:
|
||||||
value = None
|
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":
|
elif field == "place.name":
|
||||||
value = self.photo.place.name if self.photo.place else None
|
value = self.photo.place.name if self.photo.place else None
|
||||||
elif field == "place.country_code":
|
elif field == "place.country_code":
|
||||||
@ -982,33 +994,25 @@ class PhotoTemplate:
|
|||||||
elif field == "id":
|
elif field == "id":
|
||||||
value = format_str_value(self.photo._info["pk"], subfield)
|
value = format_str_value(self.photo._info["pk"], subfield)
|
||||||
elif field.startswith("album_seq") or field.startswith("folder_album_seq"):
|
elif field.startswith("album_seq") or field.startswith("folder_album_seq"):
|
||||||
dest_path = self.dest_path
|
if dest_path := self.dest_path:
|
||||||
if not dest_path:
|
|
||||||
value = None
|
|
||||||
else:
|
|
||||||
if field.startswith("album_seq"):
|
if field.startswith("album_seq"):
|
||||||
album = pathlib.Path(dest_path).name
|
album = pathlib.Path(dest_path).name
|
||||||
album_info = _get_album_by_name(self.photo, album)
|
album_info = _get_album_by_name(self.photo, album)
|
||||||
else:
|
else:
|
||||||
album_info = _get_album_by_path(self.photo, dest_path)
|
album_info = _get_album_by_path(self.photo, dest_path)
|
||||||
value = album_info.photo_index(self.photo) if album_info else None
|
value = album_info.photo_index(self.photo) if album_info else None
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
if value is not None:
|
if value is not None:
|
||||||
try:
|
with suppress(IndexError):
|
||||||
start_id = field.split(".", 1)
|
start_id = field.split(".", 1)
|
||||||
value = int(value) + int(start_id[1])
|
value = int(value) + int(start_id[1])
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
value = format_str_value(value, subfield)
|
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:
|
else:
|
||||||
# if here, didn't get a match
|
# if here, didn't get a match
|
||||||
raise ValueError(f"Unhandled template value: {field}")
|
raise ValueError(f"Unhandled template value: {field}")
|
||||||
|
|
||||||
|
# sanitize filename or directory name if needed
|
||||||
if self.filename:
|
if self.filename:
|
||||||
value = sanitize_pathpart(value)
|
value = sanitize_pathpart(value)
|
||||||
elif self.dirname:
|
elif self.dirname:
|
||||||
@ -1038,8 +1042,8 @@ class PhotoTemplate:
|
|||||||
field_value = None
|
field_value = None
|
||||||
try:
|
try:
|
||||||
field_value = getattr(self, field_stem)
|
field_value = getattr(self, field_stem)
|
||||||
except AttributeError:
|
except AttributeError as e:
|
||||||
raise ValueError(f"Unknown path-like field: {field_stem}")
|
raise ValueError(f"Unknown path-like field: {field_stem}") from e
|
||||||
|
|
||||||
value = _get_pathlib_value(field, field_value, self.quote)
|
value = _get_pathlib_value(field, field_value, self.quote)
|
||||||
|
|
||||||
@ -1083,14 +1087,14 @@ class PhotoTemplate:
|
|||||||
value = ["{" + values + "}"] if values else []
|
value = ["{" + values + "}"] if values else []
|
||||||
elif filter_ == "parens":
|
elif filter_ == "parens":
|
||||||
if values and type(values) == list:
|
if values and type(values) == list:
|
||||||
value = ["(" + v + ")" for v in values]
|
value = [f"({v})" for v in values]
|
||||||
else:
|
else:
|
||||||
value = ["(" + values + ")"] if values else []
|
value = [f"({values})"] if values else []
|
||||||
elif filter_ == "brackets":
|
elif filter_ == "brackets":
|
||||||
if values and type(values) == list:
|
if values and type(values) == list:
|
||||||
value = ["[" + v + "]" for v in values]
|
value = [f"[{v}]" for v in values]
|
||||||
else:
|
else:
|
||||||
value = ["[" + values + "]"] if values else []
|
value = [f"[{values}]"] if values else []
|
||||||
elif filter_ == "shell_quote":
|
elif filter_ == "shell_quote":
|
||||||
if values and type(values) == list:
|
if values and type(values) == list:
|
||||||
value = [shlex.quote(v) for v in values]
|
value = [shlex.quote(v) for v in values]
|
||||||
@ -1406,7 +1410,7 @@ def get_template_field_table():
|
|||||||
*TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items(),
|
*TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.items(),
|
||||||
]:
|
]:
|
||||||
# replace '|' with '\|' to avoid markdown parsing issues (e.g. in {pipe} description)
|
# 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}|"
|
template_table += f"\n|{subst}|{descr}|"
|
||||||
return template_table
|
return template_table
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from click.testing import CliRunner
|
|||||||
from osxmetadata import OSXMetaData, Tag
|
from osxmetadata import OSXMetaData, Tag
|
||||||
|
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
from osxphotos._version import __version__
|
||||||
from osxphotos._constants import OSXPHOTOS_EXPORT_DB
|
from osxphotos._constants import OSXPHOTOS_EXPORT_DB
|
||||||
from osxphotos.cli import (
|
from osxphotos.cli import (
|
||||||
about,
|
about,
|
||||||
@ -5481,6 +5482,28 @@ def test_export_report():
|
|||||||
assert os.path.exists("report.csv")
|
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():
|
def test_export_report_not_a_file():
|
||||||
"""test export with --report option and bad report value"""
|
"""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", "."]
|
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."]
|
||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
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():
|
def test_export_as_hardlink_download_missing():
|
||||||
@ -7794,6 +7817,7 @@ def test_export_limit():
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "limit: 0/20 exported" in result.output
|
assert "limit: 0/20 exported" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_export_no_keyword():
|
def test_export_no_keyword():
|
||||||
"""test export --no-keyword"""
|
"""test export --no-keyword"""
|
||||||
|
|
||||||
@ -7812,4 +7836,4 @@ def test_export_no_keyword():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Exporting 11" in result.output
|
assert "Exporting 11" in result.output
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user