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

141 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-06 03:49 +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 

31from __future__ import annotations 

32 

33from rich.console import Console as _Console 

34from rich.style import Style as _Style 

35 

36from . import __ 

37 

38 

39_validate_arguments = ( 

40 __.validate_arguments( 

41 globalvars = globals( ), 

42 errorclass = __.exceptions.ArgumentClassInvalidity ) ) 

43 

44 

45InterpolantsStylesRegistry: __.typx.TypeAlias = ( 

46 __.AccretiveDictionary[ str, _Style ] ) 

47 

48 

49class Auxiliaries( metaclass = __.ImmutableCompleteDataclass ): 

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

51 

52 Typically used by unit tests to inject mock dependencies, 

53 but can also be used to deeply customize output. 

54 ''' 

55 

56 exc_info_discoverer: __.typx.Annotated[ 

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

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

59 ] = __.sys.exc_info 

60 pid_discoverer: __.typx.Annotated[ 

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

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

63 ] = __.os.getpid 

64 thread_discoverer: __.typx.Annotated[ 

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

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

67 ] = __.threads.current_thread 

68 time_formatter: __.typx.Annotated[ 

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

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

71 ] = __.time.strftime 

72 

73 

74 

75class FlavorSpecification( metaclass = __.ImmutableCompleteDataclass ): 

76 ''' Specification for custom flavor. ''' 

77 

78 color: __.typx.Annotated[ 

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

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

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

82 stack: __.typx.Annotated[ 

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

84 ] = False 

85 

86 

87class PrefixDecorations( __.enum.IntFlag ): 

88 ''' Decoration styles for prefix emission. ''' 

89 

90 Plain = 0 

91 Color = __.enum.auto( ) 

92 Emoji = __.enum.auto( ) 

93 

94 

95class PrefixLabelPresentations( __.enum.IntFlag ): 

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

97 

98 Nothing = 0 

99 Words = __.enum.auto( ) 

100 Emoji = __.enum.auto( ) 

101 

102 

103class PrefixFormatControl( metaclass = __.ImmutableCompleteDataclass ): 

104 ''' Format control for prefix emission. ''' 

105 

106 colorize: __.typx.Annotated[ 

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

108 ] = True 

109 label_as: __.typx.Annotated[ 

110 PrefixLabelPresentations, 

111 __.typx.Doc( 

112 ''' How to present prefix label. 

113 

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

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

116 

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

118 ''' ) 

119 ] = PrefixLabelPresentations.Words 

120 styles: __.typx.Annotated[ 

121 InterpolantsStylesRegistry, 

122 __.typx.Doc( 

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

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

125 template: __.typx.Annotated[ 

126 str, 

127 __.typx.Doc( 

128 ''' String format for prefix. 

129 

130 The following interpolants are supported: 

131 ``flavor``: Decorated flavor. 

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

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

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

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

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

137 ''' ), 

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

139 ts_format: __.typx.Annotated[ 

140 str, 

141 __.typx.Doc( 

142 ''' String format for prefix timestamp. 

143 

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

145 ''' ), 

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

147 

148 

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

150 __.Absential[ Auxiliaries ], 

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

152] 

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

154 __.Absential[ bool ], 

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

156] 

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

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

159 __.typx.Doc( 

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

161] 

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

163 __.Absential[ PrefixLabelPresentations ], 

164 __.typx.Doc( 

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

166] 

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

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

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

170] 

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

172 __.Absential[ str ], 

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

174] 

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

176 __.Absential[ str ], 

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

178] 

179 

180 

181_flavor_specifications: __.ImmutableDictionary[ 

182 str, FlavorSpecification 

183] = __.ImmutableDictionary( 

184 note = FlavorSpecification( 

185 color = 'blue', 

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

187 label = 'NOTE' ), 

188 monition = FlavorSpecification( 

189 color = 'yellow', 

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

191 label = 'MONITION' ), 

192 error = FlavorSpecification( 

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

194 errorx = FlavorSpecification( 

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

196 abort = FlavorSpecification( 

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

198 abortx = FlavorSpecification( 

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

200 future = FlavorSpecification( 

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

202 success = FlavorSpecification( 

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

204) 

205 

206_flavor_aliases: __.ImmutableDictionary[ 

207 str, str 

208] = __.ImmutableDictionary( { 

209 'n': 'note', 'm': 'monition', 

210 'e': 'error', 'a': 'abort', 

211 'ex': 'errorx', 'ax': 'abortx', 

212 'f': 'future', 's': 'success', 

213} ) 

214 

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

216 'grey85', 'grey82', 'grey78', 'grey74', 'grey70', 

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

218 

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

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

221 

222 

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

224 # TODO? safe_box = True 

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

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

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

228 if not stream.isatty( ): continue 

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

230 blackhole = open( # noqa: SIM115 

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

232 # TODO? height = 24, width = 80 

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

234 

235 

236@_validate_arguments 

237def produce_module_configuration( # noqa: PLR0913 

238 colorize: ProduceModulecfgColorizeArgument = __.absent, 

239 prefix_label_as: ProduceModulecfgPrefixLabelAsArgument = __.absent, 

240 prefix_styles: ProduceModulecfgPrefixStylesArgument = __.absent, 

241 prefix_template: ProduceModulecfgPrefixTemplateArgument = __.absent, 

242 prefix_ts_format: ProduceModulecfgPrefixTsFormatArgument = __.absent, 

243 console_factory: ProduceModulecfgConsoleFactoryArgument = __.absent, 

244 auxiliaries: ProduceModulecfgAuxiliariesArgument = __.absent, 

245) -> __.ModuleConfiguration: 

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

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

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

249 console = console_factory( ) 

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

251 if not __.is_absent( colorize ): 

252 prefix_fmtctl_initargs[ 'colorize' ] = colorize 

253 if not __.is_absent( prefix_label_as ): 

254 prefix_fmtctl_initargs[ 'label_as' ] = prefix_label_as 

255 if not __.is_absent( prefix_styles ): 

256 prefix_fmtctl_initargs[ 'styles' ] = prefix_styles 

257 if not __.is_absent( prefix_template ): 

258 prefix_fmtctl_initargs[ 'template' ] = prefix_template 

259 if not __.is_absent( prefix_ts_format ): 

260 prefix_fmtctl_initargs[ 'ts_format' ] = prefix_ts_format 

261 prefix_fmtctl = PrefixFormatControl( **prefix_fmtctl_initargs ) 

262 flavors = _produce_flavors( console, auxiliaries, prefix_fmtctl ) 

263 formatter_factory = _produce_formatter_factory( console, auxiliaries ) 

264 return __.ModuleConfiguration( 

265 flavors = flavors, formatter_factory = formatter_factory ) 

266 

267 

268@_validate_arguments 

269def register_module( # noqa: PLR0913 

270 name: __.RegisterModuleNameArgument = __.absent, 

271 colorize: ProduceModulecfgColorizeArgument = __.absent, 

272 prefix_label_as: ProduceModulecfgPrefixLabelAsArgument = __.absent, 

273 prefix_styles: ProduceModulecfgPrefixStylesArgument = __.absent, 

274 prefix_template: ProduceModulecfgPrefixTemplateArgument = __.absent, 

275 prefix_ts_format: ProduceModulecfgPrefixTsFormatArgument = __.absent, 

276 console_factory: ProduceModulecfgConsoleFactoryArgument = __.absent, 

277 auxiliaries: ProduceModulecfgAuxiliariesArgument = __.absent, 

278) -> __.ModuleConfiguration: 

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

280 configuration = produce_module_configuration( 

281 colorize = colorize, 

282 prefix_label_as = prefix_label_as, 

283 prefix_styles = prefix_styles, 

284 prefix_template = prefix_template, 

285 prefix_ts_format = prefix_ts_format, 

286 console_factory = console_factory, 

287 auxiliaries = auxiliaries ) 

288 return __.register_module( 

289 name = name, 

290 flavors = configuration.flavors, 

291 formatter_factory = configuration.formatter_factory ) 

292 

293 

294def _produce_flavors( 

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

296) -> __.FlavorsRegistry: 

297 emitter = _produce_prefix_emitter( console, auxiliaries, control ) 

298 flavors: __.FlavorsRegistryLiberal = { } 

299 for name in _flavor_specifications: 

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

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

302 flavors[ alias ] = flavors[ name ] 

303 for level in range( 10 ): 

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

305 return __.ImmutableDictionary( flavors ) 

306 

307 

308def _produce_formatter_factory( 

309 console: _Console, auxiliaries: Auxiliaries 

310) -> __.FormatterFactory: 

311 

312 def factory( 

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

314 ) -> __.Formatter: 

315 

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

317 tb_text = '' 

318 if isinstance( flavor, str ): 

319 flavor_ = _flavor_aliases.get( flavor, flavor ) 

320 spec = _flavor_specifications[ flavor_ ] 

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

322 with console.capture( ) as capture: 

323 console.print_exception( ) 

324 tb_text = capture.get( ) 

325 else: flavor_ = flavor 

326 with console.capture( ) as capture: 

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

328 text = capture.get( ) 

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

330 return text 

331 

332 return formatter 

333 

334 return factory 

335 

336 

337def _produce_prefix_emitter( 

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

339) -> __.PrefixEmitter: 

340 

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

342 if isinstance( flavor, int ): 

343 return _produce_trace_prefix( 

344 console, auxiliaries, control, mname, flavor ) 

345 name = _flavor_aliases.get( flavor, flavor ) 

346 return _produce_special_prefix( 

347 console, auxiliaries, control, mname, name ) 

348 

349 return emitter 

350 

351 

352def _produce_special_prefix( 

353 console: _Console, 

354 auxiliaries: Auxiliaries, 

355 control: PrefixFormatControl, 

356 mname: str, 

357 flavor: str, 

358) -> str: 

359 styles = dict( control.styles ) 

360 spec = _flavor_specifications[ flavor ] 

361 label = '' 

362 if control.label_as & PrefixLabelPresentations.Emoji: 

363 if control.label_as & PrefixLabelPresentations.Words: 

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

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

366 elif control.label_as & PrefixLabelPresentations.Words: 

367 label = f"{spec.label}" 

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

369 return _render_prefix( 

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

371 

372 

373def _produce_trace_prefix( 

374 console: _Console, 

375 auxiliaries: Auxiliaries, 

376 control: PrefixFormatControl, 

377 mname: str, 

378 level: int, 

379) -> str: 

380 # TODO? Option to render indentation guides. 

381 styles = dict( control.styles ) 

382 label = '' 

383 if control.label_as & PrefixLabelPresentations.Emoji: 

384 if control.label_as & PrefixLabelPresentations.Words: 

385 label = f"🔎 TRACE{level}" 

386 else: label = '🔎' 

387 elif control.label_as & PrefixLabelPresentations.Words: 

388 label = f"TRACE{level}" 

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

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

391 indent = ' ' * level 

392 return _render_prefix( 

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

394 

395 

396def _render_prefix( # noqa: PLR0913 

397 console: _Console, 

398 auxiliaries: Auxiliaries, 

399 control: PrefixFormatControl, 

400 mname: str, 

401 flavor: str, 

402 styles: dict[ str, _Style ], 

403) -> str: 

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

405 # and timestamp, if capabilities set permits. 

406 thread = auxiliaries.thread_discoverer( ) 

407 interpolants: dict[ str, str ] = { 

408 'flavor': flavor, 

409 'module_qname': mname, 

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

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

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

413 'thread_name': thread.name, 

414 } 

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

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

417 

418 

419def _stylize_interpolants( 

420 console: _Console, 

421 interpolants: dict[ str, str ], 

422 styles: dict[ str, _Style ], 

423) -> None: 

424 style_default = styles.get( 'flavor' ) 

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

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

427 style = styles.get( iname, style_default ) 

428 if not style: continue # pragma: no branch 

429 with console.capture( ) as capture: 

430 console.print( 

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

432 interpolants_[ iname ] = capture.get( ) 

433 interpolants.update( interpolants_ )