Coverage for sources/ictruck/recipes/sundae.py: 100%
140 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-07 00:10 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-07 00:10 +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 ) )
45class Auxiliaries( metaclass = __.ImmutableCompleteDataclass ):
46 ''' Auxiliary functions used by formatters and interpolation.
48 Typically used by unit tests to inject mock dependencies,
49 but can also be used to deeply customize output.
50 '''
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
71class FlavorSpecification( metaclass = __.ImmutableCompleteDataclass ):
72 ''' Specification for custom flavor. '''
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
83class PrefixDecorations( __.enum.IntFlag ):
84 ''' Decoration styles for prefix emission. '''
86 Plain = 0
87 Color = __.enum.auto( )
88 Emoji = __.enum.auto( )
91class PrefixLabelPresentations( __.enum.IntFlag ):
92 ''' How prefix label should be presented. '''
94 Nothing = 0
95 Words = __.enum.auto( )
96 Emoji = __.enum.auto( )
99class PrefixFormatControl( metaclass = __.ImmutableCompleteDataclass ):
100 ''' Format control for prefix emission. '''
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.
110 ``Words``: As words like ``TRACE0`` or ``ERROR``.
111 ``Emoji``: As emoji like ``🔎`` or ``❌``.
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.
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.
140 Used by :py:func:`time.strftime` or equivalent.
141 ''' ),
142 ] = '%Y-%m-%d %H:%M:%S.%f'
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]
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)
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} )
211_trace_color_names: tuple[ str, ... ] = (
212 'grey85', 'grey82', 'grey78', 'grey74', 'grey70',
213 'grey66', 'grey62', 'grey58', 'grey54', 'grey50' )
215_trace_prefix_styles: tuple[ _Style, ... ] = tuple(
216 _Style( color = name ) for name in _trace_color_names )
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 )
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 )
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 )
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 )
304def _produce_formatter_factory(
305 console: _Console, auxiliaries: Auxiliaries
306) -> __.FormatterFactory:
308 def factory(
309 control: __.FormatterControl, mname: str, flavor: __.Flavor
310 ) -> __.Formatter:
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
328 return formatter
330 return factory
333def _produce_prefix_emitter(
334 console: _Console, auxiliaries: Auxiliaries, control: PrefixFormatControl
335) -> __.PrefixEmitter:
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 )
345 return emitter
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 )
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
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 )
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_ )