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

176 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-04 05:50 +0000

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

2 

3import asyncio 

4import json 

5from pathlib import Path 

6from typing import Optional 

7 

8import click 

9from click.decorators import group, option 

10from click.exceptions import ClickException 

11from click.types import Choice 

12from click.types import Path as ClickPath 

13from click.utils import echo 

14from rich.console import Console 

15from rich.table import Table 

16from rich.panel import Panel 

17from rich.text import Text 

18 

19from qdrant_loader.cli.asyncio import async_command 

20from qdrant_loader.config import Settings, get_settings 

21from qdrant_loader.config.workspace import WorkspaceConfig, validate_workspace_flags 

22from qdrant_loader.core.project_manager import ProjectManager 

23from qdrant_loader.core.qdrant_manager import QdrantManager 

24from qdrant_loader.utils.logging import LoggingConfig 

25 

26# Rich console for better output formatting 

27console = Console() 

28 

29 

30@group(name="project") 

31def project_cli(): 

32 """Project management commands.""" 

33 pass 

34 

35 

36def _get_all_sources_from_config(sources_config): 

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

38 all_sources = {} 

39 all_sources.update(sources_config.publicdocs) 

40 all_sources.update(sources_config.git) 

41 all_sources.update(sources_config.confluence) 

42 all_sources.update(sources_config.jira) 

43 all_sources.update(sources_config.localfile) 

44 return all_sources 

45 

46 

47@project_cli.command() 

48@option( 

49 "--workspace", 

50 type=ClickPath(path_type=Path), 

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

52) 

53@option( 

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

55) 

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

57@option( 

58 "--format", 

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

60 default="table", 

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

62) 

63@async_command 

64async def list( 

65 workspace: Path | None, 

66 config: Path | None, 

67 env: Path | None, 

68 format: str, 

69): 

70 """List all configured projects.""" 

71 try: 

72 # Validate flag combinations 

73 validate_workspace_flags(workspace, config, env) 

74 

75 # Load configuration and initialize components 

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

77 

78 # Get project contexts 

79 project_contexts = project_manager.get_all_project_contexts() 

80 

81 if format == "json": 

82 # JSON output 

83 projects_data = [] 

84 for context in project_contexts.values(): 

85 source_count = ( 

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

87 if context.config 

88 else 0 

89 ) 

90 projects_data.append( 

91 { 

92 "project_id": context.project_id, 

93 "display_name": context.display_name, 

94 "description": context.description, 

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

96 "source_count": source_count, 

97 } 

98 ) 

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

100 else: 

101 # Table output using Rich 

102 if not project_contexts: 

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

104 return 

105 

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

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

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

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

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

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

112 

113 for context in project_contexts.values(): 

114 source_count = ( 

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

116 if context.config 

117 else 0 

118 ) 

119 table.add_row( 

120 context.project_id, 

121 context.display_name or "N/A", 

122 context.description or "N/A", 

123 context.collection_name or "N/A", 

124 str(source_count), 

125 ) 

126 

127 console.print(table) 

128 

129 except Exception as e: 

130 logger = LoggingConfig.get_logger(__name__) 

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

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

133 

134 

135@project_cli.command() 

136@option( 

137 "--workspace", 

138 type=ClickPath(path_type=Path), 

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

140) 

141@option( 

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

143) 

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

145@option( 

146 "--project-id", 

147 type=str, 

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

149) 

150@option( 

151 "--format", 

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

153 default="table", 

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

155) 

156@async_command 

157async def status( 

158 workspace: Path | None, 

159 config: Path | None, 

160 env: Path | None, 

161 project_id: str | None, 

162 format: str, 

163): 

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

165 try: 

166 # Validate flag combinations 

167 validate_workspace_flags(workspace, config, env) 

168 

169 # Load configuration and initialize components 

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

171 

172 # Get project contexts 

173 if project_id: 

174 context = project_manager.get_project_context(project_id) 

175 if not context: 

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

177 project_contexts = {project_id: context} 

178 else: 

179 project_contexts = project_manager.get_all_project_contexts() 

180 

181 if format == "json": 

182 # JSON output 

183 status_data = [] 

184 for context in project_contexts.values(): 

185 status_data.append( 

186 { 

187 "project_id": context.project_id, 

188 "display_name": context.display_name, 

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

190 "source_count": ( 

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

192 if context.config 

193 else 0 

194 ), 

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

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

197 } 

198 ) 

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

200 else: 

201 # Table output using Rich 

202 if not project_contexts: 

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

204 return 

205 

206 for context in project_contexts.values(): 

207 source_count = ( 

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

209 if context.config 

210 else 0 

211 ) 

212 

213 # Create project panel 

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

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

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

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

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

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

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

221 

222 console.print( 

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

224 ) 

225 

226 except Exception as e: 

227 logger = LoggingConfig.get_logger(__name__) 

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

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

230 

231 

232@project_cli.command() 

233@option( 

234 "--workspace", 

235 type=ClickPath(path_type=Path), 

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

237) 

238@option( 

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

240) 

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

242@option( 

243 "--project-id", 

244 type=str, 

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

246) 

247@async_command 

248async def validate( 

249 workspace: Path | None, 

250 config: Path | None, 

251 env: Path | None, 

252 project_id: str | None, 

253): 

254 """Validate project configurations.""" 

255 try: 

256 # Validate flag combinations 

257 validate_workspace_flags(workspace, config, env) 

258 

259 # Load configuration and initialize components 

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

261 

262 # Get project contexts to validate 

263 if project_id: 

264 context = project_manager.get_project_context(project_id) 

265 if not context: 

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

267 project_contexts = {project_id: context} 

268 else: 

269 project_contexts = project_manager.get_all_project_contexts() 

270 

271 validation_results = [] 

272 all_valid = True 

273 

274 for context in project_contexts.values(): 

275 try: 

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

277 if not context.config: 

278 validation_results.append( 

279 { 

280 "project_id": context.project_id, 

281 "valid": False, 

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

283 "source_count": 0, 

284 } 

285 ) 

286 all_valid = False 

287 continue 

288 

289 # Check source configurations 

290 source_errors = [] 

291 all_sources = _get_all_sources_from_config(context.config.sources) 

292 

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

294 try: 

295 # Basic validation - check required fields 

296 if ( 

297 not hasattr(source_config, "source_type") 

298 or not source_config.source_type 

299 ): 

300 source_errors.append( 

301 f"Missing source_type for {source_name}" 

302 ) 

303 if ( 

304 not hasattr(source_config, "source") 

305 or not source_config.source 

306 ): 

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

308 except Exception as e: 

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

310 

311 validation_results.append( 

312 { 

313 "project_id": context.project_id, 

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

315 "errors": source_errors, 

316 "source_count": len(all_sources), 

317 } 

318 ) 

319 

320 if source_errors: 

321 all_valid = False 

322 

323 except Exception as e: 

324 validation_results.append( 

325 { 

326 "project_id": context.project_id, 

327 "valid": False, 

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

329 "source_count": 0, 

330 } 

331 ) 

332 all_valid = False 

333 

334 # Display results 

335 for result in validation_results: 

336 if result["valid"]: 

337 console.print( 

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

339 ) 

340 else: 

341 console.print( 

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

343 ) 

344 for error in result["errors"]: 

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

346 

347 if all_valid: 

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

349 else: 

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

351 raise ClickException("Project validation failed") 

352 

353 except Exception as e: 

354 logger = LoggingConfig.get_logger(__name__) 

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

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

357 

358 

359async def _setup_project_manager( 

360 workspace: Path | None, 

361 config: Path | None, 

362 env: Path | None, 

363) -> tuple[Settings, ProjectManager]: 

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

365 from qdrant_loader.cli.cli import ( 

366 _setup_workspace, 

367 _load_config_with_workspace, 

368 _check_settings, 

369 ) 

370 

371 # Setup workspace if provided 

372 workspace_config = None 

373 if workspace: 

374 workspace_config = _setup_workspace(workspace) 

375 

376 # Load configuration 

377 _load_config_with_workspace(workspace_config, config, env) 

378 settings = _check_settings() 

379 

380 # Create project manager 

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

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

383 

384 project_manager = ProjectManager( 

385 projects_config=settings.projects_config, 

386 global_collection_name=settings.global_config.qdrant.collection_name, 

387 ) 

388 

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

390 await _initialize_project_contexts_from_config(project_manager) 

391 

392 return settings, project_manager 

393 

394 

395async def _initialize_project_contexts_from_config( 

396 project_manager: ProjectManager, 

397) -> None: 

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

399 logger = LoggingConfig.get_logger(__name__) 

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

401 

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

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

404 

405 # Determine collection name using the project's method 

406 collection_name = project_config.get_effective_collection_name( 

407 project_manager.global_collection_name 

408 ) 

409 

410 # Create project context 

411 from qdrant_loader.core.project_manager import ProjectContext 

412 

413 context = ProjectContext( 

414 project_id=project_id, 

415 display_name=project_config.display_name, 

416 description=project_config.description, 

417 collection_name=collection_name, 

418 config=project_config, 

419 ) 

420 

421 project_manager._project_contexts[project_id] = context 

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

423 

424 logger.debug( 

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

426 )