Coverage for sources/ictr/standard/compositors.py: 94%
116 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 12:43 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 12:43 +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''' Standard compositor and renderers. '''
24from . import __
25from . import core as _core
26from . import linearizers as _linearizers
28from .linearizers import linearize_omni as _linearize_omni
31class CompositorConfiguration( __.immut.DataclassObject ):
32 ''' Behaviors and format for text from standard compositor. '''
34 detail_prefix_initial: __.typx.Annotated[
35 str, __.ddoc.Doc( ''' Initial prefix for message detail. ''' )
36 ] = ''
37 detail_prefix_subsequent: __.typx.Annotated[
38 __.typx.Optional[ str ],
39 __.ddoc.Doc(
40 ''' Subsequent prefix for message detail.
42 If ``None``, then automatic padding is calculated based on the
43 visual width of the initial prefix for message detail.
44 ''' ),
45 ] = None
46 # TODO? 'details_maximum'
47 details_separator: __.typx.Annotated[
48 str, __.ddoc.Doc( ''' Separator between details. ''' )
49 ] = '\n\n'
50 line_prefix_initial: __.typx.Annotated[
51 str, __.ddoc.Doc( ''' Prefix before first line. ''' )
52 ] = ''
53 line_prefix_subsequent: __.typx.Annotated[
54 __.typx.Optional[ str ],
55 __.ddoc.Doc(
56 ''' Prefix before each line after the first.
58 If ``None``, then automatic padding is calculated based on the
59 visual width of the initial line prefix.
60 ''' ),
61 ] = None
62 linearizercfg: __.typx.Annotated[
63 _linearizers.LinearizerConfiguration,
64 __.ddoc.Doc(
65 ''' Text linearization and pretty-formatting behaviors. ''' ),
66 ] = __.dcls.field( default_factory = _linearizers.LinearizerConfiguration )
67 summary_incision_ratio: __.typx.Annotated[
68 float,
69 __.ddoc.Doc(
70 ''' Ratio of introduction width to full width at which to split.
72 If ratio is met or exceeded, then introduction and summary are
73 split onto consecutive lines.
74 ''' ),
75 ] = 0.3
78COMPOSITOR_CONFIGURATION_DEFAULT = CompositorConfiguration( )
81class CompositorState( __.immut.DataclassObject ):
82 ''' Data transfer object for textualizer state. '''
84 configuration: CompositorConfiguration
85 linearizer: _linearizers.LinearizerState
87 @classmethod
88 def from_configuration(
89 cls,
90 configuration: CompositorConfiguration,
91 control: __.TextualizationControl,
92 ) -> __.typx.Self:
93 linearizer = _linearizers.LinearizerState.from_configuration(
94 configuration = configuration.linearizercfg, control = control )
95 return cls( configuration = configuration, linearizer = linearizer )
98class Compositor( __.Compositor ):
99 ''' Standard compositor. '''
101 configuration: __.typx.Annotated[
102 CompositorConfiguration,
103 __.ddoc.Doc( ''' Default behaviors and format for text. ''' ),
104 ] = __.dcls.field( default_factory = CompositorConfiguration )
105 introducer: __.typx.Annotated[
106 __.IntroducerUnion,
107 __.ddoc.Doc(
108 ''' String or factory which produces introduction string.
110 Factory takes control object and record as arguments.
111 Returns introduction string.
112 ''' ),
113 ] = f"{__.package_name}| "
115 def __call__(
116 self, control: __.TextualizationControl, record: __.Record
117 ) -> str:
118 configuration = self.configuration
119 ecfg = configuration.linearizercfg.exceptionscfg
120 auxdata = CompositorState.from_configuration(
121 configuration = configuration, control = control )
122 content = record.content
123 introducer = self.introducer
124 introduction = (
125 introducer if isinstance( introducer, str )
126 else introducer(
127 control, record, auxdata.linearizer.columns_max ) )
128 if isinstance( content, __.MessageContent ): 128 ↛ 139line 128 didn't jump to line 139 because the condition on line 128 was always true
129 summary_ = content.summary
130 exception = (
131 None if isinstance( summary_, BaseException )
132 else ecfg.discover( ) )
133 summary = _render_summary( auxdata, introduction, summary_ )
134 details = tuple(
135 _render_detail( auxdata, detail )
136 for detail in filter( None, ( exception, *content.details ) ) )
137 return configuration.details_separator.join( (
138 summary, *details ) )
139 raise __.ContentMisclassification( type( content ) )
142def _calculate_ccount_max(
143 initial: str, subsequent: __.typx.Optional[ str ]
144) -> int:
145 i_ccount = __.count_columns_visual( initial )
146 if subsequent is None: return i_ccount
147 return max( i_ccount, __.count_columns_visual( subsequent ) )
150def _render_detail( auxdata: CompositorState, detail: object ) -> str:
151 configuration = auxdata.configuration
152 columns_max = auxdata.linearizer.columns_max
153 detail_prefix_i = configuration.detail_prefix_initial
154 detail_prefix_i_ccount = __.count_columns_visual( detail_prefix_i )
155 detail_prefix_s = configuration.detail_prefix_subsequent
156 if detail_prefix_s is None:
157 detail_prefix_s = ' ' * detail_prefix_i_ccount
158 detail_prefix_ccount = _calculate_ccount_max(
159 detail_prefix_i, detail_prefix_s )
160 line_prefix_ccount = _calculate_ccount_max(
161 configuration.line_prefix_initial,
162 configuration.line_prefix_subsequent )
163 prefix_ccount = line_prefix_ccount + detail_prefix_ccount
164 match auxdata.linearizer.columns_constraint:
165 case _core.ColumnsConstraints.Complect:
166 remainder_ccount = (
167 __.absent if __.is_absent( columns_max )
168 else columns_max - prefix_ccount )
169 case _core.ColumnsConstraints.Exceed: 169 ↛ 171line 169 didn't jump to line 171 because the pattern on line 169 always matched
170 remainder_ccount = __.absent
171 lines = iter( _linearize_omni(
172 auxdata.linearizer, detail, remainder_ccount ) )
173 lines_final: list[ str ] = [ ]
174 line_i = next( lines )
175 _update_lines_collection(
176 configuration, lines_final,
177 f"{detail_prefix_i}{line_i}",
178 tuple( f"{detail_prefix_s}{line}" for line in lines ) )
179 return '\n'.join( lines_final )
182def _render_summary(
183 auxdata: CompositorState, introduction: str, summary: object
184) -> str:
185 match auxdata.linearizer.columns_constraint:
186 case _core.ColumnsConstraints.Complect:
187 return _complect_render_summary( auxdata, introduction, summary )
188 case _core.ColumnsConstraints.Exceed: 188 ↛ exitline 188 didn't return from function '_render_summary' because the pattern on line 188 always matched
189 return _exceed_render_summary( auxdata, introduction, summary )
192def _complect_render_summary(
193 auxdata: CompositorState, introduction: str, summary: object
194) -> str:
195 configuration = auxdata.configuration
196 columns_max = auxdata.linearizer.columns_max
197 line_prefix_i = configuration.line_prefix_initial
198 intro_ccount = __.count_columns_visual( introduction )
199 prefix_ccount = _calculate_ccount_max(
200 line_prefix_i, configuration.line_prefix_subsequent )
201 remainder_ccount = (
202 __.absent if __.is_absent( columns_max )
203 else columns_max - prefix_ccount )
204 lines_final: list[ str ] = [ ]
205 lines = _linearize_omni( auxdata.linearizer, summary, remainder_ccount )
206 match len( lines ):
207 case 0: raise __.SummaryLinearizationFailure( ) 207 ↛ exitline 207 didn't except from function '_complect_render_summary' because the raise on line 207 wasn't executed
208 case 1:
209 content = lines[ 0 ]
210 incision_point = 0
211 if not __.is_absent( columns_max ): 211 ↛ 214line 211 didn't jump to line 214 because the condition on line 211 was always true
212 incision_point = (
213 configuration.summary_incision_ratio * columns_max )
214 isolate_introduction = incision_point <= intro_ccount
215 if not isolate_introduction:
216 candidate = f"{introduction} {content}"
217 candidate_ccount = (
218 prefix_ccount + intro_ccount
219 + __.count_columns_visual( content ) + 1 )
220 if candidate_ccount <= columns_max: 220 ↛ 223line 220 didn't jump to line 223 because the condition on line 220 was always true
221 lines_final.append( f"{line_prefix_i}{candidate}" )
222 else:
223 _update_lines_collection(
224 configuration, lines_final, introduction, lines )
225 else:
226 _update_lines_collection(
227 configuration, lines_final, introduction, lines )
228 case _:
229 _update_lines_collection(
230 configuration, lines_final, introduction, lines )
231 return '\n'.join( lines_final )
234def _exceed_render_summary(
235 auxdata: CompositorState, introduction: str, summary: object
236) -> str:
237 configuration = auxdata.configuration
238 line_prefix_i = configuration.line_prefix_initial
239 lines_final: list[ str ] = [ ]
240 lines = _linearize_omni( auxdata.linearizer, summary )
241 match len( lines ):
242 case 0: raise __.SummaryLinearizationFailure( ) 242 ↛ exitline 242 didn't except from function '_exceed_render_summary' because the raise on line 242 wasn't executed
243 case 1:
244 content = lines[ 0 ]
245 lines_final.append( f"{line_prefix_i}{introduction} {content}" )
246 case _:
247 _update_lines_collection(
248 configuration, lines_final, introduction, lines )
249 return '\n'.join( lines_final )
252def _update_lines_collection(
253 configuration: CompositorConfiguration,
254 collector: list[ str ],
255 line_initial: str,
256 lines_subsequent: __.typx.Optional[ tuple[ str, ... ] ] = None,
257) -> None:
258 line_prefix_i = configuration.line_prefix_initial
259 line_prefix_s = configuration.line_prefix_subsequent
260 if line_prefix_s is None:
261 line_prefix_s = ' ' * __.count_columns_visual( line_prefix_i )
262 collector.append( f"{line_prefix_i}{line_initial}" )
263 if lines_subsequent:
264 collector.extend(
265 f"{line_prefix_s}{line}" for line in lines_subsequent )