Coverage for sources/ictruck/recipes/sundae.py: 100%
141 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-01 16:01 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-01 16:01 +0000
1# vim: set filetype=python fileencoding=utf-8:
2# -*- coding: utf-8 -*-
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#============================================================================#
21''' Recipe for advanced formatters.
23 .. note::
25 To use this module, you must have the ``rich`` package installed.
26'''
28# TODO? Allow selection of trace color gradients.
31from __future__ import annotations
33from rich.console import Console as _Console
34from rich.style import Style as _Style
36from . import __
39_validate_arguments = (
40 __.validate_arguments(
41 globalvars = globals( ),
42 errorclass = __.exceptions.ArgumentClassInvalidity ) )
45InterpolantsStylesRegistry: __.typx.TypeAlias = (
46 __.accret.Dictionary[ str, _Style ] )
49class Auxiliaries( __.immut.DataclassObject ):
50 ''' Auxiliary functions used by formatters and interpolation.
52 Typically used by unit tests to inject mock dependencies,
53 but can also be used to deeply customize output.
54 '''
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
75class FlavorSpecification( __.immut.DataclassObject ):
76 ''' Specification for custom flavor. '''
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
87class PrefixDecorations( __.enum.IntFlag ):
88 ''' Decoration styles for prefix emission. '''
90 Plain = 0
91 Color = __.enum.auto( )
92 Emoji = __.enum.auto( )
95class PrefixLabelPresentations( __.enum.IntFlag ):
96 ''' How prefix label should be presented. '''
98 Nothing = 0
99 Words = __.enum.auto( )
100 Emoji = __.enum.auto( )
103class PrefixFormatControl( __.immut.DataclassObject ):
104 ''' Format control for prefix emission. '''
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.
114 ``Words``: As words like ``TRACE0`` or ``ERROR``.
115 ``Emoji``: As emoji like ``🔎`` or ``❌``.
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.
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.
144 Used by :py:func:`time.strftime` or equivalent.
145 ''' ),
146 ] = '%Y-%m-%d %H:%M:%S.%f'
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]
181_flavor_specifications: __.immut.Dictionary[
182 str, FlavorSpecification
183] = __.immut.Dictionary(
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)
206_flavor_aliases: __.immut.Dictionary[
207 str, str
208] = __.immut.Dictionary( {
209 'n': 'note', 'm': 'monition',
210 'e': 'error', 'a': 'abort',
211 'ex': 'errorx', 'ax': 'abortx',
212 'f': 'future', 's': 'success',
213} )
215_trace_color_names: tuple[ str, ... ] = (
216 'grey85', 'grey82', 'grey78', 'grey74', 'grey70',
217 'grey66', 'grey62', 'grey58', 'grey54', 'grey50' )
219_trace_prefix_styles: tuple[ _Style, ... ] = tuple(
220 _Style( color = name ) for name in _trace_color_names )
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 )
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 )
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 )
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 __.immut.Dictionary( flavors )
308def _produce_formatter_factory(
309 console: _Console, auxiliaries: Auxiliaries
310) -> __.FormatterFactory:
312 def factory(
313 control: __.FormatterControl, mname: str, flavor: __.Flavor
314 ) -> __.Formatter:
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
332 return formatter
334 return factory
337def _produce_prefix_emitter(
338 console: _Console, auxiliaries: Auxiliaries, control: PrefixFormatControl
339) -> __.PrefixEmitter:
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 )
349 return emitter
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 )
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
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 )
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_ )