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

162 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-10 09:40 +0000

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

2 

3from pathlib import Path 

4 

5from click.decorators import group, option 

6from click.exceptions import ClickException 

7from click.types import Choice 

8from click.types import Path as ClickPath 

9from click.utils import echo 

10from rich.console import Console 

11from rich.panel import Panel # noqa: F401 - kept for potential rich layouts 

12from rich.table import Table # noqa: F401 - kept for potential rich layouts 

13from sqlalchemy import func, select 

14 

15from qdrant_loader.cli.asyncio import async_command 

16from qdrant_loader.cli.commands.project import run_project_list as _run_project_list 

17from qdrant_loader.cli.commands.project import run_project_status as _run_project_status 

18from qdrant_loader.cli.commands.project import ( 

19 run_project_validate as _run_project_validate, 

20) 

21from qdrant_loader.config import Settings 

22from qdrant_loader.config.workspace import validate_workspace_flags 

23from qdrant_loader.core.project_manager import ProjectManager 

24from qdrant_loader.core.state.models import DocumentStateRecord, IngestionHistory 

25from qdrant_loader.core.state.state_manager import StateManager 

26from qdrant_loader.utils.logging import LoggingConfig 

27from qdrant_loader.utils.sensitive import sanitize_exception_message 

28 

29# Initialize Rich console for enhanced output formatting. 

30console = Console() 

31 

32 

33@group(name="project") 

34def project_cli(): 

35 """Project management commands.""" 

36 pass 

37 

38 

39def _get_all_sources_from_config(sources_config): 

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

41 all_sources = {} 

42 all_sources.update(sources_config.publicdocs) 

43 all_sources.update(sources_config.git) 

44 all_sources.update(sources_config.confluence) 

45 all_sources.update(sources_config.jira) 

46 all_sources.update(sources_config.localfile) 

47 return all_sources 

48 

49 

50async def _get_project_document_count( 

51 state_manager: StateManager, project_id: str 

52) -> int: 

53 """Get the count of non-deleted documents for a project.""" 

54 try: 

55 # Prefer direct session factory if available (matches tests/mocks) 

56 session_factory = getattr(state_manager, "_session_factory", None) 

57 if session_factory is None: 

58 ctx = await state_manager.get_session() 

59 else: 

60 ctx = session_factory() if callable(session_factory) else session_factory 

61 async with ctx as session: # type: ignore 

62 result = await session.execute( 

63 select(func.count(DocumentStateRecord.id)) 

64 .filter_by(project_id=project_id) 

65 .filter_by(is_deleted=False) 

66 ) 

67 count = result.scalar() or 0 

68 return count 

69 except Exception: 

70 # Return zero count if database query fails to ensure graceful degradation. 

71 return 0 

72 

73 

74async def _get_project_latest_ingestion( 

75 state_manager: StateManager, project_id: str 

76) -> str | None: 

77 """Get the latest ingestion timestamp for a project.""" 

78 try: 

79 # Prefer direct session factory if available (matches tests/mocks) 

80 session_factory = getattr(state_manager, "_session_factory", None) 

81 if session_factory is None: 

82 ctx = await state_manager.get_session() 

83 else: 

84 ctx = session_factory() if callable(session_factory) else session_factory 

85 async with ctx as session: # type: ignore 

86 result = await session.execute( 

87 select(IngestionHistory.last_successful_ingestion) 

88 .filter_by(project_id=project_id) 

89 .order_by(IngestionHistory.last_successful_ingestion.desc()) 

90 .limit(1) 

91 ) 

92 timestamp = result.scalar_one_or_none() 

93 return timestamp.isoformat() if timestamp else None 

94 except Exception: 

95 # Return None if database query fails to indicate no ingestion data available. 

96 return None 

97 

98 

99@project_cli.command() 

100@option( 

101 "--workspace", 

102 type=ClickPath(path_type=Path), 

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

104) 

105@option( 

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

107) 

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

109@option( 

110 "--format", 

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

112 default="table", 

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

114) 

115@async_command 

116async def list( 

117 workspace: Path | None, 

118 config: Path | None, 

119 env: Path | None, 

120 format: str, 

121): 

122 """List all configured projects.""" 

123 try: 

124 validate_workspace_flags(workspace, config, env) 

125 settings, project_manager, _ = await _setup_project_manager( 

126 workspace, config, env 

127 ) 

128 

129 project_contexts = project_manager.get_all_project_contexts() 

130 if not project_contexts and format != "json": 

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

132 return 

133 

134 output = _run_project_list(settings, project_manager, output_format=format) 

135 if format == "json": 

136 echo(output) 

137 else: 

138 console.print(output) 

139 except ClickException: 

140 raise 

141 except Exception as e: 

142 logger = LoggingConfig.get_logger(__name__) 

143 safe_error = sanitize_exception_message(e) 

144 # Standardized error logging: user-friendly message + technical details + troubleshooting hint 

145 logger.error( 

146 "Failed to list projects from configuration", 

147 error=safe_error, 

148 error_type=type(e).__name__, 

149 suggestion="Try running 'qdrant-loader project validate' to check configuration", 

150 ) 

151 raise ClickException(f"Failed to list projects: {safe_error}") from e 

152 

153 

154@project_cli.command() 

155@option( 

156 "--workspace", 

157 type=ClickPath(path_type=Path), 

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

159) 

160@option( 

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

162) 

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

164@option( 

165 "--project-id", 

166 type=str, 

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

168) 

169@option( 

170 "--format", 

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

172 default="table", 

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

174) 

175@async_command 

176async def status( 

177 workspace: Path | None, 

178 config: Path | None, 

179 env: Path | None, 

180 project_id: str | None, 

181 format: str, 

182): 

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

184 try: 

185 validate_workspace_flags(workspace, config, env) 

186 settings, project_manager, state_manager = await _setup_project_manager( 

187 workspace, config, env 

188 ) 

189 if project_id: 

190 context = project_manager.get_project_context(project_id) 

191 if not context: 

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

193 output = await _run_project_status( 

194 settings, 

195 project_manager, 

196 state_manager, 

197 project_id=project_id, 

198 output_format=format, 

199 ) 

200 if format == "json": 

201 echo(output) 

202 else: 

203 console.print(output) 

204 except ClickException: 

205 raise 

206 except Exception as e: 

207 logger = LoggingConfig.get_logger(__name__) 

208 safe_error = sanitize_exception_message(e) 

209 # Standardized error logging: user-friendly message + technical details + troubleshooting hint 

210 logger.error( 

211 "Failed to retrieve project status information", 

212 error=safe_error, 

213 error_type=type(e).__name__, 

214 suggestion="Verify project configuration and database connectivity", 

215 ) 

216 raise ClickException(f"Failed to get project status: {safe_error}") from e 

217 

218 

219@project_cli.command() 

220@option( 

221 "--workspace", 

222 type=ClickPath(path_type=Path), 

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

224) 

225@option( 

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

227) 

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

229@option( 

230 "--project-id", 

231 type=str, 

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

233) 

234@async_command 

235async def validate( 

236 workspace: Path | None, 

237 config: Path | None, 

238 env: Path | None, 

239 project_id: str | None, 

240): 

241 """Validate project configurations.""" 

242 try: 

243 validate_workspace_flags(workspace, config, env) 

244 settings, project_manager, _ = await _setup_project_manager( 

245 workspace, config, env 

246 ) 

247 results, all_valid = _run_project_validate( 

248 settings, project_manager, project_id=project_id 

249 ) 

250 for result in results: 

251 if result["valid"]: 

252 console.print( 

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

254 ) 

255 else: 

256 console.print( 

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

258 ) 

259 for error in result["errors"]: 

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

261 if not all_valid: 

262 raise ClickException("Project validation failed") 

263 except ClickException: 

264 raise 

265 except Exception as e: 

266 logger = LoggingConfig.get_logger(__name__) 

267 safe_error = sanitize_exception_message(e) 

268 # Standardized error logging: user-friendly message + technical details + troubleshooting hint 

269 logger.error( 

270 "Failed to validate project configurations", 

271 error=safe_error, 

272 error_type=type(e).__name__, 

273 suggestion="Check config.yaml syntax and data source accessibility", 

274 ) 

275 raise ClickException(f"Failed to validate projects: {safe_error}") from e 

276 

277 

278async def _setup_project_manager( 

279 workspace: Path | None, 

280 config: Path | None, 

281 env: Path | None, 

282) -> tuple[Settings, ProjectManager, StateManager]: 

283 """Setup project manager and state manager with configuration loading.""" 

284 from qdrant_loader.cli.cli import ( 

285 _check_settings, 

286 _load_config_with_workspace, 

287 _setup_workspace, 

288 ) 

289 

290 # Setup workspace if provided 

291 workspace_config = None 

292 if workspace: 

293 workspace_config = _setup_workspace(workspace) 

294 

295 # Load configuration 

296 _load_config_with_workspace(workspace_config, config, env) 

297 settings = _check_settings() 

298 

299 # Create project manager 

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

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

302 

303 project_manager = ProjectManager( 

304 projects_config=settings.projects_config, 

305 global_collection_name=settings.global_config.qdrant.collection_name, 

306 ) 

307 

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

309 await _initialize_project_contexts_from_config(project_manager) 

310 

311 # Create and initialize state manager 

312 state_manager = StateManager(settings.global_config.state_management) 

313 try: 

314 await state_manager.initialize() 

315 except Exception: 

316 # If state manager initialization fails, we'll continue without it 

317 # The database queries will return default values (0 count, None timestamp) 

318 pass 

319 

320 return settings, project_manager, state_manager 

321 

322 

323async def _initialize_project_contexts_from_config( 

324 project_manager: ProjectManager, 

325) -> None: 

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

327 logger = LoggingConfig.get_logger(__name__) 

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

329 

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

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

332 

333 # Determine collection name using the project's method 

334 collection_name = project_config.get_effective_collection_name( 

335 project_manager.global_collection_name 

336 ) 

337 

338 # Create project context 

339 from qdrant_loader.core.project_manager import ProjectContext 

340 

341 context = ProjectContext( 

342 project_id=project_id, 

343 display_name=project_config.display_name, 

344 description=project_config.description, 

345 collection_name=collection_name, 

346 config=project_config, 

347 ) 

348 

349 project_manager._project_contexts[project_id] = context 

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

351 

352 logger.debug( 

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

354 )