From 770d85759da6acf94a08d046aea29e101daa3fc7 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Wed, 25 Jan 2023 06:17:06 -0800 Subject: [PATCH] Implemented {counter} template (#957) --- osxphotos/phototemplate.py | 10 +++ osxphotos/template_counter.py | 115 +++++++++++++++++++++++++++++++++ tests/test_template_counter.py | 82 +++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 osxphotos/template_counter.py create mode 100644 tests/test_template_counter.py diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index 14ed42e4..7af59ec8 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -14,6 +14,8 @@ from typing import List, Optional, Tuple from textx import TextXSyntaxError, metamodel_from_file +import osxphotos.template_counter as counter + from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD from ._version import __version__ from .datetime_formatter import DateTimeFormatter @@ -151,6 +153,9 @@ TEMPLATE_SUBSTITUTIONS = { + "May be formatted using a python string format code. " + "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in " + "00001, 00002, 00003...etc. ", + "{counter}": counter.DESCRIPTION + + " Note: {counter} is not suitable for use with 'export' and '--update' " + + "as the counter associated with a photo may change between export sessions. See also {id}.", "{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. " + "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. " + 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. ' @@ -301,6 +306,9 @@ FIELD_NAMES = ( INPLACE_DEFAULT = "," PATH_SEP_DEFAULT = os.path.sep +# globals for tracking {seq} substitutions +_global_seq_count = 0 + PUNCTUATION = { "comma": ",", "semicolon": ";", @@ -919,6 +927,8 @@ class PhotoTemplate: start_id = int(field_arg) if field_arg is not None else 0 value = int(value) + start_id value = format_str_value(value, subfield) + elif field.startswith("counter"): + value = counter.get_counter_value(field, subfield, field_arg) else: # if here, didn't get a match raise ValueError(f"Unhandled template value: {field}") diff --git a/osxphotos/template_counter.py b/osxphotos/template_counter.py new file mode 100644 index 00000000..17b55f28 --- /dev/null +++ b/osxphotos/template_counter.py @@ -0,0 +1,115 @@ +""" {counter} template for Metadata Template Language """ + +from __future__ import annotations + +from collections import namedtuple +from dataclasses import dataclass +from textwrap import dedent +from typing import Any + +__all__ = [ + "DESCRIPTION", + "get_counter_value", + "reset_all_counters", + "reset_counter", +] + +# counter settings +CounterSettings = namedtuple("CounterSettings", ("start", "stop", "step")) + + +@dataclass +class Counter: + settings: CounterSettings + count: int = 0 + + +# global variable to hold state of all counters +_counter_state: dict[str, Counter] = {} + +DESCRIPTION = dedent( + """ + A sequential counter, starting at 0, that increments each time it is evaluated. + To start counting at a value other than 0, append append '(starting_value)' to the field name. + For example, to start counting at 1 instead of 0: '{counter(1)}'. + May be formatted using a python string format code. + For example, to format as a 5-digit integer and pad with zeros, use '{counter:05d(1)}' + which results in 00001, 00002, 00003...etc. + You may also specify a stop value which causes the counter to reset to the starting value + when the stop value is reached and a step size which causes the counter to increment by + the specified value instead of 1. Use the format '{counter(start,stop,step)}' where start, + stop, and step are integers. For example, to count from 1 to 10 by 2, use '{counter(1,11,2)}'. + Note that the counter stops counting when the stop value is reached and does not return the + stop value. Start, stop, and step are optional and may be omitted. For example, to count + from 0 by 2s, use '{counter(,,2)}'. + You may create an arbitrary number of counters by appending a unique name to the field name + preceded by a period: '{counter.a}', '{counter.b}', etc. Each counter will have its own state + and will start at 0 and increment by 1 unless otherwise specified. + """ +) + + +def get_counter_value(field: str, subfield: str | None, fieldarg: str | None) -> str: + """Get value for {counter} template field""" + + if not field.startswith("counter"): + raise ValueError(f"Unknown field: {field}") + + if len(field) > 7 and field[7] != ".": + raise ValueError(f"Invalid field: {field}") + + fieldarg = fieldarg or "" + args = fieldarg.split(",", 3) + start, stop, step = args + [""] * (3 - len(args)) + try: + start = int(start) if start != "" else 0 + stop = int(stop) if stop != "" else 0 + step = int(step) if step != "" else 1 + except TypeError as e: + raise ValueError( + f"start, stop, step must be integers: {start}, {stop}, {step}" + ) from e + + if stop and stop < start: + raise ValueError(f"stop must be > start: {start=}, {stop=}") + + settings = CounterSettings(start, stop, step) + if field not in _counter_state: + _counter_state[field] = Counter(settings=settings) + elif _counter_state[field].settings != settings: + raise ValueError( + f"Counter arguments cannot be changed after initialization: {settings} != {_counter_state[field].settings}" + ) + + counter = _counter_state[field] + value = counter.settings.start + counter.count + counter.count += counter.settings.step + if counter.settings.stop and value >= counter.settings.stop: + # stop counting, reset to start + value = counter.settings.start + counter.count = counter.settings.step + + if format_str := subfield or "": + value = format_str_value(value, format_str) + return str(value) + + +def format_str_value(value: Any, format_str: str | None) -> str: + """Format value based on format code in field in format id:02d""" + if not format_str: + return str(value) + format_str = "{0:" + f"{format_str}" + "}" + return format_str.format(value) + + +def reset_all_counters(): + """Reset all counters to 0""" + global _counter_state + _counter_state = {} + + +def reset_counter(field: str): + """Reset counter to 0""" + global _counter_state + if field in _counter_state: + _counter_state[field].count = _counter_state[field].settings.start diff --git a/tests/test_template_counter.py b/tests/test_template_counter.py new file mode 100644 index 00000000..47fdb75e --- /dev/null +++ b/tests/test_template_counter.py @@ -0,0 +1,82 @@ +"""Test {seq} template """ + +import pytest + +import osxphotos.phototemplate +import osxphotos.template_counter as template_counter + +PHOTOSDB = "tests/Test-13.0.0.photoslibrary" + +TEMPLATE_TEST_DATA = [ + ("{counter}", "0"), + ("{counter:03d}", "000"), + ("{counter:03d} {counter:03d}", "000 001"), + ("{counter:05d(2,,2)}-{counter:05d(2,,2)}", "00002-00004"), + ("{counter.a}-{counter.b}-{counter.a}", "0-0-1"), + ("{counter.a:03d(3)}", "003"), + ("{counter(1,3,)}{counter(1,3,)}{counter(1,3,)}{counter(1,3,)}", "1212"), + ("{counter(,,2)}{counter(,,2)}{counter(,,2)}{counter(,,2)}", "0246"), +] + +INVALID_TEMPLATES = [ + "{counter(1,2,3,4)}", + "{counter(1,-1,1)}", + "{counter.a}-{counter.a(1,10,2)}", + "{counter(a,b,c)}", +] + + +@pytest.fixture(scope="function", autouse=True) +def reset_seq_count(): + """Reset _global_seq_count to 0 before each test""" + template_counter.reset_all_counters() + + +@pytest.fixture(scope="module") +def photosdb(): + return osxphotos.PhotosDB(dbfile=PHOTOSDB) + + +@pytest.mark.parametrize("template,expected", TEMPLATE_TEST_DATA) +def test_counter(photosdb, template, expected): + """Test {seq} template""" + photo = photosdb.photos()[0] + result = photo.render_template(template) + assert result[0][0] == expected + template_counter.reset_all_counters() + + +@pytest.mark.parametrize("template", INVALID_TEMPLATES) +def test_invalid_counter(photosdb, template): + """Test invalid {counter} template""" + photo = photosdb.photos()[0] + with pytest.raises(ValueError): + photo.render_template(template) + + +def test_reset_counter(photosdb): + """Test reset_counter()""" + photo = photosdb.photos()[0] + result = photo.render_template("{counter}") + assert result[0][0] == "0" + result = photo.render_template("{counter}") + assert result[0][0] == "1" + + template_counter.reset_counter("counter") + result = photo.render_template("{counter}") + assert result[0][0] == "0" + + +def test_reset_all_counters(photosdb): + """Test reset_all_counters()""" + photo = photosdb.photos()[0] + result = photo.render_template("{counter.a}") + assert result[0][0] == "0" + result = photo.render_template("{counter.b}") + assert result[0][0] == "0" + + template_counter.reset_all_counters() + result = photo.render_template("{counter.a}") + assert result[0][0] == "0" + result = photo.render_template("{counter.b}") + assert result[0][0] == "0"