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

1# SPDX-FileCopyrightText: 2025-present Trey Hunner 

2# 

3# SPDX-License-Identifier: MIT 

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

5 

6import re 

7import textwrap 

8from string import Formatter 

9from string.templatelib import Interpolation, Template 

10 

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

12 

13 

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 

20 

21 

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

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)