Coverage for sources/librovore/cli.py: 46%

164 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-03 21:59 +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''' Command-line interface. ''' 

22 

23 

24from . import __ 

25from . import cacheproxy as _cacheproxy 

26from . import exceptions as _exceptions 

27from . import functions as _functions 

28from . import interfaces as _interfaces 

29from . import results as _results 

30from . import server as _server 

31from . import state as _state 

32 

33_scribe = __.acquire_scribe( __name__ ) 

34 

35 

36def intercept_errors( ) -> __.cabc.Callable[ 

37 [ __.cabc.Callable[ ..., __.cabc.Awaitable[ None ] ] ], 

38 __.cabc.Callable[ ..., __.cabc.Awaitable[ None ] ] 

39]: 

40 ''' Decorator for CLI handlers to intercept exceptions. 

41  

42 Catches Omnierror exceptions and renders them appropriately. 

43 Other exceptions are logged and formatted simply. 

44 ''' 

45 def decorator( 

46 func: __.cabc.Callable[ ..., __.cabc.Awaitable[ None ] ] 

47 ) -> __.cabc.Callable[ ..., __.cabc.Awaitable[ None ] ]: 

48 @__.funct.wraps( func ) 

49 async def wrapper( 

50 self: __.typx.Any, 

51 auxdata: _state.Globals, 

52 display: __.DisplayTarget, 

53 display_format: _interfaces.DisplayFormat, 

54 color: bool, 

55 *posargs: __.typx.Any, 

56 **nomargs: __.typx.Any, 

57 ) -> None: 

58 stream = await display.provide_stream( ) 

59 try: 

60 return await func( 

61 self, auxdata, display, display_format, color, 

62 *posargs, **nomargs ) 

63 except _exceptions.Omnierror as exc: 

64 match display_format: 

65 case _interfaces.DisplayFormat.JSON: 

66 serialized = dict( exc.render_as_json( ) ) 

67 error_message = __.json.dumps( serialized, indent = 2 ) 

68 case _interfaces.DisplayFormat.Markdown: 

69 lines = exc.render_as_markdown( ) 

70 error_message = '\n'.join( lines ) 

71 print( error_message, file = stream ) 

72 raise SystemExit( 1 ) from None 

73 except Exception as exc: 

74 _scribe.error( f"{func.__name__} failed: %s", exc ) 

75 match display_format: 

76 case _interfaces.DisplayFormat.JSON: 

77 error_data = { 

78 "type": "unexpected_error", 

79 "title": "Unexpected Error", 

80 "message": str( exc ), 

81 "suggestion": ( 

82 "Please report this issue if it persists." ), 

83 } 

84 error_message = __.json.dumps( error_data, indent = 2 ) 

85 case _interfaces.DisplayFormat.Markdown: 

86 error_message = f"❌ Unexpected error: {exc}" 

87 print( error_message, file = stream ) 

88 raise SystemExit( 1 ) from None 

89 

90 return wrapper 

91 return decorator 

92 

93 

94GroupByArgument: __.typx.TypeAlias = __.typx.Annotated[ 

95 __.typx.Optional[ str ], 

96 __.tyro.conf.arg( help = __.access_doctab( 'group by argument' ) ), 

97] 

98PortArgument: __.typx.TypeAlias = __.typx.Annotated[ 

99 __.typx.Optional[ int ], 

100 __.tyro.conf.arg( help = __.access_doctab( 'server port argument' ) ), 

101] 

102TermArgument: __.typx.TypeAlias = __.typx.Annotated[ 

103 __.tyro.conf.Positional[ str ], 

104 __.tyro.conf.arg( help = __.access_doctab( 'term argument' ) ), 

105] 

106ResultsMax: __.typx.TypeAlias = __.typx.Annotated[ 

107 int, 

108 __.tyro.conf.arg( help = __.access_doctab( 'results max argument' ) ), 

109] 

110LocationArgument: __.typx.TypeAlias = __.typx.Annotated[ 

111 __.tyro.conf.Positional[ str ], 

112 __.tyro.conf.arg( help = __.access_doctab( 'location argument' ) ), 

113] 

114TransportArgument: __.typx.TypeAlias = __.typx.Annotated[ 

115 __.typx.Optional[ str ], 

116 __.tyro.conf.arg( help = __.access_doctab( 'transport argument' ) ), 

117] 

118 

119 

120_search_behaviors_default = _interfaces.SearchBehaviors( ) 

121_filters_default = __.immut.Dictionary[ str, __.typx.Any ]( ) 

122 

123_MARKDOWN_OBJECT_LIMIT = 10 

124_MARKDOWN_CONTENT_LIMIT = 200 

125 

126 

127def decide_rich_markdown( 

128 stream: __.typx.TextIO, colorize: bool 

129) -> bool: 

130 ''' Determines whether to use Rich markdown rendering. ''' 

131 return ( 

132 colorize 

133 and stream.isatty( ) 

134 and not __.os.environ.get( 'NO_COLOR' ) 

135 ) 

136 

137 

138async def _render_and_print_result( 

139 result: _results.ResultBase, 

140 display_format: _interfaces.DisplayFormat, 

141 stream: __.typx.TextIO, 

142 color: bool, 

143 **nomargs: __.typx.Any 

144) -> None: 

145 ''' Centralizes result rendering logic with Rich formatting support. ''' 

146 match display_format: 

147 case _interfaces.DisplayFormat.JSON: 

148 nomargs_filtered = { 

149 key: value for key, value in nomargs.items() 

150 if key in [ 'lines_max' ] # Only pass relevant nomargs 

151 } 

152 serialized = dict( result.render_as_json( **nomargs_filtered ) ) 

153 output = __.json.dumps( serialized, indent = 2 ) 

154 print( output, file = stream ) 

155 case _interfaces.DisplayFormat.Markdown: 

156 lines = result.render_as_markdown( **nomargs ) 

157 if decide_rich_markdown( stream, color ): 

158 from rich.console import ( 

159 Console, 

160 ) 

161 from rich.markdown import ( 

162 Markdown, 

163 ) 

164 console = Console( file = stream, force_terminal = True ) 

165 markdown_obj = Markdown( '\n'.join( lines ) ) 

166 console.print( markdown_obj ) 

167 else: 

168 output = '\n'.join( lines ) 

169 print( output, file = stream ) 

170 

171 

172class _CliCommand( 

173 __.immut.DataclassProtocol, __.typx.Protocol, 

174 decorators = ( __.typx.runtime_checkable, ), 

175): 

176 ''' CLI command. ''' 

177 

178 @__.abc.abstractmethod 

179 def __call__( 

180 self, 

181 auxdata: _state.Globals, 

182 display: __.DisplayTarget, 

183 display_format: _interfaces.DisplayFormat, 

184 color: bool, 

185 ) -> __.cabc.Awaitable[ None ]: 

186 ''' Executes command with global state. ''' 

187 raise NotImplementedError 

188 

189 

190class DetectCommand( 

191 _CliCommand, decorators = ( __.standard_tyro_class, ), 

192): 

193 ''' Detect which processors can handle a documentation source. ''' 

194 

195 location: LocationArgument 

196 genus: __.typx.Annotated[ 

197 _interfaces.ProcessorGenera, 

198 __.tyro.conf.arg( help = "Processor genus (inventory or structure)." ), 

199 ] 

200 processor_name: __.typx.Annotated[ 

201 __.typx.Optional[ str ], 

202 __.tyro.conf.arg( help = "Specific processor to use." ), 

203 ] = None 

204 

205 @intercept_errors( ) 

206 async def __call__( 

207 self, 

208 auxdata: _state.Globals, 

209 display: __.DisplayTarget, 

210 display_format: _interfaces.DisplayFormat, 

211 color: bool, 

212 ) -> None: 

213 stream = await display.provide_stream( ) 

214 processor_name = ( 

215 self.processor_name if self.processor_name is not None 

216 else __.absent ) 

217 result = await _functions.detect( 

218 auxdata, self.location, self.genus, 

219 processor_name = processor_name ) 

220 await _render_and_print_result( 

221 result, display_format, stream, color, reveal_internals = True ) 

222 

223 

224class QueryInventoryCommand( 

225 _CliCommand, decorators = ( __.standard_tyro_class, ), 

226): 

227 ''' Explores documentation structure and object inventory. 

228 

229 Use before content searches to: 

230  

231 - Discover available topics and object types 

232 - Identify relevant search terms and filters 

233 - Understand documentation scope and organization 

234 ''' 

235 

236 location: LocationArgument 

237 term: TermArgument 

238 details: __.typx.Annotated[ 

239 _interfaces.InventoryQueryDetails, 

240 __.tyro.conf.arg( 

241 help = __.access_doctab( 'query details argument' ) ), 

242 ] = _interfaces.InventoryQueryDetails.Name 

243 filters: __.typx.Annotated[ 

244 __.cabc.Mapping[ str, __.typx.Any ], 

245 __.tyro.conf.arg( prefix_name = False ), 

246 ] = __.dcls.field( default_factory = lambda: dict( _filters_default ) ) 

247 search_behaviors: __.typx.Annotated[ 

248 _interfaces.SearchBehaviors, 

249 __.tyro.conf.arg( prefix_name = False ), 

250 ] = __.dcls.field( 

251 default_factory = lambda: _interfaces.SearchBehaviors( ) ) 

252 results_max: __.typx.Annotated[ 

253 int, 

254 __.tyro.conf.arg( help = __.access_doctab( 'results max argument' ) ), 

255 ] = 5 

256 

257 @intercept_errors( ) 

258 async def __call__( 

259 self, 

260 auxdata: _state.Globals, 

261 display: __.DisplayTarget, 

262 display_format: _interfaces.DisplayFormat, 

263 color: bool, 

264 ) -> None: 

265 stream = await display.provide_stream( ) 

266 result = await _functions.query_inventory( 

267 auxdata, 

268 self.location, 

269 self.term, 

270 search_behaviors = self.search_behaviors, 

271 filters = self.filters, 

272 results_max = self.results_max, 

273 details = self.details ) 

274 await _render_and_print_result( 

275 result, display_format, stream, color, 

276 reveal_internals = True ) 

277 

278 

279class QueryContentCommand( 

280 _CliCommand, decorators = ( __.standard_tyro_class, ), 

281): 

282 ''' Searches documentation with flexible preview/extraction modes. 

283 

284 Workflows: 

285  

286 - Sample: Use --lines-max 5-10 to preview results and identify relevant 

287 content 

288 - Extract: Use --content-id from sample results to retrieve full 

289 content  

290 - Direct: Search with higher --lines-max for immediate full results 

291 ''' 

292 

293 location: LocationArgument 

294 term: TermArgument 

295 search_behaviors: __.typx.Annotated[ 

296 _interfaces.SearchBehaviors, 

297 __.tyro.conf.arg( prefix_name = False ), 

298 ] = __.dcls.field( 

299 default_factory = lambda: _interfaces.SearchBehaviors( ) ) 

300 filters: __.typx.Annotated[ 

301 __.cabc.Mapping[ str, __.typx.Any ], 

302 __.tyro.conf.arg( prefix_name = False ), 

303 ] = __.dcls.field( default_factory = lambda: dict( _filters_default ) ) 

304 results_max: ResultsMax = 10 

305 lines_max: __.typx.Annotated[ 

306 int, 

307 __.tyro.conf.arg( 

308 help = ( 

309 "Lines per result for preview/sampling. Use 5-10 for " 

310 "discovery, omit for full content extraction via " 

311 "content-id." ) ), 

312 ] = 40 

313 content_id: __.typx.Annotated[ 

314 __.typx.Optional[ str ], 

315 __.tyro.conf.arg( 

316 help = ( 

317 "Extract full content for specific result. Obtain IDs from " 

318 "previous query-content calls with limited lines-max." ) ), 

319 ] = None 

320 

321 @intercept_errors( ) 

322 async def __call__( 

323 self, 

324 auxdata: _state.Globals, 

325 display: __.DisplayTarget, 

326 display_format: _interfaces.DisplayFormat, 

327 color: bool, 

328 ) -> None: 

329 stream = await display.provide_stream( ) 

330 content_id_ = ( 

331 __.absent if self.content_id is None else self.content_id ) 

332 result = await _functions.query_content( 

333 auxdata, self.location, self.term, 

334 search_behaviors = self.search_behaviors, 

335 filters = self.filters, 

336 content_id = content_id_, 

337 results_max = self.results_max, 

338 lines_max = self.lines_max ) 

339 await _render_and_print_result( 

340 result, display_format, stream, color, 

341 reveal_internals = True, lines_max = self.lines_max ) 

342 

343 

344 

345 

346class SurveyProcessorsCommand( 

347 _CliCommand, decorators = ( __.standard_tyro_class, ), 

348): 

349 ''' List processors for specified genus and their capabilities. ''' 

350 

351 genus: __.typx.Annotated[ 

352 _interfaces.ProcessorGenera, 

353 __.tyro.conf.arg( help = "Processor genus (inventory or structure)." ), 

354 ] 

355 name: __.typx.Annotated[ 

356 __.typx.Optional[ str ], 

357 __.tyro.conf.arg( help = "Name of processor to describe" ), 

358 ] = None 

359 

360 @intercept_errors( ) 

361 async def __call__( 

362 self, 

363 auxdata: _state.Globals, 

364 display: __.DisplayTarget, 

365 display_format: _interfaces.DisplayFormat, 

366 color: bool, 

367 ) -> None: 

368 stream = await display.provide_stream( ) 

369 nomargs: __.NominativeArguments = { 'genus': self.genus } 

370 if self.name is not None: nomargs[ 'name' ] = self.name 

371 result = await _functions.survey_processors( auxdata, **nomargs ) 

372 await _render_and_print_result( 

373 result, display_format, stream, color, 

374 reveal_internals = False ) 

375 

376 

377 

378class ServeCommand( 

379 _CliCommand, decorators = ( __.standard_tyro_class, ), 

380): 

381 ''' Starts MCP server. ''' 

382 

383 port: PortArgument = None 

384 transport: TransportArgument = None 

385 extra_functions: __.typx.Annotated[ 

386 bool, 

387 __.tyro.conf.arg( 

388 help = "Enable extra functions (detect and survey-processors)." ), 

389 ] = False 

390 serve_function: __.typx.Callable[ 

391 [ _state.Globals ], __.cabc.Awaitable[ None ] 

392 ] = _server.serve 

393 async def __call__( 

394 self, 

395 auxdata: _state.Globals, 

396 display: __.DisplayTarget, 

397 display_format: _interfaces.DisplayFormat, 

398 color: bool, 

399 ) -> None: 

400 nomargs: __.NominativeArguments = { } 

401 if self.port is not None: nomargs[ 'port' ] = self.port 

402 if self.transport is not None: nomargs[ 'transport' ] = self.transport 

403 nomargs[ 'extra_functions' ] = self.extra_functions 

404 await self.serve_function( auxdata, **nomargs ) 

405 

406 

407class Cli( __.immut.DataclassObject, decorators = ( __.simple_tyro_class, ) ): 

408 ''' MCP server CLI. ''' 

409 

410 display: __.DisplayTarget 

411 display_format: __.typx.Annotated[ 

412 _interfaces.DisplayFormat, 

413 __.tyro.conf.arg( help = "Output format for command results." ), 

414 ] = _interfaces.DisplayFormat.Markdown 

415 color: __.typx.Annotated[ 

416 bool, 

417 __.tyro.conf.arg( 

418 aliases = ( "--ansi-sgr", ), 

419 help = "Enable colored output and terminal formatting" 

420 ), 

421 ] = True 

422 command: __.typx.Union[ 

423 __.typx.Annotated[ 

424 DetectCommand, 

425 __.tyro.conf.subcommand( 'detect', prefix_name = False ), 

426 ], 

427 __.typx.Annotated[ 

428 QueryInventoryCommand, 

429 __.tyro.conf.subcommand( 'query-inventory', prefix_name = False ), 

430 ], 

431 __.typx.Annotated[ 

432 QueryContentCommand, 

433 __.tyro.conf.subcommand( 'query-content', prefix_name = False ), 

434 ], 

435 __.typx.Annotated[ 

436 SurveyProcessorsCommand, 

437 __.tyro.conf.subcommand( 

438 'survey-processors', prefix_name = False ), 

439 ], 

440 __.typx.Annotated[ 

441 ServeCommand, 

442 __.tyro.conf.subcommand( 'serve', prefix_name = False ), 

443 ], 

444 ] 

445 logfile: __.typx.Annotated[ 

446 __.typx.Optional[ str ], 

447 __.ddoc.Doc( ''' Path to log capture file. ''' ), 

448 ] = None 

449 

450 async def __call__( self ): 

451 ''' Invokes command after library preparation. ''' 

452 nomargs = self.prepare_invocation_args( ) 

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

454 auxdata = await _prepare( exits = exits, **nomargs ) 

455 from . import xtnsmgr 

456 await xtnsmgr.register_processors( auxdata ) 

457 await self.command( 

458 auxdata = auxdata, 

459 display = self.display, 

460 display_format = self.display_format, 

461 color = self.color ) 

462 

463 def prepare_invocation_args( 

464 self, 

465 ) -> __.cabc.Mapping[ str, __.typx.Any ]: 

466 ''' Prepares arguments for initial configuration. ''' 

467 args: dict[ str, __.typx.Any ] = dict( 

468 environment = True, 

469 logfile = self.logfile, 

470 ) 

471 return args 

472 

473 

474def execute( ) -> None: 

475 ''' Entrypoint for CLI execution. ''' 

476 config = ( 

477 __.tyro.conf.HelptextFromCommentsOff, 

478 ) 

479 with __.warnings.catch_warnings( ): 

480 __.warnings.filterwarnings( 

481 'ignore', 

482 message = r'Mutable type .* is used as a default value.*', 

483 category = UserWarning, 

484 module = 'tyro.constructors._struct_spec_dataclass' ) 

485 try: __.asyncio.run( __.tyro.cli( Cli, config = config )( ) ) 

486 except SystemExit: raise 

487 except BaseException as exc: 

488 __.report_exceptions( exc, _scribe ) 

489 raise SystemExit( 1 ) from None 

490 

491 

492 

493 

494 

495 

496 

497 

498 

499 

500 

501 

502async def _prepare( 

503 environment: __.typx.Annotated[ 

504 bool, 

505 __.ddoc.Doc( ''' Whether to configure environment. ''' ) 

506 ], 

507 exits: __.typx.Annotated[ 

508 __.ctxl.AsyncExitStack, 

509 __.ddoc.Doc( ''' Exit stack for resource management. ''' ) 

510 ], 

511 logfile: __.typx.Annotated[ 

512 __.typx.Optional[ str ], 

513 __.ddoc.Doc( ''' Path to log capture file. ''' ) 

514 ], 

515) -> __.typx.Annotated[ 

516 _state.Globals, 

517 __.ddoc.Doc( ''' Configured global state. ''' ) 

518]: 

519 ''' Configures application based on arguments. ''' 

520 nomargs: __.NominativeArguments = { 

521 'environment': environment, 

522 'exits': exits, 

523 } 

524 if logfile: 

525 logfile_p = __.Path( logfile ).resolve( ) 

526 ( logfile_p.parent ).mkdir( parents = True, exist_ok = True ) 

527 logstream = exits.enter_context( logfile_p.open( 'w' ) ) 

528 inscription = __.appcore.inscription.Control( 

529 level = 'debug', target = logstream ) 

530 nomargs[ 'inscription' ] = inscription 

531 auxdata = await __.appcore.prepare( **nomargs ) 

532 content_cache, probe_cache, robots_cache = _cacheproxy.prepare( auxdata ) 

533 return _state.Globals( 

534 application = auxdata.application, 

535 configuration = auxdata.configuration, 

536 directories = auxdata.directories, 

537 distribution = auxdata.distribution, 

538 exits = auxdata.exits, 

539 content_cache = content_cache, 

540 probe_cache = probe_cache, 

541 robots_cache = robots_cache )