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
« 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 -*-
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.
32from rich.console import Console as _Console
33from rich.style import Style as _Style
35from . import __
38_validate_arguments = (
39 __.validate_arguments(
40 globalvars = globals( ),
41 errorclass = __.exceptions.ArgumentClassInvalidity ) )
44InterpolantsStylesRegistry: __.typx.TypeAlias = (
45 __.accret.Dictionary[ str, _Style ] )
48class Auxiliaries( __.immut.DataclassObject ):
49 ''' Auxiliary functions used by formatters and interpolation.
51 Typically used by unit tests to inject mock dependencies,
52 but can also be used to deeply customize output.
53 '''
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
74class FlavorSpecification( __.immut.DataclassObject ):
75 ''' Specification for custom flavor. '''
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
86class PrefixDecorations( __.enum.IntFlag ):
87 ''' Decoration styles for prefix emission. '''
89 Plain = 0
90 Color = __.enum.auto( )
91 Emoji = __.enum.auto( )
94class PrefixLabelPresentations( __.enum.IntFlag ):
95 ''' How prefix label should be presented. '''
97 Nothing = 0
98 Words = __.enum.auto( )
99 Emoji = __.enum.auto( )
102class PrefixFormatControl( __.immut.DataclassObject ):
103 ''' Format control for prefix emission. '''
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.
113 ``Words``: As words like ``TRACE0`` or ``ERROR``.
114 ``Emoji``: As emoji like ``🔎`` or ``❌``.
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.
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.
143 Used by :py:func:`time.strftime` or equivalent.
144 ''' ),
145 ] = '%Y-%m-%d %H:%M:%S.%f'
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]
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)
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} )
214_trace_color_names: tuple[ str, ... ] = (
215 'grey85', 'grey82', 'grey78', 'grey74', 'grey70',
216 'grey66', 'grey62', 'grey58', 'grey54', 'grey50' )
218_trace_prefix_styles: tuple[ _Style, ... ] = tuple(
219 _Style( color = name ) for name in _trace_color_names )
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 )
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 )
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 )
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 )
307def _produce_formatter_factory(
308 console: _Console, auxiliaries: Auxiliaries
309) -> __.FormatterFactory:
311 def factory(
312 control: __.FormatterControl, mname: str, flavor: __.Flavor
313 ) -> __.Formatter:
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
331 return formatter
333 return factory
336def _produce_prefix_emitter(
337 console: _Console, auxiliaries: Auxiliaries, control: PrefixFormatControl
338) -> __.PrefixEmitter:
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 )
348 return emitter
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 )
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
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 )
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_ )