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

152 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-08 06:05 +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 

27 

28# Initialize Rich console for enhanced output formatting. 

29console = Console() 

30 

31 

32@group(name="project") 

33def project_cli(): 

34 """Project management commands.""" 

35 pass 

36 

37 

38def _get_all_sources_from_config(sources_config): 

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

40 all_sources = {} 

41 all_sources.update(sources_config.publicdocs) 

42 all_sources.update(sources_config.git) 

43 all_sources.update(sources_config.confluence) 

44 all_sources.update(sources_config.jira) 

45 all_sources.update(sources_config.localfile) 

46 return all_sources 

47 

48 

49async def _get_project_document_count( 

50 state_manager: StateManager, project_id: str 

51) -> int: 

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

53 try: 

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

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

56 if session_factory is None: 

57 ctx = await state_manager.get_session() 

58 else: 

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

60 async with ctx as session: # type: ignore 

61 result = await session.execute( 

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

63 .filter_by(project_id=project_id) 

64 .filter_by(is_deleted=False) 

65 ) 

66 count = result.scalar() or 0 

67 return count 

68 except Exception: 

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

70 return 0 

71 

72 

73async def _get_project_latest_ingestion( 

74 state_manager: StateManager, project_id: str 

75) -> str | None: 

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

77 try: 

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

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

80 if session_factory is None: 

81 ctx = await state_manager.get_session() 

82 else: 

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

84 async with ctx as session: # type: ignore 

85 result = await session.execute( 

86 select(IngestionHistory.last_successful_ingestion) 

87 .filter_by(project_id=project_id) 

88 .order_by(IngestionHistory.last_successful_ingestion.desc()) 

89 .limit(1) 

90 ) 

91 timestamp = result.scalar_one_or_none() 

92 return timestamp.isoformat() if timestamp else None 

93 except Exception: 

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

95 return None 

96 

97 

98@project_cli.command() 

99@option( 

100 "--workspace", 

101 type=ClickPath(path_type=Path), 

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

103) 

104@option( 

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

106) 

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

108@option( 

109 "--format", 

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

111 default="table", 

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

113) 

114@async_command 

115async def list( 

116 workspace: Path | None, 

117 config: Path | None, 

118 env: Path | None, 

119 format: str, 

120): 

121 """List all configured projects.""" 

122 try: 

123 validate_workspace_flags(workspace, config, env) 

124 settings, project_manager, _ = await _setup_project_manager( 

125 workspace, config, env 

126 ) 

127 

128 project_contexts = project_manager.get_all_project_contexts() 

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

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

131 return 

132 

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

134 if format == "json": 

135 echo(output) 

136 else: 

137 console.print(output) 

138 except Exception as e: 

139 logger = LoggingConfig.get_logger(__name__) 

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

141 logger.error( 

142 "Failed to list projects from configuration", 

143 error=str(e), 

144 error_type=type(e).__name__, 

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

146 ) 

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

148 

149 

150@project_cli.command() 

151@option( 

152 "--workspace", 

153 type=ClickPath(path_type=Path), 

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

155) 

156@option( 

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

158) 

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

160@option( 

161 "--project-id", 

162 type=str, 

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

164) 

165@option( 

166 "--format", 

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

168 default="table", 

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

170) 

171@async_command 

172async def status( 

173 workspace: Path | None, 

174 config: Path | None, 

175 env: Path | None, 

176 project_id: str | None, 

177 format: str, 

178): 

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

180 try: 

181 validate_workspace_flags(workspace, config, env) 

182 settings, project_manager, state_manager = await _setup_project_manager( 

183 workspace, config, env 

184 ) 

185 if project_id: 

186 context = project_manager.get_project_context(project_id) 

187 if not context: 

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

189 output = await _run_project_status( 

190 settings, 

191 project_manager, 

192 state_manager, 

193 project_id=project_id, 

194 output_format=format, 

195 ) 

196 if format == "json": 

197 echo(output) 

198 else: 

199 console.print(output) 

200 except Exception as e: 

201 logger = LoggingConfig.get_logger(__name__) 

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

203 logger.error( 

204 "Failed to retrieve project status information", 

205 error=str(e), 

206 error_type=type(e).__name__, 

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

208 ) 

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

210 

211 

212@project_cli.command() 

213@option( 

214 "--workspace", 

215 type=ClickPath(path_type=Path), 

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

217) 

218@option( 

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

220) 

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

222@option( 

223 "--project-id", 

224 type=str, 

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

226) 

227@async_command 

228async def validate( 

229 workspace: Path | None, 

230 config: Path | None, 

231 env: Path | None, 

232 project_id: str | None, 

233): 

234 """Validate project configurations.""" 

235 try: 

236 validate_workspace_flags(workspace, config, env) 

237 settings, project_manager, _ = await _setup_project_manager( 

238 workspace, config, env 

239 ) 

240 results, all_valid = _run_project_validate( 

241 settings, project_manager, project_id=project_id 

242 ) 

243 for result in results: 

244 if result["valid"]: 

245 console.print( 

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

247 ) 

248 else: 

249 console.print( 

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

251 ) 

252 for error in result["errors"]: 

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

254 if not all_valid: 

255 raise ClickException("Project validation failed") 

256 except Exception as e: 

257 logger = LoggingConfig.get_logger(__name__) 

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

259 logger.error( 

260 "Failed to validate project configurations", 

261 error=str(e), 

262 error_type=type(e).__name__, 

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

264 ) 

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

266 

267 

268async def _setup_project_manager( 

269 workspace: Path | None, 

270 config: Path | None, 

271 env: Path | None, 

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

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

274 from qdrant_loader.cli.cli import ( 

275 _check_settings, 

276 _load_config_with_workspace, 

277 _setup_workspace, 

278 ) 

279 

280 # Setup workspace if provided 

281 workspace_config = None 

282 if workspace: 

283 workspace_config = _setup_workspace(workspace) 

284 

285 # Load configuration 

286 _load_config_with_workspace(workspace_config, config, env) 

287 settings = _check_settings() 

288 

289 # Create project manager 

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

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

292 

293 project_manager = ProjectManager( 

294 projects_config=settings.projects_config, 

295 global_collection_name=settings.global_config.qdrant.collection_name, 

296 ) 

297 

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

299 await _initialize_project_contexts_from_config(project_manager) 

300 

301 # Create and initialize state manager 

302 state_manager = StateManager(settings.global_config.state_management) 

303 try: 

304 await state_manager.initialize() 

305 except Exception: 

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

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

308 pass 

309 

310 return settings, project_manager, state_manager 

311 

312 

313async def _initialize_project_contexts_from_config( 

314 project_manager: ProjectManager, 

315) -> None: 

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

317 logger = LoggingConfig.get_logger(__name__) 

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

319 

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

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

322 

323 # Determine collection name using the project's method 

324 collection_name = project_config.get_effective_collection_name( 

325 project_manager.global_collection_name 

326 ) 

327 

328 # Create project context 

329 from qdrant_loader.core.project_manager import ProjectContext 

330 

331 context = ProjectContext( 

332 project_id=project_id, 

333 display_name=project_config.display_name, 

334 description=project_config.description, 

335 collection_name=collection_name, 

336 config=project_config, 

337 ) 

338 

339 project_manager._project_contexts[project_id] = context 

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

341 

342 logger.debug( 

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

344 )