Coverage for src/qdrant_loader/cli/cli.py: 90%

154 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-11 07:21 +0000

1"""CLI module for QDrant Loader.""" 

2 

3from pathlib import Path 

4 

5import click 

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 

11 

12from qdrant_loader.cli.async_utils import cancel_all_tasks as _cancel_all_tasks_helper 

13from qdrant_loader.cli.asyncio import async_command 

14from qdrant_loader.cli.commands import run_init as _commands_run_init 

15from qdrant_loader.cli.config_loader import setup_workspace as _setup_workspace_impl 

16from qdrant_loader.cli.logging_utils import get_logger as _get_logger_impl # noqa: F401 

17from qdrant_loader.cli.logging_utils import ( # noqa: F401 

18 setup_logging as _setup_logging_impl, 

19) 

20from qdrant_loader.cli.path_utils import ( 

21 create_database_directory as _create_db_dir_helper, 

22) 

23from qdrant_loader.cli.update_check import check_for_updates as _check_updates_helper 

24from qdrant_loader.cli.version import get_version_str as _get_version_str 

25 

26# Use minimal imports at startup to improve CLI responsiveness. 

27logger = None # Logger will be initialized when first accessed. 

28 

29 

30def _get_version() -> str: 

31 try: 

32 return _get_version_str() 

33 except Exception: 

34 # Maintain CLI resilience: if version lookup fails for any reason, 

35 # surface as 'unknown' rather than crashing the CLI. 

36 return "unknown" 

37 

38 

39# Back-compat helpers for tests: implement wrappers that operate on this module's global logger 

40 

41 

42def _get_logger(): 

43 global logger 

44 if logger is None: 

45 from qdrant_loader.utils.logging import LoggingConfig 

46 

47 logger = LoggingConfig.get_logger(__name__) 

48 return logger 

49 

50 

51def _setup_logging(log_level: str, workspace_config=None) -> None: 

52 try: 

53 from qdrant_loader.utils.logging import LoggingConfig 

54 

55 log_format = "console" 

56 log_file = ( 

57 str(workspace_config.logs_path) if workspace_config else "qdrant-loader.log" 

58 ) 

59 LoggingConfig.setup(level=log_level, format=log_format, file=log_file) 

60 # update module-global logger 

61 global logger 

62 logger = LoggingConfig.get_logger(__name__) 

63 except Exception as e: # pragma: no cover - exercised via tests with mock 

64 from click.exceptions import ClickException 

65 

66 raise ClickException(f"Failed to setup logging: {str(e)!s}") from e 

67 

68 

69def _check_for_updates() -> None: 

70 _check_updates_helper(_get_version()) 

71 

72 

73def _setup_workspace(workspace_path: Path): 

74 workspace_config = _setup_workspace_impl(workspace_path) 

75 return workspace_config 

76 

77 

78@group(name="qdrant-loader") 

79@option( 

80 "--log-level", 

81 type=Choice( 

82 ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False 

83 ), 

84 default="INFO", 

85 help="Set the logging level.", 

86) 

87@click.version_option( 

88 version=_get_version(), 

89 message="qDrant Loader v.%(version)s", 

90) 

91def cli(log_level: str = "INFO") -> None: 

92 """QDrant Loader CLI.""" 

93 # Check for available updates in background without blocking CLI startup. 

94 _check_for_updates() 

95 

96 

97def _create_database_directory(path: Path) -> bool: 

98 """Create database directory with user confirmation. 

99 

100 Args: 

101 path: Path to the database directory 

102 

103 Returns: 

104 bool: True if directory was created, False if user declined 

105 """ 

106 try: 

107 abs_path = path.resolve() 

108 _get_logger().info("The database directory does not exist", path=str(abs_path)) 

109 created = _create_db_dir_helper(abs_path) 

110 if created: 

111 _get_logger().info(f"Created directory: {abs_path}") 

112 return created 

113 except ClickException: 

114 # Propagate ClickException from helper directly 

115 raise 

116 except Exception as e: 

117 # Wrap any other unexpected errors 

118 raise ClickException(f"Failed to create directory: {str(e)!s}") from e 

119 

120 

121def _load_config( 

122 config_path: Path | None = None, 

123 env_path: Path | None = None, 

124 skip_validation: bool = False, 

125) -> None: 

126 """Load configuration from file. 

127 

128 Args: 

129 config_path: Optional path to config file 

130 env_path: Optional path to .env file 

131 skip_validation: If True, skip directory validation and creation 

132 """ 

133 try: 

134 # Lazy import to avoid slow startup 

135 from qdrant_loader.config import initialize_config 

136 

137 # Step 1: If config path is provided, use it 

138 if config_path is not None: 

139 if not config_path.exists(): 

140 _get_logger().error("config_not_found", path=str(config_path)) 

141 raise ClickException(f"Config file not found: {str(config_path)!s}") 

142 initialize_config(config_path, env_path, skip_validation=skip_validation) 

143 return 

144 

145 # Step 2: If no config path, look for config.yaml in current folder 

146 default_config = Path("config.yaml") 

147 if default_config.exists(): 

148 initialize_config(default_config, env_path, skip_validation=skip_validation) 

149 return 

150 

151 # Step 4: If no file is found, raise an error 

152 raise ClickException( 

153 f"No config file found. Please specify a config file or create config.yaml in the current directory: {str(default_config)!s}" 

154 ) 

155 

156 except Exception as e: 

157 # Handle DatabaseDirectoryError and other exceptions 

158 from qdrant_loader.config.state import DatabaseDirectoryError 

159 

160 if isinstance(e, DatabaseDirectoryError): 

161 if skip_validation: 

162 # For config display, we don't need to create the directory 

163 return 

164 

165 # Get the path from the error - it's already a Path object 

166 error_path = e.path 

167 # Resolve to absolute path for consistency 

168 abs_path = error_path.resolve() 

169 

170 if not _create_database_directory(abs_path): 

171 raise ClickException( 

172 "Database directory creation declined. Exiting." 

173 ) from e 

174 

175 # No need to retry _load_config since the directory is now created 

176 # Just initialize the config with the expanded path 

177 if config_path is not None: 

178 initialize_config( 

179 config_path, env_path, skip_validation=skip_validation 

180 ) 

181 else: 

182 initialize_config( 

183 Path("config.yaml"), env_path, skip_validation=skip_validation 

184 ) 

185 elif isinstance(e, ClickException): 

186 raise e from None 

187 else: 

188 _get_logger().error("config_load_failed", error=str(e)) 

189 raise ClickException(f"Failed to load configuration: {str(e)!s}") from e 

190 

191 

192def _check_settings(): 

193 """Check if settings are available.""" 

194 # Lazy import to avoid slow startup 

195 from qdrant_loader.config import get_settings 

196 

197 settings = get_settings() 

198 if settings is None: 

199 _get_logger().error("settings_not_available") 

200 raise ClickException("Settings not available") 

201 return settings 

202 

203 

204def _load_config_with_workspace( 

205 workspace_config, 

206 config_path: Path | None = None, 

207 env_path: Path | None = None, 

208 skip_validation: bool = False, 

209): 

210 """Compatibility wrapper used by tests and project commands. 

211 

212 Delegates to qdrant_loader.cli.config_loader.load_config_with_workspace. 

213 """ 

214 from qdrant_loader.cli.config_loader import ( 

215 load_config_with_workspace as _load_with_ws, 

216 ) 

217 

218 _load_with_ws( 

219 workspace_config, 

220 config_path=config_path, 

221 env_path=env_path, 

222 skip_validation=skip_validation, 

223 ) 

224 

225 

226async def _run_init(settings, force: bool) -> None: 

227 """Run initialization process via command helper, keeping existing logging.""" 

228 try: 

229 await _commands_run_init(settings, force) 

230 if force: 

231 _get_logger().info( 

232 "Collection recreated successfully", 

233 collection=settings.qdrant_collection_name, 

234 ) 

235 else: 

236 _get_logger().info( 

237 "Collection initialized successfully", 

238 collection=settings.qdrant_collection_name, 

239 ) 

240 except Exception as e: 

241 _get_logger().error("init_failed", error=str(e)) 

242 raise ClickException(f"Failed to initialize collection: {str(e)!s}") from e 

243 

244 

245@cli.command() 

246@option( 

247 "--workspace", 

248 type=ClickPath(path_type=Path), 

249 help="Workspace directory containing config.yaml and .env files. All output will be stored here.", 

250) 

251@option( 

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

253) 

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

255@option("--force", is_flag=True, help="Force reinitialization of collection.") 

256@option( 

257 "--log-level", 

258 type=Choice( 

259 ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False 

260 ), 

261 default="INFO", 

262 help="Set the logging level.", 

263) 

264@async_command 

265async def init( 

266 workspace: Path | None, 

267 config: Path | None, 

268 env: Path | None, 

269 force: bool, 

270 log_level: str, 

271): 

272 """Initialize QDrant collection.""" 

273 from qdrant_loader.cli.commands.init_cmd import run_init_command 

274 

275 await run_init_command(workspace, config, env, force, log_level) 

276 

277 

278async def _cancel_all_tasks(): 

279 await _cancel_all_tasks_helper() 

280 

281 

282@cli.command() 

283@option( 

284 "--workspace", 

285 type=ClickPath(path_type=Path), 

286 help="Workspace directory containing config.yaml and .env files. All output will be stored here.", 

287) 

288@option( 

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

290) 

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

292@option( 

293 "--project", 

294 type=str, 

295 help="Project ID to process. If specified, --source-type and --source will filter within this project.", 

296) 

297@option( 

298 "--source-type", 

299 type=str, 

300 help="Source type to process (e.g., confluence, jira, git). If --project is specified, filters within that project; otherwise applies to all projects.", 

301) 

302@option( 

303 "--source", 

304 type=str, 

305 help="Source name to process. If --project is specified, filters within that project; otherwise applies to all projects.", 

306) 

307@option( 

308 "--log-level", 

309 type=Choice( 

310 ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False 

311 ), 

312 default="INFO", 

313 help="Set the logging level.", 

314) 

315@option( 

316 "--profile/--no-profile", 

317 default=False, 

318 help="Run the ingestion under cProfile and save output to 'profile.out' (for performance analysis).", 

319) 

320@option( 

321 "--force", 

322 is_flag=True, 

323 help="Force processing of all documents, bypassing change detection. Warning: May significantly increase processing time and costs.", 

324) 

325@async_command 

326async def ingest( 

327 workspace: Path | None, 

328 config: Path | None, 

329 env: Path | None, 

330 project: str | None, 

331 source_type: str | None, 

332 source: str | None, 

333 log_level: str, 

334 profile: bool, 

335 force: bool, 

336): 

337 """Ingest documents from configured sources. 

338 

339 Examples: 

340 # Ingest all projects 

341 qdrant-loader ingest 

342 

343 # Ingest specific project 

344 qdrant-loader ingest --project my-project 

345 

346 # Ingest specific source type from all projects 

347 qdrant-loader ingest --source-type git 

348 

349 # Ingest specific source type from specific project 

350 qdrant-loader ingest --project my-project --source-type git 

351 

352 # Ingest specific source from specific project 

353 qdrant-loader ingest --project my-project --source-type git --source my-repo 

354 

355 # Force processing of all documents (bypass change detection) 

356 qdrant-loader ingest --force 

357 """ 

358 from qdrant_loader.cli.commands.ingest_cmd import run_ingest_command 

359 

360 await run_ingest_command( 

361 workspace, 

362 config, 

363 env, 

364 project, 

365 source_type, 

366 source, 

367 log_level, 

368 profile, 

369 force, 

370 ) 

371 

372 

373@cli.command() 

374@option( 

375 "--workspace", 

376 type=ClickPath(path_type=Path), 

377 help="Workspace directory containing config.yaml and .env files. All output will be stored here.", 

378) 

379@option( 

380 "--log-level", 

381 type=Choice( 

382 ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False 

383 ), 

384 default="INFO", 

385 help="Set the logging level.", 

386) 

387@option( 

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

389) 

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

391def config( 

392 workspace: Path | None, log_level: str, config: Path | None, env: Path | None 

393): 

394 """Display current configuration.""" 

395 try: 

396 echo("Current Configuration:") 

397 from qdrant_loader.cli.commands.config import ( 

398 run_show_config as _run_show_config, 

399 ) 

400 

401 output = _run_show_config(workspace, config, env, log_level) 

402 echo(output) 

403 except Exception as e: 

404 from qdrant_loader.utils.logging import LoggingConfig 

405 

406 LoggingConfig.get_logger(__name__).error("config_failed", error=str(e)) 

407 raise ClickException(f"Failed to display configuration: {str(e)!s}") from e 

408 

409 

410# Add project management commands with lazy import 

411def _add_project_commands(): 

412 """Lazily add project commands to avoid slow startup.""" 

413 from qdrant_loader.cli.project_commands import project_cli 

414 

415 cli.add_command(project_cli) 

416 

417 

418# Only add project commands when CLI is actually used 

419if __name__ == "__main__": 

420 _add_project_commands() 

421 cli() 

422else: 

423 # For when imported as a module, add commands on first access 

424 import atexit 

425 

426 atexit.register(_add_project_commands)