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