Coverage for src/better_dedent/__init__.py: 98%

1# SPDX-FileCopyrightText: 2025-present Trey Hunner

2#

3# SPDX-License-Identifier: MIT

4"""better_dedent - textwrap.dedent, with sensible t-string support"""

5

6 import re

7 import textwrap

8 from string import Formatter

9 from string.templatelib import Interpolation, Template

10

11 __all__ = ["dedent", "undent"]

12

13

14 def 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

20

21

22 def dedent(text_or_template: Template) -> str:

23 """

24 Like textwrap.dedent but handle t-strings sensibly.

25

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)

34

35

36# https://discuss.python.org/t/add-convert-function-to-string-templatelib/94569/10

37 _convert = classmethod(Formatter.convert_field).__get__(Formatter)

38

39

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)

51

52

53 def _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)