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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 09:40 +0000
1"""Project management CLI commands for QDrant Loader."""
3from pathlib import Path
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
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
29# Initialize Rich console for enhanced output formatting.
30console = Console()
33@group(name="project")
34def project_cli():
35 """Project management commands."""
36 pass
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
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
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
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 )
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
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
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
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
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 )
290 # Setup workspace if provided
291 workspace_config = None
292 if workspace:
293 workspace_config = _setup_workspace(workspace)
295 # Load configuration
296 _load_config_with_workspace(workspace_config, config, env)
297 settings = _check_settings()
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")
303 project_manager = ProjectManager(
304 projects_config=settings.projects_config,
305 global_collection_name=settings.global_config.qdrant.collection_name,
306 )
308 # Initialize project contexts directly from configuration (without database)
309 await _initialize_project_contexts_from_config(project_manager)
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
320 return settings, project_manager, state_manager
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")
330 for project_id, project_config in project_manager.projects_config.projects.items():
331 logger.debug(f"Creating context for project: {project_id}")
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 )
338 # Create project context
339 from qdrant_loader.core.project_manager import ProjectContext
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 )
349 project_manager._project_contexts[project_id] = context
350 logger.debug(f"Created context for project: {project_id}")
352 logger.debug(
353 f"Initialized {len(project_manager._project_contexts)} project contexts"
354 )