fancy-template 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ """fancy-template -- document templates for the Fancy writers.
2
+
3
+ One specification (``SPEC.md`` in the repository root, normative), three
4
+ implementations: this one, ``@particle-academy/fancy-template`` (TypeScript) and
5
+ ``particle-academy/fancy-template`` (PHP). All three assert the same
6
+ ``template/*`` rows from ``fancy-conformance``.
7
+
8
+ >>> from fancy_template import inspect, render
9
+ >>> template = {"fancyTemplate": 1, "writer": "fine-print",
10
+ ... "variables": {"name": {"type": "string", "required": True}},
11
+ ... "document": {"blocks": [{"type": "paragraph", "runs": [{"text": "Dear {{ name }}"}]}]}}
12
+ >>> inspect(template)["ok"]
13
+ True
14
+ >>> render(template, {"name": "Ada"})["blocks"][0]["runs"][0]["text"]
15
+ 'Dear Ada'
16
+
17
+ The output is plain writer JSON, so the writers never depend on this package and
18
+ stay zero-dependency. Pass it through the writer's own validate and write.
19
+
20
+ Missing data is LOUD: ``render`` raises :class:`TemplateError` with a stable
21
+ ``code``, a JSON Pointer ``path`` into the template and the ``variable`` path --
22
+ never a data value.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ from ._declarations import apply_defaults, validate_members
30
+ from ._errors import TemplateError
31
+ from ._inspector import Inspector, declared_variables
32
+ from ._json import first_too_deep, plain
33
+ from ._limits import DEFAULT_LIMITS, Limits, limits_from
34
+ from ._profile import Profile
35
+ from ._renderer import Renderer
36
+ from ._scanner import tokenize as _scan
37
+
38
+ __all__ = [
39
+ "DEFAULT_LIMITS",
40
+ "TemplateError",
41
+ "inspect",
42
+ "preview",
43
+ "render",
44
+ "tokenize",
45
+ "validate_data",
46
+ ]
47
+
48
+ __version__ = "0.2.0"
49
+
50
+
51
+ def inspect(template: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
52
+ """``{"ok": bool, "issues": [{code, path, variable?}]}`` -- at SAVE time, no data."""
53
+ limits = limits_from(options)
54
+ is_plain, value = plain(template)
55
+ issues = Inspector.run(value, limits) if is_plain else [{"code": "TEMPLATE_SHAPE", "path": ""}]
56
+ return {"ok": not issues, "issues": issues}
57
+
58
+
59
+ def validate_data(template: Any, data: Any, options: dict[str, Any] | None = None) -> list[dict[str, str]]:
60
+ """Data errors in declaration order; empty when valid."""
61
+ return _prepare(template, data, limits_from(options))[1]
62
+
63
+
64
+ def render(template: Any, data: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
65
+ """Render a template with data: inspect, then validate the data, then render.
66
+
67
+ Raises :class:`TemplateError` with the first failure (SPEC.md section 11).
68
+ """
69
+ limits = limits_from(options)
70
+ is_plain, value = plain(template)
71
+ if not is_plain or not isinstance(value, dict):
72
+ raise TemplateError("TEMPLATE_SHAPE", "")
73
+ issues = Inspector.run(value, limits)
74
+ if issues:
75
+ first = issues[0]
76
+ raise TemplateError(first["code"], first["path"], first.get("variable"))
77
+
78
+ defaulted, errors = _prepare(value, data, limits)
79
+ if errors:
80
+ raise TemplateError(errors[0]["code"], errors[0]["path"])
81
+
82
+ profile = Profile.for_writer(value["writer"])
83
+ assert profile is not None
84
+ return Renderer.run(value["document"], defaulted, declared_variables(value), profile, limits)
85
+
86
+
87
+ def preview(template: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
88
+ """Render with each variable's ``example``, falling back to its ``default``.
89
+
90
+ A required variable with neither fails, rather than inventing a value the
91
+ real data could not produce (template/resolve 0202).
92
+ """
93
+ data: dict[str, Any] = {}
94
+ is_plain, value = plain(template)
95
+ variables = value.get("variables") if is_plain and isinstance(value, dict) else None
96
+ if isinstance(variables, dict):
97
+ for name, decl in variables.items():
98
+ if isinstance(decl, dict) and "example" in decl:
99
+ data[name] = decl["example"]
100
+ return render(template, data, options)
101
+
102
+
103
+ def tokenize(text: str, options: dict[str, Any] | None = None) -> list[dict[str, Any]]:
104
+ """Split one string into text and token parts (SPEC.md section 5)."""
105
+ return _scan(text, limits_from(options).token_length)
106
+
107
+
108
+ def _prepare(template: Any, data: Any, limits: Limits) -> tuple[dict[str, Any], list[dict[str, str]]]:
109
+ plain_template, t = plain(template)
110
+ plain_data, d = plain(data)
111
+ if not plain_data:
112
+ return {}, [{"path": "", "code": "DATA_TYPE"}]
113
+ too_deep = first_too_deep(d, limits.depth)
114
+ if too_deep is not None:
115
+ return {}, [{"path": too_deep, "code": "DATA_TOO_DEEP"}]
116
+ if not isinstance(d, dict):
117
+ return {}, [{"path": "", "code": "DATA_TYPE"}]
118
+
119
+ members = declared_variables(t) if plain_template else {}
120
+ errors = validate_members(d, members, "")
121
+ return apply_defaults(d, members), errors
@@ -0,0 +1,172 @@
1
+ """The ``date`` type and the ``date:"pattern"`` filter (SPEC.md sections 3 and 7).
2
+
3
+ English names only, no time-zone database, no locale, never ``datetime``
4
+ arithmetic: a certificate's date is the date the issuer wrote, and an offset is
5
+ carried but never applied.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _MONTHS = (
13
+ "January",
14
+ "February",
15
+ "March",
16
+ "April",
17
+ "May",
18
+ "June",
19
+ "July",
20
+ "August",
21
+ "September",
22
+ "October",
23
+ "November",
24
+ "December",
25
+ )
26
+ _DAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
27
+
28
+ #: Pattern letter => allowed run lengths.
29
+ _RUNS: dict[str, tuple[int, ...]] = {
30
+ "y": (2, 4),
31
+ "M": (1, 2, 3, 4),
32
+ "d": (1, 2),
33
+ "E": (3, 4),
34
+ "H": (1, 2),
35
+ "h": (1, 2),
36
+ "m": (2,),
37
+ "s": (2,),
38
+ "a": (1,),
39
+ }
40
+
41
+ # Data, not template text, anchored with no nested quantifier: linear.
42
+ # `[0-9]` and re.ASCII, never a bare `\d`: in Python `\d` matches every Unicode
43
+ # digit, so a date spelled in Arabic-Indic digits would be one here and in no other leg. fullmatch,
44
+ # never `$`, which also matches before a trailing newline.
45
+ _ISO = re.compile(
46
+ r"([0-9]{4})-([0-9]{2})-([0-9]{2})"
47
+ r"(?:T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.[0-9]+)?)?(Z|[+-]([0-9]{2}):([0-9]{2}))?)?",
48
+ re.ASCII,
49
+ )
50
+
51
+ CivilDate = dict[str, int]
52
+ Piece = tuple[str, "str | int"]
53
+
54
+
55
+ def parse_date(value: object) -> CivilDate | None:
56
+ """Parse an ISO date or date-time. None when it is not one, or not a real calendar date and time."""
57
+ if not isinstance(value, str):
58
+ return None
59
+ m = _ISO.fullmatch(value)
60
+ if m is None:
61
+ return None
62
+ y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
63
+ hour = int(m.group(4)) if m.group(4) is not None else 0
64
+ minute = int(m.group(5)) if m.group(5) is not None else 0
65
+ second = int(m.group(6)) if m.group(6) is not None else 0
66
+ if mo < 1 or mo > 12 or d < 1 or d > _days_in(y, mo):
67
+ return None
68
+ if hour > 23 or minute > 59 or second > 59:
69
+ return None
70
+ if m.group(8) is not None and (int(m.group(8)) > 23 or int(m.group(9)) > 59):
71
+ return None
72
+ return {"y": y, "m": mo, "d": d, "H": hour, "i": minute, "s": second}
73
+
74
+
75
+ def _is_ascii_alpha(c: str) -> bool:
76
+ return "A" <= c <= "Z" or "a" <= c <= "z"
77
+
78
+
79
+ def compile_pattern(pattern: str) -> list[Piece] | None:
80
+ """Compile a pattern to pieces, or None if invalid. Forward-only; quotes are literal, '' is one quote."""
81
+ out: list[Piece] = []
82
+ n = len(pattern)
83
+ i = 0
84
+ while i < n:
85
+ c = pattern[i]
86
+ if c == "'":
87
+ if i + 1 < n and pattern[i + 1] == "'":
88
+ out.append(("lit", "'"))
89
+ i += 2
90
+ continue
91
+ i += 1
92
+ lit = ""
93
+ while True:
94
+ if i >= n:
95
+ return None
96
+ if pattern[i] == "'":
97
+ if i + 1 < n and pattern[i + 1] == "'":
98
+ lit += "'"
99
+ i += 2
100
+ continue
101
+ i += 1
102
+ break
103
+ lit += pattern[i]
104
+ i += 1
105
+ out.append(("lit", lit))
106
+ continue
107
+ if _is_ascii_alpha(c):
108
+ run = 1
109
+ while i + run < n and pattern[i + run] == c:
110
+ run += 1
111
+ if c not in _RUNS or run not in _RUNS[c]:
112
+ return None
113
+ out.append((c, run))
114
+ i += run
115
+ continue
116
+ out.append(("lit", c))
117
+ i += 1
118
+ return out
119
+
120
+
121
+ def format_date(date: CivilDate, pieces: list[Piece]) -> str:
122
+ out = ""
123
+ for kind, value in pieces:
124
+ if kind == "lit":
125
+ out += str(value)
126
+ elif kind == "y":
127
+ out += f"{date['y']:04d}" if value == 4 else f"{date['y'] % 100:02d}"
128
+ elif kind == "M":
129
+ name = _MONTHS[date["m"] - 1]
130
+ out += {4: name, 3: name[:3], 2: f"{date['m']:02d}"}.get(int(value), str(date["m"]))
131
+ elif kind == "d":
132
+ out += f"{date['d']:02d}" if value == 2 else str(date["d"])
133
+ elif kind == "E":
134
+ day = _DAYS[_weekday(date)]
135
+ out += day if value == 4 else day[:3]
136
+ elif kind == "H":
137
+ out += f"{date['H']:02d}" if value == 2 else str(date["H"])
138
+ elif kind == "h":
139
+ h = _twelve(date["H"])
140
+ out += f"{h:02d}" if value == 2 else str(h)
141
+ elif kind == "m":
142
+ out += f"{date['i']:02d}"
143
+ elif kind == "s":
144
+ out += f"{date['s']:02d}"
145
+ elif kind == "a":
146
+ out += "AM" if date["H"] < 12 else "PM"
147
+ return out
148
+
149
+
150
+ def _twelve(hour: int) -> int:
151
+ h = hour % 12
152
+ return 12 if h == 0 else h
153
+
154
+
155
+ def _weekday(date: CivilDate) -> int:
156
+ """0 = Monday, from days since the civil epoch (Howard Hinnant's algorithm)."""
157
+ y = date["y"] - (1 if date["m"] <= 2 else 0)
158
+ # Truncating division, as in C: Python's // floors, which differs for negatives.
159
+ era = int((y if y >= 0 else y - 399) / 400)
160
+ yoe = y - era * 400
161
+ mp = (date["m"] + 9) % 12
162
+ doy = (153 * mp + 2) // 5 + date["d"] - 1
163
+ doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
164
+ days = era * 146097 + doe - 719468
165
+ # 1970-01-01 was a Thursday (3). Python's % is already non-negative.
166
+ return (days % 7 + 3) % 7
167
+
168
+
169
+ def _days_in(y: int, m: int) -> int:
170
+ if m == 2:
171
+ return 29 if y % 4 == 0 and (y % 100 != 0 or y % 400 == 0) else 28
172
+ return 30 if m in (4, 6, 9, 11) else 31
@@ -0,0 +1,188 @@
1
+ """Variable declarations: checking them (inspect) and checking data against
2
+ them (validate_data). SPEC.md sections 3 and 4."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import math
8
+ from typing import Any
9
+
10
+ from ._dates import parse_date
11
+ from ._json import code_points, image_type, is_number, pointer
12
+ from ._scanner import is_name
13
+
14
+ TYPES = ("string", "number", "integer", "boolean", "date", "image", "array", "object")
15
+
16
+ Issue = dict[str, str]
17
+
18
+
19
+ def _is_count(v: Any) -> bool:
20
+ """A non-negative integer as section 3 defines integer: an integral VALUE, so 3.0 counts."""
21
+ return is_number(v) and math.isfinite(v) and float(v).is_integer() and v >= 0
22
+
23
+
24
+ def inspect_decl(decl: Any, path: str, issues: list[Issue]) -> None:
25
+ """Issues in one declaration, its own keys before its nested declarations."""
26
+ if not isinstance(decl, dict) or len(decl) == 0 or "type" not in decl:
27
+ issues.append({"code": "DECL_INVALID", "path": path})
28
+ return
29
+ type_ = decl["type"]
30
+ if not isinstance(type_, str) or type_ not in TYPES:
31
+ issues.append({"code": "DECL_INVALID", "path": pointer(path, "type")})
32
+ return
33
+
34
+ nested: list[tuple[Any, str]] = []
35
+ for key, value in decl.items():
36
+ at = pointer(path, key)
37
+ if key == "type":
38
+ bad = False
39
+ elif key == "required":
40
+ bad = not isinstance(value, bool)
41
+ elif key == "description":
42
+ bad = not isinstance(value, str)
43
+ elif key == "enum":
44
+ bad = (
45
+ type_ not in ("string", "number", "integer")
46
+ or not isinstance(value, list)
47
+ or len(value) == 0
48
+ or not all(type_ok(type_, v) for v in value)
49
+ )
50
+ elif key == "maxLength":
51
+ bad = type_ not in ("string", "date", "image") or not _is_count(value)
52
+ elif key == "maxItems":
53
+ bad = type_ != "array" or not _is_count(value)
54
+ elif key == "items":
55
+ bad = type_ != "array"
56
+ elif key == "properties":
57
+ bad = type_ != "object" or not isinstance(value, dict)
58
+ elif key in ("default", "example"):
59
+ bad = len(validate(value, decl, "")) > 0
60
+ else:
61
+ bad = True
62
+ if bad:
63
+ issues.append({"code": "DECL_INVALID", "path": at})
64
+ continue
65
+ if key == "items":
66
+ nested.append((value, at))
67
+ if key == "properties":
68
+ for name, sub in value.items():
69
+ if not is_name(name):
70
+ issues.append({"code": "DECL_INVALID", "path": pointer(at, name)})
71
+ continue
72
+ nested.append((sub, pointer(at, name)))
73
+
74
+ if (type_ == "array" and "items" not in decl) or (type_ == "object" and "properties" not in decl):
75
+ issues.append({"code": "DECL_INVALID", "path": path})
76
+
77
+ for sub, at in nested:
78
+ inspect_decl(sub, at, issues)
79
+
80
+
81
+ def usable(decl: Any) -> bool:
82
+ """Whether a declaration passed inspect, so that validation may lean on its shape."""
83
+ issues: list[Issue] = []
84
+ inspect_decl(decl, "", issues)
85
+ return not issues
86
+
87
+
88
+ def validate(value: Any, decl: dict[str, Any], path: str) -> list[dict[str, str]]:
89
+ """Validate one PRESENT value against a declaration."""
90
+ type_ = decl["type"]
91
+ if not type_ok(type_, value):
92
+ return [{"path": path, "code": "DATA_TYPE"}]
93
+ if type_ == "date" and parse_date(value) is None:
94
+ return [{"path": path, "code": "DATA_FORMAT"}]
95
+ if type_ == "image" and image_type(value) is None:
96
+ return [{"path": path, "code": "DATA_FORMAT"}]
97
+
98
+ errors: list[dict[str, str]] = []
99
+ enum = decl.get("enum")
100
+ if enum is not None and not _in_enum(value, enum):
101
+ errors.append({"path": path, "code": "DATA_ENUM"})
102
+ max_length = decl.get("maxLength")
103
+ if is_number(max_length) and isinstance(value, str) and code_points(value) > max_length:
104
+ errors.append({"path": path, "code": "DATA_MAX_LENGTH"})
105
+ if type_ == "array" and isinstance(value, list):
106
+ max_items = decl.get("maxItems")
107
+ if is_number(max_items) and len(value) > max_items:
108
+ errors.append({"path": path, "code": "DATA_MAX_ITEMS"})
109
+ items = decl.get("items")
110
+ if isinstance(items, dict):
111
+ for i, item in enumerate(value):
112
+ errors.extend(validate(item, items, pointer(path, i)))
113
+ if type_ == "object" and isinstance(value, dict):
114
+ properties = decl.get("properties")
115
+ if isinstance(properties, dict):
116
+ errors.extend(validate_members(value, properties, path))
117
+ return errors
118
+
119
+
120
+ def validate_members(obj: dict[str, Any], members: dict[str, Any], path: str) -> list[dict[str, str]]:
121
+ """Validate an object's declared members, in declaration order."""
122
+ errors: list[dict[str, str]] = []
123
+ for name, decl in members.items():
124
+ if not usable(decl):
125
+ continue
126
+ at = pointer(path, name)
127
+ if name not in obj:
128
+ if "default" not in decl and decl.get("required") is True:
129
+ errors.append({"path": at, "code": "DATA_REQUIRED"})
130
+ continue
131
+ errors.extend(validate(obj[name], decl, at))
132
+ return errors
133
+
134
+
135
+ def apply_defaults(obj: dict[str, Any], members: dict[str, Any]) -> dict[str, Any]:
136
+ """Fill absent members that declare a default, recursively (SPEC.md section 4)."""
137
+ for name, decl in members.items():
138
+ if not isinstance(decl, dict):
139
+ continue
140
+ if name not in obj and "default" in decl:
141
+ # A copy: the default is the template's, and must not be edited in place below.
142
+ obj[name] = copy.deepcopy(decl["default"])
143
+ if name not in obj:
144
+ continue
145
+ current = obj[name]
146
+ properties = decl.get("properties")
147
+ if decl.get("type") == "object" and isinstance(properties, dict) and isinstance(current, dict):
148
+ obj[name] = apply_defaults(current, properties)
149
+ items = decl.get("items")
150
+ if (
151
+ decl.get("type") == "array"
152
+ and isinstance(items, dict)
153
+ and items.get("type") == "object"
154
+ and isinstance(items.get("properties"), dict)
155
+ and isinstance(current, list)
156
+ ):
157
+ for i, item in enumerate(current):
158
+ if isinstance(item, dict):
159
+ current[i] = apply_defaults(item, items["properties"])
160
+ return obj
161
+
162
+
163
+ def type_ok(type_: str, v: Any) -> bool:
164
+ if type_ in ("string", "date", "image"):
165
+ return isinstance(v, str)
166
+ if type_ == "number":
167
+ return is_number(v) and math.isfinite(v)
168
+ if type_ == "integer":
169
+ return is_number(v) and math.isfinite(v) and float(v).is_integer()
170
+ if type_ == "boolean":
171
+ return isinstance(v, bool)
172
+ if type_ == "array":
173
+ return isinstance(v, list)
174
+ if type_ == "object":
175
+ return isinstance(v, dict)
176
+ return False
177
+
178
+
179
+ def _in_enum(value: Any, candidates: Any) -> bool:
180
+ if not isinstance(candidates, list):
181
+ return False
182
+ for c in candidates:
183
+ if isinstance(value, str) and isinstance(c, str) and value == c:
184
+ return True
185
+ # Compared by type AND value: `True == 1` is True in Python and must not be here.
186
+ if is_number(value) and is_number(c) and value == c:
187
+ return True
188
+ return False
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class TemplateError(Exception):
5
+ """Every refusal this package makes (SPEC.md section 6).
6
+
7
+ A stable ``code``, a JSON Pointer ``path`` into the TEMPLATE, and optionally
8
+ the ``variable`` PATH a token named. Never a data value, in the message or
9
+ anywhere else (plan section 5): a certificate's recipient name is personal
10
+ data, and an error is the thing that gets logged, shown to an admin and
11
+ pasted into an issue.
12
+ """
13
+
14
+ def __init__(self, code: str, path: str, variable: str | None = None) -> None:
15
+ where = "the root" if path == "" else path
16
+ what = "" if variable is None else f" (variable `{variable}`)"
17
+ super().__init__(f"{code} at {where}{what}")
18
+ self.code = code
19
+ self.path = path
20
+ self.variable = variable
21
+
22
+ def to_dict(self) -> dict[str, str]:
23
+ out = {"code": self.code, "path": self.path}
24
+ if self.variable is not None:
25
+ out["variable"] = self.variable
26
+ return out
@@ -0,0 +1,118 @@
1
+ """The fixed filter list (SPEC.md section 7).
2
+
3
+ Not extensible from a template, and not from a host either in v1: every filter
4
+ is a conformance-pinned behaviour, and a host-registered one would be a fourth
5
+ dialect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Final
11
+
12
+ from ._dates import compile_pattern, format_date, parse_date
13
+ from ._errors import TemplateError
14
+ from ._json import is_number
15
+ from ._numbers import CURRENCIES, fixed, money, to_js_string
16
+ from ._scanner import is_digits
17
+
18
+
19
+ class _Absent:
20
+ """The absent value. A private singleton rather than a magic string because
21
+ data can contain any string -- a sentinel spelled in characters is one a
22
+ hostile value can forge -- and nothing that went through ``plain()`` is ever
23
+ an instance of this class."""
24
+
25
+ __slots__ = ()
26
+
27
+ def __repr__(self) -> str:
28
+ return "ABSENT"
29
+
30
+
31
+ ABSENT: Final = _Absent()
32
+
33
+ FILTER_NAMES = ("default", "upper", "lower", "number", "money", "date", "join")
34
+
35
+
36
+ def check_filter(filt: dict[str, str]) -> str | None:
37
+ """Static check of one filter. None when fine, else the issue code."""
38
+ name = filt["name"]
39
+ if name not in FILTER_NAMES:
40
+ return "FILTER_UNKNOWN"
41
+ arg = filt.get("arg")
42
+ if name == "default":
43
+ ok = arg is not None
44
+ elif name in ("upper", "lower"):
45
+ ok = arg is None
46
+ elif name == "number":
47
+ ok = arg is not None and is_digits(arg) and len(arg) <= 2 and int(arg) <= 10
48
+ elif name == "money":
49
+ ok = arg is not None and arg in CURRENCIES
50
+ elif name == "date":
51
+ ok = arg is not None and compile_pattern(arg) is not None
52
+ else: # join
53
+ ok = True
54
+ return None if ok else "FILTER_ARG"
55
+
56
+
57
+ def _input() -> TemplateError:
58
+ return TemplateError("FILTER_INPUT", "")
59
+
60
+
61
+ def scalar_text(value: Any) -> str:
62
+ """A scalar as text (SPEC.md section 6). Arrays and objects are not scalars."""
63
+ if isinstance(value, str):
64
+ return value
65
+ if isinstance(value, bool): # before int: bool IS an int in Python
66
+ return "true" if value else "false"
67
+ if is_number(value):
68
+ return to_js_string(value)
69
+ raise TemplateError("TEMPLATE_NOT_SCALAR", "")
70
+
71
+
72
+ def _ascii_case(s: str, upper: bool) -> str:
73
+ # str.upper() is Unicode-aware ("ß".upper() is "SS"); the spec maps ASCII only.
74
+ if upper:
75
+ return "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in s)
76
+ return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in s)
77
+
78
+
79
+ def apply_filter(filt: dict[str, str], value: Any) -> Any:
80
+ """Apply one filter. ``value`` may be ABSENT. Raises TemplateError with an empty path."""
81
+ name = filt["name"]
82
+ arg = filt.get("arg")
83
+
84
+ if name == "default":
85
+ return arg if value is ABSENT or value is None else value
86
+ if value is ABSENT:
87
+ raise TemplateError("TEMPLATE_MISSING", "")
88
+
89
+ if name == "upper":
90
+ return _ascii_case(scalar_text(value), True)
91
+ if name == "lower":
92
+ return _ascii_case(scalar_text(value), False)
93
+ if name == "number":
94
+ if not is_number(value):
95
+ raise _input()
96
+ return fixed(value, int(arg or "0"))
97
+ if name == "money":
98
+ minor = value
99
+ if isinstance(minor, float) and minor.is_integer() and abs(minor) < 2**53:
100
+ minor = int(minor)
101
+ if isinstance(minor, bool) or not isinstance(minor, int):
102
+ raise _input()
103
+ return money(minor, str(arg))
104
+ if name == "date":
105
+ date = parse_date(value)
106
+ pieces = compile_pattern(str(arg))
107
+ if date is None or pieces is None:
108
+ raise _input()
109
+ return format_date(date, pieces)
110
+ # join
111
+ if not isinstance(value, list):
112
+ raise _input()
113
+ parts = []
114
+ for item in value:
115
+ if item is None or isinstance(item, (dict, list)):
116
+ raise _input()
117
+ parts.append(scalar_text(item))
118
+ return (", " if arg is None else arg).join(parts)