Coverage for sources/ictr/standard/linearizers.py: 95%

123 statements  

« 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 -*- 

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''' Conversion of objects to lines of text. ''' 

22 

23 

24from . import __ 

25from . import core as _core 

26 

27 

28class LinearizerConfiguration( __.immut.DataclassObject ): 

29 ''' Behaviors for standard textual linearizer. ''' 

30 

31 colorize: __.typx.Annotated[ 

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

33 ] = True 

34 columns_constraint: __.typx.Annotated[ 

35 _core.ColumnsConstraints, 

36 __.ddoc.Doc( 

37 ''' How to constrain text which exceeds maximum columns. ''' ), 

38 ] = _core.ColumnsConstraints.Complect 

39 columns_max: __.typx.Annotated[ 

40 __.typx.Optional[ int ], 

41 __.ddoc.Doc( 

42 ''' How many columns per line to assume if printer does not tell. 

43 

44 If ``None``, then infinite number of columns is assumed. 

45 ''' ), 

46 ] = None 

47 exceptionscfg: __.typx.Annotated[ 

48 _core.ExceptionsConfiguration, 

49 __.ddoc.Doc( ''' Configuration pertaining to exceptions. ''' ), 

50 ] = __.dcls.field( default_factory = _core.ExceptionsConfiguration ) 

51 incision_boundary: __.typx.Annotated[ 

52 _core.IncisionBoundaries, 

53 __.ddoc.Doc( 

54 ''' Where to constrain text which exceeds maximum columns. ''' ), 

55 ] = _core.IncisionBoundaries.Wordsplits 

56 

57 

58class LinearizerState( __.immut.DataclassObject ): 

59 ''' Data transfer object for linearizer state. ''' 

60 

61 configuration: LinearizerConfiguration 

62 control: __.TextualizationControl 

63 colorize: __.typx.Annotated[ bool, __.ddoc.Doc( ''' Colorize? ''' ) ] 

64 columns_constraint: __.typx.Annotated[ 

65 _core.ColumnsConstraints, 

66 __.ddoc.Doc( ''' Effective columns constraint for lines. ''' ), 

67 ] = _core.ColumnsConstraints.Exceed 

68 columns_max: __.typx.Annotated[ 

69 __.Absential[ int ], 

70 __.ddoc.Doc( 

71 ''' Available line length (maximum columns) of target. ''' ), 

72 ] = __.absent 

73 

74 @classmethod 

75 def from_configuration( 

76 cls, 

77 configuration: LinearizerConfiguration, 

78 control: __.TextualizationControl, 

79 ) -> __.typx.Self: 

80 colorize = __.ENRICH and control.colorize and configuration.colorize 

81 columns_constraint = configuration.columns_constraint 

82 columns_max = control.columns_max or configuration.columns_max 

83 if columns_max is None: 

84 columns_constraint = _core.ColumnsConstraints.Exceed 

85 columns_max = __.absent 

86 return cls( 

87 configuration = configuration, 

88 control = control, 

89 colorize = colorize, 

90 columns_constraint = columns_constraint, 

91 columns_max = columns_max ) 

92 

93 

94class Linearizer( __.Linearizer ): 

95 

96 configuration: __.typx.Annotated[ 

97 LinearizerConfiguration, 

98 __.ddoc.Doc( ''' Default behaviors for textual linearizer. ''' ), 

99 ] = __.dcls.field( default_factory = LinearizerConfiguration ) 

100 

101 def __call__( 

102 self, 

103 control: __.TextualizationControl, 

104 entity: object, 

105 columns_max: __.Absential[ int ] = __.absent, 

106 ) -> tuple[ str, ... ]: 

107 auxdata = LinearizerState.from_configuration( 

108 configuration = self.configuration, control = control ) 

109 return linearize_omni( auxdata, entity, columns_max = columns_max ) 

110 

111 

112def linearize_exception_plain( 

113 auxdata: LinearizerState, 

114 exception: BaseException, 

115 columns_max: __.Absential[ int ] = __.absent, 

116) -> tuple[ str, ... ]: 

117 ecfg = auxdata.configuration.exceptionscfg 

118 tbe = __.tb.TracebackException.from_exception( exception ) 

119 lines = [ *ecfg.interpolate( exception ) ] 

120 if ecfg.enable_stacktraces: 

121 lines.extend( 

122 linearize_stacktrace_plain( auxdata, tbe.stack, columns_max ) ) 

123 # TODO: Process '__cause__' and '__context__'. 

124 # TODO: Process exception groups. 

125 return tuple( lines ) 

126 

127 

128def linearize_exception_rich( 

129 auxdata: LinearizerState, 

130 exception: BaseException, 

131 columns_max: __.Absential[ int ] = __.absent, 

132) -> tuple[ str, ... ]: 

133 # TODO: Ensure that exception groups are handled properly. 

134 ecfg = auxdata.configuration.exceptionscfg 

135 capture = __.io.StringIO( ) 

136 console = __.produce_rich_console( auxdata.control, capture, columns_max ) 

137 if not ecfg.enable_stacktraces: 

138 console.print( exception ) 

139 return tuple( capture.getvalue( ).split( '\n' ) ) 

140 traceback = __.rich_traceback.Traceback.from_exception( 

141 type( exception ), exception, exception.__traceback__ ) 

142 console.print( traceback ) 

143 return tuple( capture.getvalue( ).split( '\n' ) ) 

144 

145 

146def linearize_object_plain( 

147 auxdata: LinearizerState, 

148 entity: object, 

149 columns_max: __.Absential[ int ] = __.absent, 

150) -> tuple[ str, ... ]: 

151 # TODO? Pass configurable indentation width. 

152 text = ( 

153 __.pprint.saferepr( entity ) if __.is_absent( columns_max ) 

154 else __.pprint.pformat( entity, indent = 2, width = columns_max ) ) 

155 return tuple( text.split( '\n' ) ) 

156 

157 

158def linearize_object_rich( 

159 auxdata: LinearizerState, 

160 entity: object, 

161 columns_max: __.Absential[ int ] = __.absent, 

162) -> tuple[ str, ... ]: 

163 capture = __.io.StringIO( ) 

164 console = __.produce_rich_console( auxdata.control, capture, columns_max ) 

165 console.print( entity ) 

166 return tuple( capture.getvalue( ).split( '\n' ) ) 

167 

168 

169def linearize_omni( 

170 auxdata: LinearizerState, 

171 entity: object, 

172 columns_max: __.Absential[ int ] = __.absent, 

173) -> tuple[ str, ... ]: 

174 if auxdata.colorize: 

175 return linearize_omni_rich( auxdata, entity, columns_max ) 

176 return linearize_omni_plain( auxdata, entity, columns_max ) 

177 

178 

179def linearize_omni_plain( 

180 auxdata: LinearizerState, 

181 entity: object, 

182 columns_max: __.Absential[ int ] = __.absent, 

183) -> tuple[ str, ... ]: 

184 if isinstance( entity, str ): 

185 return linearize_text_plain( auxdata, entity, columns_max ) 

186 if isinstance( entity, BaseException ): 

187 return linearize_exception_plain( auxdata, entity, columns_max ) 

188 return linearize_object_plain( auxdata, entity, columns_max ) 

189 

190 

191def linearize_omni_rich( 

192 auxdata: LinearizerState, 

193 entity: object, 

194 columns_max: __.Absential[ int ] = __.absent, 

195) -> tuple[ str, ... ]: 

196 if isinstance( entity, str ): 

197 return linearize_text_rich( auxdata, entity, columns_max ) 

198 if isinstance( entity, BaseException ): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 return linearize_exception_rich( auxdata, entity, columns_max ) 

200 return linearize_object_rich( auxdata, entity, columns_max ) 

201 

202 

203def linearize_stacktrace_plain( 

204 auxdata: LinearizerState, 

205 stacktrace: __.tb.StackSummary, 

206 columns_max: __.Absential[ int ] = __.absent, 

207) -> tuple[ str, ... ]: 

208 infinite_lines = __.is_absent( columns_max ) 

209 lines: list[ str ] = [ ] 

210 for frame in stacktrace: 

211 filename_part = f"File '{frame.filename}'" 

212 lineno_part = f"line {frame.lineno}" if frame.lineno else '' 

213 name_part = f"in {frame.name}" 

214 parts = ( filename_part, lineno_part, name_part ) 

215 address = ', '.join( filter( None, parts ) ) 

216 address_size = len( address ) 

217 if infinite_lines or address_size <= columns_max: 

218 lines.append( address ) 

219 else: 

220 excess_size = address_size - columns_max - 2 # sans ', ' 

221 parts = ( lineno_part, name_part ) 

222 address = ', '.join( filter( None, parts ) ) 

223 address_size = len( address ) 

224 if excess_size <= address_size: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 lines.append( filename_part ) 

226 else: 

227 excess_size = columns_max - len( frame.filename ) + 4 

228 filename = frame.filename[ excess_size : ] 

229 # TODO? Drop middle rather than start. 

230 lines.append( f"File '... {filename}'" ) 

231 lines.append( address ) 

232 if frame.line: 232 ↛ 210line 232 didn't jump to line 210 because the condition on line 232 was always true

233 line = frame.line.strip( ) 

234 # TODO? Apply Pygments to line. 

235 lines_ = iter( 

236 linearize_text_plain( 

237 auxdata, line, 

238 __.absent if infinite_lines else columns_max - 4 ) ) 

239 lines.append( " {}".format( next( lines_ ) ) ) 

240 lines.extend( f" {line_}" for line_ in lines_ ) 

241 return tuple( lines ) 

242 

243 

244def linearize_stacktrace_rich( 

245 auxdata: LinearizerState, 

246 stacktrace: __.tb.StackSummary, 

247 columns_max: __.Absential[ int ] = __.absent, 

248) -> tuple[ str, ... ]: 

249 frames = [ 

250 __.rich_traceback.Frame( 

251 frame.filename, frame.lineno or -1, frame.name, frame.line or '' ) 

252 for frame in stacktrace ] 

253 stack = __.rich_traceback.Stack( 

254 exc_type = 'Callstack', exc_value = 'Inspection', frames = frames ) 

255 trace = __.rich_traceback.Trace( stacks = [ stack ] ) 

256 traceback = __.rich_traceback.Traceback( trace = trace ) 

257 capture = __.io.StringIO( ) 

258 console = __.produce_rich_console( auxdata.control, capture, columns_max ) 

259 console.print( traceback ) 

260 # TODO? Remove exception lines. 

261 return tuple( capture.getvalue( ).split( '\n' ) ) 

262 

263 

264def linearize_text_plain( 

265 auxdata: LinearizerState, 

266 text: str, 

267 columns_max: __.Absential[ int ] = __.absent, 

268) -> tuple[ str, ... ]: 

269 text_no_ansi = __.remove_ansi_c1_sequences( text ) 

270 if __.is_absent( columns_max ): 

271 return tuple( text_no_ansi.split( '\n' ) ) 

272 configuration = auxdata.configuration 

273 incise_excesses = ( 

274 configuration.incision_boundary 

275 is not _core.IncisionBoundaries.Nowhere ) 

276 incise_naturally = ( 

277 configuration.incision_boundary 

278 is _core.IncisionBoundaries.Wordsplits ) 

279 # TODO? Account for wide characters. 

280 return tuple( __.textwrap.wrap( 

281 text_no_ansi, 

282 break_long_words = incise_excesses, 

283 break_on_hyphens = incise_naturally, 

284 width = columns_max ) ) 

285 

286 

287def linearize_text_rich( 

288 auxdata: LinearizerState, 

289 text: str, 

290 columns_max: __.Absential[ int ] = __.absent, 

291) -> tuple[ str, ... ]: 

292 configuration = auxdata.configuration 

293 text_ = __.rich_text.Text.from_ansi( text ) 

294 infinite_lines = __.is_absent( columns_max ) 

295 incise = ( 

296 not infinite_lines 

297 and configuration.incision_boundary 

298 is not _core.IncisionBoundaries.Nowhere ) 

299 capture = __.io.StringIO( ) 

300 console = __.produce_rich_console( auxdata.control, capture, columns_max ) 

301 console.print( 

302 text_, 

303 overflow = 'ignore' if infinite_lines else 'fold', 

304 no_wrap = not incise ) 

305 return tuple( capture.getvalue( ).split( '\n' ) )