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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-11 07:21 +0000
1"""CLI module for QDrant Loader."""
3from pathlib import Path
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
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
26# Use minimal imports at startup to improve CLI responsiveness.
27logger = None # Logger will be initialized when first accessed.
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"
39# Back-compat helpers for tests: implement wrappers that operate on this module's global logger
42def _get_logger():
43 global logger
44 if logger is None:
45 from qdrant_loader.utils.logging import LoggingConfig
47 logger = LoggingConfig.get_logger(__name__)
48 return logger
51def _setup_logging(log_level: str, workspace_config=None) -> None:
52 try:
53 from qdrant_loader.utils.logging import LoggingConfig
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
66 raise ClickException(f"Failed to setup logging: {str(e)!s}") from e
69def _check_for_updates() -> None:
70 _check_updates_helper(_get_version())
73def _setup_workspace(workspace_path: Path):
74 workspace_config = _setup_workspace_impl(workspace_path)
75 return workspace_config
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()
97def _create_database_directory(path: Path) -> bool:
98 """Create database directory with user confirmation.
100 Args:
101 path: Path to the database directory
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
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.
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
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
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
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 )
156 except Exception as e:
157 # Handle DatabaseDirectoryError and other exceptions
158 from qdrant_loader.config.state import DatabaseDirectoryError
160 if isinstance(e, DatabaseDirectoryError):
161 if skip_validation:
162 # For config display, we don't need to create the directory
163 return
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()
170 if not _create_database_directory(abs_path):
171 raise ClickException(
172 "Database directory creation declined. Exiting."
173 ) from e
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
192def _check_settings():
193 """Check if settings are available."""
194 # Lazy import to avoid slow startup
195 from qdrant_loader.config import get_settings
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
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.
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 )
218 _load_with_ws(
219 workspace_config,
220 config_path=config_path,
221 env_path=env_path,
222 skip_validation=skip_validation,
223 )
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
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
275 await run_init_command(workspace, config, env, force, log_level)
278async def _cancel_all_tasks():
279 await _cancel_all_tasks_helper()
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.
339 Examples:
340 # Ingest all projects
341 qdrant-loader ingest
343 # Ingest specific project
344 qdrant-loader ingest --project my-project
346 # Ingest specific source type from all projects
347 qdrant-loader ingest --source-type git
349 # Ingest specific source type from specific project
350 qdrant-loader ingest --project my-project --source-type git
352 # Ingest specific source from specific project
353 qdrant-loader ingest --project my-project --source-type git --source my-repo
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
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 )
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 )
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
406 LoggingConfig.get_logger(__name__).error("config_failed", error=str(e))
407 raise ClickException(f"Failed to display configuration: {str(e)!s}") from e
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
415 cli.add_command(project_cli)
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
426 atexit.register(_add_project_commands)