Coverage for sources/appcore/cli.py: 98%
71 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-27 22:40 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-27 22:40 +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''' CLI foundation classes and interfaces.
23 This module provides the core infrastructure for building command-line
24 interfaces. It offers a comprehensive framework for creating CLI
25 applications with rich presentation options, flexible output routing, and
26 integrated logging capabilities.
28 Key Components
29 ==============
31 Command Framework
32 -----------------
33 * :class:`Command` - Abstract base class for CLI command implementations
34 * :class:`Application` dataclass for command-line application configuration
35 * Rich integration with tyro for automatic argument parsing and help
36 generation
38 Display and Output Control
39 --------------------------
40 * :class:`DisplayOptions` - Configuration for output presentation and
41 routing
42 * :class:`InscriptionControl` - Configuration for logging and diagnostic
43 output
44 * Stream routing (stdout/stderr) and file output capabilities
45 * Rich terminal detection with colorization control
47 Example Usage
48 =============
50 Basic CLI application with custom display options and subcommands::
52 from appcore import cli, state
54 class MyDisplayOptions( cli.DisplayOptions ):
55 format: str = 'table'
57 class MyGlobals( state.Globals ):
58 display: MyDisplayOptions
60 class StatusCommand( cli.Command ):
61 async def execute( self, auxdata: state.Globals ) -> None:
62 if isinstance( auxdata, MyGlobals ):
63 format_val = auxdata.display.format
64 print( f"Status: Running (format: {format_val})" )
66 class InfoCommand( cli.Command ):
67 async def execute( self, auxdata: state.Globals ) -> None:
68 print( f"App: {auxdata.application.name}" )
70 class MyApplication( cli.Application ):
71 display: MyDisplayOptions = __.dcls.field(
72 default_factory = MyDisplayOptions )
73 command: __.typx.Union[
74 __.typx.Annotated[
75 StatusCommand,
76 _tyro.conf.subcommand( 'status', prefix_name = False ),
77 ],
78 __.typx.Annotated[
79 InfoCommand,
80 _tyro.conf.subcommand( 'info', prefix_name = False ),
81 ],
82 ] = __.dcls.field( default_factory = StatusCommand )
84 async def execute( self, auxdata: state.Globals ) -> None:
85 await self.command( auxdata )
87 async def prepare( self, exits ) -> state.Globals:
88 auxdata_base = await super( ).prepare( exits )
89 return MyGlobals(
90 display = self.display, **auxdata_base.__dict__ )
91'''
94from . import __
95from . import exceptions as _exceptions
96from . import inscription as _inscription
97from . import preparation as _preparation
98from . import state as _state
101try: import rich
102except ImportError as _error: # pragma: no cover
103 raise _exceptions.DependencyAbsence( 'rich', 'CLI' ) from _error
104else: del rich
105try: import tomli_w
106except ImportError as _error: # pragma: no cover
107 raise _exceptions.DependencyAbsence( 'tomli-w', 'CLI' ) from _error
108else: del tomli_w
109try: import tyro as _tyro
110except ImportError as _error: # pragma: no cover
111 raise _exceptions.DependencyAbsence( 'tyro', 'CLI' ) from _error
114_DisplayTargetMutex = _tyro.conf.create_mutex_group( required = False )
115_InscriptionTargetMutex = _tyro.conf.create_mutex_group( required = False )
118class TargetStreams( __.enum.Enum ): # TODO: Python 3.11: StrEnum
119 ''' Target stream selection. '''
121 Stdout = 'stdout'
122 Stderr = 'stderr'
125class DisplayOptions( __.immut.DataclassObject ):
126 ''' Standardized display configuration for CLI applications.
128 Example::
130 class MyDisplayOptions( DisplayOptions ):
131 format: str = 'table'
132 compact: bool = False
133 '''
135 colorize: __.typx.Annotated[
136 bool,
137 _tyro.conf.arg(
138 aliases = ( '--ansi-sgr', ),
139 help = "Enable colored output and terminal formatting." ),
140 ] = True
141 target_file: __.typx.Annotated[
142 __.typx.Optional[ __.Path ],
143 _DisplayTargetMutex,
144 _tyro.conf.DisallowNone,
145 _tyro.conf.arg( help = "Render output to specified file." ),
146 ] = None
147 target_stream: __.typx.Annotated[
148 __.typx.Optional[ TargetStreams ],
149 _DisplayTargetMutex,
150 _tyro.conf.DisallowNone,
151 _tyro.conf.arg( help = "Render output on stdout or stderr." ),
152 ] = TargetStreams.Stdout
153 assume_rich_terminal: __.typx.Annotated[
154 bool,
155 _tyro.conf.arg(
156 aliases = ( '--force-tty', ),
157 help = "Assume Rich terminal capabilities regardless of TTY." ),
158 ] = False
160 def determine_colorization( self, stream: __.typx.TextIO ) -> bool:
161 ''' Determines whether to use colorized output. '''
162 if self.assume_rich_terminal: return self.colorize
163 if not self.colorize: return False 163 ↛ exitline 163 didn't return from function 'determine_colorization' because the return on line 163 wasn't executed
164 if __.os.environ.get( 'NO_COLOR' ): return False 164 ↛ exitline 164 didn't return from function 'determine_colorization' because the return on line 164 wasn't executed
165 return hasattr( stream, 'isatty' ) and stream.isatty( )
167 async def provide_stream(
168 self, exits: __.ctxl.AsyncExitStack
169 ) -> __.typx.TextIO:
170 ''' Provides target stream from options. '''
171 if self.target_file is not None:
172 target_location = self.target_file.resolve( )
173 target_location.parent.mkdir( exist_ok = True, parents = True )
174 return exits.enter_context( target_location.open( 'w' ) )
175 target_stream = self.target_stream or TargetStreams.Stderr
176 match target_stream:
177 case TargetStreams.Stdout: return __.sys.stdout
178 case TargetStreams.Stderr: return __.sys.stderr
181class InscriptionControl( __.immut.DataclassObject ):
182 ''' Inscription (logging, debug prints) control. '''
184 level: __.typx.Annotated[
185 _inscription.Levels, _tyro.conf.arg( help = "Log verbosity." )
186 ] = 'info'
187 presentation: __.typx.Annotated[
188 _inscription.Presentations,
189 _tyro.conf.arg( help = "Log presentation mode (format)." ),
190 ] = _inscription.Presentations.Plain
191 target_file: __.typx.Annotated[
192 __.typx.Optional[ __.Path ],
193 _InscriptionTargetMutex,
194 _tyro.conf.DisallowNone,
195 _tyro.conf.arg( help = "Log to specified file." ),
196 ] = None
197 target_stream: __.typx.Annotated[
198 __.typx.Optional[ TargetStreams ],
199 _InscriptionTargetMutex,
200 _tyro.conf.DisallowNone,
201 _tyro.conf.arg( help = "Log to stdout or stderr." ),
202 ] = TargetStreams.Stderr
204 def as_control(
205 self, exits: __.ctxl.AsyncExitStack
206 ) -> _inscription.Control:
207 ''' Produces compatible inscription control for appcore. '''
208 if self.target_file is not None:
209 target_location = self.target_file.resolve( )
210 target_location.parent.mkdir( exist_ok = True, parents = True )
211 target_stream = exits.enter_context( target_location.open( 'w' ) )
212 else:
213 target_stream_ = self.target_stream or TargetStreams.Stderr
214 match target_stream_:
215 case TargetStreams.Stdout: target_stream = __.sys.stdout
216 case TargetStreams.Stderr: target_stream = __.sys.stderr
217 return _inscription.Control(
218 mode = self.presentation,
219 level = self.level,
220 target = target_stream )
223class Command(
224 __.immut.DataclassProtocol, __.typx.Protocol,
225 decorators = ( __.typx.runtime_checkable, ),
226):
227 ''' Standard interface for command implementations.
229 Example::
231 class StatusCommand( Command ):
232 async def execute( self, auxdata: state.Globals ) -> None:
233 print( f"Application: {auxdata.application.name}" )
234 '''
236 async def __call__( self, auxdata: _state.Globals ) -> None:
237 ''' Prepares session context and executes command. '''
238 await self.execute( await self.prepare( auxdata ) )
240 @__.abc.abstractmethod
241 async def execute( self, auxdata: _state.Globals ) -> None:
242 ''' Executes command. '''
243 raise NotImplementedError # pragma: no cover
245 async def prepare( self, auxdata: _state.Globals ) -> _state.Globals:
246 ''' Prepares session context. '''
247 return auxdata
250class Application(
251 __.immut.DataclassProtocol, __.typx.Protocol,
252 decorators = ( __.typx.runtime_checkable, ),
253):
254 ''' Common infrastructure and standard interface for applications.
256 Example::
258 class MyApplication( Application ):
259 display: DisplayOptions = __.dcls.field(
260 default_factory = DisplayOptions )
262 async def execute( self, auxdata: state.Globals ) -> None:
263 print( f"Application: {auxdata.application.name}" )
264 '''
266 configfile: __.typx.Annotated[
267 __.typx.Optional[ __.Path ],
268 _tyro.conf.arg( help = "Path to configuration file." ),
269 ] = None
270 environment: __.typx.Annotated[
271 bool, _tyro.conf.arg( help = "Load environment from dotfiles?" )
272 ] = True
273 inscription: InscriptionControl = __.dcls.field(
274 default_factory = InscriptionControl )
276 async def __call__( self ) -> None:
277 ''' Prepares session context and executes command. '''
278 async with __.ctxl.AsyncExitStack( ) as exits:
279 auxdata = await self.prepare( exits )
280 await self.execute( auxdata )
282 @__.abc.abstractmethod
283 async def execute( self, auxdata: _state.Globals ) -> None:
284 ''' Executes command. '''
285 raise NotImplementedError # pragma: no cover
287 async def prepare( self, exits: __.ctxl.AsyncExitStack ) -> _state.Globals:
288 ''' Prepares session context. '''
289 nomargs: __.NominativeArguments = dict(
290 environment = self.environment,
291 inscription = self.inscription.as_control( exits ) )
292 if self.configfile is not None:
293 nomargs[ 'configfile' ] = self.configfile
294 return await _preparation.prepare( exits, **nomargs )