Implemented {counter} template (#957)
This commit is contained in:
parent
80ee142c7f
commit
770d85759d
@ -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}")
|
||||
|
||||
115
osxphotos/template_counter.py
Normal file
115
osxphotos/template_counter.py
Normal 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
|
||||
82
tests/test_template_counter.py
Normal file
82
tests/test_template_counter.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user