Coverage for sources/ictruck/vehicles.py: 100%

175 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-03-31 03: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''' Vehicles which vend flavors of Icecream debugger. ''' 

22 

23# TODO: Always add module configuration for 'ictruck' itself to trucks. 

24# This allows for it to trace much of its own execution if its flavors 

25# are activated. Suggested flavors: 

26# ictruck-note: Noteworthy event. 

27# ictruck-error: Error. 

28 

29 

30from __future__ import annotations 

31 

32import icecream as _icecream 

33 

34from . import __ 

35from . import configuration as _cfg 

36from . import exceptions as _exceptions 

37 

38 

39# pylint: disable=import-error,import-private-name 

40if __.typx.TYPE_CHECKING: # pragma: no cover 

41 import _typeshed 

42# pylint: enable=import-error,import-private-name 

43 

44 

45_installer_lock: __.threads.Lock = __.threads.Lock( ) 

46_validate_arguments = ( 

47 __.validate_arguments( 

48 globalvars = globals( ), 

49 errorclass = _exceptions.ArgumentClassInvalidity ) ) 

50 

51 

52builtins_alias_default: __.typx.Annotated[ 

53 str, 

54 __.typx.Doc( ''' Default alias for global truck in builtins module. ''' ), 

55] = 'ictr' 

56 

57 

58class Truck( metaclass = __.ImmutableCompleteDataclass ): 

59 ''' Vends flavors of Icecream debugger. ''' 

60 

61 # pylint: disable=invalid-field-call 

62 active_flavors: __.typx.Annotated[ 

63 ActiveFlavorsRegistry, 

64 __.typx.Doc( 

65 ''' Mapping of module names to active flavor sets. 

66 

67 Key ``None`` applies globally. Module-specific entries 

68 override globals for that module. 

69 ''' ), 

70 ] = __.dcls.field( default_factory = __.ImmutableDictionary ) # pyright: ignore 

71 generalcfg: __.typx.Annotated[ 

72 _cfg.VehicleConfiguration, 

73 __.typx.Doc( 

74 ''' General configuration. 

75 

76 Top of configuration inheritance hierarchy. 

77 Default is suitable for application use. 

78 ''' ), 

79 ] = __.dcls.field( default_factory = _cfg.VehicleConfiguration ) 

80 modulecfgs: __.typx.Annotated[ 

81 __.AccretiveDictionary[ str, _cfg.ModuleConfiguration ], 

82 __.typx.Doc( 

83 ''' Registry of per-module configurations. 

84 

85 Modules inherit configuration from their parent packages. 

86 Top-level packages inherit from general instance 

87 configruration. 

88 ''' ), 

89 ] = __.dcls.field( default_factory = __.AccretiveDictionary ) # pyright: ignore 

90 printer_factory: __.typx.Annotated[ 

91 PrinterFactoryUnion, 

92 __.typx.Doc( 

93 ''' Factory which produces callables to output text somewhere. 

94 

95 May also be writable text stream. 

96 Factories take two arguments, module name and flavor, and 

97 return a callable which takes one argument, the string 

98 produced by a formatter. 

99 ''' ), 

100 ] = __.dcls.field( 

101 default_factory = ( 

102 lambda: lambda mname, flavor: _icecream.DEFAULT_OUTPUT_FUNCTION ) ) 

103 trace_levels: __.typx.Annotated[ 

104 TraceLevelsRegistry, 

105 __.typx.Doc( 

106 ''' Mapping of module names to maximum trace depths. 

107 

108 Key ``None`` applies globally. Module-specific entries 

109 override globals for that module. 

110 ''' ), 

111 ] = __.dcls.field( 

112 default_factory = lambda: __.ImmutableDictionary( { None: -1 } ) ) 

113 _debuggers: __.typx.Annotated[ 

114 __.AccretiveDictionary[ 

115 tuple[ str, _cfg.Flavor ], _icecream.IceCreamDebugger ], 

116 __.typx.Doc( 

117 ''' Cache of debugger instances by module and flavor. ''' ), 

118 ] = __.dcls.field( default_factory = __.AccretiveDictionary ) # pyright: ignore 

119 _debuggers_lock: __.typx.Annotated[ 

120 __.threads.Lock, 

121 __.typx.Doc( ''' Access lock for cache of debugger instances. ''' ), 

122 ] = __.dcls.field( default_factory = __.threads.Lock ) 

123 # pylint: enable=invalid-field-call 

124 

125 @_validate_arguments 

126 def __call__( self, flavor: _cfg.Flavor ) -> _icecream.IceCreamDebugger: 

127 ''' Vends flavor of Icecream debugger. ''' 

128 mname = _discover_invoker_module_name( ) 

129 cache_index = ( mname, flavor ) 

130 if cache_index in self._debuggers: # pylint: disable=unsupported-membership-test 

131 with self._debuggers_lock: # pylint: disable=not-context-manager 

132 return self._debuggers[ cache_index ] # pylint: disable=unsubscriptable-object 

133 configuration = _produce_ic_configuration( self, mname, flavor ) 

134 control = _cfg.FormatterControl( ) 

135 initargs = _calculate_ic_initargs( 

136 self, configuration, control, mname, flavor ) 

137 debugger = _icecream.IceCreamDebugger( **initargs ) 

138 if isinstance( flavor, int ): 

139 trace_level = ( 

140 _calculate_effective_trace_level( self.trace_levels, mname) ) 

141 debugger.enabled = flavor <= trace_level 

142 elif isinstance( flavor, str ): # pragma: no branch 

143 active_flavors = ( 

144 _calculate_effective_flavors( self.active_flavors, mname ) ) 

145 debugger.enabled = flavor in active_flavors 

146 with self._debuggers_lock: # pylint: disable=not-context-manager 

147 self._debuggers[ cache_index ] = debugger # pylint: disable=unsupported-assignment-operation 

148 return debugger 

149 

150 @_validate_arguments 

151 def install( self, alias: str = builtins_alias_default ) -> __.typx.Self: 

152 ''' Installs truck into builtins with provided alias. 

153 

154 Replaces an existing truck, preserving its module configurations. 

155 

156 Library developers should call :py:func:`register_module` instead. 

157 ''' 

158 import builtins 

159 with _installer_lock: 

160 truck_o = getattr( builtins, alias, None ) 

161 if isinstance( truck_o, Truck ): 

162 # TODO: self( 'ictruck-note' )( 'truck replacement', self ) 

163 self.modulecfgs.update( truck_o.modulecfgs ) 

164 setattr( builtins, alias, self ) 

165 else: 

166 __.install_builtin_safely( 

167 alias, self, _exceptions.AttributeNondisplacement ) 

168 return self 

169 

170 @_validate_arguments 

171 def register_module( 

172 self, 

173 name: __.Absential[ str ] = __.absent, 

174 configuration: __.Absential[ _cfg.ModuleConfiguration ] = __.absent, 

175 ) -> __.typx.Self: 

176 ''' Registers configuration for module. 

177 

178 If no module or package name is given, then the current module is 

179 inferred. 

180 

181 If no configuration is provided, then a default is generated. 

182 ''' 

183 if __.is_absent( name ): 

184 name = _discover_invoker_module_name( ) 

185 if __.is_absent( configuration ): 

186 configuration = _cfg.ModuleConfiguration( ) 

187 self.modulecfgs[ name ] = configuration # pylint: disable=unsupported-assignment-operation 

188 return self 

189 

190 

191ActiveFlavors: __.typx.TypeAlias = frozenset[ _cfg.Flavor ] 

192ActiveFlavorsLiberal: __.typx.TypeAlias = ( 

193 __.cabc.Sequence[ _cfg.Flavor ] | __.cabc.Set[ _cfg.Flavor ] ) 

194ActiveFlavorsRegistry: __.typx.TypeAlias = ( 

195 __.ImmutableDictionary[ str | None, ActiveFlavors ] ) 

196ActiveFlavorsRegistryLiberal: __.typx.TypeAlias = ( 

197 __.cabc.Mapping[ str | None, ActiveFlavorsLiberal ] ) 

198Printer: __.typx.TypeAlias = __.cabc.Callable[ [ str ], None ] 

199PrinterFactory: __.typx.TypeAlias = ( 

200 __.cabc.Callable[ [ str, _cfg.Flavor ], Printer ] ) 

201PrinterFactoryUnion: __.typx.TypeAlias = __.io.TextIOBase | PrinterFactory 

202TraceLevelsRegistry: __.typx.TypeAlias = ( 

203 __.ImmutableDictionary[ str | None, int ] ) 

204TraceLevelsRegistryLiberal: __.typx.TypeAlias = ( 

205 __.cabc.Mapping[ str | None, int ] ) 

206 

207InstallAliasArgument: __.typx.TypeAlias = __.typx.Annotated[ 

208 str, 

209 __.typx.Doc( 

210 ''' Alias under which the truck is installed in builtins. ''' ), 

211] 

212ProduceTruckActiveFlavorsArgument: __.typx.TypeAlias = __.typx.Annotated[ 

213 __.Absential[ ActiveFlavorsLiberal | ActiveFlavorsRegistryLiberal ], 

214 __.typx.Doc( 

215 ''' Flavors to activate. 

216 

217 Can be collection, which applies globally across all registered 

218 modules. Or, can be mapping of module names to sets. 

219 

220 Module-specific entries merge with global entries. 

221 ''' ), 

222] 

223ProduceTruckFlavorsArgument: __.typx.TypeAlias = __.typx.Annotated[ 

224 __.Absential[ _cfg.FlavorsRegistryLiberal ], 

225 __.typx.Doc( ''' Registry of flavor identifiers to configurations. ''' ), 

226] 

227ProduceTruckGeneralcfgArgument: __.typx.TypeAlias = __.typx.Annotated[ 

228 __.Absential[ _cfg.VehicleConfiguration ], 

229 __.typx.Doc( 

230 ''' General configuration for the truck. 

231 

232 Top of configuration inheritance hierarchy. If absent, 

233 defaults to a suitable configuration for application use. 

234 ''' ), 

235] 

236ProduceTruckPrinterFactoryArgument: __.typx.TypeAlias = __.typx.Annotated[ 

237 __.Absential[ PrinterFactoryUnion ], 

238 __.typx.Doc( 

239 ''' Factory which produces callables to output text somewhere. 

240 

241 May also be writable text stream. 

242 Factories take two arguments, module name and flavor, and 

243 return a callable which takes one argument, the string 

244 produced by a formatter. 

245 

246 If absent, uses a default. 

247 ''' ), 

248] 

249ProduceTruckTraceLevelsArgument: __.typx.TypeAlias = __.typx.Annotated[ 

250 __.Absential[ int | TraceLevelsRegistryLiberal ], 

251 __.typx.Doc( 

252 ''' Maximum trace depths. 

253 

254 Can be an integer, which applies globally across all registered 

255 modules. Or, can be a mapping of module names to integers. 

256 

257 Module-specific entries override global entries. 

258 ''' ), 

259] 

260RegisterModuleFormatterFactoryArgument: __.typx.TypeAlias = __.typx.Annotated[ 

261 __.Absential[ _cfg.FormatterFactory ], 

262 __.typx.Doc( 

263 ''' Factory which produces formatter callable. 

264 

265 Takes formatter control, module name, and flavor as arguments. 

266 Returns formatter to convert an argument to a string. 

267 ''' ), 

268] 

269RegisterModuleIncludeContextArgument: __.typx.TypeAlias = __.typx.Annotated[ 

270 __.Absential[ bool ], 

271 __.typx.Doc( ''' Include stack frame with output? ''' ), 

272] 

273RegisterModuleNameArgument: __.typx.TypeAlias = __.typx.Annotated[ 

274 __.Absential[ str ], 

275 __.typx.Doc( 

276 ''' Name of the module to register. 

277 

278 If absent, infers the current module name. 

279 ''' ), 

280] 

281RegisterModulePrefixEmitterArgument: __.typx.TypeAlias = __.typx.Annotated[ 

282 __.Absential[ _cfg.PrefixEmitterUnion ], 

283 __.typx.Doc( 

284 ''' String or factory which produces output prefix string. 

285 

286 Factory takes formatter control, module name, and flavor as 

287 arguments. Returns prefix string. 

288 ''' ), 

289] 

290 

291@_validate_arguments 

292def install( 

293 alias: InstallAliasArgument = builtins_alias_default, 

294 active_flavors: ProduceTruckActiveFlavorsArgument = __.absent, 

295 generalcfg: ProduceTruckGeneralcfgArgument = __.absent, 

296 printer_factory: ProduceTruckPrinterFactoryArgument = __.absent, 

297 trace_levels: ProduceTruckTraceLevelsArgument = __.absent, 

298) -> Truck: 

299 ''' Produces truck and installs it into builtins with alias. 

300 

301 Replaces an existing truck, preserving its module configurations. 

302 

303 Library developers should call :py:func:`register_module` instead. 

304 ''' 

305 truck = produce_truck( 

306 active_flavors = active_flavors, 

307 generalcfg = generalcfg, 

308 printer_factory = printer_factory, 

309 trace_levels = trace_levels ) 

310 return truck.install( alias = alias ) 

311 

312 

313@_validate_arguments 

314def produce_truck( 

315 active_flavors: ProduceTruckActiveFlavorsArgument = __.absent, 

316 generalcfg: ProduceTruckGeneralcfgArgument = __.absent, 

317 printer_factory: ProduceTruckPrinterFactoryArgument = __.absent, 

318 trace_levels: ProduceTruckTraceLevelsArgument = __.absent, 

319) -> Truck: 

320 ''' Produces icecream truck with some shorthand argument values. ''' 

321 # TODO: Deeper validation of active flavors and trace levels. 

322 # TODO: Deeper validation of printer factory. 

323 nomargs: dict[ str, __.typx.Any ] = { } 

324 if not __.is_absent( generalcfg ): 

325 nomargs[ 'generalcfg' ] = generalcfg 

326 if not __.is_absent( printer_factory ): 

327 nomargs[ 'printer_factory' ] = printer_factory 

328 if not __.is_absent( active_flavors ): 

329 if isinstance( active_flavors, ( __.cabc.Sequence, __.cabc.Set ) ): 

330 nomargs[ 'active_flavors' ] = __.ImmutableDictionary( 

331 { None: frozenset( active_flavors ) } ) 

332 else: 

333 nomargs[ 'active_flavors' ] = __.ImmutableDictionary( { 

334 mname: frozenset( flavors ) 

335 for mname, flavors in active_flavors.items( ) } ) 

336 if not __.is_absent( trace_levels ): 

337 if isinstance( trace_levels, int ): 

338 nomargs[ 'trace_levels' ] = __.ImmutableDictionary( 

339 { None: trace_levels } ) 

340 else: 

341 nomargs[ 'trace_levels' ] = __.ImmutableDictionary( trace_levels ) 

342 return Truck( **nomargs ) 

343 

344 

345@_validate_arguments 

346def register_module( 

347 name: RegisterModuleNameArgument = __.absent, 

348 flavors: ProduceTruckFlavorsArgument = __.absent, 

349 formatter_factory: RegisterModuleFormatterFactoryArgument = __.absent, 

350 include_context: RegisterModuleIncludeContextArgument = __.absent, 

351 prefix_emitter: RegisterModulePrefixEmitterArgument = __.absent, 

352) -> None: 

353 ''' Registers module configuration on the builtin truck. 

354 

355 If no truck exists in builtins, installs one which produces null 

356 printers. 

357 

358 Intended for library developers to configure debugging flavors 

359 without overriding anything set by the application or other libraries. 

360 Application developers should call :py:func:`install` instead. 

361 ''' 

362 import builtins 

363 truck = getattr( builtins, builtins_alias_default, None ) 

364 if not isinstance( truck, Truck ): 

365 truck = Truck( printer_factory = lambda mname, flavor: lambda x: None ) 

366 __.install_builtin_safely( 

367 builtins_alias_default, 

368 truck, 

369 _exceptions.AttributeNondisplacement ) 

370 nomargs: dict[ str, __.typx.Any ] = { } 

371 if not __.is_absent( flavors ): 

372 nomargs[ 'flavors' ] = __.ImmutableDictionary( flavors ) 

373 if not __.is_absent( formatter_factory ): 

374 nomargs[ 'formatter_factory' ] = formatter_factory 

375 if not __.is_absent( include_context ): 

376 nomargs[ 'include_context' ] = include_context 

377 if not __.is_absent( prefix_emitter ): 

378 nomargs[ 'prefix_emitter' ] = prefix_emitter 

379 configuration = _cfg.ModuleConfiguration( **nomargs ) 

380 truck.register_module( name = name, configuration = configuration ) 

381 

382 

383def _calculate_effective_flavors( 

384 flavors: ActiveFlavorsRegistry, mname: str 

385) -> ActiveFlavors: 

386 result = set( flavors.get( None, frozenset( ) ) ) 

387 for mname_ in _iterate_module_name_ancestry( mname ): 

388 if mname_ in flavors: 

389 result |= set( flavors[ mname_ ] ) 

390 return frozenset( result ) 

391 

392 

393def _calculate_effective_trace_level( 

394 levels: TraceLevelsRegistry, mname: str 

395) -> int: 

396 result = levels.get( None, -1 ) 

397 for mname_ in _iterate_module_name_ancestry( mname ): 

398 if mname_ in levels: 

399 result = levels[ mname_ ] 

400 return result 

401 

402 

403def _calculate_ic_initargs( 

404 truck: Truck, 

405 configuration: __.ImmutableDictionary[ str, __.typx.Any ], 

406 control: _cfg.FormatterControl, 

407 mname: str, 

408 flavor: _cfg.Flavor, 

409) -> dict[ str, __.typx.Any ]: 

410 nomargs: dict[ str, __.typx.Any ] = { } 

411 nomargs[ 'argToStringFunction' ] = ( 

412 configuration[ 'formatter_factory' ]( control, mname, flavor ) ) 

413 nomargs[ 'includeContext' ] = configuration[ 'include_context' ] 

414 if isinstance( truck.printer_factory, __.io.TextIOBase ): 

415 printer = __.funct.partial( print, file = truck.printer_factory ) 

416 else: printer = truck.printer_factory( mname, flavor ) # pylint: disable=not-callable 

417 nomargs[ 'outputFunction' ] = printer 

418 prefix_emitter = configuration[ 'prefix_emitter' ] 

419 nomargs[ 'prefix' ] = ( 

420 prefix_emitter if isinstance( prefix_emitter, str ) 

421 else prefix_emitter( mname, flavor ) ) 

422 return nomargs 

423 

424 

425def _dict_from_dataclass( 

426 obj: _typeshed.DataclassInstance 

427) -> dict[ str, __.typx.Any ]: 

428 return { 

429 field.name: getattr( obj, field.name ) 

430 for field in __.dcls.fields( obj ) } 

431 

432 

433def _discover_invoker_module_name( ) -> str: 

434 frame = __.inspect.currentframe( ) 

435 while frame: # pragma: no branch 

436 module = __.inspect.getmodule( frame ) 

437 if module is None: 

438 # pylint: disable=magic-value-comparison 

439 if '<stdin>' == frame.f_code.co_filename: # pragma: no cover 

440 name = '__main__' 

441 break 

442 # pylint: enable=magic-value-comparison 

443 raise _exceptions.ModuleInferenceFailure 

444 name = module.__name__ 

445 if not name.startswith( f"{__package__}." ): break 

446 frame = frame.f_back 

447 return name 

448 

449 

450def _iterate_module_name_ancestry( name: str ) -> __.cabc.Iterator[ str ]: 

451 parts = name.split( '.' ) 

452 for i in range( len( parts ) ): 

453 yield '.'.join( parts[ : i + 1 ] ) 

454 

455 

456def _merge_ic_configuration( 

457 base: dict[ str, __.typx.Any ], update_obj: _typeshed.DataclassInstance 

458) -> dict[ str, __.typx.Any ]: 

459 update: dict[ str, __.typx.Any ] = _dict_from_dataclass( update_obj ) 

460 result: dict[ str, __.typx.Any ] = { } 

461 result[ 'flavors' ] = ( 

462 dict( base.get( 'flavors', dict( ) ) ) 

463 | dict( update.get( 'flavors', dict( ) ) ) ) 

464 for ename in ( 'formatter_factory', 'include_context', 'prefix_emitter' ): 

465 uvalue = update.get( ename ) 

466 if uvalue is not None: result[ ename ] = uvalue 

467 elif ename in base: result[ ename ] = base[ ename ] 

468 return result 

469 

470 

471def _produce_ic_configuration( 

472 vehicle: Truck, mname: str, flavor: _cfg.Flavor 

473) -> __.ImmutableDictionary[ str, __.typx.Any ]: 

474 fconfigs: list[ _cfg.FlavorConfiguration ] = [ ] 

475 vconfig = vehicle.generalcfg 

476 configd: dict[ str, __.typx.Any ] = { 

477 field.name: getattr( vconfig, field.name ) 

478 for field in __.dcls.fields( vconfig ) } 

479 if flavor in vconfig.flavors: 

480 fconfigs.append( vconfig.flavors[ flavor ] ) 

481 for mname_ in _iterate_module_name_ancestry( mname ): 

482 if mname_ not in vehicle.modulecfgs: continue 

483 mconfig = vehicle.modulecfgs[ mname_ ] 

484 configd = _merge_ic_configuration( configd, mconfig ) 

485 if flavor in mconfig.flavors: 

486 fconfigs.append( mconfig.flavors[ flavor ] ) 

487 if not fconfigs: raise _exceptions.FlavorInavailability( flavor ) 

488 # Apply collected flavor configs after general and module configs. 

489 # (Applied in top-down order for correct overrides.) 

490 for fconfig in fconfigs: 

491 configd = _merge_ic_configuration( configd, fconfig ) 

492 return __.ImmutableDictionary( configd )