Coverage for src / toolbox_python / output.py: 100%

45 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-02 22:56 +0000

1# ============================================================================ # 

2# # 

3# Title : Output # 

4# Purpose : Streamline how data is outputted. # 

5# Including `print`'ing and `logg`'ing # 

6# # 

7# ============================================================================ # 

8 

9 

10# ---------------------------------------------------------------------------- # 

11# # 

12# Overview #### 

13# # 

14# ---------------------------------------------------------------------------- # 

15 

16 

17# ---------------------------------------------------------------------------- # 

18# Description #### 

19# ---------------------------------------------------------------------------- # 

20 

21 

22""" 

23!!! note "Summary" 

24 The `output` module is for streamlining how data is outputted. 

25 This includes `#!py print()`'ing to the terminal and `#!py log()`'ing to files. 

26""" 

27 

28 

29# ---------------------------------------------------------------------------- # 

30# # 

31# Setup #### 

32# # 

33# ---------------------------------------------------------------------------- # 

34 

35 

36# ---------------------------------------------------------------------------- # 

37# Imports #### 

38# ---------------------------------------------------------------------------- # 

39 

40 

41# ## Python StdLib Imports ---- 

42from collections.abc import Collection, Generator 

43from logging import Logger, _nameToLevel 

44from math import ceil 

45from typing import Any, Literal, Optional, Union, overload 

46 

47# ## Python Third Party Imports ---- 

48from typeguard import typechecked 

49 

50# ## Local First Party Imports ---- 

51from toolbox_python.checkers import ( 

52 assert_all_is_type, 

53 assert_is_type, 

54 assert_is_valid, 

55 is_type, 

56) 

57 

58 

59# ---------------------------------------------------------------------------- # 

60# Exports #### 

61# ---------------------------------------------------------------------------- # 

62 

63 

64__all__: list[str] = ["print_or_log_output", "list_columns", "log_levels"] 

65 

66 

67## --------------------------------------------------------------------------- # 

68## Constants #### 

69## --------------------------------------------------------------------------- # 

70 

71 

72log_levels = Literal["debug", "info", "warning", "error", "critical"] 

73""" 

74!!! note "Summary" 

75 To streamline other functions, this `type` alias is created for all of the `log` levels available. 

76!!! abstract "Details" 

77 The structure of the `type` is as follows: 

78 ```pycon {.py .python linenums="1" title="Type structure"} 

79 Literal["debug", "info", "warning", "error", "critical"] 

80 ``` 

81""" 

82 

83 

84# ---------------------------------------------------------------------------- # 

85# # 

86# Functions #### 

87# # 

88# ---------------------------------------------------------------------------- # 

89 

90 

91@overload 

92def print_or_log_output( 

93 message: str, 

94 print_or_log: Literal["print"], 

95) -> None: ... 

96@overload 

97def print_or_log_output( 

98 message: str, 

99 print_or_log: Literal["log"], 

100 *, 

101 log: Logger, 

102 log_level: log_levels = "info", 

103) -> None: ... 

104@overload 

105def print_or_log_output( 

106 message: str, 

107 print_or_log: Optional[Literal["print", "log"]] = None, 

108 *, 

109 log: Optional[Logger] = None, 

110 log_level: Optional[log_levels] = None, 

111) -> None: ... 

112@typechecked 

113def print_or_log_output( 

114 message: str, 

115 print_or_log: Optional[Literal["print", "log"]] = "print", 

116 *, 

117 log: Optional[Logger] = None, 

118 log_level: Optional[log_levels] = None, 

119) -> None: 

120 """ 

121 !!! note "Summary" 

122 Determine whether to `#!py print()` or `#!py log()` a given `message`. 

123 

124 Params: 

125 message (str): 

126 The `message` to be processed. 

127 print_or_log (Optional[Literal["print", "log"]], optional): 

128 The option for what to do with the `message`.<br> 

129 Defaults to `#!py "print"`. 

130 log (Optional[Logger], optional): 

131 If `#!py print_or_log=="log"`, then this parameter must contain the `#!py Logger` object to be processed, 

132 otherwise it will raise an `#!py AssertError`.<br> 

133 Defaults to `#!py None`. 

134 log_level (Optional[log_levels], optional): 

135 If `#!py print_or_log=="log"`, then this parameter must contain the required log level for the `message`. 

136 Must be one of the log-levels available in the `#!py logging` module.<br> 

137 Defaults to `#!py None`. 

138 

139 Raises: 

140 (TypeCheckError): 

141 If any of the inputs parsed to the parameters of this function are not the correct type. Uses the [`@typeguard.typechecked`](https://typeguard.readthedocs.io/en/stable/api.html#typeguard.typechecked) decorator. 

142 (AssertError): 

143 If `#!py print_or_log=="log"` and `#!py log` is not an instance of `#!py Logger`. 

144 

145 Returns: 

146 (None): 

147 Nothing is returned. Only printed or logged. 

148 

149 ???+ example "Examples" 

150 

151 ```pycon {.py .python linenums="1" title="Set up data for examples"} 

152 >>> from toolbox_python.output import print_or_log_output 

153 >>> import logging 

154 >>> logging.basicConfig(filename="logs.log", encoding="utf-8") 

155 >>> log = logging.getLogger("root") 

156 >>> default_message = "This is a" 

157 ``` 

158 

159 ```pycon {.py .python linenums="1" title="Example 1: Print output"} 

160 >>> print_or_log_output( 

161 ... message=f"{default_message} print", 

162 ... print_or_log="print", 

163 ... ) 

164 ``` 

165 <div class="result" markdown> 

166 ```{.txt .text title="Terminal"} 

167 This is a print 

168 ``` 

169 !!! success "Conclusion: Successfully printed the message." 

170 </div> 

171 

172 ```pycon {.py .python linenums="1" title="Example 2: Log `info`"} 

173 >>> print_or_log_output( 

174 ... message=f"{default_message}n info", 

175 ... print_or_log="log", 

176 ... log=log, 

177 ... log_level="info", 

178 ... ) 

179 ``` 

180 <div class="result" markdown> 

181 ```{.log .log title="logs.log"} 

182 INFO:root:This is an info 

183 ``` 

184 !!! success "Conclusion: Successfully logged the message." 

185 </div> 

186 

187 ```pycon {.py .python linenums="1" title="Example 3: Log `debug`"} 

188 >>> print_or_log_output( 

189 ... message=f"{default_message} debug", 

190 ... print_or_log="log", 

191 ... log=log, 

192 ... log_level="debug", 

193 ... ) 

194 ``` 

195 <div class="result" markdown> 

196 ```{.log .log title="logs.log"} 

197 INFO:root:This is an info 

198 DEBUG:root:This is a debug 

199 ``` 

200 !!! success "Conclusion: Successfully added message to logs." 

201 !!! observation "Note: This logging structure will continue for every new call to `print_or_log_output()` when `print_or_log="log"`, and the `log` and `log_level` parameters are valid." 

202 </div> 

203 

204 ```pycon {.py .python linenums="1" title="Example 7: Invalid `print_or_log` input"} 

205 >>> print_or_log_output( 

206 ... message=f"{default_message} invalid", 

207 ... print_or_log="error", 

208 ... ) 

209 ``` 

210 <div class="result" markdown> 

211 ```{.txt .text title="Terminal"} 

212 TypeError: ... 

213 ``` 

214 !!! failure "Conclusion: `print_or_log` can only have the string values `"print"` or `"log"`." 

215 </div> 

216 

217 ```pycon {.py .python linenums="1" title="Example 8: Invalid `log` input"} 

218 >>> print_or_log_output( 

219 ... message=f"{default_message} invalid", 

220 ... print_or_log="log", 

221 ... log=None, 

222 ... log_level="info", 

223 ... ) 

224 ``` 

225 <div class="result" markdown> 

226 ```{.txt .text title="Terminal"} 

227 AssertionError: When `print_or_log=='log'` then `log` must be type `Logger`. Here, you have parsed: '<class 'NoneType'>' 

228 ``` 

229 !!! failure "Conclusion: When `print_or_log="log"` then `#!py log` must be an instance of `#!py Logger`." 

230 </div> 

231 

232 ```pycon {.py .python linenums="1" title="Example 9: Invalid `log_level` input"} 

233 >>> print_or_log_output( 

234 ... message=f"{default_message} invalid", 

235 ... print_or_log="log", 

236 ... log=log, 

237 ... log_level="invalid", 

238 ... ) 

239 ``` 

240 <div class="result" markdown> 

241 ```{.txt .text title="Terminal"} 

242 TypeError: ... 

243 ``` 

244 !!! failure "Conclusion: `log_level` must be a valid log level from the `logging` module." 

245 </div> 

246 """ 

247 

248 # Early exit when printing the message 

249 if print_or_log == "print": 

250 print(message) 

251 return None 

252 

253 # Check in put for logging 

254 if not is_type(log, Logger): 

255 raise TypeError( 

256 f"When `print_or_log=='log'` then `log` must be type `Logger`. " f"Here, you have parsed: '{type(log)}'" 

257 ) 

258 if log_level is None: 

259 raise ValueError( 

260 f"When `print_or_log=='log'` then `log_level` must be parsed " f"with a valid value from: {log_levels}." 

261 ) 

262 

263 # Assertions to keep `mypy` happy 

264 assert print_or_log is not None 

265 assert log is not None 

266 assert log_level is not None 

267 

268 # Do logging 

269 log.log( 

270 level=_nameToLevel[log_level.upper()], 

271 msg=message, 

272 ) 

273 

274 # Return 

275 return None 

276 

277 

278@typechecked 

279def list_columns( 

280 obj: Union[Collection[Any], Generator], 

281 cols_wide: int = 4, 

282 columnwise: bool = True, 

283 gap: int = 4, 

284 print_output: Literal[True, False] = False, 

285) -> Optional[str]: 

286 """ 

287 !!! note "Summary" 

288 Print the given list in evenly-spaced columns. 

289 

290 Params: 

291 obj (Union[Collection[Any], Generator]): 

292 The list to be formatted. 

293 

294 cols_wide (int, optional): 

295 The number of columns in which the list should be formatted.<br> 

296 Defaults to: `#!py 4`. 

297 

298 columnwise (bool, optional): 

299 Whether or not to print columnwise or rowwise. 

300 

301 - `#!py True`: Will be formatted column-wise. 

302 - `#!py False`: Will be formatted row-wise. 

303 

304 Defaults to: `#!py True`. 

305 

306 gap (int, optional): 

307 The number of spaces that should separate the longest column 

308 item/s from the next column. This is the effective spacing 

309 between columns based on the maximum `#!py len()` of the list items.<br> 

310 Defaults to: `#!py 4`. 

311 

312 print_output (Literal[True, False], optional): 

313 Whether or not to print the output to the terminal. 

314 

315 - `#!py True`: Will print and return. 

316 - `#!py False`: Will not print; only return. 

317 

318 Defaults to: `#!py True`. 

319 

320 Raises: 

321 (TypeCheckError): 

322 If any of the inputs parsed to the parameters of this function are not the correct type. Uses the [`@typeguard.typechecked`](https://typeguard.readthedocs.io/en/stable/api.html#typeguard.typechecked) decorator. 

323 (TypeError): 

324 If `#!py obj` is not a valid type. Must be one of: `#!py list`, `#!py set`, `#!py tuple`, or `#!py Generator`. 

325 (ValueError): 

326 If `#!py cols_wide` is not greater than `0`, or if `#!py gap` is not greater than `0`. 

327 

328 Returns: 

329 printer (Optional[str]): 

330 The formatted string object. 

331 

332 ???+ example "Examples" 

333 

334 ```pycon {.py .python linenums="1" title="Set up"} 

335 >>> # Imports 

336 >>> from toolbox_python.output import list_columns 

337 >>> import requests 

338 >>> 

339 >>> # Define function to fetch list of words 

340 >>> def get_list_of_words(num_words: int = 100): 

341 ... word_url = "https://www.mit.edu/~ecprice/wordlist.10000" 

342 ... response = requests.get(word_url) 

343 ... words = response.content.decode().splitlines() 

344 ... return words[:num_words] 

345 ... 

346 ``` 

347 

348 ```pycon {.py .python linenums="1" title="Example 1: Default parameters"} 

349 >>> list_columns(get_list_of_words(4 * 5)) 

350 ``` 

351 <div class="result" markdown> 

352 ```{.txt .text title="Terminal"} 

353 a abandoned able abraham 

354 aa abc aboriginal abroad 

355 aaa aberdeen abortion abs 

356 aaron abilities about absence 

357 ab ability above absent 

358 ``` 

359 !!! success "Conclusion: Successfully printed the list in columns." 

360 </div> 

361 

362 ```pycon {.py .python linenums="1" title="Example 2: Columnwise with 2 columns"} 

363 >>> list_columns( 

364 ... get_list_of_words(5), 

365 ... cols_wide=2, 

366 ... columnwise=True, 

367 ... ) 

368 ``` 

369 <div class="result" markdown> 

370 ```{.txt .text title="Terminal"} 

371 a aaron 

372 aa ab 

373 aaa 

374 ``` 

375 !!! success "Conclusion: Successfully printed the list in columns." 

376 </div> 

377 

378 ```pycon {.py .python linenums="1" title="Example 3: Rowwise with 3 columns"} 

379 >>> list_columns( 

380 ... get_list_of_words(4 * 3), 

381 ... columnwise=False, 

382 ... cols_wide=3, 

383 ... print_output=True, 

384 ... ) 

385 ``` 

386 <div class="result" markdown> 

387 ```{.txt .text title="Terminal"} 

388 a aa aaa 

389 aaron ab abandoned 

390 abc aberdeen abilities 

391 ability able aboriginal 

392 ``` 

393 !!! success "Conclusion: Successfully printed the list in rows." 

394 </div> 

395 

396 ```pycon {.py .python linenums="1" title="Example 4: Rowwise with 2 columns, no print output"} 

397 >>> output = list_columns( 

398 ... get_list_of_words(4 * 2), 

399 ... columnwise=False, 

400 ... cols_wide=2, 

401 ... print_output=False, 

402 ... ) 

403 >>> print(output) 

404 ``` 

405 <div class="result" markdown> 

406 ```{.txt .text title="Terminal"} 

407 a aa 

408 aaa aaron 

409 ab abandoned 

410 abc aberdeen 

411 ``` 

412 !!! success "Conclusion: Successfully returned the formatted string." 

413 </div> 

414 

415 ??? Success "Credit" 

416 Full credit goes to:<br> 

417 https://stackoverflow.com/questions/1524126/how-to-print-a-list-more-nicely#answer-36085705 

418 """ 

419 

420 # Validations 

421 assert_is_type(obj, (list, set, tuple, Generator)) 

422 assert_all_is_type((cols_wide, gap), int) 

423 assert_all_is_type((columnwise, print_output), bool) 

424 assert_is_valid(cols_wide, ">", 0) 

425 assert_is_valid(gap, ">", 0) 

426 

427 # Prepare the string representation of the object 

428 string_list: list[str] = [str(item) for item in obj] 

429 cols_wide = min(cols_wide, len(string_list)) 

430 max_len: int = max(len(item) for item in string_list) 

431 

432 # Adjust column width if column-wise output 

433 if columnwise: 

434 cols_wide = int(ceil(len(string_list) / cols_wide)) 

435 

436 # Segment the list into chunks 

437 segmented_list: list[list[str]] = [ 

438 string_list[index : index + cols_wide] for index in range(0, len(string_list), cols_wide) 

439 ] 

440 

441 # Ensure the last segment has the correct number of columns 

442 if columnwise: 

443 if len(segmented_list[-1]) != cols_wide: 

444 segmented_list[-1].extend([""] * (len(string_list) - len(segmented_list[-1]))) 

445 combined_list: Union[list[list[str]], Any] = zip(*segmented_list) 

446 else: 

447 combined_list = segmented_list 

448 

449 # Create the formatted string with proper spacing 

450 printer: str = "\n".join(["".join([element.ljust(max_len + gap) for element in group]) for group in combined_list]) 

451 

452 # Print the output if requested 

453 if print_output: 

454 print(printer) 

455 return printer