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

178 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-29 01:14 +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 

34_scribe = __.acquire_scribe( __name__ ) 

35 

36 

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

38 __.typx.Optional[ str ], 

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

40] 

41IncludeSnippets: __.typx.TypeAlias = __.typx.Annotated[ 

42 bool, 

43 __.tyro.conf.arg( help = __.access_doctab( 'include snippets argument' ) ), 

44] 

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

46 __.typx.Optional[ int ], 

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

48] 

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

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

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

52] 

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

54 int, 

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

56] 

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

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

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

60] 

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

62 __.typx.Optional[ str ], 

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

64] 

65 

66 

67_search_behaviors_default = _interfaces.SearchBehaviors( ) 

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

69 

70_MARKDOWN_OBJECT_LIMIT = 10 

71_MARKDOWN_CONTENT_LIMIT = 200 

72 

73 

74 

75class _CliCommand( 

76 __.immut.DataclassProtocol, __.typx.Protocol, 

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

78): 

79 ''' CLI command. ''' 

80 

81 @__.abc.abstractmethod 

82 async def __call__( 

83 self, 

84 auxdata: _state.Globals, 

85 display: __.DisplayTarget, 

86 display_format: _interfaces.DisplayFormat, 

87 ) -> None: 

88 ''' Executes command with global state. ''' 

89 raise NotImplementedError 

90 

91 

92class DetectCommand( 

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

94): 

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

96 

97 location: LocationArgument 

98 genus: __.typx.Annotated[ 

99 _interfaces.ProcessorGenera, 

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

101 ] 

102 processor_name: __.typx.Annotated[ 

103 __.typx.Optional[ str ], 

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

105 ] = None 

106 

107 async def __call__( 

108 self, 

109 auxdata: _state.Globals, 

110 display: __.DisplayTarget, 

111 display_format: _interfaces.DisplayFormat, 

112 ) -> None: 

113 stream = await display.provide_stream( ) 

114 processor_name = ( 

115 self.processor_name if self.processor_name is not None 

116 else __.absent ) 

117 try: 

118 result = await _functions.detect( 

119 auxdata, self.location, self.genus, 

120 processor_name = processor_name ) 

121 except Exception as exc: 

122 _scribe.error( "detect failed: %s", exc ) 

123 print( _format_cli_exception( exc ), file = stream ) 

124 raise SystemExit( 1 ) from None 

125 match display_format: 

126 case _interfaces.DisplayFormat.JSON: 

127 if isinstance( result, _results.ErrorResponse ): 

128 serialized = dict( result.render_as_json( ) ) 

129 else: 

130 serialized = dict( result.render_as_json( ) ) 

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

132 case _interfaces.DisplayFormat.Markdown: 

133 if isinstance( result, _results.ErrorResponse ): 

134 lines = result.render_as_markdown( 

135 reveal_internals = True ) 

136 else: 

137 lines = result.render_as_markdown( 

138 reveal_internals = True ) 

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

140 print( output, file = stream ) 

141 

142 

143class QueryInventoryCommand( 

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

145): 

146 ''' Searches object inventory by name with fuzzy matching. ''' 

147 

148 location: LocationArgument 

149 term: TermArgument 

150 details: __.typx.Annotated[ 

151 _interfaces.InventoryQueryDetails, 

152 __.tyro.conf.arg( 

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

154 ] = _interfaces.InventoryQueryDetails.Documentation 

155 filters: __.typx.Annotated[ 

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

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

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

159 search_behaviors: __.typx.Annotated[ 

160 _interfaces.SearchBehaviors, 

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

162 ] = __.dcls.field( 

163 default_factory = lambda: _interfaces.SearchBehaviors( ) ) 

164 results_max: __.typx.Annotated[ 

165 int, 

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

167 ] = 5 

168 

169 async def __call__( 

170 self, 

171 auxdata: _state.Globals, 

172 display: __.DisplayTarget, 

173 display_format: _interfaces.DisplayFormat, 

174 ) -> None: 

175 stream = await display.provide_stream( ) 

176 try: 

177 result = await _functions.query_inventory( 

178 auxdata, 

179 self.location, 

180 self.term, 

181 search_behaviors = self.search_behaviors, 

182 filters = self.filters, 

183 results_max = self.results_max, 

184 details = self.details ) 

185 except Exception as exc: 

186 _scribe.error( "query-inventory failed: %s", exc ) 

187 print( _format_cli_exception( exc ), file = stream ) 

188 raise SystemExit( 1 ) from None 

189 match display_format: 

190 case _interfaces.DisplayFormat.JSON: 

191 output = __.json.dumps( 

192 dict( result.render_as_json( ) ), indent = 2 ) 

193 case _interfaces.DisplayFormat.Markdown: 

194 lines = result.render_as_markdown( 

195 reveal_internals = True ) 

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

197 print( output, file = stream ) 

198 

199 

200class QueryContentCommand( 

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

202): 

203 ''' Searches documentation content with relevance ranking and snippets. ''' 

204 

205 location: LocationArgument 

206 term: TermArgument 

207 search_behaviors: __.typx.Annotated[ 

208 _interfaces.SearchBehaviors, 

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

210 ] = __.dcls.field( 

211 default_factory = lambda: _interfaces.SearchBehaviors( ) ) 

212 filters: __.typx.Annotated[ 

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

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

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

216 include_snippets: IncludeSnippets = True 

217 results_max: ResultsMax = 10 

218 lines_max: __.typx.Annotated[ 

219 int, 

220 __.tyro.conf.arg( 

221 help = "Maximum number of lines to display per result." ), 

222 ] = 40 

223 

224 async def __call__( 

225 self, 

226 auxdata: _state.Globals, 

227 display: __.DisplayTarget, 

228 display_format: _interfaces.DisplayFormat, 

229 ) -> None: 

230 stream = await display.provide_stream( ) 

231 try: 

232 result = await _functions.query_content( 

233 auxdata, self.location, self.term, 

234 search_behaviors = self.search_behaviors, 

235 filters = self.filters, 

236 results_max = self.results_max, 

237 include_snippets = self.include_snippets ) 

238 except Exception as exc: 

239 _scribe.error( "query-content failed: %s", exc ) 

240 print( _format_cli_exception( exc ), file = stream ) 

241 raise SystemExit( 1 ) from None 

242 match display_format: 

243 case _interfaces.DisplayFormat.JSON: 

244 output = __.json.dumps( 

245 dict( result.render_as_json( ) ), indent = 2 ) 

246 case _interfaces.DisplayFormat.Markdown: 

247 if isinstance( result, _results.ContentQueryResult ): 

248 lines = result.render_as_markdown( 

249 reveal_internals = True, lines_max = self.lines_max ) 

250 else: 

251 lines = result.render_as_markdown( 

252 reveal_internals = True ) 

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

254 print( output, file = stream ) 

255 

256 

257 

258 

259class SurveyProcessorsCommand( 

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

261): 

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

263 

264 genus: __.typx.Annotated[ 

265 _interfaces.ProcessorGenera, 

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

267 ] 

268 name: __.typx.Annotated[ 

269 __.typx.Optional[ str ], 

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

271 ] = None 

272 

273 async def __call__( 

274 self, 

275 auxdata: _state.Globals, 

276 display: __.DisplayTarget, 

277 display_format: _interfaces.DisplayFormat, 

278 ) -> None: 

279 stream = await display.provide_stream( ) 

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

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

282 try: 

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

284 except Exception as exc: 

285 _scribe.error( "survey-processors failed: %s", exc ) 

286 print( _format_cli_exception( exc ), file = stream ) 

287 raise SystemExit( 1 ) from None 

288 match display_format: 

289 case _interfaces.DisplayFormat.JSON: 

290 output = __.json.dumps( 

291 dict( result.render_as_json( ) ), indent = 2 ) 

292 case _interfaces.DisplayFormat.Markdown: 

293 lines = result.render_as_markdown( 

294 reveal_internals = False ) 

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

296 print( output, file = stream ) 

297 

298 

299 

300class ServeCommand( 

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

302): 

303 ''' Starts MCP server. ''' 

304 

305 port: PortArgument = None 

306 transport: TransportArgument = None 

307 extra_functions: __.typx.Annotated[ 

308 bool, 

309 __.tyro.conf.arg( 

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

311 ] = False 

312 serve_function: __.typx.Callable[ 

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

314 ] = _server.serve 

315 async def __call__( 

316 self, 

317 auxdata: _state.Globals, 

318 display: __.DisplayTarget, 

319 display_format: _interfaces.DisplayFormat, 

320 ) -> None: 

321 nomargs: __.NominativeArguments = { } 

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

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

324 nomargs[ 'extra_functions' ] = self.extra_functions 

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

326 

327 

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

329 ''' MCP server CLI. ''' 

330 

331 display: __.DisplayTarget 

332 display_format: __.typx.Annotated[ 

333 _interfaces.DisplayFormat, 

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

335 ] = _interfaces.DisplayFormat.Markdown 

336 command: __.typx.Union[ 

337 __.typx.Annotated[ 

338 DetectCommand, 

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

340 ], 

341 __.typx.Annotated[ 

342 QueryInventoryCommand, 

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

344 ], 

345 __.typx.Annotated[ 

346 QueryContentCommand, 

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

348 ], 

349 __.typx.Annotated[ 

350 SurveyProcessorsCommand, 

351 __.tyro.conf.subcommand( 

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

353 ], 

354 __.typx.Annotated[ 

355 ServeCommand, 

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

357 ], 

358 ] 

359 logfile: __.typx.Annotated[ 

360 __.typx.Optional[ str ], 

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

362 ] = None 

363 

364 async def __call__( self ): 

365 ''' Invokes command after library preparation. ''' 

366 nomargs = self.prepare_invocation_args( ) 

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

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

369 from . import xtnsmgr 

370 await xtnsmgr.register_processors( auxdata ) 

371 await self.command( 

372 auxdata = auxdata, 

373 display = self.display, 

374 display_format = self.display_format ) 

375 

376 def prepare_invocation_args( 

377 self, 

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

379 ''' Prepares arguments for initial configuration. ''' 

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

381 environment = True, 

382 logfile = self.logfile, 

383 ) 

384 return args 

385 

386 

387def execute( ) -> None: 

388 ''' Entrypoint for CLI execution. ''' 

389 config = ( 

390 __.tyro.conf.HelptextFromCommentsOff, 

391 ) 

392 with __.warnings.catch_warnings( ): 

393 __.warnings.filterwarnings( 

394 'ignore', 

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

396 category = UserWarning, 

397 module = 'tyro.constructors._struct_spec_dataclass' ) 

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

399 except SystemExit: raise 

400 except BaseException as exc: 

401 __.report_exceptions( exc, _scribe ) 

402 raise SystemExit( 1 ) from None 

403 

404 

405 

406 

407 

408 

409 

410 

411 

412 

413def _format_cli_exception( exc: Exception ) -> str: # noqa: PLR0911 

414 ''' Formats exceptions for user-friendly CLI output. ''' 

415 match exc: 

416 case _exceptions.ProcessorInavailability( ): 

417 return ( 

418 f"❌ No processor found to handle source: {exc.source}\n" 

419 f"💡 Verify this is a Sphinx documentation site" ) 

420 case _exceptions.InventoryInaccessibility( ): 

421 return ( 

422 f"❌ Cannot access documentation inventory: {exc.source}\n" 

423 f"💡 Check URL accessibility and network connection" ) 

424 case _exceptions.DocumentationContentAbsence( ): 

425 return ( 

426 f"❌ Documentation structure not recognized: {exc.url}\n" 

427 f"💡 This may be an unsupported Sphinx theme" ) 

428 case _exceptions.DocumentationObjectAbsence( ): 

429 return ( 

430 f"❌ Object '{exc.object_id}' not found in page: {exc.url}\n" 

431 f"💡 Verify the object name and try a broader search" ) 

432 case _exceptions.InventoryInvalidity( ): 

433 return ( 

434 f"❌ Invalid documentation inventory: {exc.source}\n" 

435 f"💡 The documentation site may be corrupted" ) 

436 case _exceptions.DocumentationInaccessibility( ): 

437 return ( 

438 f"❌ Documentation inaccessible: {exc.url}\n" 

439 f"💡 Check URL accessibility and network connection" ) 

440 case _: 

441 return f"❌ Unexpected error: {exc}" 

442 

443 

444async def _prepare( 

445 environment: __.typx.Annotated[ 

446 bool, 

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

448 ], 

449 exits: __.typx.Annotated[ 

450 __.ctxl.AsyncExitStack, 

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

452 ], 

453 logfile: __.typx.Annotated[ 

454 __.typx.Optional[ str ], 

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

456 ], 

457) -> __.typx.Annotated[ 

458 _state.Globals, 

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

460]: 

461 ''' Configures application based on arguments. ''' 

462 nomargs: __.NominativeArguments = { 

463 'environment': environment, 

464 'exits': exits, 

465 } 

466 if logfile: 

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

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

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

470 inscription = __.appcore.inscription.Control( 

471 level = 'debug', target = logstream ) 

472 nomargs[ 'inscription' ] = inscription 

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

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

475 return _state.Globals( 

476 application = auxdata.application, 

477 configuration = auxdata.configuration, 

478 directories = auxdata.directories, 

479 distribution = auxdata.distribution, 

480 exits = auxdata.exits, 

481 content_cache = content_cache, 

482 probe_cache = probe_cache, 

483 robots_cache = robots_cache )