Implemented {counter} template (#957)

This commit is contained in:
Rhet Turnbull 2023-01-25 06:17:06 -08:00 committed by GitHub
parent 80ee142c7f
commit 770d85759d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 207 additions and 0 deletions

View File

@ -14,6 +14,8 @@ from typing import List, Optional, Tuple
from textx import TextXSyntaxError, metamodel_from_file from textx import TextXSyntaxError, metamodel_from_file
import osxphotos.template_counter as counter
from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD
from ._version import __version__ from ._version import __version__
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
@ -151,6 +153,9 @@ TEMPLATE_SUBSTITUTIONS = {
+ "May be formatted using a python string format code. " + "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 " + "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in "
+ "00001, 00002, 00003...etc. ", + "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. " "{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. " + "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}"\'. ' + 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. '
@ -301,6 +306,9 @@ FIELD_NAMES = (
INPLACE_DEFAULT = "," INPLACE_DEFAULT = ","
PATH_SEP_DEFAULT = os.path.sep PATH_SEP_DEFAULT = os.path.sep
# globals for tracking {seq} substitutions
_global_seq_count = 0
PUNCTUATION = { PUNCTUATION = {
"comma": ",", "comma": ",",
"semicolon": ";", "semicolon": ";",
@ -919,6 +927,8 @@ class PhotoTemplate:
start_id = int(field_arg) if field_arg is not None else 0 start_id = int(field_arg) if field_arg is not None else 0
value = int(value) + start_id value = int(value) + start_id
value = format_str_value(value, subfield) value = format_str_value(value, subfield)
elif field.startswith("counter"):
value = counter.get_counter_value(field, subfield, field_arg)
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}")

View File

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

View File

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