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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-08 06:05 +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
28# Initialize Rich console for enhanced output formatting.
29console = Console()
32@group(name="project")
33def project_cli():
34 """Project management commands."""
35 pass
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
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
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
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 )
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
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
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
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
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 )
280 # Setup workspace if provided
281 workspace_config = None
282 if workspace:
283 workspace_config = _setup_workspace(workspace)
285 # Load configuration
286 _load_config_with_workspace(workspace_config, config, env)
287 settings = _check_settings()
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")
293 project_manager = ProjectManager(
294 projects_config=settings.projects_config,
295 global_collection_name=settings.global_config.qdrant.collection_name,
296 )
298 # Initialize project contexts directly from configuration (without database)
299 await _initialize_project_contexts_from_config(project_manager)
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
310 return settings, project_manager, state_manager
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")
320 for project_id, project_config in project_manager.projects_config.projects.items():
321 logger.debug(f"Creating context for project: {project_id}")
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 )
328 # Create project context
329 from qdrant_loader.core.project_manager import ProjectContext
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 )
339 project_manager._project_contexts[project_id] = context
340 logger.debug(f"Created context for project: {project_id}")
342 logger.debug(
343 f"Initialized {len(project_manager._project_contexts)} project contexts"
344 )