pgtern 0.3.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.
pgtern/_types.py ADDED
@@ -0,0 +1,442 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Python <-> PostgreSQL text-format codecs.
3
+
4
+ Parameters are sent in text format. Strings go out with the "unknown" type OID
5
+ (0) so the server infers the type from context (``%s::int``, ``col = %s``); ints
6
+ are sent as int2/int4/int8/numeric by size, so ``f(smallint)`` calls
7
+ still resolve. Results are decoded from text format; types without a decoder
8
+ come back as ``str``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import binascii
13
+ import json
14
+ import re
15
+ import uuid
16
+ from datetime import date, datetime, time, timedelta, timezone
17
+ from decimal import Decimal
18
+ from typing import Any, Callable, Sequence
19
+
20
+ from . import errors
21
+ from .types import Json, Jsonb
22
+
23
+ BOOL, BYTEA, CHAR, NAME, INT8, INT2, INT4, TEXT, OID = 16, 17, 18, 19, 20, 21, 23, 25, 26
24
+ JSON, FLOAT4, FLOAT8, BPCHAR, VARCHAR = 114, 700, 701, 1042, 1043
25
+ DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, TIMETZ = 1082, 1083, 1114, 1184, 1186, 1266
26
+ NUMERIC, UUID, JSONB = 1700, 2950, 3802
27
+
28
+ ARRAY_OF = {
29
+ BOOL: 1000, BYTEA: 1001, CHAR: 1002, NAME: 1003, INT2: 1005, INT4: 1007, TEXT: 1009,
30
+ OID: 1028, VARCHAR: 1015, INT8: 1016, FLOAT4: 1021, FLOAT8: 1022, BPCHAR: 1014, JSON: 199,
31
+ DATE: 1182, TIME: 1183, TIMESTAMP: 1115, TIMESTAMPTZ: 1185, INTERVAL: 1187, TIMETZ: 1270,
32
+ NUMERIC: 1231, UUID: 2951, JSONB: 3807,
33
+ }
34
+ ELEMENT_OF = {array: elem for elem, array in ARRAY_OF.items()}
35
+
36
+ _UNQUOTED_IN_ARRAYS = {BOOL, INT2, INT4, INT8, FLOAT8, NUMERIC}
37
+ _INT_ORDER = (INT2, INT4, INT8, NUMERIC)
38
+
39
+
40
+ # ---------------------------------------------------------------- dumping
41
+
42
+ def render_literal(value: Any) -> str:
43
+ """A value as an SQL literal (for client-side binding)."""
44
+ from .sql import quote_literal
45
+ _oid, data = dump(value)
46
+ return "NULL" if data is None else quote_literal(data.decode("utf-8"))
47
+
48
+
49
+ def dump(value: Any) -> tuple[int, bytes | None]:
50
+ """Return ``(type_oid, text_bytes)`` for a parameter; ``(0, None)`` is SQL NULL."""
51
+ if value is None:
52
+ return 0, None
53
+ if _DUMPERS:
54
+ fn = _dumper_for(type(value))
55
+ if fn is not None: # user dumpers win; the text goes out untyped so the server infers the type
56
+ text = fn(value)
57
+ return (0, None) if text is None else (0, text.encode("utf-8"))
58
+ if value is True or value is False:
59
+ return BOOL, b"t" if value else b"f"
60
+ if isinstance(value, int):
61
+ if -32768 <= value <= 32767:
62
+ oid = INT2
63
+ elif -2147483648 <= value <= 2147483647:
64
+ oid = INT4
65
+ elif -9223372036854775808 <= value <= 9223372036854775807:
66
+ oid = INT8
67
+ else:
68
+ oid = NUMERIC
69
+ return oid, str(int(value)).encode()
70
+ if isinstance(value, str):
71
+ if "\x00" in value:
72
+ raise errors.DataError("PostgreSQL text fields cannot contain NUL (0x00) bytes")
73
+ return 0, str.encode(value, "utf-8")
74
+ if isinstance(value, float):
75
+ if value != value:
76
+ return FLOAT8, b"NaN"
77
+ if value in (float("inf"), float("-inf")):
78
+ return FLOAT8, b"Infinity" if value > 0 else b"-Infinity"
79
+ return FLOAT8, repr(value).encode()
80
+ if isinstance(value, Decimal):
81
+ return NUMERIC, str(value).encode()
82
+ if isinstance(value, (bytes, bytearray, memoryview)):
83
+ return BYTEA, b"\\x" + binascii.hexlify(bytes(value))
84
+ if isinstance(value, datetime):
85
+ return (TIMESTAMPTZ if value.tzinfo is not None else TIMESTAMP), value.isoformat(" ").encode()
86
+ if isinstance(value, date):
87
+ return DATE, value.isoformat().encode()
88
+ if isinstance(value, time):
89
+ return (TIMETZ if value.tzinfo is not None else TIME), value.isoformat().encode()
90
+ if isinstance(value, timedelta):
91
+ return INTERVAL, f"{value.days} days {value.seconds} seconds {value.microseconds} microseconds".encode()
92
+ if isinstance(value, uuid.UUID):
93
+ return UUID, str(value).encode()
94
+ if isinstance(value, Json):
95
+ return (JSONB if isinstance(value, Jsonb) else JSON), value.encode().encode()
96
+ if isinstance(value, list):
97
+ return _dump_list(value)
98
+ raise errors.ProgrammingError(
99
+ f"cannot adapt type {type(value).__name__!r}; wrap dicts in Jsonb() and use lists for arrays")
100
+
101
+
102
+ def _unify(oids: set[int]) -> int:
103
+ if not oids:
104
+ return 0
105
+ if len(oids) == 1:
106
+ return next(iter(oids))
107
+ if oids <= set(_INT_ORDER):
108
+ return max(oids, key=_INT_ORDER.index)
109
+ raise errors.ProgrammingError(f"cannot dump a list of mixed types (type OIDs {sorted(oids)})")
110
+
111
+
112
+ def _dump_list(items: list) -> tuple[int, bytes]:
113
+ leaves: list[tuple[int, bytes | None]] = []
114
+
115
+ def walk(node: Any) -> Any:
116
+ if isinstance(node, list):
117
+ return [walk(child) for child in node]
118
+ if node is None:
119
+ return None
120
+ pair = dump(node)
121
+ leaves.append(pair)
122
+ return pair
123
+
124
+ tree = walk(items)
125
+ elem = _unify({oid for oid, _ in leaves})
126
+
127
+ def render(node: Any) -> str:
128
+ if node is None:
129
+ return "NULL"
130
+ if isinstance(node, list):
131
+ return "{" + ",".join(render(child) for child in node) + "}"
132
+ oid, data = node
133
+ text = data.decode()
134
+ if oid in _UNQUOTED_IN_ARRAYS:
135
+ return text
136
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
137
+
138
+ return (ARRAY_OF.get(elem, 0) if elem else 0), render(tree).encode()
139
+
140
+
141
+ # ---------------------------------------------------------------- decoding
142
+
143
+ _DATE_RE = re.compile(r"^(\d{4,})-(\d\d)-(\d\d)( BC)?$")
144
+ _TIMESTAMP_RE = re.compile(
145
+ r"^(\d{4,})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))?"
146
+ r"(?:([+-])(\d\d)(?::?(\d\d))?(?::?(\d\d))?)?( BC)?$")
147
+ _TIME_RE = re.compile(
148
+ r"^(\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))?(?:([+-])(\d\d)(?::?(\d\d))?(?::?(\d\d))?)?$")
149
+ _INTERVAL_RE = re.compile(
150
+ r"^(?:([+-]?\d+) years?)?\s*(?:([+-]?\d+) mons?)?\s*(?:([+-]?\d+) days?)?\s*"
151
+ r"(?:([+-])?(\d+):(\d\d):(\d\d)(?:\.(\d{1,6}))?)?$")
152
+
153
+
154
+ def _tz(sign: str | None, hh: str | None, mm: str | None, ss: str | None) -> timezone | None:
155
+ if sign is None:
156
+ return None
157
+ delta = timedelta(hours=int(hh or 0), minutes=int(mm or 0), seconds=int(ss or 0))
158
+ if sign == "-":
159
+ delta = -delta
160
+ return timezone.utc if not delta else timezone(delta)
161
+
162
+
163
+ def _dec_bool(s: str) -> bool:
164
+ return s == "t"
165
+
166
+
167
+ def _dec_bytea(s: str) -> bytes:
168
+ if s.startswith("\\x"):
169
+ return binascii.unhexlify(s[2:])
170
+ out = bytearray() # legacy "escape" output format
171
+ i = 0
172
+ while i < len(s):
173
+ if s[i] != "\\":
174
+ out.append(ord(s[i]))
175
+ i += 1
176
+ elif s[i + 1:i + 2] == "\\":
177
+ out.append(0x5C)
178
+ i += 2
179
+ else:
180
+ out.append(int(s[i + 1:i + 4], 8))
181
+ i += 4
182
+ return bytes(out)
183
+
184
+
185
+ def _dec_date(s: str) -> date:
186
+ m = _DATE_RE.match(s)
187
+ if not m or m.group(4) or int(m.group(1)) > 9999:
188
+ raise errors.DataError(f"can't decode date {s!r}: BC dates, years above 9999 and infinity are not supported")
189
+ try:
190
+ return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
191
+ except ValueError as exc:
192
+ raise errors.DataError(f"can't decode date {s!r}: {exc}") from None
193
+
194
+
195
+ def _make_timestamp(s: str, aware: bool) -> datetime:
196
+ m = _TIMESTAMP_RE.match(s)
197
+ if not m or m.group(12) or int(m.group(1)) > 9999:
198
+ raise errors.DataError(
199
+ f"can't decode timestamp {s!r}: BC dates, years above 9999 and infinity are not supported")
200
+ year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
201
+ micro = int((m.group(7) or "").ljust(6, "0"))
202
+ tz = _tz(m.group(8), m.group(9), m.group(10), m.group(11)) if aware else None
203
+ try:
204
+ return datetime(year, month, day, hour, minute, second, micro, tzinfo=tz)
205
+ except ValueError as exc:
206
+ raise errors.DataError(f"can't decode timestamp {s!r}: {exc}") from None
207
+
208
+
209
+ def _dec_timestamp(s: str) -> datetime:
210
+ return _make_timestamp(s, False)
211
+
212
+
213
+ def _dec_timestamptz(s: str) -> datetime:
214
+ return _make_timestamp(s, True)
215
+
216
+
217
+ def _make_time(s: str, aware: bool) -> time:
218
+ m = _TIME_RE.match(s)
219
+ if not m:
220
+ raise errors.DataError(f"can't decode time {s!r}")
221
+ micro = int((m.group(4) or "").ljust(6, "0"))
222
+ tz = _tz(m.group(5), m.group(6), m.group(7), m.group(8)) if aware else None
223
+ try:
224
+ return time(int(m.group(1)), int(m.group(2)), int(m.group(3)), micro, tzinfo=tz)
225
+ except ValueError as exc:
226
+ raise errors.DataError(f"can't decode time {s!r}: {exc}") from None
227
+
228
+
229
+ def _dec_time(s: str) -> time:
230
+ return _make_time(s, False)
231
+
232
+
233
+ def _dec_timetz(s: str) -> time:
234
+ return _make_time(s, True)
235
+
236
+
237
+ def _dec_interval(s: str) -> timedelta:
238
+ """PostgreSQL ``postgres`` IntervalStyle. A year counts as 365 days and a month as 30."""
239
+ m = _INTERVAL_RE.match(s.strip())
240
+ if not m:
241
+ raise errors.NotSupportedError(f"can't decode interval {s!r}: only IntervalStyle 'postgres' is supported")
242
+ years, months, days = (int(m.group(i) or 0) for i in (1, 2, 3))
243
+ delta = timedelta(days=years * 365 + months * 30 + days)
244
+ if m.group(5) is not None:
245
+ clock = timedelta(hours=int(m.group(5)), minutes=int(m.group(6)), seconds=int(m.group(7)),
246
+ microseconds=int((m.group(8) or "").ljust(6, "0")))
247
+ delta += -clock if m.group(4) == "-" else clock
248
+ return delta
249
+
250
+
251
+ _SCALAR_DECODERS: dict[int, Callable[[str], Any] | None] = {
252
+ BOOL: _dec_bool, BYTEA: _dec_bytea, INT2: int, INT4: int, INT8: int, OID: int,
253
+ FLOAT4: float, FLOAT8: float, NUMERIC: Decimal, UUID: uuid.UUID,
254
+ JSON: json.loads, JSONB: json.loads,
255
+ DATE: _dec_date, TIME: _dec_time, TIMETZ: _dec_timetz, TIMESTAMP: _dec_timestamp,
256
+ TIMESTAMPTZ: _dec_timestamptz, INTERVAL: _dec_interval,
257
+ TEXT: None, VARCHAR: None, BPCHAR: None, NAME: None, CHAR: None,
258
+ }
259
+ _DATE_OIDS = {DATE, TIME, TIMETZ, TIMESTAMP, TIMESTAMPTZ, INTERVAL}
260
+
261
+
262
+ def parse_array(text: str, element: Callable[[str], Any] | None) -> list:
263
+ """Parse a PostgreSQL array literal (any number of dimensions) with ``element`` per item."""
264
+ if text.startswith("["): # explicit bounds, e.g. "[0:1]={a,b}"
265
+ text = text[text.index("=") + 1:]
266
+ root: list = []
267
+ stack: list[list] = []
268
+ i, n = 0, len(text)
269
+ while i < n:
270
+ c = text[i]
271
+ if c == "{":
272
+ node: list = []
273
+ (stack[-1] if stack else _Holder(root)).append(node)
274
+ stack.append(node)
275
+ i += 1
276
+ elif c == "}":
277
+ stack.pop()
278
+ i += 1
279
+ elif c == ",":
280
+ i += 1
281
+ elif c == '"':
282
+ i += 1
283
+ buf: list[str] = []
284
+ while text[i] != '"':
285
+ if text[i] == "\\":
286
+ i += 1
287
+ buf.append(text[i])
288
+ i += 1
289
+ i += 1
290
+ token = "".join(buf)
291
+ stack[-1].append(token if element is None else element(token))
292
+ else:
293
+ j = i
294
+ while j < n and text[j] not in ",}":
295
+ j += 1
296
+ token = text[i:j]
297
+ i = j
298
+ stack[-1].append(None if token == "NULL" else (token if element is None else element(token)))
299
+ return root[0] if root else []
300
+
301
+
302
+ class _Holder(list):
303
+ """Lets the first ``{`` replace the root list contents."""
304
+
305
+ def __init__(self, root: list) -> None:
306
+ super().__init__()
307
+ self._root = root
308
+
309
+ def append(self, item: Any) -> None: # type: ignore[override]
310
+ self._root.append(item)
311
+
312
+
313
+ def _decoder_for(oid: int, dates_ok: bool) -> Callable[[str], Any] | None:
314
+ base = ELEMENT_OF.get(oid)
315
+ if base is not None:
316
+ element = _decoder_for(base, dates_ok)
317
+ return lambda s: parse_array(s, element)
318
+ decoder = _SCALAR_DECODERS.get(oid)
319
+ if decoder is not None and oid in _DATE_OIDS and not dates_ok:
320
+ def refuse(s: str) -> Any:
321
+ raise errors.NotSupportedError(
322
+ "date/time values can't be decoded: the connection's DateStyle is not ISO")
323
+ return refuse
324
+ return decoder
325
+
326
+
327
+ class BinaryColumn:
328
+ """Marks a column whose values are binary (decoder takes bytes, not text)."""
329
+
330
+ __slots__ = ("fn",)
331
+
332
+ def __init__(self, fn: Callable[[bytes], Any]) -> None:
333
+ self.fn = fn
334
+
335
+
336
+ def make_loaders(oids: Sequence[int], dates_ok: bool = True,
337
+ cache: dict[int, Any] | None = None,
338
+ formats: Sequence[int] | None = None) -> list[Any]:
339
+ """One loader per column: a text decoder (``str`` in), a :class:`BinaryColumn`, or ``None`` (keep text)."""
340
+ out: list[Any] = []
341
+ for i, oid in enumerate(oids):
342
+ if formats is not None and formats[i] == 1:
343
+ from ._binary import scalar_decoder # imported here: _binary depends on this module
344
+ decoder = scalar_decoder(oid)
345
+ if decoder is None:
346
+ raise errors.InterfaceError(f"no binary decoder for type oid {oid}")
347
+ out.append(BinaryColumn(decoder))
348
+ continue
349
+ user = cache.get(oid) if cache else None
350
+ out.append(user if user is not None else _decoder_for(oid, dates_ok))
351
+ return out
352
+
353
+
354
+ # ---------------------------------------------------------------- user adapters
355
+ _DUMPERS: dict[type, Callable[[Any], str | None]] = {}
356
+ _LOADERS: dict[str, Callable[[str], Any]] = {}
357
+
358
+
359
+ def _dumper_for(cls: type) -> Callable[[Any], str | None] | None:
360
+ for klass in cls.__mro__:
361
+ fn = _DUMPERS.get(klass)
362
+ if fn is not None:
363
+ return fn
364
+ return None
365
+
366
+
367
+ def has_loaders() -> bool:
368
+ return bool(_LOADERS)
369
+
370
+
371
+ def loader_for_name(nspname: str, typname: str) -> Callable[[str], Any] | None:
372
+ return _LOADERS.get(f"{nspname}.{typname}") or _LOADERS.get(typname)
373
+
374
+
375
+ def _nn(value: bytes | None) -> bytes:
376
+ if value is None:
377
+ raise errors.InterfaceError("unexpected NULL in a catalog row")
378
+ return value
379
+
380
+
381
+ def resolve_loaders(pg_type_rows: list[list[bytes | None]], cache: dict[int, Any], wanted: Sequence[int]) -> None:
382
+ """Fill ``cache`` (oid -> loader or None) for ``wanted``.
383
+
384
+ ``pg_type_rows`` are rows of (oid, nspname, typname, typelem, typcategory) for the wanted
385
+ types and their element types. Arrays get a loader when their element type has one.
386
+ """
387
+ info: dict[int, tuple[str, str, int, str]] = {}
388
+ for oid_b, nsp_b, name_b, elem_b, cat_b in pg_type_rows:
389
+ info[int(_nn(oid_b))] = (_nn(nsp_b).decode(), _nn(name_b).decode(), int(_nn(elem_b)), _nn(cat_b).decode())
390
+
391
+ def loader_for(oid: int, depth: int = 0) -> Callable[[str], Any] | None:
392
+ if oid in cache:
393
+ return cache[oid]
394
+ result: Callable[[str], Any] | None = None
395
+ entry = info.get(oid)
396
+ if entry is not None and depth < 4:
397
+ nsp, name, elem, cat = entry
398
+ if cat == "A" and elem:
399
+ element = loader_for(elem, depth + 1)
400
+ if element is not None:
401
+ result = lambda s, _e=element: parse_array(s, _e) # noqa: E731
402
+ if result is None:
403
+ result = loader_for_name(nsp, name)
404
+ cache[oid] = result
405
+ return result
406
+
407
+ for oid in wanted:
408
+ loader_for(int(oid))
409
+
410
+
411
+
412
+ def load_row(raw: Sequence[bytes | None], loaders: Sequence[Any]) -> tuple:
413
+ out: list[Any] = []
414
+ for v, ld in zip(raw, loaders):
415
+ if v is None:
416
+ out.append(None)
417
+ elif type(ld) is BinaryColumn:
418
+ out.append(ld.fn(v))
419
+ else:
420
+ out.append(v.decode() if ld is None else ld(v.decode()))
421
+ return tuple(out)
422
+
423
+
424
+ def binary_param_plan(server_oids: Sequence[int], values: Sequence[Any]) -> tuple[list[int], list[bytes | None]]:
425
+ """Per parameter: (format, bytes). Binary when the server's type has a binary encoder for the
426
+ value; otherwise text, which the server parses exactly as it would without binary mode."""
427
+ from ._binary import encode_scalar
428
+ formats: list[int] = []
429
+ payloads: list[bytes | None] = []
430
+ for oid, value in zip(server_oids, values):
431
+ if value is None:
432
+ formats.append(0)
433
+ payloads.append(None)
434
+ continue
435
+ data = encode_scalar(value, oid)
436
+ if data is not None:
437
+ formats.append(1)
438
+ payloads.append(data)
439
+ else:
440
+ formats.append(0)
441
+ payloads.append(dump(value)[1])
442
+ return formats, payloads
pgtern/adapters.py ADDED
@@ -0,0 +1,85 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Custom type adapters: register how your own Python types go to and from the database.
3
+
4
+ Loaders are registered by PostgreSQL type name (``"mood"`` or ``"public.mood"``) and apply
5
+ to results (and to arrays of that type). Dumpers are registered by Python class and apply to
6
+ parameters. Registrations are process-wide.
7
+
8
+ from pgtern import adapters
9
+
10
+ adapters.register_loader("mood", adapters.enum_loader(Mood))
11
+ adapters.register_loader("address", adapters.record_loader(Address, (str, int)))
12
+ adapters.register_loader("span", adapters.range_loader(int))
13
+ adapters.register_dumper(Money, lambda m: f"{m.cents / 100:.2f}")
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from enum import Enum
18
+ from typing import Any, Callable, Sequence
19
+
20
+ from . import _types
21
+ from ._composite import Range, format_range, format_record, parse_range, parse_record
22
+
23
+ __all__ = [
24
+ "register_loader", "unregister_loader", "register_dumper", "unregister_dumper",
25
+ "enum_loader", "record_loader", "range_loader", "range_dumper", "record_dumper",
26
+ "Range", "parse_record", "format_record", "parse_range", "format_range",
27
+ ]
28
+
29
+
30
+ def register_loader(type_name: str, loader: Callable[[str], Any]) -> None:
31
+ """``loader(text)`` builds the Python value from the server's text form of the type."""
32
+ _types._LOADERS[type_name] = loader
33
+
34
+
35
+ def unregister_loader(type_name: str) -> None:
36
+ _types._LOADERS.pop(type_name, None)
37
+
38
+
39
+ def register_dumper(cls: type, dumper: Callable[[Any], str | None]) -> None:
40
+ """``dumper(value)`` returns the text form (``None`` for SQL NULL). Sent untyped, so the
41
+ server infers the type from context (``col = %s``); add a cast in SQL if needed."""
42
+ _types._DUMPERS[cls] = dumper
43
+
44
+
45
+ def unregister_dumper(cls: type) -> None:
46
+ _types._DUMPERS.pop(cls, None)
47
+
48
+
49
+ def enum_loader(enum_cls: type[Enum]) -> Callable[[str], Enum]:
50
+ return lambda text: enum_cls(text)
51
+
52
+
53
+ def record_loader(factory: Callable[..., Any], fields: Sequence[Callable[[str], Any] | type | None] = ()) -> Callable[[str], Any]:
54
+ """Build ``factory(*fields)`` from a composite value. ``fields`` converts each text field
55
+ (e.g. ``(str, int)``); a missing or ``None`` converter keeps the text, NULL stays ``None``."""
56
+ def load(text: str) -> Any:
57
+ values = parse_record(text)
58
+ converted = []
59
+ for i, v in enumerate(values):
60
+ conv = fields[i] if i < len(fields) else None
61
+ converted.append(v if v is None or conv is None else conv(v))
62
+ return factory(*converted)
63
+ return load
64
+
65
+
66
+ def _dumped_text(value: Any) -> str:
67
+ _oid, data = _types.dump(value)
68
+ if data is None:
69
+ raise TypeError(f"{type(value).__name__} cannot be sent as text")
70
+ return data.decode()
71
+
72
+
73
+ def record_dumper(getter: Callable[[Any], Sequence[Any]]) -> Callable[[Any], str]:
74
+ """``getter(obj)`` returns the fields; each is sent in its text form (``None`` is NULL)."""
75
+ def dump(obj: Any) -> str:
76
+ return format_record([None if v is None else _dumped_text(v) for v in getter(obj)])
77
+ return dump
78
+
79
+
80
+ def range_loader(element: Callable[[str], Any] | None = None) -> Callable[[str], Range]:
81
+ return lambda text: parse_range(text, element)
82
+
83
+
84
+ def range_dumper() -> Callable[[Range], str]:
85
+ return lambda r: format_range(r, _dumped_text)