Coverage for lintro / utils / formatting.py: 88%

74 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Formatting utilities for core output. 

2 

3Includes helpers to read multi-section ASCII art files and normalize 

4ASCII blocks to a fixed size (width/height) while preserving shape. 

5""" 

6 

7import secrets 

8from pathlib import Path 

9 

10 

11def read_ascii_art(filename: str) -> list[str]: 

12 """Read ASCII art from a file. 

13 

14 Args: 

15 filename: Name of the ASCII art file. 

16 

17 Returns: 

18 List of lines from one randomly selected ASCII art section. 

19 """ 

20 try: 

21 # Get the path to the ASCII art file 

22 ascii_art_dir: Path = Path(__file__).parent.parent / "ascii-art" 

23 file_path: Path = ascii_art_dir / filename 

24 

25 # Read the file and parse sections 

26 with file_path.open("r", encoding="utf-8") as f: 

27 lines: list[str] = [line.rstrip() for line in f.readlines()] 

28 

29 # Find non-empty sections (separated by empty lines) 

30 sections: list[list[str]] = [] 

31 current_section: list[str] = [] 

32 

33 for line in lines: 

34 if line.strip(): 

35 current_section.append(line) 

36 elif current_section: 

37 sections.append(current_section) 

38 current_section = [] 

39 

40 # Add the last section if it's not empty 

41 if current_section: 

42 sections.append(current_section) 

43 

44 # Return a random section if there are multiple, otherwise return all lines 

45 if sections: 

46 # Use ``secrets.choice`` to avoid Bandit B311; cryptographic 

47 # strength is not required here, but this silences the warning. 

48 return secrets.choice(sections) 

49 return lines 

50 except (FileNotFoundError, OSError): 

51 # Return empty list if file not found or can't be read 

52 return [] 

53 

54 

55def normalize_ascii_block( 

56 lines: list[str], 

57 *, 

58 width: int, 

59 height: int, 

60 align: str = "center", 

61 valign: str = "middle", 

62) -> list[str]: 

63 """Normalize an ASCII block to a fixed width/height. 

64 

65 Lines are trimmed on the right only (rstrip), then padded to ``width``. 

66 If a line exceeds ``width``, it is truncated. The whole block is then 

67 vertically padded/truncated to ``height``. 

68 

69 Args: 

70 lines: Original ASCII block lines. 

71 width: Target width in characters. 

72 height: Target height in lines. 

73 align: Horizontal alignment: 'left', 'center', or 'right'. 

74 valign: Vertical alignment: 'top', 'middle', or 'bottom'. 

75 

76 Returns: 

77 list[str]: Normalized lines of length == ``height`` where each line 

78 has exactly ``width`` characters. 

79 """ 

80 if width <= 0 or height <= 0: 

81 return [] 

82 

83 def _pad_line(s: str) -> str: 

84 s = s.rstrip("\n").rstrip() 

85 # Truncate if necessary 

86 if len(s) > width: 

87 return s[:width] 

88 space = width - len(s) 

89 if align == "left": 

90 return s + (" " * space) 

91 if align == "right": 

92 return (" " * space) + s 

93 # center 

94 left = space // 2 

95 right = space - left 

96 return (" " * left) + s + (" " * right) 

97 

98 padded_lines: list[str] = [_pad_line(line) for line in lines] 

99 

100 # Vertical pad/truncate 

101 if len(padded_lines) >= height: 

102 # Truncate based on valign 

103 if valign == "top": 

104 return padded_lines[:height] 

105 if valign == "bottom": 

106 return padded_lines[-height:] 

107 # middle 

108 extra = len(padded_lines) - height 

109 top_cut = extra // 2 

110 return padded_lines[top_cut : top_cut + height] 

111 

112 # Need to add blank lines 

113 blank = " " * width 

114 missing = height - len(padded_lines) 

115 if valign == "top": 

116 return padded_lines + [blank] * missing 

117 if valign == "bottom": 

118 return [blank] * missing + padded_lines 

119 top_pad = missing // 2 

120 bottom_pad = missing - top_pad 

121 return [blank] * top_pad + padded_lines + [blank] * bottom_pad 

122 

123 

124def normalize_ascii_file_sections( 

125 file_path: Path, 

126 *, 

127 width: int, 

128 height: int, 

129 align: str = "center", 

130 valign: str = "middle", 

131) -> list[list[str]]: 

132 """Read a multi-section ASCII file and normalize all sections. 

133 

134 Sections are separated by empty lines. Each section is normalized 

135 independently and returned as a list of lines. 

136 

137 Args: 

138 file_path: Path to the ASCII art file. 

139 width: Target width. 

140 height: Target height. 

141 align: Horizontal alignment. 

142 valign: Vertical alignment. 

143 

144 Returns: 

145 list[list[str]]: List of normalized sections. 

146 """ 

147 try: 

148 with file_path.open("r", encoding="utf-8") as f: 

149 raw_lines: list[str] = [line.rstrip("\n") for line in f] 

150 except (FileNotFoundError, OSError): 

151 return [] 

152 

153 sections: list[list[str]] = [] 

154 current: list[str] = [] 

155 for line in raw_lines: 

156 if line.strip() == "": 

157 if current: 

158 sections.append(current) 

159 current = [] 

160 else: 

161 current.append(line) 

162 if current: 

163 sections.append(current) 

164 

165 normalized: list[list[str]] = [ 

166 normalize_ascii_block( 

167 sec, 

168 width=width, 

169 height=height, 

170 align=align, 

171 valign=valign, 

172 ) 

173 for sec in sections 

174 ] 

175 return normalized