Coverage for src/qdrant_loader/cli/project_commands.py: 92%
176 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-04 05:50 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-04 05:50 +0000
1"""Project management CLI commands for QDrant Loader."""
3import asyncio
4import json
5from pathlib import Path
6from typing import Optional
8import click
9from click.decorators import group, option
10from click.exceptions import ClickException
11from click.types import Choice
12from click.types import Path as ClickPath
13from click.utils import echo
14from rich.console import Console
15from rich.table import Table
16from rich.panel import Panel
17from rich.text import Text
19from qdrant_loader.cli.asyncio import async_command
20from qdrant_loader.config import Settings, get_settings
21from qdrant_loader.config.workspace import WorkspaceConfig, validate_workspace_flags
22from qdrant_loader.core.project_manager import ProjectManager
23from qdrant_loader.core.qdrant_manager import QdrantManager
24from qdrant_loader.utils.logging import LoggingConfig
26# Rich console for better output formatting
27console = Console()
30@group(name="project")
31def project_cli():
32 """Project management commands."""
33 pass
36def _get_all_sources_from_config(sources_config):
37 """Get all sources from a SourcesConfig object."""
38 all_sources = {}
39 all_sources.update(sources_config.publicdocs)
40 all_sources.update(sources_config.git)
41 all_sources.update(sources_config.confluence)
42 all_sources.update(sources_config.jira)
43 all_sources.update(sources_config.localfile)
44 return all_sources
47@project_cli.command()
48@option(
49 "--workspace",
50 type=ClickPath(path_type=Path),
51 help="Workspace directory containing config.yaml and .env files.",
52)
53@option(
54 "--config", type=ClickPath(exists=True, path_type=Path), help="Path to config file."
55)
56@option("--env", type=ClickPath(exists=True, path_type=Path), help="Path to .env file.")
57@option(
58 "--format",
59 type=Choice(["table", "json"], case_sensitive=False),
60 default="table",
61 help="Output format for project list.",
62)
63@async_command
64async def list(
65 workspace: Path | None,
66 config: Path | None,
67 env: Path | None,
68 format: str,
69):
70 """List all configured projects."""
71 try:
72 # Validate flag combinations
73 validate_workspace_flags(workspace, config, env)
75 # Load configuration and initialize components
76 settings, project_manager = await _setup_project_manager(workspace, config, env)
78 # Get project contexts
79 project_contexts = project_manager.get_all_project_contexts()
81 if format == "json":
82 # JSON output
83 projects_data = []
84 for context in project_contexts.values():
85 source_count = (
86 len(_get_all_sources_from_config(context.config.sources))
87 if context.config
88 else 0
89 )
90 projects_data.append(
91 {
92 "project_id": context.project_id,
93 "display_name": context.display_name,
94 "description": context.description,
95 "collection_name": context.collection_name or "N/A",
96 "source_count": source_count,
97 }
98 )
99 echo(json.dumps(projects_data, indent=2))
100 else:
101 # Table output using Rich
102 if not project_contexts:
103 console.print("[yellow]No projects configured.[/yellow]")
104 return
106 table = Table(title="Configured Projects")
107 table.add_column("Project ID", style="cyan", no_wrap=True)
108 table.add_column("Display Name", style="magenta")
109 table.add_column("Description", style="green")
110 table.add_column("Collection", style="blue")
111 table.add_column("Sources", justify="right", style="yellow")
113 for context in project_contexts.values():
114 source_count = (
115 len(_get_all_sources_from_config(context.config.sources))
116 if context.config
117 else 0
118 )
119 table.add_row(
120 context.project_id,
121 context.display_name or "N/A",
122 context.description or "N/A",
123 context.collection_name or "N/A",
124 str(source_count),
125 )
127 console.print(table)
129 except Exception as e:
130 logger = LoggingConfig.get_logger(__name__)
131 logger.error("project_list_failed", error=str(e))
132 raise ClickException(f"Failed to list projects: {str(e)!s}") from e
135@project_cli.command()
136@option(
137 "--workspace",
138 type=ClickPath(path_type=Path),
139 help="Workspace directory containing config.yaml and .env files.",
140)
141@option(
142 "--config", type=ClickPath(exists=True, path_type=Path), help="Path to config file."
143)
144@option("--env", type=ClickPath(exists=True, path_type=Path), help="Path to .env file.")
145@option(
146 "--project-id",
147 type=str,
148 help="Specific project ID to check status for.",
149)
150@option(
151 "--format",
152 type=Choice(["table", "json"], case_sensitive=False),
153 default="table",
154 help="Output format for project status.",
155)
156@async_command
157async def status(
158 workspace: Path | None,
159 config: Path | None,
160 env: Path | None,
161 project_id: str | None,
162 format: str,
163):
164 """Show project status including document counts and ingestion history."""
165 try:
166 # Validate flag combinations
167 validate_workspace_flags(workspace, config, env)
169 # Load configuration and initialize components
170 settings, project_manager = await _setup_project_manager(workspace, config, env)
172 # Get project contexts
173 if project_id:
174 context = project_manager.get_project_context(project_id)
175 if not context:
176 raise ClickException(f"Project '{project_id}' not found")
177 project_contexts = {project_id: context}
178 else:
179 project_contexts = project_manager.get_all_project_contexts()
181 if format == "json":
182 # JSON output
183 status_data = []
184 for context in project_contexts.values():
185 status_data.append(
186 {
187 "project_id": context.project_id,
188 "display_name": context.display_name,
189 "collection_name": context.collection_name or "N/A",
190 "source_count": (
191 len(_get_all_sources_from_config(context.config.sources))
192 if context.config
193 else 0
194 ),
195 "document_count": "N/A", # TODO: Implement database query
196 "latest_ingestion": None, # TODO: Implement database query
197 }
198 )
199 echo(json.dumps(status_data, indent=2))
200 else:
201 # Table output using Rich
202 if not project_contexts:
203 console.print("[yellow]No projects configured.[/yellow]")
204 return
206 for context in project_contexts.values():
207 source_count = (
208 len(_get_all_sources_from_config(context.config.sources))
209 if context.config
210 else 0
211 )
213 # Create project panel
214 project_info = f"""[bold cyan]Project ID:[/bold cyan] {context.project_id}
215[bold magenta]Display Name:[/bold magenta] {context.display_name or 'N/A'}
216[bold green]Description:[/bold green] {context.description or 'N/A'}
217[bold blue]Collection:[/bold blue] {context.collection_name or 'N/A'}
218[bold yellow]Sources:[/bold yellow] {source_count}
219[bold red]Documents:[/bold red] N/A (requires database)
220[bold red]Latest Ingestion:[/bold red] N/A (requires database)"""
222 console.print(
223 Panel(project_info, title=f"Project: {context.project_id}")
224 )
226 except Exception as e:
227 logger = LoggingConfig.get_logger(__name__)
228 logger.error("project_status_failed", error=str(e))
229 raise ClickException(f"Failed to get project status: {str(e)!s}") from e
232@project_cli.command()
233@option(
234 "--workspace",
235 type=ClickPath(path_type=Path),
236 help="Workspace directory containing config.yaml and .env files.",
237)
238@option(
239 "--config", type=ClickPath(exists=True, path_type=Path), help="Path to config file."
240)
241@option("--env", type=ClickPath(exists=True, path_type=Path), help="Path to .env file.")
242@option(
243 "--project-id",
244 type=str,
245 help="Specific project ID to validate.",
246)
247@async_command
248async def validate(
249 workspace: Path | None,
250 config: Path | None,
251 env: Path | None,
252 project_id: str | None,
253):
254 """Validate project configurations."""
255 try:
256 # Validate flag combinations
257 validate_workspace_flags(workspace, config, env)
259 # Load configuration and initialize components
260 settings, project_manager = await _setup_project_manager(workspace, config, env)
262 # Get project contexts to validate
263 if project_id:
264 context = project_manager.get_project_context(project_id)
265 if not context:
266 raise ClickException(f"Project '{project_id}' not found")
267 project_contexts = {project_id: context}
268 else:
269 project_contexts = project_manager.get_all_project_contexts()
271 validation_results = []
272 all_valid = True
274 for context in project_contexts.values():
275 try:
276 # Basic validation - check that config exists and has required fields
277 if not context.config:
278 validation_results.append(
279 {
280 "project_id": context.project_id,
281 "valid": False,
282 "errors": ["Missing project configuration"],
283 "source_count": 0,
284 }
285 )
286 all_valid = False
287 continue
289 # Check source configurations
290 source_errors = []
291 all_sources = _get_all_sources_from_config(context.config.sources)
293 for source_name, source_config in all_sources.items():
294 try:
295 # Basic validation - check required fields
296 if (
297 not hasattr(source_config, "source_type")
298 or not source_config.source_type
299 ):
300 source_errors.append(
301 f"Missing source_type for {source_name}"
302 )
303 if (
304 not hasattr(source_config, "source")
305 or not source_config.source
306 ):
307 source_errors.append(f"Missing source for {source_name}")
308 except Exception as e:
309 source_errors.append(f"Error in {source_name}: {str(e)}")
311 validation_results.append(
312 {
313 "project_id": context.project_id,
314 "valid": len(source_errors) == 0,
315 "errors": source_errors,
316 "source_count": len(all_sources),
317 }
318 )
320 if source_errors:
321 all_valid = False
323 except Exception as e:
324 validation_results.append(
325 {
326 "project_id": context.project_id,
327 "valid": False,
328 "errors": [str(e)],
329 "source_count": 0,
330 }
331 )
332 all_valid = False
334 # Display results
335 for result in validation_results:
336 if result["valid"]:
337 console.print(
338 f"[green]✓[/green] Project '{result['project_id']}' is valid ({result['source_count']} sources)"
339 )
340 else:
341 console.print(
342 f"[red]✗[/red] Project '{result['project_id']}' has errors:"
343 )
344 for error in result["errors"]:
345 console.print(f" [red]•[/red] {error}")
347 if all_valid:
348 console.print("\n[green]All projects are valid![/green]")
349 else:
350 console.print("\n[red]Some projects have validation errors.[/red]")
351 raise ClickException("Project validation failed")
353 except Exception as e:
354 logger = LoggingConfig.get_logger(__name__)
355 logger.error("project_validate_failed", error=str(e))
356 raise ClickException(f"Failed to validate projects: {str(e)!s}") from e
359async def _setup_project_manager(
360 workspace: Path | None,
361 config: Path | None,
362 env: Path | None,
363) -> tuple[Settings, ProjectManager]:
364 """Setup project manager with configuration loading."""
365 from qdrant_loader.cli.cli import (
366 _setup_workspace,
367 _load_config_with_workspace,
368 _check_settings,
369 )
371 # Setup workspace if provided
372 workspace_config = None
373 if workspace:
374 workspace_config = _setup_workspace(workspace)
376 # Load configuration
377 _load_config_with_workspace(workspace_config, config, env)
378 settings = _check_settings()
380 # Create project manager
381 if not settings.global_config or not settings.global_config.qdrant:
382 raise ClickException("Global configuration or Qdrant configuration is missing")
384 project_manager = ProjectManager(
385 projects_config=settings.projects_config,
386 global_collection_name=settings.global_config.qdrant.collection_name,
387 )
389 # Initialize project contexts directly from configuration (without database)
390 await _initialize_project_contexts_from_config(project_manager)
392 return settings, project_manager
395async def _initialize_project_contexts_from_config(
396 project_manager: ProjectManager,
397) -> None:
398 """Initialize project contexts directly from configuration without database."""
399 logger = LoggingConfig.get_logger(__name__)
400 logger.debug("Initializing project contexts from configuration")
402 for project_id, project_config in project_manager.projects_config.projects.items():
403 logger.debug(f"Creating context for project: {project_id}")
405 # Determine collection name using the project's method
406 collection_name = project_config.get_effective_collection_name(
407 project_manager.global_collection_name
408 )
410 # Create project context
411 from qdrant_loader.core.project_manager import ProjectContext
413 context = ProjectContext(
414 project_id=project_id,
415 display_name=project_config.display_name,
416 description=project_config.description,
417 collection_name=collection_name,
418 config=project_config,
419 )
421 project_manager._project_contexts[project_id] = context
422 logger.debug(f"Created context for project: {project_id}")
424 logger.debug(
425 f"Initialized {len(project_manager._project_contexts)} project contexts"
426 )