Skip to content

Commit d3b39af

Browse files
authored
Merge pull request #143 from lbedner/services-cli-validation
Services CLI Validaiton
2 parents 8443bc2 + d3f423d commit d3b39af

7 files changed

Lines changed: 238 additions & 18 deletions

File tree

aegis/cli/interactive.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import typer
99

10-
from ..core.components import COMPONENTS, ComponentSpec, ComponentType
10+
from ..core.components import COMPONENTS, CORE_COMPONENTS, ComponentSpec, ComponentType
1111

1212

1313
def get_interactive_infrastructure_components() -> list[ComponentSpec]:
@@ -32,7 +32,9 @@ def interactive_component_selection() -> tuple[list[str], str]:
3232

3333
typer.echo("🎯 Component Selection")
3434
typer.echo("=" * 40)
35-
typer.echo("✅ Core components (backend + frontend) included automatically\n")
35+
typer.echo(
36+
f"✅ Core components ({' + '.join(CORE_COMPONENTS)}) included automatically\n"
37+
)
3638

3739
selected = []
3840
database_engine = None # Track database engine selection

aegis/commands/components.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
44

55
import typer
66

7-
from ..core.components import ComponentType, get_components_by_type
7+
from ..core.components import CORE_COMPONENTS, ComponentType, get_components_by_type
88

99

1010
def components_command() -> None:
1111
"""List available components and their dependencies."""
1212

1313
typer.echo("\n📦 CORE COMPONENTS")
1414
typer.echo("=" * 40)
15-
typer.echo(" backend - FastAPI backend server (always included)")
16-
typer.echo(" frontend - Flet frontend interface (always included)")
15+
for component in CORE_COMPONENTS:
16+
if component == "backend":
17+
typer.echo(" backend - FastAPI backend server (always included)")
18+
elif component == "frontend":
19+
typer.echo(" frontend - Flet frontend interface (always included)")
1720

1821
typer.echo("\n🏗️ INFRASTRUCTURE COMPONENTS")
1922
typer.echo("=" * 40)

aegis/commands/init.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
extract_base_component_name,
2020
restore_engine_info,
2121
)
22-
from ..core.components import COMPONENTS, ComponentType
22+
from ..core.components import COMPONENTS, CORE_COMPONENTS, ComponentType
2323
from ..core.dependency_resolver import DependencyResolver
2424
from ..core.service_resolver import ServiceResolver
2525
from ..core.template_generator import TemplateGenerator
@@ -104,6 +104,35 @@ def init_command(
104104

105105
# Resolve services to components if services were provided
106106
if selected_services:
107+
# If user provided explicit components, validate compatibility first
108+
if components is not None: # User provided explicit --components
109+
# Include core components (always present) for validation
110+
components_for_validation = list(set(selected_components + CORE_COMPONENTS))
111+
errors = ServiceResolver.validate_service_component_compatibility(
112+
selected_services, components_for_validation
113+
)
114+
if errors:
115+
typer.echo("❌ Service-component compatibility errors:", err=True)
116+
for error in errors:
117+
typer.echo(f" • {error}", err=True)
118+
119+
# Show suggestion
120+
missing_components = (
121+
ServiceResolver.get_missing_components_for_services(
122+
selected_services, components_for_validation
123+
)
124+
)
125+
if missing_components:
126+
typer.echo(
127+
f"💡 Suggestion: Add missing components: --components {','.join(list(dict.fromkeys(selected_components + missing_components)))}",
128+
err=True,
129+
)
130+
typer.echo(
131+
" Alternatively, remove --components to let services auto-add their dependencies.",
132+
err=True,
133+
)
134+
raise typer.Exit(1)
135+
107136
service_components, _ = ServiceResolver.resolve_service_dependencies(
108137
selected_services
109138
)
@@ -136,7 +165,7 @@ def init_command(
136165

137166
# Calculate auto-added components using clean names
138167
clean_selected_only = clean_component_names(
139-
[c for c in selected_components if c not in ["backend", "frontend"]]
168+
[c for c in selected_components if c not in CORE_COMPONENTS]
140169
)
141170
auto_added = DependencyResolver.get_missing_dependencies(
142171
clean_selected_only
@@ -153,7 +182,7 @@ def init_command(
153182
typer.echo()
154183
typer.echo(f"📁 Project Name: {project_name}")
155184
typer.echo("🏗️ Project Structure:")
156-
typer.echo(" ✅ Core: backend, frontend")
185+
typer.echo(f" ✅ Core: {', '.join(CORE_COMPONENTS)}")
157186

158187
# Show infrastructure components
159188
infra_components = []

aegis/core/components.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class ComponentType(Enum):
1616
INFRASTRUCTURE = "infra" # Redis, workers - foundation for services to use
1717

1818

19+
# Core components that are always included in every project
20+
CORE_COMPONENTS = ["backend", "frontend"]
21+
22+
1923
@dataclass
2024
class ComponentSpec:
2125
"""Specification for a single component."""

aegis/core/template_generator.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any
1010

1111
from .component_utils import extract_base_component_name, extract_engine_info
12-
from .components import COMPONENTS
12+
from .components import COMPONENTS, CORE_COMPONENTS
1313
from .services import SERVICES
1414

1515

@@ -38,7 +38,7 @@ def __init__(
3838
self.selected_services = selected_services or []
3939

4040
# Always include core components
41-
all_components = ["backend", "frontend"] + selected_components
41+
all_components = CORE_COMPONENTS + selected_components
4242
# Remove duplicates, preserve order
4343
self.components = list(dict.fromkeys(all_components))
4444

@@ -74,7 +74,7 @@ def get_template_context(self) -> dict[str, Any]:
7474
Dictionary containing all template variables
7575
"""
7676
# Store the originally selected components (without core)
77-
selected_only = [c for c in self.components if c not in ["backend", "frontend"]]
77+
selected_only = [c for c in self.components if c not in CORE_COMPONENTS]
7878

7979
# Check for components using base names
8080
has_database = any(c.startswith("database") for c in self.components)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,6 @@ asyncio_default_fixture_loop_scope = "function"
152152
exclude = [
153153
"aegis/templates/**",
154154
"test-project/**",
155+
"my-app/**",
156+
"test-*/**",
155157
]

tests/cli/test_services_cli.py

Lines changed: 187 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def test_init_with_services_and_components_together(self):
218218
"--services",
219219
"auth",
220220
"--components",
221-
"worker",
221+
"database,worker", # Auth needs database (backend always included), plus explicit worker
222222
"--no-interactive",
223223
"--yes",
224224
"--output-dir",
@@ -234,12 +234,12 @@ def test_init_with_services_and_components_together(self):
234234
# Should show both services and components
235235
assert "🔧 Services: auth" in output
236236
assert "📦 Infrastructure:" in output
237-
# Both auth (database) and worker (redis) dependencies should be present
238-
assert (
239-
("database" in output and "redis" in output)
240-
or "database, redis" in output
241-
or "redis, database" in output
242-
)
237+
# Should have all components: backend, database (auth), worker and redis (worker dep)
238+
assert "backend" in output
239+
assert "database" in output
240+
assert "worker" in output
241+
# Worker adds redis as dependency
242+
assert "redis" in output
243243

244244
def test_init_services_help_text_accuracy(self):
245245
"""Test that init command help shows correct services help text."""
@@ -478,3 +478,183 @@ def test_service_with_whitespace(self):
478478
)
479479

480480
assert result.returncode == 0 # Should handle whitespace gracefully
481+
482+
483+
class TestServiceComponentCompatibilityValidation:
484+
"""Test service-component compatibility validation in CLI."""
485+
486+
def test_services_with_compatible_explicit_components_success(self):
487+
"""Test that services work when user provides compatible explicit components."""
488+
with tempfile.TemporaryDirectory() as temp_dir:
489+
result = subprocess.run(
490+
[
491+
"uv",
492+
"run",
493+
"python",
494+
"-m",
495+
"aegis",
496+
"init",
497+
"test-compatible",
498+
"--services",
499+
"auth",
500+
"--components",
501+
"database", # Auth requires database (backend is always included)
502+
"--no-interactive",
503+
"--yes",
504+
"--output-dir",
505+
temp_dir,
506+
],
507+
capture_output=True,
508+
text=True,
509+
)
510+
511+
assert result.returncode == 0
512+
assert "🔧 Services: auth" in result.stdout
513+
assert "backend" in result.stdout
514+
assert "database" in result.stdout
515+
516+
def test_services_with_insufficient_explicit_components_failure(self):
517+
"""Test that services fail when user provides insufficient explicit components."""
518+
result = subprocess.run(
519+
[
520+
"uv",
521+
"run",
522+
"python",
523+
"-m",
524+
"aegis",
525+
"init",
526+
"test-insufficient",
527+
"--services",
528+
"auth",
529+
"--components",
530+
"worker", # Auth requires database, but user only provided worker
531+
"--no-interactive",
532+
"--yes",
533+
"--output-dir",
534+
"/tmp", # Won't be created due to validation failure
535+
],
536+
capture_output=True,
537+
text=True,
538+
)
539+
540+
assert result.returncode == 1
541+
assert "Service-component compatibility errors:" in result.stderr
542+
assert "Service 'auth' requires component 'database'" in result.stderr
543+
assert "💡 Suggestion:" in result.stderr
544+
# Should suggest adding missing components (worker auto-adds redis dependency)
545+
assert "database" in result.stderr
546+
547+
def test_services_with_no_explicit_components_auto_add(self):
548+
"""Test that services auto-add components when user doesn't provide --components."""
549+
with tempfile.TemporaryDirectory() as temp_dir:
550+
result = subprocess.run(
551+
[
552+
"uv",
553+
"run",
554+
"python",
555+
"-m",
556+
"aegis",
557+
"init",
558+
"test-auto-add",
559+
"--services",
560+
"auth",
561+
"--no-interactive",
562+
"--yes",
563+
"--output-dir",
564+
temp_dir,
565+
],
566+
capture_output=True,
567+
text=True,
568+
)
569+
570+
assert result.returncode == 0
571+
assert "🔧 Services: auth" in result.stdout
572+
# Should auto-add both required components
573+
assert "backend" in result.stdout
574+
assert "database" in result.stdout
575+
576+
def test_services_with_partial_explicit_components_failure(self):
577+
"""Test that services fail when explicit components are partially sufficient."""
578+
result = subprocess.run(
579+
[
580+
"uv",
581+
"run",
582+
"python",
583+
"-m",
584+
"aegis",
585+
"init",
586+
"test-partial",
587+
"--services",
588+
"auth",
589+
"--components",
590+
"redis", # Auth requires database (missing database, backend is always included)
591+
"--no-interactive",
592+
"--yes",
593+
"--output-dir",
594+
"/tmp",
595+
],
596+
capture_output=True,
597+
text=True,
598+
)
599+
600+
assert result.returncode == 1
601+
assert "Service-component compatibility errors:" in result.stderr
602+
assert "Service 'auth' requires component 'database'" in result.stderr
603+
604+
def test_multiple_services_with_insufficient_components_failure(self):
605+
"""Test validation with multiple services and insufficient components."""
606+
# Note: This test assumes we might add more services in the future
607+
# For now, we only have auth service, so this tests the error handling pattern
608+
result = subprocess.run(
609+
[
610+
"uv",
611+
"run",
612+
"python",
613+
"-m",
614+
"aegis",
615+
"init",
616+
"test-multi-insufficient",
617+
"--services",
618+
"auth", # Only auth available for now
619+
"--components",
620+
"worker,scheduler", # Missing database for auth
621+
"--no-interactive",
622+
"--yes",
623+
"--output-dir",
624+
"/tmp",
625+
],
626+
capture_output=True,
627+
text=True,
628+
)
629+
630+
assert result.returncode == 1
631+
assert "Service-component compatibility errors:" in result.stderr
632+
assert "Service 'auth' requires component 'database'" in result.stderr
633+
634+
def test_services_error_message_suggests_alternatives(self):
635+
"""Test that error message suggests both adding components and removing --components."""
636+
result = subprocess.run(
637+
[
638+
"uv",
639+
"run",
640+
"python",
641+
"-m",
642+
"aegis",
643+
"init",
644+
"test-suggestions",
645+
"--services",
646+
"auth",
647+
"--components",
648+
"redis", # Wrong component for auth
649+
"--no-interactive",
650+
"--yes",
651+
"--output-dir",
652+
"/tmp",
653+
],
654+
capture_output=True,
655+
text=True,
656+
)
657+
658+
assert result.returncode == 1
659+
assert "💡 Suggestion: Add missing components" in result.stderr
660+
assert "remove --components to let services auto-add" in result.stderr

0 commit comments

Comments
 (0)