Coverage for src/qdrant_loader/cli/project_commands.py: 92%

171 statements  

« prev     ^ index     » next       coverage.py v7.10.0, created at 2025-07-25 11:39 +0000

1"""Project management CLI commands for QDrant Loader.""" 

2 

3import json 

4from pathlib import Path 

5 

6from click.decorators import group, option 

7from click.exceptions import ClickException 

8from click.types import Choice 

9from click.types import Path as ClickPath 

10from click.utils import echo 

11from rich.console import Console 

12from rich.panel import Panel 

13from rich.table import Table 

14 

15from qdrant_loader.cli.asyncio import async_command 

16from qdrant_loader.config import Settings 

17from qdrant_loader.config.workspace import validate_workspace_flags 

18from qdrant_loader.core.project_manager import ProjectManager 

19from qdrant_loader.utils.logging import LoggingConfig 

20 

21# Rich console for better output formatting 

22console = Console() 

23 

24 

25@group(name="project") 

26def project_cli(): 

27 """Project management commands.""" 

28 pass 

29 

30 

31def _get_all_sources_from_config(sources_config): 

32 """Get all sources from a SourcesConfig object.""" 

33 all_sources = {} 

34 all_sources.update(sources_config.publicdocs) 

35 all_sources.update(sources_config.git) 

36 all_sources.update(sources_config.confluence) 

37 all_sources.update(sources_config.jira) 

38 all_sources.update(sources_config.localfile) 

39 return all_sources 

40 

41 

42@project_cli.command() 

43@option( 

44 "--workspace", 

45 type=ClickPath(path_type=Path), 

46 help="Workspace directory containing config.yaml and .env files.", 

47) 

48@option( 

49 "--config", type=ClickPath(exists=True, path_type=Path), help="Path to config file." 

50) 

51@option("--env", type=ClickPath(exists=True, path_type=Path), help="Path to .env file.") 

52@option( 

53 "--format", 

54 type=Choice(["table", "json"], case_sensitive=False), 

55 default="table", 

56 help="Output format for project list.", 

57) 

58@async_command 

59async def list( 

60 workspace: Path | None, 

61 config: Path | None, 

62 env: Path | None, 

63 format: str, 

64): 

65 """List all configured projects.""" 

66 try: 

67 # Validate flag combinations 

68 validate_workspace_flags(workspace, config, env) 

69 

70 # Load configuration and initialize components 

71 settings, project_manager = await _setup_project_manager(workspace, config, env) 

72 

73 # Get project contexts 

74 project_contexts = project_manager.get_all_project_contexts() 

75 

76 if format == "json": 

77 # JSON output 

78 projects_data = [] 

79 for context in project_contexts.values(): 

80 source_count = ( 

81 len(_get_all_sources_from_config(context.config.sources)) 

82 if context.config 

83 else 0 

84 ) 

85 projects_data.append( 

86 { 

87 "project_id": context.project_id, 

88 "display_name": context.display_name, 

89 "description": context.description, 

90 "collection_name": context.collection_name or "N/A", 

91 "source_count": source_count, 

92 } 

93 ) 

94 echo(json.dumps(projects_data, indent=2)) 

95 else: 

96 # Table output using Rich 

97 if not project_contexts: 

98 console.print("[yellow]No projects configured.[/yellow]") 

99 return 

100 

101 table = Table(title="Configured Projects") 

102 table.add_column("Project ID", style="cyan", no_wrap=True) 

103 table.add_column("Display Name", style="magenta") 

104 table.add_column("Description", style="green") 

105 table.add_column("Collection", style="blue") 

106 table.add_column("Sources", justify="right", style="yellow") 

107 

108 for context in project_contexts.values(): 

109 source_count = ( 

110 len(_get_all_sources_from_config(context.config.sources)) 

111 if context.config 

112 else 0 

113 ) 

114 table.add_row( 

115 context.project_id, 

116 context.display_name or "N/A", 

117 context.description or "N/A", 

118 context.collection_name or "N/A", 

119 str(source_count), 

120 ) 

121 

122 console.print(table) 

123 

124 except Exception as e: 

125 logger = LoggingConfig.get_logger(__name__) 

126 logger.error("project_list_failed", error=str(e)) 

127 raise ClickException(f"Failed to list projects: {str(e)!s}") from e 

128 

129 

130@project_cli.command() 

131@option( 

132 "--workspace", 

133 type=ClickPath(path_type=Path), 

134 help="Workspace directory containing config.yaml and .env files.", 

135) 

136@option( 

137 "--config", type=ClickPath(exists=True, path_type=Path), help="Path to config file." 

138) 

139@option("--env", type=ClickPath(exists=True, path_type=Path), help="Path to .env file.") 

140@option( 

141 "--project-id", 

142 type=str, 

143 help="Specific project ID to check status for.", 

144) 

145@option( 

146 "--format", 

147 type=Choice(["table", "json"], case_sensitive=False), 

148 default="table", 

149 help="Output format for project status.", 

150) 

151@async_command 

152async def status( 

153 workspace: Path | None, 

154 config: Path | None, 

155 env: Path | None, 

156 project_id: str | None, 

157 format: str, 

158): 

159 """Show project status including document counts and ingestion history.""" 

160 try: 

161 # Validate flag combinations 

162 validate_workspace_flags(workspace, config, env) 

163 

164 # Load configuration and initialize components 

165 settings, project_manager = await _setup_project_manager(workspace, config, env) 

166 

167 # Get project contexts 

168 if project_id: 

169 context = project_manager.get_project_context(project_id) 

170 if not context: 

171 raise ClickException(f"Project '{project_id}' not found") 

172 project_contexts = {project_id: context} 

173 else: 

174 project_contexts = project_manager.get_all_project_contexts() 

175 

176 if format == "json": 

177 # JSON output 

178 status_data = [] 

179 for context in project_contexts.values(): 

180 status_data.append( 

181 { 

182 "project_id": context.project_id, 

183 "display_name": context.display_name, 

184 "collection_name": context.collection_name or "N/A", 

185 "source_count": ( 

186 len(_get_all_sources_from_config(context.config.sources)) 

187 if context.config 

188 else 0 

189 ), 

190 "document_count": "N/A", # TODO: Implement database query 

191 "latest_ingestion": None, # TODO: Implement database query 

192 } 

193 ) 

194 echo(json.dumps(status_data, indent=2)) 

195 else: 

196 # Table output using Rich 

197 if not project_contexts: 

198 console.print("[yellow]No projects configured.[/yellow]") 

199 return 

200 

201 for context in project_contexts.values(): 

202 source_count = ( 

203 len(_get_all_sources_from_config(context.config.sources)) 

204 if context.config 

205 else 0 

206 ) 

207 

208 # Create project panel 

209 project_info = f"""[bold cyan]Project ID:[/bold cyan] {context.project_id} 

210[bold magenta]Display Name:[/bold magenta] {context.display_name or 'N/A'} 

211[bold green]Description:[/bold green] {context.description or 'N/A'} 

212[bold blue]Collection:[/bold blue] {context.collection_name or 'N/A'} 

213[bold yellow]Sources:[/bold yellow] {source_count} 

214[bold red]Documents:[/bold red] N/A (requires database) 

215[bold red]Latest Ingestion:[/bold red] N/A (requires database)""" 

216 

217 console.print( 

218 Panel(project_info, title=f"Project: {context.project_id}") 

219 ) 

220 

221 except Exception as e: 

222 logger = LoggingConfig.get_logger(__name__) 

223 logger.error("project_status_failed", error=str(e)) 

224 raise ClickException(f"Failed to get project status: {str(e)!s}") from e 

225 

226 

227@project_cli.command() 

228@option( 

229 "--workspace", 

230 type=ClickPath(path_type=Path), 

231 help="Workspace directory containing config.yaml and .env files.", 

232) 

233@option( 

234 "--config", type=ClickPath(exists=True, path_type=Path), help="Path to config file." 

235) 

236@option("--env", type=ClickPath(exists=True, path_type=Path), help="Path to .env file.") 

237@option( 

238 "--project-id", 

239 type=str, 

240 help="Specific project ID to validate.", 

241) 

242@async_command 

243async def validate( 

244 workspace: Path | None, 

245 config: Path | None, 

246 env: Path | None, 

247 project_id: str | None, 

248): 

249 """Validate project configurations.""" 

250 try: 

251 # Validate flag combinations 

252 validate_workspace_flags(workspace, config, env) 

253 

254 # Load configuration and initialize components 

255 settings, project_manager = await _setup_project_manager(workspace, config, env) 

256 

257 # Get project contexts to validate 

258 if project_id: 

259 context = project_manager.get_project_context(project_id) 

260 if not context: 

261 raise ClickException(f"Project '{project_id}' not found") 

262 project_contexts = {project_id: context} 

263 else: 

264 project_contexts = project_manager.get_all_project_contexts() 

265 

266 validation_results = [] 

267 all_valid = True 

268 

269 for context in project_contexts.values(): 

270 try: 

271 # Basic validation - check that config exists and has required fields 

272 if not context.config: 

273 validation_results.append( 

274 { 

275 "project_id": context.project_id, 

276 "valid": False, 

277 "errors": ["Missing project configuration"], 

278 "source_count": 0, 

279 } 

280 ) 

281 all_valid = False 

282 continue 

283 

284 # Check source configurations 

285 source_errors = [] 

286 all_sources = _get_all_sources_from_config(context.config.sources) 

287 

288 for source_name, source_config in all_sources.items(): 

289 try: 

290 # Basic validation - check required fields 

291 if ( 

292 not hasattr(source_config, "source_type") 

293 or not source_config.source_type 

294 ): 

295 source_errors.append( 

296 f"Missing source_type for {source_name}" 

297 ) 

298 if ( 

299 not hasattr(source_config, "source") 

300 or not source_config.source 

301 ): 

302 source_errors.append(f"Missing source for {source_name}") 

303 except Exception as e: 

304 source_errors.append(f"Error in {source_name}: {str(e)}") 

305 

306 validation_results.append( 

307 { 

308 "project_id": context.project_id, 

309 "valid": len(source_errors) == 0, 

310 "errors": source_errors, 

311 "source_count": len(all_sources), 

312 } 

313 ) 

314 

315 if source_errors: 

316 all_valid = False 

317 

318 except Exception as e: 

319 validation_results.append( 

320 { 

321 "project_id": context.project_id, 

322 "valid": False, 

323 "errors": [str(e)], 

324 "source_count": 0, 

325 } 

326 ) 

327 all_valid = False 

328 

329 # Display results 

330 for result in validation_results: 

331 if result["valid"]: 

332 console.print( 

333 f"[green]✓[/green] Project '{result['project_id']}' is valid ({result['source_count']} sources)" 

334 ) 

335 else: 

336 console.print( 

337 f"[red]✗[/red] Project '{result['project_id']}' has errors:" 

338 ) 

339 for error in result["errors"]: 

340 console.print(f" [red]•[/red] {error}") 

341 

342 if all_valid: 

343 console.print("\n[green]All projects are valid![/green]") 

344 else: 

345 console.print("\n[red]Some projects have validation errors.[/red]") 

346 raise ClickException("Project validation failed") 

347 

348 except Exception as e: 

349 logger = LoggingConfig.get_logger(__name__) 

350 logger.error("project_validate_failed", error=str(e)) 

351 raise ClickException(f"Failed to validate projects: {str(e)!s}") from e 

352 

353 

354async def _setup_project_manager( 

355 workspace: Path | None, 

356 config: Path | None, 

357 env: Path | None, 

358) -> tuple[Settings, ProjectManager]: 

359 """Setup project manager with configuration loading.""" 

360 from qdrant_loader.cli.cli import ( 

361 _check_settings, 

362 _load_config_with_workspace, 

363 _setup_workspace, 

364 ) 

365 

366 # Setup workspace if provided 

367 workspace_config = None 

368 if workspace: 

369 workspace_config = _setup_workspace(workspace) 

370 

371 # Load configuration 

372 _load_config_with_workspace(workspace_config, config, env) 

373 settings = _check_settings() 

374 

375 # Create project manager 

376 if not settings.global_config or not settings.global_config.qdrant: 

377 raise ClickException("Global configuration or Qdrant configuration is missing") 

378 

379 project_manager = ProjectManager( 

380 projects_config=settings.projects_config, 

381 global_collection_name=settings.global_config.qdrant.collection_name, 

382 ) 

383 

384 # Initialize project contexts directly from configuration (without database) 

385 await _initialize_project_contexts_from_config(project_manager) 

386 

387 return settings, project_manager 

388 

389 

390async def _initialize_project_contexts_from_config( 

391 project_manager: ProjectManager, 

392) -> None: 

393 """Initialize project contexts directly from configuration without database.""" 

394 logger = LoggingConfig.get_logger(__name__) 

395 logger.debug("Initializing project contexts from configuration") 

396 

397 for project_id, project_config in project_manager.projects_config.projects.items(): 

398 logger.debug(f"Creating context for project: {project_id}") 

399 

400 # Determine collection name using the project's method 

401 collection_name = project_config.get_effective_collection_name( 

402 project_manager.global_collection_name 

403 ) 

404 

405 # Create project context 

406 from qdrant_loader.core.project_manager import ProjectContext 

407 

408 context = ProjectContext( 

409 project_id=project_id, 

410 display_name=project_config.display_name, 

411 description=project_config.description, 

412 collection_name=collection_name, 

413 config=project_config, 

414 ) 

415 

416 project_manager._project_contexts[project_id] = context 

417 logger.debug(f"Created context for project: {project_id}") 

418 

419 logger.debug( 

420 f"Initialized {len(project_manager._project_contexts)} project contexts" 

421 )