mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 18:50:19 +01:00
Add configurable word separator for naming templates (#1333)
Closes #1230 ## What Adds a "Word Separator" setting (Space / Dot / Underscore / Hyphen / Custom) that replaces internal whitespace in each naming-template placeholder's rendered value — e.g. `{Author}` renders "Arthur.Conan.Doyle" instead of "Arthur Conan Doyle" when Dot is selected. This follows option 2 from the issue rather than inventing new dotted-keyword template syntax (`{Author.}`), since it's a smaller surface: one setting applies uniformly across all four templates (books/audiobooks × rename/organize) instead of needing a parallel token for every existing one. ## How it works - Literal characters typed into the template itself (e.g. the `.` in `{Author}.-.{Title}`) are never touched — only whitespace *inside* a placeholder's resolved value is affected. - Default is "Space", which is a no-op: existing templates produce byte-identical output after this change (verified via the existing test suite, unmodified, still passing). ## Where - `shelfmark/core/naming.py` — `word_separator` param on `parse_naming_template` / `build_library_path`. - `shelfmark/download/postprocess/policy.py` — `get_word_separator()`, mirroring the existing `get_file_organization()` accessor. - `shelfmark/download/postprocess/transfer.py` — wires the resolved separator through the four existing template-rendering call sites. - `shelfmark/config/settings.py` — new `Word Separator` / `Custom Word Separator` fields next to the existing naming-template fields. - `src/frontend/.../namingTemplatePreview.ts` + `NamingTemplateField.tsx` — the settings UI has its own TS mirror of the Python renderer for the live preview; updated it in lockstep so the preview doesn't lie about what the separator will actually do. - Tests added on both sides (pytest + vitest). ## Testing - `uv run pytest tests/core/test_naming.py tests/core/test_destination_file_organization.py` — all pass, including new cases. - `uv run pytest` (full suite) — same pre-existing failures as on `main` before this change (browser/network-dependent bypass & e2e tests unrelated to this diff), everything else green. - `uv run ruff check` / `ruff format --check` / `basedpyright` — clean. - `npm run lint` / `format:check` / `typecheck` / `test:unit` (196 tests) — clean.
This commit is contained in:
@@ -434,6 +434,7 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
|
||||
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
|
||||
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
|
||||
| `NAMING_WORD_SEPARATOR` | Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is. | string | _empty string_ |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
|
||||
@@ -497,6 +498,15 @@ Choose how downloaded book files are named and organized.
|
||||
- **Default:** `rename`
|
||||
- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize)
|
||||
|
||||
#### `NAMING_WORD_SEPARATOR`
|
||||
|
||||
**Word Separator**
|
||||
|
||||
Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `TEMPLATE_RENAME`
|
||||
|
||||
**Naming Template**
|
||||
|
||||
@@ -1023,6 +1023,24 @@ def download_settings() -> list[SettingsField]:
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
key="NAMING_WORD_SEPARATOR",
|
||||
label="Word Separator",
|
||||
description=(
|
||||
"Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> "
|
||||
"'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and "
|
||||
"organize templates alike. Literal characters typed into a template "
|
||||
"(like the '-' in '{Author} - {Title}') are left as-is. Leave empty to "
|
||||
"keep spaces as-is."
|
||||
),
|
||||
default="",
|
||||
placeholder=".",
|
||||
max_length=5,
|
||||
show_when={
|
||||
"field": "BOOKS_OUTPUT_MODE",
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
# Rename mode template - filename only
|
||||
_naming_template_field(
|
||||
key="TEMPLATE_RENAME",
|
||||
|
||||
@@ -42,6 +42,10 @@ BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
|
||||
# Characters that are invalid in filenames on various filesystems
|
||||
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
# Runs of whitespace inside a single placeholder's rendered value, e.g. "Conan Doyle"
|
||||
# -- collapsed to the configured word separator (see `parse_naming_template`).
|
||||
WHITESPACE_RUN = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _sanitize(name: str | None, max_length: int = 245) -> str:
|
||||
"""Sanitize a string for filesystem use."""
|
||||
@@ -158,8 +162,16 @@ def parse_naming_template(
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
*,
|
||||
allow_path_separators: bool = True,
|
||||
word_separator: str = " ",
|
||||
) -> str:
|
||||
"""Render a naming template with Shelfmark metadata placeholders."""
|
||||
"""Render a naming template with Shelfmark metadata placeholders.
|
||||
|
||||
`word_separator` replaces whitespace *inside* each placeholder's rendered
|
||||
value (e.g. "Conan Doyle" -> "Conan.Doyle" for a "." separator). It never
|
||||
touches literal characters typed into the template itself, so a template
|
||||
like "{Author}.-.{Title}" keeps its own dots regardless of this setting.
|
||||
The default (" ") leaves values untouched, matching prior behavior.
|
||||
"""
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
@@ -195,6 +207,8 @@ def parse_naming_template(
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if word_separator != " ":
|
||||
value = WHITESPACE_RUN.sub(word_separator, value)
|
||||
if not allow_path_separators:
|
||||
value = value.replace("/", "_")
|
||||
value = sanitize_filename(value)
|
||||
@@ -260,9 +274,13 @@ def build_library_path(
|
||||
template: str,
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
extension: str | None = None,
|
||||
*,
|
||||
word_separator: str = " ",
|
||||
) -> Path:
|
||||
"""Build a final library path from a template and metadata."""
|
||||
relative = parse_naming_template(template, metadata, allow_path_separators=True)
|
||||
relative = parse_naming_template(
|
||||
template, metadata, allow_path_separators=True, word_separator=word_separator
|
||||
)
|
||||
|
||||
if not relative:
|
||||
# Fallback to title if template produces empty result
|
||||
|
||||
@@ -55,6 +55,17 @@ def get_file_organization(*, is_audiobook: bool) -> str:
|
||||
return mode if mode in ("none", "rename", "rename_and_group", "organize") else "rename"
|
||||
|
||||
|
||||
def get_word_separator() -> str:
|
||||
"""Get the configured word separator for naming template values.
|
||||
|
||||
Replaces whitespace inside each placeholder's rendered value (e.g. "Conan
|
||||
Doyle" -> "Conan.Doyle"). The setting holds the separator character
|
||||
directly (e.g. "." or "_"); empty means a plain space, which leaves
|
||||
values unchanged.
|
||||
"""
|
||||
return _config_text(core_config.config.get("NAMING_WORD_SEPARATOR", "")) or " "
|
||||
|
||||
|
||||
def get_template(*, is_audiobook: bool, organization_mode: str) -> str:
|
||||
"""Get the template for the content type and organization mode."""
|
||||
if is_audiobook:
|
||||
|
||||
@@ -25,7 +25,11 @@ from shelfmark.download.fs import (
|
||||
atomic_move,
|
||||
run_blocking_io,
|
||||
)
|
||||
from shelfmark.download.postprocess.policy import get_file_organization, get_template
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_file_organization,
|
||||
get_template,
|
||||
get_word_separator,
|
||||
)
|
||||
|
||||
from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files
|
||||
from .scan import collect_directory_files, scan_directory_tree
|
||||
@@ -198,6 +202,7 @@ def transfer_book_files(
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook)
|
||||
word_separator = get_word_separator()
|
||||
|
||||
groups = resolve_book_groups(task, book_files, organization_mode=organization_mode)
|
||||
if groups is not None:
|
||||
@@ -229,6 +234,7 @@ def transfer_book_files(
|
||||
template,
|
||||
file_metadata,
|
||||
extension=ext or None,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -256,6 +262,7 @@ def transfer_book_files(
|
||||
template,
|
||||
file_metadata,
|
||||
extension=ext or None,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -289,7 +296,9 @@ def transfer_book_files(
|
||||
metadata = build_file_metadata(task, book_file)
|
||||
extension = book_file.suffix.lstrip(".") or task.format or ""
|
||||
|
||||
filename = parse_naming_template(template, metadata, allow_path_separators=False)
|
||||
filename = parse_naming_template(
|
||||
template, metadata, allow_path_separators=False, word_separator=word_separator
|
||||
)
|
||||
filename = Path(filename).name if filename else ""
|
||||
if filename and extension:
|
||||
filename = f"{sanitize_filename(filename)}.{extension}"
|
||||
@@ -483,7 +492,12 @@ def transfer_file_to_library(
|
||||
template_metadata = dict(metadata)
|
||||
template_metadata.setdefault("OriginalName", source_path.stem)
|
||||
dest_path = run_blocking_io(
|
||||
build_library_path, library_base, template, template_metadata, extension
|
||||
build_library_path,
|
||||
library_base,
|
||||
template,
|
||||
template_metadata,
|
||||
extension,
|
||||
word_separator=get_word_separator(),
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -538,12 +552,14 @@ def transfer_directory_to_library(
|
||||
safe_cleanup_path(temp_file, task)
|
||||
return None
|
||||
|
||||
word_separator = get_word_separator()
|
||||
base_library_path = run_blocking_io(
|
||||
build_library_path,
|
||||
library_base,
|
||||
template,
|
||||
metadata,
|
||||
extension=None,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -574,7 +590,12 @@ def transfer_directory_to_library(
|
||||
ext = source_file.suffix.lstrip(".")
|
||||
file_metadata = {**metadata, "PartNumber": part_number}
|
||||
file_path = run_blocking_io(
|
||||
build_library_path, library_base, template, file_metadata, extension=ext
|
||||
build_library_path,
|
||||
library_base,
|
||||
template,
|
||||
file_metadata,
|
||||
extension=ext,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TextFieldConfig } from '../../../types/settings';
|
||||
import {
|
||||
buildNamingTemplatePreview,
|
||||
NAMING_TEMPLATE_TOKENS,
|
||||
resolveWordSeparator,
|
||||
type NamingTemplateContent,
|
||||
type NamingTemplateMode,
|
||||
type NamingTemplateToken,
|
||||
@@ -66,7 +67,8 @@ export const NamingTemplateField = ({
|
||||
(token) => !token.audiobookOnly || content === 'audiobook',
|
||||
);
|
||||
const tokenGroups = groupTokens(availableTokens);
|
||||
const preview = buildNamingTemplatePreview(value, mode, content);
|
||||
const wordSeparator = resolveWordSeparator(values.NAMING_WORD_SEPARATOR);
|
||||
const preview = buildNamingTemplatePreview(value, mode, content, wordSeparator);
|
||||
const hasPathSeparatorInFilename = mode === 'filename' && /[\\/]/.test(value);
|
||||
|
||||
const insertToken = (token: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildNamingTemplatePreview,
|
||||
NAMING_TEMPLATE_TOKENS,
|
||||
renderNamingTemplate,
|
||||
resolveWordSeparator,
|
||||
SAMPLE_NAMING_METADATA,
|
||||
} from '../utils/namingTemplatePreview';
|
||||
|
||||
@@ -97,6 +98,41 @@ describe('namingTemplatePreview', () => {
|
||||
expect(swedish.value).not.toBe(english.value);
|
||||
});
|
||||
|
||||
it('replaces internal whitespace with the configured word separator', () => {
|
||||
const preview = renderNamingTemplate('{Author}/{PrimaryTitle}', SAMPLE_NAMING_METADATA, {
|
||||
allowPathSeparators: true,
|
||||
wordSeparator: '.',
|
||||
});
|
||||
|
||||
expect(preview.value).toBe('Arthur.Conan.Doyle/The.Hound.of.the.Baskervilles');
|
||||
});
|
||||
|
||||
it('leaves values unchanged for the default space separator', () => {
|
||||
const preview = renderNamingTemplate('{Author}', SAMPLE_NAMING_METADATA, {
|
||||
allowPathSeparators: true,
|
||||
});
|
||||
|
||||
expect(preview.value).toBe('Arthur Conan Doyle');
|
||||
});
|
||||
|
||||
it('never touches literal template characters, only placeholder values', () => {
|
||||
const preview = renderNamingTemplate('{Author}.-.{PrimaryTitle}', SAMPLE_NAMING_METADATA, {
|
||||
allowPathSeparators: true,
|
||||
wordSeparator: '.',
|
||||
});
|
||||
|
||||
expect(preview.value).toBe('Arthur.Conan.Doyle.-.The.Hound.of.the.Baskervilles');
|
||||
});
|
||||
|
||||
it('resolves the word separator setting like the backend policy module', () => {
|
||||
expect(resolveWordSeparator('.')).toBe('.');
|
||||
expect(resolveWordSeparator('_')).toBe('_');
|
||||
expect(resolveWordSeparator('-')).toBe('-');
|
||||
expect(resolveWordSeparator('~')).toBe('~');
|
||||
expect(resolveWordSeparator('')).toBe(' ');
|
||||
expect(resolveWordSeparator(undefined)).toBe(' ');
|
||||
});
|
||||
|
||||
it('keeps the picker and the known-token list in lockstep', () => {
|
||||
// KNOWN_TOKENS is a hand-maintained duplicate of the Python list. A token
|
||||
// added to the picker but not to it would render as an unknown variable.
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface NamingTemplateToken {
|
||||
|
||||
interface RenderOptions {
|
||||
allowPathSeparators: boolean;
|
||||
/** Mirrors `word_separator` in shelfmark/core/naming.py. Defaults to ' ' (no change). */
|
||||
wordSeparator?: string;
|
||||
}
|
||||
|
||||
interface RenderResult {
|
||||
@@ -128,6 +130,7 @@ const firstAuthor = (value: string): string => value.split(/\s*[,;]\s*/)[0]?.tri
|
||||
|
||||
const BRACE_PATTERN = /\{([^}]+)\}/g;
|
||||
const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g;
|
||||
const WHITESPACE_RUN_PATTERN = /\s+/g;
|
||||
|
||||
export const SAMPLE_NAMING_METADATA = NAMING_TEMPLATE_TOKENS.reduce<Record<string, string>>(
|
||||
(metadata, token) => {
|
||||
@@ -193,11 +196,16 @@ export const renderNamingTemplate = (
|
||||
|
||||
const prefix = content.slice(0, index);
|
||||
const suffix = content.slice(index + name.length);
|
||||
const rawValue = placeholderValue(name);
|
||||
let rawValue = placeholderValue(name);
|
||||
if (!rawValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const wordSeparator = options.wordSeparator ?? ' ';
|
||||
if (wordSeparator !== ' ') {
|
||||
rawValue = rawValue.replace(WHITESPACE_RUN_PATTERN, wordSeparator);
|
||||
}
|
||||
|
||||
const value = sanitizeFilename(
|
||||
options.allowPathSeparators ? rawValue : rawValue.replace(/\//g, '_'),
|
||||
);
|
||||
@@ -253,13 +261,20 @@ export const renderNamingTemplate = (
|
||||
return { value: result, unknownTokens };
|
||||
};
|
||||
|
||||
// Mirrors get_word_separator() in shelfmark/download/postprocess/policy.py.
|
||||
export const resolveWordSeparator = (value: unknown): string => {
|
||||
return (typeof value === 'string' ? value : '') || ' ';
|
||||
};
|
||||
|
||||
export const buildNamingTemplatePreview = (
|
||||
template: string,
|
||||
mode: NamingTemplateMode,
|
||||
content: NamingTemplateContent,
|
||||
wordSeparator = ' ',
|
||||
): RenderResult => {
|
||||
const rendered = renderNamingTemplate(template, SAMPLE_NAMING_METADATA, {
|
||||
allowPathSeparators: mode === 'path',
|
||||
wordSeparator,
|
||||
});
|
||||
const fallback = SAMPLE_NAMING_METADATA.PrimaryTitle;
|
||||
const extension = content === 'audiobook' ? 'mp3' : 'epub';
|
||||
|
||||
@@ -143,3 +143,37 @@ def test_get_template_defaults_when_missing_and_ignores_pre_release_library_temp
|
||||
policy.get_template(is_audiobook=True, organization_mode="rename")
|
||||
== "{Author} - {Title} ({Year})"
|
||||
)
|
||||
|
||||
|
||||
def test_get_word_separator_defaults_to_space(monkeypatch):
|
||||
import shelfmark.download.postprocess.policy as policy
|
||||
|
||||
monkeypatch.setattr(policy.core_config.config, "get", lambda key, default=None: default)
|
||||
|
||||
assert policy.get_word_separator() == " "
|
||||
|
||||
|
||||
def test_get_word_separator_uses_configured_character(monkeypatch):
|
||||
import shelfmark.download.postprocess.policy as policy
|
||||
|
||||
for value, expected in [(".", "."), ("_", "_"), ("-", "-"), ("~", "~")]:
|
||||
monkeypatch.setattr(
|
||||
policy.core_config.config,
|
||||
"get",
|
||||
lambda key, default=None, value=value: {"NAMING_WORD_SEPARATOR": value}.get(
|
||||
key, default
|
||||
),
|
||||
)
|
||||
assert policy.get_word_separator() == expected
|
||||
|
||||
|
||||
def test_get_word_separator_falls_back_to_space_for_blank_value(monkeypatch):
|
||||
import shelfmark.download.postprocess.policy as policy
|
||||
|
||||
monkeypatch.setattr(
|
||||
policy.core_config.config,
|
||||
"get",
|
||||
lambda key, default=None: {"NAMING_WORD_SEPARATOR": ""}.get(key, default),
|
||||
)
|
||||
|
||||
assert policy.get_word_separator() == " "
|
||||
|
||||
@@ -220,6 +220,61 @@ class TestParseNamingTemplate:
|
||||
assert result == "Brandon Sanderson/The Way of Kings"
|
||||
|
||||
|
||||
class TestWordSeparator:
|
||||
"""Tests for the `word_separator` option (#1230)."""
|
||||
|
||||
def test_default_space_matches_prior_behavior(self):
|
||||
"""The default separator (' ') leaves placeholder values untouched."""
|
||||
result = parse_naming_template(
|
||||
"{Author}/{Title}", {"Author": "Arthur Conan Doyle", "Title": "The Hound"}
|
||||
)
|
||||
assert result == "Arthur Conan Doyle/The Hound"
|
||||
|
||||
def test_dot_separator_replaces_internal_spaces(self):
|
||||
result = parse_naming_template(
|
||||
"{Author}/{Title}",
|
||||
{"Author": "Arthur Conan Doyle", "Title": "The Hound of the Baskervilles"},
|
||||
word_separator=".",
|
||||
)
|
||||
assert result == "Arthur.Conan.Doyle/The.Hound.of.the.Baskervilles"
|
||||
|
||||
def test_underscore_and_hyphen_separators(self):
|
||||
metadata = {"Author": "Arthur Conan Doyle", "Title": "The Hound"}
|
||||
assert (
|
||||
parse_naming_template("{Author} - {Title}", metadata, word_separator="_")
|
||||
== "Arthur_Conan_Doyle - The_Hound"
|
||||
)
|
||||
assert (
|
||||
parse_naming_template("{Author} - {Title}", metadata, word_separator="-")
|
||||
== "Arthur-Conan-Doyle - The-Hound"
|
||||
)
|
||||
|
||||
def test_literal_template_characters_are_untouched(self):
|
||||
"""A dot typed into the template itself is not affected by the setting."""
|
||||
result = parse_naming_template(
|
||||
"{Author}/{Author}.-.{Title}.({Year})",
|
||||
{"Author": "Arthur Conan Doyle", "Title": "The Hound", "Year": 1902},
|
||||
word_separator=".",
|
||||
)
|
||||
assert result == "Arthur.Conan.Doyle/Arthur.Conan.Doyle.-.The.Hound.(1902)"
|
||||
|
||||
def test_custom_separator(self):
|
||||
result = parse_naming_template(
|
||||
"{Author}", {"Author": "Arthur Conan Doyle"}, word_separator="~"
|
||||
)
|
||||
assert result == "Arthur~Conan~Doyle"
|
||||
|
||||
def test_build_library_path_applies_separator(self):
|
||||
path = build_library_path(
|
||||
"/books",
|
||||
"{Author}/{Title}",
|
||||
{"Author": "Arthur Conan Doyle", "Title": "The Hound"},
|
||||
extension="epub",
|
||||
word_separator=".",
|
||||
)
|
||||
assert path == Path("/books/Arthur.Conan.Doyle/The.Hound.epub")
|
||||
|
||||
|
||||
class TestArbitraryPrefixSuffix:
|
||||
"""Tests for enhanced template syntax with arbitrary prefix/suffix text."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user