Coverage for sources/appcore/introspection.py: 100%

73 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-26 19:13 +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''' Application for configuration introspection. 

22 

23 This module provides a complete CLI application for introspecting 

24 configuration, environment variables, and platform directories. It serves 

25 as both a practical utility and a comprehensive example of building CLI 

26 applications with the :mod:`appcore.cli` framework. 

27 

28 Command-Line Interface 

29 ====================== 

30 

31 The ``appcore`` command provides three main introspection capabilities: 

32 

33 Configuration Inspection 

34 ------------------------ 

35 Display finalized application configuration from TOML files:: 

36 

37 appcore configuration # Default rich format 

38 appcore --display.presentation json configuration 

39 appcore --display.presentation toml configuration 

40 

41 Environment Variables 

42 --------------------- 

43 Show application-specific environment variables:: 

44 

45 appcore environment # All APPCORE_* variables 

46 appcore --display.presentation plain environment 

47 

48 Platform Directories 

49 --------------------- 

50 Display platform-specific directories for the application:: 

51 

52 appcore directories # Show all directory paths 

53 appcore --display.target-file dirs.txt directories 

54 

55 Presentation Formats 

56 ==================== 

57 

58 Multiple output formats are supported through the presentation option: 

59 

60 * ``rich`` (default) - Rich formatted output with syntax highlighting 

61 * ``json`` - JSON format for programmatic consumption 

62 * ``toml`` - TOML format matching input configuration files 

63 * ``plain`` - Plain text format for simple displays 

64 

65 Output Routing 

66 ============== 

67 

68 Flexible output destinations: 

69 

70 Stream Routing 

71 -------------- 

72 * ``--display.target-stream stdout`` (default) - Main output to stdout 

73 * ``--display.target-stream stderr`` - Main output to stderr 

74 * ``--inscription.target-stream stderr`` (default) - Logging to stderr 

75 * ``--inscription.target-stream stdout`` - Logging to stdout 

76 

77 File Output 

78 ----------- 

79 * ``--display.target-file path`` - Save main output to file 

80 * ``--inscription.target-file path`` - Save logging to file 

81 

82 Terminal Control 

83 ================ 

84 

85 Rich terminal behavior can be controlled: 

86 

87 * ``--display.colorize`` / ``--display.no-colorize`` - Control colorization 

88 * ``--display.assume-rich-terminal`` - Force Rich capabilities (testing) 

89 

90 Implementation Architecture 

91 =========================== 

92 

93 Command Structure 

94 ----------------- 

95 * :class:`IntrospectConfigurationCommand` - Configuration introspection 

96 * :class:`IntrospectEnvironmentCommand` - Environment variable inspection 

97 * :class:`IntrospectDirectoriesCommand` - Platform directories inspection 

98 * :class:`ApplicationGlobals` - Extended state for CLI context 

99 

100 Usage as Implementation Example 

101 ================================ 

102 

103 This module demonstrates comprehensive CLI application patterns: 

104 

105 * Command inheritance from :class:`appcore.cli.Command` 

106 * Async execution with proper error handling 

107 * Integration with appcore preparation and configuration systems 

108 * File and stream output routing capabilities 

109 * Rich terminal integration with automatic capability detection 

110 

111 The source code serves as a reference implementation for building similar 

112 CLI applications with the appcore framework. 

113''' 

114 

115 

116import json as _json 

117 

118from . import __ 

119from . import cli as _cli 

120from . import exceptions as _exceptions 

121from . import state as _state 

122 

123 

124try: import rich.console as _rich_console 

125except ImportError as _error: # pragma: no cover 

126 raise _exceptions.DependencyAbsence( 'rich', 'CLI' ) from _error 

127try: import tomli_w as _tomli_w 

128except ImportError as _error: # pragma: no cover 

129 raise _exceptions.DependencyAbsence( 'tomli-w', 'CLI' ) from _error 

130try: import tyro as _tyro 

131except ImportError as _error: # pragma: no cover 

132 raise _exceptions.DependencyAbsence( 'tyro', 'CLI' ) from _error 

133 

134 

135class Presentations( __.enum.Enum ): # TODO: Python 3.11: StrEnum 

136 ''' Presentation mode (format) for CLI output. ''' 

137 

138 Json = 'json' 

139 Plain = 'plain' 

140 Rich = 'rich' 

141 Toml = 'toml' 

142 

143 

144class DisplayOptions( _cli.DisplayOptions ): 

145 ''' Display options, including presentation mode. ''' 

146 

147 presentation: __.typx.Annotated[ 

148 Presentations, 

149 _tyro.conf.arg( help = "Output presentation mode (format)." ), 

150 ] = Presentations.Rich 

151 

152 async def render( self, data: __.typx.Any ) -> None: 

153 ''' Renders data according to display options. ''' 

154 async with __.ctxl.AsyncExitStack( ) as exits: 

155 target = await self.provide_stream( exits ) 

156 match self.presentation: 

157 case Presentations.Json: 

158 content = _json.dumps( 

159 data, indent = 2, ensure_ascii = False ) 

160 print( content, file = target ) 

161 case Presentations.Plain: 

162 self._render_plain( data, target ) 

163 case Presentations.Rich: 

164 if self.determine_colorization( target ): 

165 self._render_rich( data, target) 

166 else: self._render_plain( data, target ) 

167 case Presentations.Toml: 

168 content = _tomli_w.dumps( data ) 

169 print( content, file = target ) 

170 

171 def _render_plain( 

172 self, data: __.typx.Any, target: __.typx.TextIO 

173 ) -> None: 

174 ''' Renders object in plain text format. ''' 

175 if isinstance( data, __.cabc.Mapping ): 

176 for key, value in data.items( ): # pyright: ignore 

177 print( f"{key}: {value}", file = target ) 

178 else: print( data, file = target ) # pragma: no cover 

179 

180 def _render_rich( 

181 self, data: __.typx.Any, target: __.typx.TextIO 

182 ) -> None: 

183 ''' Renders object using Rich formatting. ''' 

184 console = _rich_console.Console( 

185 file = target, 

186 color_system = 'auto' if self.colorize else None ) 

187 console.print( data ) 

188 

189 

190class ApplicationGlobals( _state.Globals ): 

191 ''' Includes display options. ''' 

192 

193 display: DisplayOptions = __.dcls.field( default_factory = DisplayOptions ) 

194 

195 

196class IntrospectConfigurationCommand( _cli.Command ): 

197 ''' Shows finalized application configuration. ''' 

198 

199 async def execute( self, auxdata: _state.Globals ) -> None: 

200 if not isinstance( auxdata, ApplicationGlobals ): # pragma: no cover 

201 raise _exceptions.ContextInvalidity( auxdata ) 

202 data = dict( auxdata.configuration ) 

203 await auxdata.display.render( data ) 

204 

205 

206class IntrospectDirectoriesCommand( _cli.Command ): 

207 ''' Shows application and package directories. ''' 

208 

209 async def execute( self, auxdata: _state.Globals ): 

210 if not isinstance( auxdata, ApplicationGlobals ): # pragma: no cover 

211 raise _exceptions.ContextInvalidity( auxdata ) 

212 directories = { 

213 'application-cache': str( auxdata.provide_cache_location( ) ), 

214 'application-data': str( auxdata.provide_data_location( ) ), 

215 'application-state': str( auxdata.provide_state_location( ) ), 

216 'package-data': str( 

217 auxdata.distribution.provide_data_location( ) 

218 ), 

219 } 

220 await auxdata.display.render( directories ) 

221 

222 

223class IntrospectEnvironmentCommand( _cli.Command ): 

224 ''' Shows application-specific environment variables. ''' 

225 

226 async def execute( self, auxdata: _state.Globals ) -> None: 

227 if not isinstance( auxdata, ApplicationGlobals ): # pragma: no cover 

228 raise _exceptions.ContextInvalidity( auxdata ) 

229 name = auxdata.application.name.upper( ) 

230 envvars = { 

231 k: v for k, v in __.os.environ.items( ) 

232 if k.startswith( f"{name}_" ) } 

233 await auxdata.display.render( envvars ) 

234 

235 

236class Application( _cli.Application ): 

237 ''' Application for introspection of configuration. ''' 

238 

239 display: DisplayOptions = __.dcls.field( default_factory = DisplayOptions ) 

240 command: __.typx.Union[ 

241 __.typx.Annotated[ 

242 IntrospectConfigurationCommand, 

243 _tyro.conf.subcommand( 'configuration', prefix_name = False ), 

244 ], 

245 __.typx.Annotated[ 

246 IntrospectEnvironmentCommand, 

247 _tyro.conf.subcommand( 'environment', prefix_name = False ), 

248 ], 

249 __.typx.Annotated[ 

250 IntrospectDirectoriesCommand, 

251 _tyro.conf.subcommand( 'directories', prefix_name = False ), 

252 ], 

253 ] = __.dcls.field( default_factory = IntrospectConfigurationCommand ) 

254 

255 async def execute( self, auxdata: _state.Globals ) -> None: 

256 await self.command( auxdata ) 

257 

258 async def prepare( self, exits: __.ctxl.AsyncExitStack ) -> _state.Globals: 

259 auxdata_base = await super( ).prepare( exits ) 

260 nomargs = { 

261 field.name: getattr( auxdata_base, field.name ) 

262 for field in __.dcls.fields( auxdata_base ) 

263 if not field.name.startswith( '_' ) } 

264 return ApplicationGlobals( display = self.display, **nomargs ) 

265 

266 

267def execute_cli( ) -> None: 

268 ''' Synchronous entrypoint. ''' 

269 if ( any( arg in ( '--help', '-h' ) for arg in __.sys.argv ) 

270 and _avoid_non_utf_terminals( ) 

271 ): return 

272 configuration = ( _tyro.conf.EnumChoicesFromValues, ) 

273 __.asyncio.run( _tyro.cli( Application, config = configuration )( ) ) 

274 

275 

276def _avoid_non_utf_terminals( ) -> bool: 

277 ''' Avoids terminals which do not support UTF charset encoding. 

278 

279 E.g., Git Bash Mintty terminals with cp1252 charset encoding. 

280 ''' 

281 is_windows_cp1252 = ( 

282 __.sys.platform == 'win32' 

283 and getattr( __.sys.stdout, 'encoding', '' ).lower( ) == 'cp1252' ) 

284 if not is_windows_cp1252: return False 

285 encoding = getattr( __.sys.stdout, 'encoding', 'unknown' ) 

286 message = ( 

287 f"Help display is not available in this terminal " 

288 f"(encoding: {encoding}).\n" 

289 "Unicode characters required by the help system are not supported.\n\n" 

290 "To view help, try running this command in:\n" 

291 "- Windows Terminal\n" 

292 "- PowerShell\n" 

293 "- Command Prompt with UTF-8 support\n" 

294 "- WSL\n\n" 

295 "For basic usage: appcore <subcommand>\n" 

296 "Available subcommands: configuration, environment, directories" ) 

297 print( message, file = __.sys.stderr ) 

298 raise SystemExit( 0 )