Coverage for sources/ictruck/recipes/sundae.py: 100%

140 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-05 05:21 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Recipe for advanced formatters. 

22 

23 .. note:: 

24 

25 To use this module, you must have the ``rich`` package installed. 

26''' 

27 

28# TODO? Allow selection of trace color gradients. 

29 

30 

31 

32from rich.console import Console as _Console 

33from rich.style import Style as _Style 

34 

35from . import __ 

36 

37 

38_validate_arguments = ( 

39 __.validate_arguments( 

40 globalvars = globals( ), 

41 errorclass = __.exceptions.ArgumentClassInvalidity ) ) 

42 

43 

44InterpolantsStylesRegistry: __.typx.TypeAlias = ( 

45 __.accret.Dictionary[ str, _Style ] ) 

46 

47 

48class Auxiliaries( __.immut.DataclassObject ): 

49 ''' Auxiliary functions used by formatters and interpolation. 

50 

51 Typically used by unit tests to inject mock dependencies, 

52 but can also be used to deeply customize output. 

53 ''' 

54 

55 exc_info_discoverer: __.typx.Annotated[ 

56 __.typx.Callable[ [ ], __.ExceptionInfo ], 

57 __.typx.Doc( ''' Returns information on current exception. ''' ), 

58 ] = __.sys.exc_info 

59 pid_discoverer: __.typx.Annotated[ 

60 __.typx.Callable[ [ ], int ], 

61 __.typx.Doc( ''' Returns ID of current process. ''' ), 

62 ] = __.os.getpid 

63 thread_discoverer: __.typx.Annotated[ 

64 __.typx.Callable[ [ ], __.threads.Thread ], 

65 __.typx.Doc( ''' Returns current thread. ''' ), 

66 ] = __.threads.current_thread 

67 time_formatter: __.typx.Annotated[ 

68 __.typx.Callable[ [ str ], str ], 

69 __.typx.Doc( ''' Returns current time in specified format. ''' ), 

70 ] = __.time.strftime 

71 

72 

73 

74class FlavorSpecification( __.immut.DataclassObject ): 

75 ''' Specification for custom flavor. ''' 

76 

77 color: __.typx.Annotated[ 

78 str, __.typx.Doc( ''' Name of prefix color. ''' ) ] 

79 emoji: __.typx.Annotated[ str, __.typx.Doc( ''' Prefix emoji. ''' ) ] 

80 label: __.typx.Annotated[ str, __.typx.Doc( ''' Prefix label. ''' ) ] 

81 stack: __.typx.Annotated[ 

82 bool, __.typx.Doc( ''' Include stack trace? ''' ) 

83 ] = False 

84 

85 

86class PrefixDecorations( __.enum.IntFlag ): 

87 ''' Decoration styles for prefix emission. ''' 

88 

89 Plain = 0 

90 Color = __.enum.auto( ) 

91 Emoji = __.enum.auto( ) 

92 

93 

94class PrefixLabelPresentations( __.enum.IntFlag ): 

95 ''' How prefix label should be presented. ''' 

96 

97 Nothing = 0 

98 Words = __.enum.auto( ) 

99 Emoji = __.enum.auto( ) 

100 

101 

102class PrefixFormatControl( __.immut.DataclassObject ): 

103 ''' Format control for prefix emission. ''' 

104 

105 colorize: __.typx.Annotated[ 

106 bool, __.typx.Doc( ''' Attempt to colorize? ''' ) 

107 ] = True 

108 label_as: __.typx.Annotated[ 

109 PrefixLabelPresentations, 

110 __.typx.Doc( 

111 ''' How to present prefix label. 

112 

113 ``Words``: As words like ``TRACE0`` or ``ERROR``. 

114 ``Emoji``: As emoji like ``🔎`` or ``❌``. 

115 

116 For both emoji and words: ``Emoji | Words``. 

117 ''' ) 

118 ] = PrefixLabelPresentations.Words 

119 styles: __.typx.Annotated[ 

120 InterpolantsStylesRegistry, 

121 __.typx.Doc( 

122 ''' Mapping of interpolant names to ``rich`` style objects. ''' ), 

123 ] = __.dcls.field( default_factory = InterpolantsStylesRegistry ) 

124 template: __.typx.Annotated[ 

125 str, 

126 __.typx.Doc( 

127 ''' String format for prefix. 

128 

129 The following interpolants are supported: 

130 ``flavor``: Decorated flavor. 

131 ``module_qname``: Qualified name of invoking module. 

132 ``timestamp``: Current timestamp, formatted as string. 

133 ``process_id``: ID of current process according to OS kernel. 

134 ``thread_id``: ID of current thread. 

135 ``thread_name``: Name of current thread. 

136 ''' ), 

137 ] = "{flavor}| " # "{timestamp} [{module_qname}] {flavor}| " 

138 ts_format: __.typx.Annotated[ 

139 str, 

140 __.typx.Doc( 

141 ''' String format for prefix timestamp. 

142 

143 Used by :py:func:`time.strftime` or equivalent. 

144 ''' ), 

145 ] = '%Y-%m-%d %H:%M:%S.%f' 

146 

147 

148ProduceModulecfgAuxiliariesArgument: __.typx.TypeAlias = __.typx.Annotated[ 

149 __.Absential[ Auxiliaries ], 

150 __.typx.Doc( ''' Auxiliary functions for formatting. ''' ), 

151] 

152ProduceModulecfgColorizeArgument: __.typx.TypeAlias = __.typx.Annotated[ 

153 __.Absential[ bool ], 

154 __.typx.Doc( ''' Attempt to colorize output prefixes? ''' ), 

155] 

156ProduceModulecfgConsoleFactoryArgument: __.typx.TypeAlias = __.typx.Annotated[ 

157 __.Absential[ __.typx.Callable[ [ ], _Console ] ], 

158 __.typx.Doc( 

159 ''' Factory function that produces Rich console instances. ''' ), 

160] 

161ProduceModulecfgPrefixLabelAsArgument: __.typx.TypeAlias = __.typx.Annotated[ 

162 __.Absential[ PrefixLabelPresentations ], 

163 __.typx.Doc( 

164 ''' How to present prefix labels (words, emoji, or both). ''' ), 

165] 

166ProduceModulecfgPrefixStylesArgument: __.typx.TypeAlias = __.typx.Annotated[ 

167 __.Absential[ __.cabc.Mapping[ str, _Style ] ], 

168 __.typx.Doc( ''' Mapping of interpolant names to Rich style objects. ''' ), 

169] 

170ProduceModulecfgPrefixTemplateArgument: __.typx.TypeAlias = __.typx.Annotated[ 

171 __.Absential[ str ], 

172 __.typx.Doc( ''' String template for prefix formatting. ''' ), 

173] 

174ProduceModulecfgPrefixTsFormatArgument: __.typx.TypeAlias = __.typx.Annotated[ 

175 __.Absential[ str ], 

176 __.typx.Doc( ''' Timestamp format string for prefix. ''' ), 

177] 

178 

179 

180_flavor_specifications: __.immut.Dictionary[ 

181 str, FlavorSpecification 

182] = __.immut.Dictionary( 

183 note = FlavorSpecification( 

184 color = 'blue', 

185 emoji = '\N{Information Source}\ufe0f', 

186 label = 'NOTE' ), 

187 monition = FlavorSpecification( 

188 color = 'yellow', 

189 emoji = '\N{Warning Sign}\ufe0f', 

190 label = 'MONITION' ), 

191 error = FlavorSpecification( 

192 color = 'red', emoji = '❌', label = 'ERROR' ), 

193 errorx = FlavorSpecification( 

194 color = 'red', emoji = '❌', label = 'ERROR', stack = True ), 

195 abort = FlavorSpecification( 

196 color = 'bright_red', emoji = '💥', label = 'ABORT' ), 

197 abortx = FlavorSpecification( 

198 color = 'bright_red', emoji = '💥', label = 'ABORT', stack = True ), 

199 future = FlavorSpecification( 

200 color = 'magenta', emoji = '🔮', label = 'FUTURE' ), 

201 success = FlavorSpecification( 

202 color = 'green', emoji = '✅', label = 'SUCCESS' ), 

203) 

204 

205_flavor_aliases: __.immut.Dictionary[ 

206 str, str 

207] = __.immut.Dictionary( { 

208 'n': 'note', 'm': 'monition', 

209 'e': 'error', 'a': 'abort', 

210 'ex': 'errorx', 'ax': 'abortx', 

211 'f': 'future', 's': 'success', 

212} ) 

213 

214_trace_color_names: tuple[ str, ... ] = ( 

215 'grey85', 'grey82', 'grey78', 'grey74', 'grey70', 

216 'grey66', 'grey62', 'grey58', 'grey54', 'grey50' ) 

217 

218_trace_prefix_styles: tuple[ _Style, ... ] = tuple( 

219 _Style( color = name ) for name in _trace_color_names ) 

220 

221 

222def _produce_console( ) -> _Console: # pragma: no cover 

223 # TODO? safe_box = True 

224 # Ideally, we want TTY so that Rich can detect proper attributes. 

225 # Failing that, stream to null device. (Output capture should still work.) 

226 for stream in ( __.sys.stderr, __.sys.stdout ): 

227 if not stream.isatty( ): continue 

228 return _Console( stderr = stream is __.sys.stderr ) 

229 blackhole = open( # noqa: SIM115 

230 __.os.devnull, 'w', encoding = __.locale.getpreferredencoding( ) ) 

231 # TODO? height = 24, width = 80 

232 return _Console( file = blackhole, force_terminal = True ) 

233 

234 

235@_validate_arguments 

236def produce_module_configuration( # noqa: PLR0913 

237 colorize: ProduceModulecfgColorizeArgument = __.absent, 

238 prefix_label_as: ProduceModulecfgPrefixLabelAsArgument = __.absent, 

239 prefix_styles: ProduceModulecfgPrefixStylesArgument = __.absent, 

240 prefix_template: ProduceModulecfgPrefixTemplateArgument = __.absent, 

241 prefix_ts_format: ProduceModulecfgPrefixTsFormatArgument = __.absent, 

242 console_factory: ProduceModulecfgConsoleFactoryArgument = __.absent, 

243 auxiliaries: ProduceModulecfgAuxiliariesArgument = __.absent, 

244) -> __.ModuleConfiguration: 

245 ''' Produces module configuration with sundae-specific flavor settings. ''' 

246 if __.is_absent( console_factory ): console_factory = _produce_console 

247 if __.is_absent( auxiliaries ): auxiliaries = Auxiliaries( ) 

248 console = console_factory( ) 

249 prefix_fmtctl_initargs: dict[ str, __.typx.Any ] = { } 

250 if not __.is_absent( colorize ): 

251 prefix_fmtctl_initargs[ 'colorize' ] = colorize 

252 if not __.is_absent( prefix_label_as ): 

253 prefix_fmtctl_initargs[ 'label_as' ] = prefix_label_as 

254 if not __.is_absent( prefix_styles ): 

255 prefix_fmtctl_initargs[ 'styles' ] = prefix_styles 

256 if not __.is_absent( prefix_template ): 

257 prefix_fmtctl_initargs[ 'template' ] = prefix_template 

258 if not __.is_absent( prefix_ts_format ): 

259 prefix_fmtctl_initargs[ 'ts_format' ] = prefix_ts_format 

260 prefix_fmtctl = PrefixFormatControl( **prefix_fmtctl_initargs ) 

261 flavors = _produce_flavors( console, auxiliaries, prefix_fmtctl ) 

262 formatter_factory = _produce_formatter_factory( console, auxiliaries ) 

263 return __.ModuleConfiguration( 

264 flavors = flavors, formatter_factory = formatter_factory ) 

265 

266 

267@_validate_arguments 

268def register_module( # noqa: PLR0913 

269 name: __.RegisterModuleNameArgument = __.absent, 

270 colorize: ProduceModulecfgColorizeArgument = __.absent, 

271 prefix_label_as: ProduceModulecfgPrefixLabelAsArgument = __.absent, 

272 prefix_styles: ProduceModulecfgPrefixStylesArgument = __.absent, 

273 prefix_template: ProduceModulecfgPrefixTemplateArgument = __.absent, 

274 prefix_ts_format: ProduceModulecfgPrefixTsFormatArgument = __.absent, 

275 console_factory: ProduceModulecfgConsoleFactoryArgument = __.absent, 

276 auxiliaries: ProduceModulecfgAuxiliariesArgument = __.absent, 

277) -> __.ModuleConfiguration: 

278 ''' Registers module with sundae-specific flavor configurations. ''' 

279 configuration = produce_module_configuration( 

280 colorize = colorize, 

281 prefix_label_as = prefix_label_as, 

282 prefix_styles = prefix_styles, 

283 prefix_template = prefix_template, 

284 prefix_ts_format = prefix_ts_format, 

285 console_factory = console_factory, 

286 auxiliaries = auxiliaries ) 

287 return __.register_module( 

288 name = name, 

289 flavors = configuration.flavors, 

290 formatter_factory = configuration.formatter_factory ) 

291 

292 

293def _produce_flavors( 

294 console: _Console, auxiliaries: Auxiliaries, control: PrefixFormatControl 

295) -> __.FlavorsRegistry: 

296 emitter = _produce_prefix_emitter( console, auxiliaries, control ) 

297 flavors: __.FlavorsRegistryLiberal = { } 

298 for name in _flavor_specifications: 

299 flavors[ name ] = __.FlavorConfiguration( prefix_emitter = emitter ) 

300 for alias, name in _flavor_aliases.items( ): 

301 flavors[ alias ] = flavors[ name ] 

302 for level in range( 10 ): 

303 flavors[ level ] = __.FlavorConfiguration( prefix_emitter = emitter ) 

304 return __.immut.Dictionary( flavors ) 

305 

306 

307def _produce_formatter_factory( 

308 console: _Console, auxiliaries: Auxiliaries 

309) -> __.FormatterFactory: 

310 

311 def factory( 

312 control: __.FormatterControl, mname: str, flavor: __.Flavor 

313 ) -> __.Formatter: 

314 

315 def formatter( value: __.typx.Any ) -> str: 

316 tb_text = '' 

317 if isinstance( flavor, str ): 

318 flavor_ = _flavor_aliases.get( flavor, flavor ) 

319 spec = _flavor_specifications[ flavor_ ] 

320 if spec.stack and auxiliaries.exc_info_discoverer( )[ 0 ]: 

321 with console.capture( ) as capture: 

322 console.print_exception( ) 

323 tb_text = capture.get( ) 

324 else: flavor_ = flavor 

325 with console.capture( ) as capture: 

326 console.print( value, end = '' ) 

327 text = capture.get( ) 

328 if tb_text: return f"\n{tb_text}\n{text}" 

329 return text 

330 

331 return formatter 

332 

333 return factory 

334 

335 

336def _produce_prefix_emitter( 

337 console: _Console, auxiliaries: Auxiliaries, control: PrefixFormatControl 

338) -> __.PrefixEmitter: 

339 

340 def emitter( mname: str, flavor: __.Flavor ) -> str: 

341 if isinstance( flavor, int ): 

342 return _produce_trace_prefix( 

343 console, auxiliaries, control, mname, flavor ) 

344 name = _flavor_aliases.get( flavor, flavor ) 

345 return _produce_special_prefix( 

346 console, auxiliaries, control, mname, name ) 

347 

348 return emitter 

349 

350 

351def _produce_special_prefix( 

352 console: _Console, 

353 auxiliaries: Auxiliaries, 

354 control: PrefixFormatControl, 

355 mname: str, 

356 flavor: str, 

357) -> str: 

358 styles = dict( control.styles ) 

359 spec = _flavor_specifications[ flavor ] 

360 label = '' 

361 if control.label_as & PrefixLabelPresentations.Emoji: 

362 if control.label_as & PrefixLabelPresentations.Words: 

363 label = f"{spec.emoji} {spec.label}" 

364 else: label = f"{spec.emoji}" 

365 elif control.label_as & PrefixLabelPresentations.Words: 

366 label = f"{spec.label}" 

367 if control.colorize: styles[ 'flavor' ] = _Style( color = spec.color ) 

368 return _render_prefix( 

369 console, auxiliaries, control, mname, label, styles ) 

370 

371 

372def _produce_trace_prefix( 

373 console: _Console, 

374 auxiliaries: Auxiliaries, 

375 control: PrefixFormatControl, 

376 mname: str, 

377 level: int, 

378) -> str: 

379 # TODO? Option to render indentation guides. 

380 styles = dict( control.styles ) 

381 label = '' 

382 if control.label_as & PrefixLabelPresentations.Emoji: 

383 if control.label_as & PrefixLabelPresentations.Words: 

384 label = f"🔎 TRACE{level}" 

385 else: label = '🔎' 

386 elif control.label_as & PrefixLabelPresentations.Words: 

387 label = f"TRACE{level}" 

388 if control.colorize and level < len( _trace_color_names ): 

389 styles[ 'flavor' ] = _Style( color = _trace_color_names[ level ] ) 

390 indent = ' ' * level 

391 return _render_prefix( 

392 console, auxiliaries, control, mname, label, styles ) + indent 

393 

394 

395def _render_prefix( # noqa: PLR0913 

396 console: _Console, 

397 auxiliaries: Auxiliaries, 

398 control: PrefixFormatControl, 

399 mname: str, 

400 flavor: str, 

401 styles: dict[ str, _Style ], 

402) -> str: 

403 # TODO? Performance optimization: Only compute and interpolate PID, thread, 

404 # and timestamp, if capabilities set permits. 

405 thread = auxiliaries.thread_discoverer( ) 

406 interpolants: dict[ str, str ] = { 

407 'flavor': flavor, 

408 'module_qname': mname, 

409 'timestamp': auxiliaries.time_formatter( control.ts_format ), 

410 'process_id': str( auxiliaries.pid_discoverer( ) ), 

411 'thread_id': str( thread.ident ), 

412 'thread_name': thread.name, 

413 } 

414 if control.colorize: _stylize_interpolants( console, interpolants, styles ) 

415 return control.template.format( **interpolants ) 

416 

417 

418def _stylize_interpolants( 

419 console: _Console, 

420 interpolants: dict[ str, str ], 

421 styles: dict[ str, _Style ], 

422) -> None: 

423 style_default = styles.get( 'flavor' ) 

424 interpolants_: dict[ str, str ] = { } 

425 for iname, ivalue in interpolants.items( ): 

426 style = styles.get( iname, style_default ) 

427 if not style: continue # pragma: no branch 

428 with console.capture( ) as capture: 

429 console.print( 

430 ivalue, end = '', highlight = False, style = style ) 

431 interpolants_[ iname ] = capture.get( ) 

432 interpolants.update( interpolants_ )