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
|
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}")
|
||||||
|
|||||||
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