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

140 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-07 00:37 +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 

45class Auxiliaries( metaclass = __.ImmutableCompleteDataclass ): 

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

47 

48 Typically used by unit tests to inject mock dependencies, 

49 but can also be used to deeply customize output. 

50 ''' 

51 

52 exc_info_discoverer: __.typx.Annotated[ 

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

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

55 ] = __.sys.exc_info 

56 pid_discoverer: __.typx.Annotated[ 

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

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

59 ] = __.os.getpid 

60 thread_discoverer: __.typx.Annotated[ 

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

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

63 ] = __.threads.current_thread 

64 time_formatter: __.typx.Annotated[ 

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

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

67 ] = __.time.strftime 

68 

69 

70 

71class FlavorSpecification( metaclass = __.ImmutableCompleteDataclass ): 

72 ''' Specification for custom flavor. ''' 

73 

74 color: __.typx.Annotated[ 

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

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

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

78 stack: __.typx.Annotated[ 

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

80 ] = False 

81 

82 

83class PrefixDecorations( __.enum.IntFlag ): 

84 ''' Decoration styles for prefix emission. ''' 

85 

86 Plain = 0 

87 Color = __.enum.auto( ) 

88 Emoji = __.enum.auto( ) 

89 

90 

91class PrefixLabelPresentations( __.enum.IntFlag ): 

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

93 

94 Nothing = 0 

95 Words = __.enum.auto( ) 

96 Emoji = __.enum.auto( ) 

97 

98 

99class PrefixFormatControl( metaclass = __.ImmutableCompleteDataclass ): 

100 ''' Format control for prefix emission. ''' 

101 

102 colorize: __.typx.Annotated[ 

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

104 ] = True 

105 label_as: __.typx.Annotated[ 

106 PrefixLabelPresentations, 

107 __.typx.Doc( 

108 ''' How to present prefix label. 

109 

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

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

112 

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

114 ''' ) 

115 ] = PrefixLabelPresentations.Words 

116 styles: __.typx.Annotated[ 

117 __.AccretiveDictionary[ str, _Style ], 

118 __.typx.Doc( 

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

120 ] = __.dcls.field( default_factory = __.AccretiveDictionary ) # pyright: ignore 

121 template: __.typx.Annotated[ 

122 str, 

123 __.typx.Doc( 

124 ''' String format for prefix. 

125 

126 The following interpolants are supported: 

127 ``flavor``: Decorated flavor. 

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

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

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

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

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

133 ''' ), 

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

135 ts_format: __.typx.Annotated[ 

136 str, 

137 __.typx.Doc( 

138 ''' String format for prefix timestamp. 

139 

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

141 ''' ), 

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

143 

144 

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

146 __.Absential[ Auxiliaries ], 

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

148] 

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

150 __.Absential[ bool ], 

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

152] 

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

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

155 __.typx.Doc( 

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

157] 

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

159 __.Absential[ PrefixLabelPresentations ], 

160 __.typx.Doc( 

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

162] 

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

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

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

166] 

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

168 __.Absential[ str ], 

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

170] 

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

172 __.Absential[ str ], 

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

174] 

175 

176 

177_flavor_specifications: __.ImmutableDictionary[ 

178 str, FlavorSpecification 

179] = __.ImmutableDictionary( 

180 note = FlavorSpecification( 

181 color = 'blue', 

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

183 label = 'NOTE' ), 

184 monition = FlavorSpecification( 

185 color = 'yellow', 

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

187 label = 'MONITION' ), 

188 error = FlavorSpecification( 

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

190 errorx = FlavorSpecification( 

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

192 abort = FlavorSpecification( 

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

194 abortx = FlavorSpecification( 

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

196 future = FlavorSpecification( 

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

198 success = FlavorSpecification( 

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

200) 

201 

202_flavor_aliases: __.ImmutableDictionary[ 

203 str, str 

204] = __.ImmutableDictionary( { 

205 'n': 'note', 'm': 'monition', 

206 'e': 'error', 'a': 'abort', 

207 'ex': 'errorx', 'ax': 'abortx', 

208 'f': 'future', 's': 'success', 

209} ) 

210 

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

212 'grey85', 'grey82', 'grey78', 'grey74', 'grey70', 

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

214 

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

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

217 

218 

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

220 # TODO? safe_box = True 

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

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

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

224 if not stream.isatty( ): continue 

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

226 blackhole = open( # noqa: SIM115 

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

228 # TODO? height = 24, width = 80 

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

230 

231 

232@_validate_arguments 

233def produce_module_configuration( # noqa: PLR0913 

234 colorize: ProduceModulecfgColorizeArgument = __.absent, 

235 prefix_label_as: ProduceModulecfgPrefixLabelAsArgument = __.absent, 

236 prefix_styles: ProduceModulecfgPrefixStylesArgument = __.absent, 

237 prefix_template: ProduceModulecfgPrefixTemplateArgument = __.absent, 

238 prefix_ts_format: ProduceModulecfgPrefixTsFormatArgument = __.absent, 

239 console_factory: ProduceModulecfgConsoleFactoryArgument = __.absent, 

240 auxiliaries: ProduceModulecfgAuxiliariesArgument = __.absent, 

241) -> __.ModuleConfiguration: 

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

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

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

245 console = console_factory( ) 

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

247 if not __.is_absent( colorize ): 

248 prefix_fmtctl_initargs[ 'colorize' ] = colorize 

249 if not __.is_absent( prefix_label_as ): 

250 prefix_fmtctl_initargs[ 'label_as' ] = prefix_label_as 

251 if not __.is_absent( prefix_styles ): 

252 prefix_fmtctl_initargs[ 'styles' ] = prefix_styles 

253 if not __.is_absent( prefix_template ): 

254 prefix_fmtctl_initargs[ 'template' ] = prefix_template 

255 if not __.is_absent( prefix_ts_format ): 

256 prefix_fmtctl_initargs[ 'ts_format' ] = prefix_ts_format 

257 prefix_fmtctl = PrefixFormatControl( **prefix_fmtctl_initargs ) 

258 flavors = _produce_flavors( console, auxiliaries, prefix_fmtctl ) 

259 formatter_factory = _produce_formatter_factory( console, auxiliaries ) 

260 return __.ModuleConfiguration( 

261 flavors = flavors, formatter_factory = formatter_factory ) 

262 

263 

264@_validate_arguments 

265def register_module( # noqa: PLR0913 

266 name: __.RegisterModuleNameArgument = __.absent, 

267 colorize: ProduceModulecfgColorizeArgument = __.absent, 

268 prefix_label_as: ProduceModulecfgPrefixLabelAsArgument = __.absent, 

269 prefix_styles: ProduceModulecfgPrefixStylesArgument = __.absent, 

270 prefix_template: ProduceModulecfgPrefixTemplateArgument = __.absent, 

271 prefix_ts_format: ProduceModulecfgPrefixTsFormatArgument = __.absent, 

272 console_factory: ProduceModulecfgConsoleFactoryArgument = __.absent, 

273 auxiliaries: ProduceModulecfgAuxiliariesArgument = __.absent, 

274) -> __.ModuleConfiguration: 

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

276 configuration = produce_module_configuration( 

277 colorize = colorize, 

278 prefix_label_as = prefix_label_as, 

279 prefix_styles = prefix_styles, 

280 prefix_template = prefix_template, 

281 prefix_ts_format = prefix_ts_format, 

282 console_factory = console_factory, 

283 auxiliaries = auxiliaries ) 

284 return __.register_module( 

285 name = name, 

286 flavors = configuration.flavors, 

287 formatter_factory = configuration.formatter_factory ) 

288 

289 

290def _produce_flavors( 

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

292) -> __.FlavorsRegistry: 

293 emitter = _produce_prefix_emitter( console, auxiliaries, control ) 

294 flavors: __.FlavorsRegistryLiberal = { } 

295 for name in _flavor_specifications: 

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

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

298 flavors[ alias ] = flavors[ name ] 

299 for level in range( 10 ): 

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

301 return __.ImmutableDictionary( flavors ) 

302 

303 

304def _produce_formatter_factory( 

305 console: _Console, auxiliaries: Auxiliaries 

306) -> __.FormatterFactory: 

307 

308 def factory( 

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

310 ) -> __.Formatter: 

311 

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

313 tb_text = '' 

314 if isinstance( flavor, str ): 

315 flavor_ = _flavor_aliases.get( flavor, flavor ) 

316 spec = _flavor_specifications[ flavor_ ] 

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

318 with console.capture( ) as capture: 

319 console.print_exception( ) 

320 tb_text = capture.get( ) 

321 else: flavor_ = flavor 

322 with console.capture( ) as capture: 

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

324 text = capture.get( ) 

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

326 return text 

327 

328 return formatter 

329 

330 return factory 

331 

332 

333def _produce_prefix_emitter( 

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

335) -> __.PrefixEmitter: 

336 

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

338 if isinstance( flavor, int ): 

339 return _produce_trace_prefix( 

340 console, auxiliaries, control, mname, flavor ) 

341 name = _flavor_aliases.get( flavor, flavor ) 

342 return _produce_special_prefix( 

343 console, auxiliaries, control, mname, name ) 

344 

345 return emitter 

346 

347 

348def _produce_special_prefix( 

349 console: _Console, 

350 auxiliaries: Auxiliaries, 

351 control: PrefixFormatControl, 

352 mname: str, 

353 flavor: str, 

354) -> str: 

355 styles = dict( control.styles ) 

356 spec = _flavor_specifications[ flavor ] 

357 label = '' 

358 if control.label_as & PrefixLabelPresentations.Emoji: 

359 if control.label_as & PrefixLabelPresentations.Words: 

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

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

362 elif control.label_as & PrefixLabelPresentations.Words: 

363 label = f"{spec.label}" 

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

365 return _render_prefix( 

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

367 

368 

369def _produce_trace_prefix( 

370 console: _Console, 

371 auxiliaries: Auxiliaries, 

372 control: PrefixFormatControl, 

373 mname: str, 

374 level: int, 

375) -> str: 

376 # TODO? Option to render indentation guides. 

377 styles = dict( control.styles ) 

378 label = '' 

379 if control.label_as & PrefixLabelPresentations.Emoji: 

380 if control.label_as & PrefixLabelPresentations.Words: 

381 label = f"🔎 TRACE{level}" 

382 else: label = '🔎' 

383 elif control.label_as & PrefixLabelPresentations.Words: 

384 label = f"TRACE{level}" 

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

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

387 indent = ' ' * level 

388 return _render_prefix( 

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

390 

391 

392def _render_prefix( # noqa: PLR0913 

393 console: _Console, 

394 auxiliaries: Auxiliaries, 

395 control: PrefixFormatControl, 

396 mname: str, 

397 flavor: str, 

398 styles: dict[ str, _Style ], 

399) -> str: 

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

401 # and timestamp, if capabilities set permits. 

402 thread = auxiliaries.thread_discoverer( ) 

403 interpolants: dict[ str, str ] = { 

404 'flavor': flavor, 

405 'module_qname': mname, 

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

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

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

409 'thread_name': thread.name, 

410 } 

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

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

413 

414 

415def _stylize_interpolants( 

416 console: _Console, 

417 interpolants: dict[ str, str ], 

418 styles: dict[ str, _Style ], 

419) -> None: 

420 style_default = styles.get( 'flavor' ) 

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

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

423 style = styles.get( iname, style_default ) 

424 if not style: continue # pragma: no branch 

425 with console.capture( ) as capture: 

426 console.print( 

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

428 interpolants_[ iname ] = capture.get( ) 

429 interpolants.update( interpolants_ )