Coverage for src/better_dedent/__init__.py: 98%
38 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-06 11:04 -0700
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-06 11:04 -0700
1# SPDX-FileCopyrightText: 2025-present Trey Hunner
2#
3# SPDX-License-Identifier: MIT
4"""better_dedent - textwrap.dedent, with sensible t-string support"""
6import re
7import textwrap
8from string import Formatter
9from string.templatelib import Interpolation, Template
11__all__ = ["dedent", "undent"]
14def undent(text_or_template: Template, strip_trailing: bool = True) -> str:
15 """Dedent and strip leading (and optionally trailing) newline."""
16 text = dedent(text_or_template).removeprefix("\n")
17 if strip_trailing:
18 text = text.removesuffix("\n")
19 return text
22def dedent(text_or_template: Template) -> str:
23 """
24 Like textwrap.dedent but handle t-strings sensibly.
26 Regular strings dedent as usual, but t-strings ensure the
27 interpolated value is inserted only after dedenting.
28 """
29 match text_or_template:
30 case Template():
31 return _dedent_template(text_or_template)
32 case _:
33 return textwrap.dedent(text_or_template)
36# https://discuss.python.org/t/add-convert-function-to-string-templatelib/94569/10
37_convert = classmethod(Formatter.convert_field).__get__(Formatter)
40_INDENT_BEFORE_REPLACEMENT = re.compile(
41 r"""
42 ^ # Beginning of string
43 ( [ \t]+ ) # Indentation
44 .*? # Any characters after indentation
45 (?<! { ) # Previous character must NOT be {
46 (?: {{ )* # Even number of { characters (0, 2, 4, etc.)
47 { (\d+) } # Replacement group {N} where N is an int
48 """,
49 flags=re.MULTILINE | re.VERBOSE,
50)
53def _dedent_template(template: Template) -> str:
54 replacements = []
55 parts = []
56 n = 0
57 for item in template:
58 match item:
59 case str() as string:
60 # Double-up literal { and } characters for later .format() call
61 parts.append(string.replace("{", "{{").replace("}", "}}"))
62 case Interpolation(value, _, conversion, format_spec): 62 ↛ 57line 62 didn't jump to line 57 because the pattern on line 62 always matched
63 value = _convert(value, conversion)
64 value = format(value, format_spec)
65 replacements.append(value)
66 parts.append("{" + str(n) + "}")
67 n += 1
68 text = dedent("".join(parts))
69 for indentation, n in _INDENT_BEFORE_REPLACEMENT.findall(text):
70 n = int(n)
71 replacements[n] = textwrap.indent(replacements[n], indentation)
72 replacements[n] = replacements[n].removeprefix(indentation)
73 return text.format(*replacements)