Skip to content

Commit 0843fb1

Browse files
authored
Merge pull request #18 from lbedner/db-cli-registry
Setup DB CLI Registry
2 parents 1df1d0b + 44260ca commit 0843fb1

14 files changed

Lines changed: 243 additions & 67 deletions

File tree

aegis/__main__.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
import typer
1414

1515
from aegis import __version__
16-
from aegis.core.components import COMPONENTS, ComponentType, get_components_by_type
16+
from aegis.core.components import (
17+
COMPONENTS,
18+
ComponentSpec,
19+
ComponentType,
20+
get_components_by_type,
21+
)
1722
from aegis.core.dependency_resolver import DependencyResolver
1823
from aegis.core.template_generator import TemplateGenerator
1924

@@ -22,7 +27,7 @@
2227
name="aegis",
2328
help=(
2429
"Aegis Stack CLI - Component generation and project management. "
25-
"Available components: redis, worker, scheduler"
30+
"Available components: redis, worker, scheduler, database"
2631
),
2732
add_completion=False,
2833
)
@@ -138,7 +143,7 @@ def init(
138143
"--components",
139144
"-c",
140145
callback=validate_and_resolve_components,
141-
help="Comma-separated list of components (redis,worker,scheduler)",
146+
help="Comma-separated list of components (redis,worker,scheduler,database)",
142147
),
143148
interactive: bool = typer.Option(
144149
True,
@@ -166,8 +171,8 @@ def init(
166171
Examples:\n
167172
- aegis init my-app\n
168173
- aegis init my-app --components redis,worker\n
169-
- aegis init my-app --components redis,worker,scheduler --no-interactive\n
170-
"""
174+
- aegis init my-app --components redis,worker,scheduler,database --no-interactive\n
175+
""" # noqa
171176

172177
# Validate project name first
173178
validate_project_name(project_name)
@@ -304,6 +309,18 @@ def init(
304309
raise typer.Exit(1)
305310

306311

312+
def get_interactive_infrastructure_components() -> list[ComponentSpec]:
313+
"""Get infrastructure components available for interactive selection."""
314+
# Get all infrastructure components
315+
infra_components = []
316+
for component_spec in COMPONENTS.values():
317+
if component_spec.type == ComponentType.INFRASTRUCTURE:
318+
infra_components.append(component_spec)
319+
320+
# Sort by name for consistent ordering
321+
return sorted(infra_components, key=lambda x: x.name)
322+
323+
307324
def interactive_component_selection() -> list[str]:
308325
"""Interactive component selection with dependency awareness."""
309326

@@ -313,20 +330,41 @@ def interactive_component_selection() -> list[str]:
313330

314331
selected = []
315332

316-
# Infrastructure components
333+
# Get all infrastructure components from registry
334+
infra_components = get_interactive_infrastructure_components()
335+
317336
typer.echo("🏗️ Infrastructure Components:")
318-
if typer.confirm(" Add Redis (caching, message queues)?"):
319-
selected.append("redis")
320-
321-
if "redis" in selected:
322-
if typer.confirm(" Add worker infrastructure (background tasks)?"):
323-
selected.append("worker")
324-
else:
325-
if typer.confirm(" Add worker infrastructure? (will auto-add Redis)"):
326-
selected.extend(["redis", "worker"])
327-
328-
if typer.confirm(" Add scheduler infrastructure (scheduled tasks)?"):
329-
selected.append("scheduler")
337+
338+
# Process components in a specific order to handle dependencies
339+
component_order = ["redis", "worker", "scheduler", "database"]
340+
341+
for component_name in component_order:
342+
# Find the component spec
343+
component_spec = next(
344+
(c for c in infra_components if c.name == component_name), None
345+
)
346+
if not component_spec:
347+
continue # Skip if component doesn't exist in registry
348+
349+
# Handle special worker dependency logic
350+
if component_name == "worker":
351+
if "redis" in selected:
352+
# Redis already selected, simple worker prompt
353+
prompt = f" Add {component_spec.description.lower()}?"
354+
if typer.confirm(prompt):
355+
selected.append("worker")
356+
else:
357+
# Redis not selected, offer to add both
358+
prompt = (
359+
f" Add {component_spec.description.lower()}? (will auto-add Redis)"
360+
)
361+
if typer.confirm(prompt):
362+
selected.extend(["redis", "worker"])
363+
else:
364+
# Standard prompt for other components
365+
prompt = f" Add {component_spec.description}?"
366+
if typer.confirm(prompt):
367+
selected.append(component_name)
330368

331369
return selected
332370

aegis/core/components.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ def __post_init__(self) -> None:
8686
docker_services=["scheduler"],
8787
template_files=["app/components/scheduler.py", "app/entrypoints/scheduler.py"],
8888
),
89+
"database": ComponentSpec(
90+
name="database",
91+
type=ComponentType.INFRASTRUCTURE,
92+
description="SQLite database with SQLModel ORM",
93+
pyproject_deps=["sqlmodel>=0.0.14", "sqlalchemy>=2.0.0", "aiosqlite>=0.19.0"],
94+
template_files=["app/core/db.py"],
95+
),
8996
}
9097

9198

aegis/core/template_generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def get_template_context(self) -> dict[str, Any]:
4949
"include_redis": "yes" if "redis" in self.components else "no",
5050
"include_worker": "yes" if "worker" in self.components else "no",
5151
"include_scheduler": "yes" if "scheduler" in self.components else "no",
52+
"include_database": "yes" if "database" in self.components else "no",
5253
# Derived flags for template logic
5354
"has_background_infrastructure": any(
5455
name in self.components for name in ["worker", "scheduler"]

aegis/templates/cookiecutter-aegis-project/cookiecutter.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@
2222
"_scheduler_deps": "{% if cookiecutter.include_scheduler == 'yes' %}apscheduler>=3.10.0{% endif %}",
2323
"_redis_deps": "{% if cookiecutter.include_redis == 'yes' %}redis>=5.0.0{% endif %}",
2424
"_worker_deps": "{% if cookiecutter.include_worker == 'yes' %}arq>=0.25.0{% endif %}",
25-
"_database_deps": "{% if cookiecutter.include_database == 'yes' %}sqlalchemy[asyncio]>=2.0.0,asyncpg>=0.29.0,alembic>=1.13.0{% endif %}",
25+
"_database_deps": "{% if cookiecutter.include_database == 'yes' %}sqlmodel>=0.0.14,sqlalchemy>=2.0.0,aiosqlite>=0.19.0{% endif %}",
2626
"_cache_deps": "{% if cookiecutter.include_cache == 'yes' %}redis[hiredis]>=5.0.0{% endif %}"
2727
}

aegis/templates/cookiecutter-aegis-project/hooks/post_gen_project.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def process_j2_templates():
3939
"author_email": "{{ cookiecutter.author_email }}",
4040
"version": "{{ cookiecutter.version }}",
4141
"python_version": "{{ cookiecutter.python_version }}",
42+
"include_redis": "{{ cookiecutter.include_redis }}",
4243
"include_scheduler": "{{ cookiecutter.include_scheduler }}",
4344
"include_worker": "{{ cookiecutter.include_worker }}",
4445
"include_database": "{{ cookiecutter.include_database }}",
@@ -138,10 +139,7 @@ def main():
138139
remove_file("tests/services/test_worker_health_registration.py")
139140

140141
if "{{ cookiecutter.include_database }}" != "yes":
141-
# remove_file("app/services/database_service.py")
142-
# remove_dir("app/models")
143-
# remove_dir("alembic")
144-
pass # Placeholder for database component
142+
remove_file("app/core/db.py")
145143

146144
if "{{ cookiecutter.include_cache }}" != "yes":
147145
# remove_file("app/services/cache_service.py")

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/component_health.py.j2

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ async def startup_hook() -> None:
173173
logger.info("Worker component health check registered")
174174
{%- endif %}
175175

176-
# Future: Database and cache component detection will be added here
176+
{%- if cookiecutter.include_redis == "yes" %}
177+
# Register cache health check (Redis connectivity and operations)
178+
from app.services.system.health import check_cache_health
179+
register_health_check("cache", check_cache_health)
180+
logger.info("Cache component health check registered")
181+
{%- endif %}
177182

178183
logger.info("✅ Component health detection complete")

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/main.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from app.core.log import logger
1313
from app.services.system.health import check_system_status, register_health_check
14-
from app.services.system.models import ComponentStatus
14+
from app.services.system.models import ComponentStatus, ComponentStatusType
1515

1616
# Global scheduler instance for health checking
1717
_scheduler: AsyncIOScheduler | None = None
@@ -24,15 +24,15 @@ async def _check_scheduler_health() -> ComponentStatus:
2424
if _scheduler is None:
2525
return ComponentStatus(
2626
name="scheduler",
27-
healthy=False,
27+
status=ComponentStatusType.UNHEALTHY,
2828
message="Scheduler not initialized",
2929
response_time_ms=None,
3030
)
3131

3232
if not _scheduler.running:
3333
return ComponentStatus(
3434
name="scheduler",
35-
healthy=False,
35+
status=ComponentStatusType.UNHEALTHY,
3636
message="Scheduler is not running",
3737
response_time_ms=None,
3838
)
@@ -46,9 +46,14 @@ async def _check_scheduler_health() -> ComponentStatus:
4646
state = _scheduler.state
4747
healthy = state == 1 # STATE_RUNNING = 1
4848

49+
status = (
50+
ComponentStatusType.HEALTHY
51+
if healthy
52+
else ComponentStatusType.UNHEALTHY
53+
)
4954
return ComponentStatus(
5055
name="scheduler",
51-
healthy=healthy,
56+
status=status,
5257
message=f"Scheduler running with {job_count} jobs",
5358
response_time_ms=None,
5459
metadata={
@@ -60,7 +65,7 @@ async def _check_scheduler_health() -> ComponentStatus:
6065
except Exception as e:
6166
return ComponentStatus(
6267
name="scheduler",
63-
healthy=False,
68+
status=ComponentStatusType.UNHEALTHY,
6469
message=f"Scheduler health check failed: {str(e)}",
6570
response_time_ms=None,
6671
)

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/config.py renamed to aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/config.py.j2

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,36 @@ class Settings(BaseSettings):
5050
DISK_THRESHOLD_PERCENT: float = 85.0
5151
CPU_THRESHOLD_PERCENT: float = 95.0
5252

53+
{% if cookiecutter.include_redis == "yes" %}
5354
# Redis settings for arq background tasks
5455
REDIS_URL: str = "redis://localhost:6379"
5556
REDIS_DB: int = 0
57+
{% endif %}
5658

59+
{% if cookiecutter.include_worker == "yes" %}
5760
# arq worker settings (shared across all workers)
5861
WORKER_KEEP_RESULT_SECONDS: int = 3600 # Keep job results for 1 hour
5962
WORKER_MAX_TRIES: int = 3
6063

6164
# PURE ARQ IMPLEMENTATION - NO CONFIGURATION NEEDED!
6265
# Worker configuration comes from individual WorkerSettings classes
6366
# in app/components/worker/queues/ - just import and use as arq intended!
67+
{% endif %}
68+
69+
{% if cookiecutter.include_database == "yes" %}
70+
# Database settings (SQLite)
71+
DATABASE_URL: str = "sqlite:///data/app.db"
72+
DATABASE_ENGINE_ECHO: bool = False
73+
DATABASE_CONNECT_ARGS: dict[str, Any] = {"check_same_thread": False}
74+
{% endif %}
6475

6576
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
6677

6778

6879
settings = Settings()
6980

7081

82+
{% if cookiecutter.include_worker == "yes" %}
7183
# Pure arq queue helper functions - use dynamic discovery
7284
def get_available_queues() -> list[str]:
7385
"""Get all available queue names via dynamic discovery."""
@@ -104,3 +116,4 @@ def is_valid_queue(queue_name: str) -> bool:
104116
except ImportError:
105117
# Worker components not available, no queues are valid
106118
return False
119+
{% endif %}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# app/core/db.py
2+
"""
3+
Database configuration and session management.
4+
5+
This module provides SQLite database connectivity using SQLModel and SQLAlchemy.
6+
Includes proper session management with transaction handling and foreign key support.
7+
"""
8+
9+
from collections.abc import Generator
10+
from contextlib import contextmanager
11+
from typing import Any
12+
13+
from sqlalchemy import create_engine, event
14+
from sqlalchemy.orm import sessionmaker
15+
from sqlmodel import Session
16+
17+
from app.core.config import settings
18+
19+
# Create SQLite engine with proper configuration
20+
engine = create_engine(
21+
settings.DATABASE_URL,
22+
connect_args=settings.DATABASE_CONNECT_ARGS,
23+
echo=settings.DATABASE_ENGINE_ECHO,
24+
)
25+
26+
27+
# Enable foreign key constraints for SQLite
28+
@event.listens_for(engine, "connect")
29+
def set_sqlite_pragma(dbapi_connection: Any, connection_record: Any) -> None:
30+
"""Enable foreign key constraints in SQLite."""
31+
cursor = dbapi_connection.cursor()
32+
cursor.execute("PRAGMA foreign_keys=ON")
33+
cursor.close()
34+
35+
36+
# Configure session factory with SQLModel Session
37+
SessionLocal = sessionmaker(
38+
class_=Session, bind=engine, autoflush=False, autocommit=False
39+
)
40+
41+
42+
@contextmanager
43+
def db_session(autocommit: bool = True) -> Generator[Session, None, None]:
44+
"""
45+
Database session context manager with automatic transaction handling.
46+
47+
Args:
48+
autocommit: Whether to automatically commit the transaction on success
49+
50+
Yields:
51+
Session: Database session instance
52+
53+
Example:
54+
with db_session() as session:
55+
# Your database operations here
56+
result = session.query(MyModel).first()
57+
"""
58+
db_session: Session = SessionLocal()
59+
try:
60+
yield db_session
61+
if autocommit:
62+
db_session.commit()
63+
except Exception:
64+
db_session.rollback()
65+
raise
66+
finally:
67+
db_session.close()

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/health.py renamed to aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/health.py.j2

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ async def _check_cpu_usage() -> ComponentStatus:
472472
)
473473

474474

475+
{% if cookiecutter.include_redis == "yes" %}
475476
async def check_cache_health() -> ComponentStatus:
476477
"""
477478
Check cache connectivity and basic functionality.
@@ -558,8 +559,10 @@ async def check_cache_health() -> ComponentStatus:
558559
"error": str(e),
559560
},
560561
)
562+
{% endif %}
561563

562564

565+
{% if cookiecutter.include_worker == "yes" %}
563566
async def check_worker_health() -> ComponentStatus:
564567
"""
565568
Check arq worker status using arq's native health checks and queue configuration.
@@ -921,3 +924,4 @@ async def check_worker_health() -> ComponentStatus:
921924
},
922925
sub_components={},
923926
)
927+
{% endif %}

0 commit comments

Comments
 (0)