The new setting gives the strftime format of {pubdate}, {timestamp} and {last_modified}.
The default is Calibre's "%b, %Y".
224 lines
8.9 KiB
Python
224 lines
8.9 KiB
Python
"""The file name templates, against the results of Calibre 9.2.1.
|
|
|
|
Each expected text is what Calibre gives for the same book and template.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from bindery import filename_template as ft
|
|
|
|
BOOK = {
|
|
"title": "The Book",
|
|
"authors": ["Ann Writer", "Ben Reader"],
|
|
"series": "The Saga",
|
|
"series_index": 2.345,
|
|
"languages": ["eng", "fra"],
|
|
"tags": ["Space", "Fiction"],
|
|
"pubdate": "2020-05-06T12:00:00+00:00",
|
|
"publisher": "site.example",
|
|
}
|
|
NO_SERIES = {**BOOK, "series": "", "series_index": None}
|
|
|
|
|
|
def expand(template, order="library_order", **changes):
|
|
return ft.expand(template, ft.prepare({**BOOK, **changes}, 42, order))
|
|
|
|
|
|
# name, template, text from Calibre, series index
|
|
REFERENCE = [
|
|
("title", "{title}", "Book, The", 2.345),
|
|
("series", "{series}", "Saga, The", 2.345),
|
|
("fraction", "{series_index}", "2.35", 2.345),
|
|
("languages", "{languages}", "eng,fra", 2.345),
|
|
("tags", "{tags}", "Fiction, Space", 2.345),
|
|
("send_date", "{pubdate}", "May, 2020", 2.345),
|
|
("space_padding", "x{series_index:>5s}x", "x 2x", 2),
|
|
("zero_padding", "{series_index:0>3s}", "002", 2),
|
|
("numeric_format", "{series_index:03d}", "002", 2),
|
|
("first_character", "{author_sort[0]}", "W", 2),
|
|
("conditional", "{series:|| - }{title}", "Saga, The - Book, The", 2),
|
|
("case_insensitive", "{TITLE}", "Book, The", 2),
|
|
("unknown", "x{not_a_field}x", "xx", 2),
|
|
("literal_braces", "{{title}}", "{title}", 2),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("name,template,expected,index", REFERENCE, ids=[case[0] for case in REFERENCE])
|
|
def test_reference_cases(name, template, expected, index):
|
|
assert expand(template, series_index=index) == expected
|
|
|
|
|
|
def test_reference_cases_for_a_missing_series_and_for_alphabetic_order():
|
|
values = ft.prepare(NO_SERIES, 42)
|
|
assert ft.expand("{series}{series_index:| - | - }{title}", values) == "Book, The"
|
|
assert expand("{title}", "strictly_alphabetic") == "The Book"
|
|
assert expand("{series}", "strictly_alphabetic") == "The Saga"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"template,expected",
|
|
[
|
|
("{authors}", "Ann Writer & Ben Reader"),
|
|
("{author}", "Ann Writer & Ben Reader"),
|
|
("{author_sort}", "Writer, Ann & Reader, Ben"),
|
|
("{title_sort}", "Book, The"),
|
|
("{publisher} {id}", "site.example 42"),
|
|
("{title:.3s|<|>}", "<Boo>"),
|
|
("{title:*^15}", "***Book, The***"),
|
|
("{id:05d} {id:x} {id:.2f}", "00042 2a 42.00"),
|
|
("{title[0]}{title[1]}", "Bo"),
|
|
("{title} {{{id}}}", "Book, The {42}"),
|
|
(" {title} -\t{authors} ", "Book, The - Ann Writer & Ben Reader"),
|
|
("{ title }{other}", ""),
|
|
("a|b|c{title:|-|}", "a|b|c-Book, The"),
|
|
("{series}/{series_index:0>2s| | - }{title}", "Saga, The/ 2.35 - Book, The"),
|
|
],
|
|
)
|
|
def test_more_templates_give_the_text_of_calibre(template, expected):
|
|
assert expand(template) == expected
|
|
|
|
|
|
def test_values_follow_the_rules_of_calibre():
|
|
assert expand("{title} - {authors}", title="A/B: C\\D", authors=["X/Y"]) == "A_B: C_D - X_Y"
|
|
assert expand("{authors}", authors=["Rock & Roll", "", "Solo"]) == "Rock && Roll & Solo"
|
|
assert expand("{tags}", tags=["/root", "alpha", "Beta", "1st", "_x"]) == "_x, _root, 1st, alpha, Beta"
|
|
assert expand("{tags}", tags=["F/M", "Hurt-Comfort", "hurt comfort", "Hurt", "x2", "x10", "é", "e", "f"]) == (
|
|
"e, é, f, F_M, Hurt, hurt comfort, Hurt-Comfort, x10, x2"
|
|
)
|
|
assert expand("{tags}|{languages}|{pubdate}", tags=[], languages="", pubdate="") == "||"
|
|
assert expand("{languages}", languages="en") == "en"
|
|
assert expand("{pubdate} {timestamp}", pubdate="1999-12-31T23:59:59", timestamp="2026-10-03") == (
|
|
"Dec, 1999 Oct, 2026"
|
|
)
|
|
assert expand("{pubdate}", pubdate="not a date") == ""
|
|
|
|
|
|
def test_date_format():
|
|
# Calibre's helper_date reference case.
|
|
assert ft.expand("{pubdate}", ft.prepare(BOOK, 42, date_format="%b %Y")) == "May 2020"
|
|
assert ft.expand("{pubdate}", ft.prepare(BOOK, 42, date_format="%d/%m/%Y")) == "06_05_2020"
|
|
assert ft.expand("x{pubdate}x", ft.prepare(BOOK, 42, date_format="")) == "xx"
|
|
ft.check_date_format("%Y-%m-%d " * 10)
|
|
with pytest.raises(ft.TemplateError, match="A date format can have 100 characters at most"):
|
|
ft.check_date_format("x" * 101)
|
|
# A series without a number has no series_index, and a book without a series has neither.
|
|
assert expand("{series}{series_index:| [|]}", series_index=None) == "Saga, The"
|
|
assert ft.expand("{series_index:03d}|{series}", ft.prepare(NO_SERIES, 42)) == "|"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"title,expected",
|
|
[
|
|
("A Tale", "Tale, A"),
|
|
("An Apple", "Apple, An"),
|
|
("the lower", "lower, the"),
|
|
("Theory", "Theory"),
|
|
("The", "The"),
|
|
(" The Spaced ", "Spaced, The"),
|
|
("'The Quoted'", "Quoted, The"),
|
|
("“The Curly”", "Curly, The"),
|
|
("„The Low“", "Low, The"),
|
|
("The 'Inner", "Inner, The"),
|
|
('""', ""),
|
|
("Le Monde", "Le Monde"),
|
|
],
|
|
)
|
|
def test_title_sort(title, expected):
|
|
assert ft.title_sort(title) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"author,expected",
|
|
[
|
|
("Ann Writer", "Writer, Ann"),
|
|
("Dr. John Smith Jr.", "Smith, John Jr."),
|
|
("John Smith MD PhD", "Smith, John MD PhD"),
|
|
("Mr Smith", "Smith"),
|
|
("Dr Mr", "Dr Mr"),
|
|
("Jr Sr", "Jr Sr"),
|
|
("xX_Dark_Xx", "xX_Dark_Xx"),
|
|
("Smith, John", "Smith, John"),
|
|
("John (Jack) Doe", "Doe, John"),
|
|
("A (b c", "A (b c"),
|
|
("Acme Media", "Acme Media"),
|
|
("Ludwig van Beethoven", "Beethoven, Ludwig van"),
|
|
("J. R. R. Tolkien", "Tolkien, J. R. R."),
|
|
],
|
|
)
|
|
def test_author_sort(author, expected):
|
|
assert ft.author_sort(author) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"index,expected",
|
|
[(1, "1"), (2.0, "2"), (0, "0"), (2.5, "2.5"), (1.10, "1.1"), (3.14159, "3.14"), (2.001, "2."), (10.999, "11.")],
|
|
)
|
|
def test_series_index(index, expected):
|
|
assert ft.series_index(index) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"template,error",
|
|
[
|
|
("{title:uppercase()}", "does not support template functions"),
|
|
("{series:ifempty(none)}", "does not support template functions"),
|
|
("{title:'uppercase($)'}", "does not support template functions"),
|
|
("{title:.3s:'uppercase($)'}", "does not support template functions"),
|
|
("program: field('title')", "does not support the program modes"),
|
|
("python: def evaluate(book, context): return ''", "does not support the program modes"),
|
|
("{title!r}", "does not support the field"),
|
|
("{title.upper}", "does not support the field"),
|
|
("{title[x]}", "does not support the field"),
|
|
("{title:{id}}", "does not support the field"),
|
|
("{}", "A field needs a name"),
|
|
("{0}", "A field needs a name"),
|
|
("{title", "The braces of the template do not match"),
|
|
("title}", "The braces of the template do not match"),
|
|
("{title:5q}", "The format '5q' cannot take"),
|
|
("{series:|a|b|c}", "The format '|a' cannot take"),
|
|
("{id:.3d}", "The format '.3d' cannot take"),
|
|
("{title:>256s}", "A format can ask for 255 characters at most"),
|
|
("{id:.999f}", "A format can ask for 255 characters at most"),
|
|
("x" * 1025, "A template can have 1024 characters at most"),
|
|
],
|
|
)
|
|
def test_check_refuses_what_bindery_does_not_support(template, error):
|
|
with pytest.raises(ft.TemplateError, match=error.replace("|", "\\|")):
|
|
ft.check(template)
|
|
|
|
|
|
def test_check_accepts_the_templates_of_calibre():
|
|
for template in ("", "{author_sort}/{title}/{title} - {authors}", "{title:>255s}", "x" * 1024):
|
|
ft.check(template)
|
|
|
|
|
|
@pytest.mark.parametrize("template", ["{series_index:03d}", "{title:d}", "{title[50]}", "{id:c}{title:c}"])
|
|
def test_a_template_can_fail_for_one_book(template):
|
|
ft.check(template)
|
|
with pytest.raises(ft.TemplateError):
|
|
expand(template)
|
|
|
|
|
|
def file_name(template, **changes):
|
|
return ft.file_name(template, ft.prepare({**BOOK, **changes}, 42))
|
|
|
|
|
|
def test_file_name_is_the_last_part_of_the_path():
|
|
assert file_name("{author_sort}/{title}/{title} - {authors}") == "Book, The - Ann Writer & Ben Reader"
|
|
assert file_name("{series}//{title} /") == "Book, The"
|
|
assert file_name("{author_sort[0]}/{series}", series="") == "W"
|
|
assert file_name("{series}/{other}", series="") == ""
|
|
assert file_name(" / ") == ""
|
|
|
|
|
|
def test_file_name_is_safe():
|
|
assert file_name("{title}?", title="A/B: C\\D") == "A_B_ C_D_"
|
|
assert file_name("<{id:c}>") == "___"
|
|
assert file_name("{title}", title="con") == "_con"
|
|
assert file_name("{title}.{id}", title="NUL") == "_NUL.42"
|
|
assert file_name("{title}", title="Console") == "Console"
|
|
assert file_name("{title}...") == "Book, The"
|
|
long = file_name("{title}", title="é" * 150)
|
|
assert long == "é" * 100
|
|
assert len(file_name("{title}", title="\U0001f600" * 150).encode()) == 200
|