pulse-engine 0.2.0.dev20260407065251__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse_engine/__init__.py +0 -0
- pulse_engine/api/__init__.py +0 -0
- pulse_engine/api/v1/__init__.py +0 -0
- pulse_engine/api/v1/auth.py +91 -0
- pulse_engine/api/v1/health.py +62 -0
- pulse_engine/api/v1/router.py +16 -0
- pulse_engine/chain_recovery.py +131 -0
- pulse_engine/cli/__init__.py +0 -0
- pulse_engine/cli/main.py +169 -0
- pulse_engine/cli/templates/cookiecutter.json +4 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
- pulse_engine/client.py +95 -0
- pulse_engine/config.py +153 -0
- pulse_engine/core/__init__.py +0 -0
- pulse_engine/core/error_handlers.py +64 -0
- pulse_engine/core/exceptions.py +67 -0
- pulse_engine/core/job_token.py +109 -0
- pulse_engine/core/logging.py +45 -0
- pulse_engine/core/scope.py +23 -0
- pulse_engine/core/security.py +130 -0
- pulse_engine/database.py +30 -0
- pulse_engine/dependencies.py +166 -0
- pulse_engine/deployment/__init__.py +0 -0
- pulse_engine/deployment/backend_deployment_repository.py +83 -0
- pulse_engine/deployment/backends/__init__.py +0 -0
- pulse_engine/deployment/backends/base.py +50 -0
- pulse_engine/deployment/backends/exceptions.py +20 -0
- pulse_engine/deployment/backends/native_lambda.py +125 -0
- pulse_engine/deployment/backends/prefect_ecs.py +116 -0
- pulse_engine/deployment/backends/prefect_k8s.py +131 -0
- pulse_engine/deployment/backends/registry.py +50 -0
- pulse_engine/deployment/infra_provisioner.py +278 -0
- pulse_engine/deployment/job_launcher.py +178 -0
- pulse_engine/deployment/models.py +48 -0
- pulse_engine/deployment/repository.py +54 -0
- pulse_engine/deployment/router.py +22 -0
- pulse_engine/deployment/schemas.py +18 -0
- pulse_engine/deployment/service.py +65 -0
- pulse_engine/extractor/__init__.py +0 -0
- pulse_engine/extractor/base.py +48 -0
- pulse_engine/extractor/models.py +50 -0
- pulse_engine/extractor/orchestrator/__init__.py +15 -0
- pulse_engine/extractor/orchestrator/base.py +34 -0
- pulse_engine/extractor/orchestrator/noop.py +37 -0
- pulse_engine/extractor/orchestrator/prefect.py +163 -0
- pulse_engine/extractor/repository.py +163 -0
- pulse_engine/extractor/router.py +102 -0
- pulse_engine/extractor/schemas.py +93 -0
- pulse_engine/extractor/service.py +431 -0
- pulse_engine/extractor/stage_models.py +36 -0
- pulse_engine/extractor/stage_repository.py +109 -0
- pulse_engine/main.py +195 -0
- pulse_engine/mcp/__init__.py +0 -0
- pulse_engine/mcp/__main__.py +5 -0
- pulse_engine/mcp/server.py +103 -0
- pulse_engine/mcp/tools_jobs.py +159 -0
- pulse_engine/mcp/tools_kb.py +88 -0
- pulse_engine/mcp/tools_processor.py +208 -0
- pulse_engine/middleware/__init__.py +0 -0
- pulse_engine/middleware/rate_limit.py +144 -0
- pulse_engine/middleware/request_id.py +16 -0
- pulse_engine/middleware/security_headers.py +25 -0
- pulse_engine/middleware/tenant.py +90 -0
- pulse_engine/pipeline/__init__.py +0 -0
- pulse_engine/pipeline/config_parser.py +120 -0
- pulse_engine/pipeline/models.py +67 -0
- pulse_engine/pipeline/repositories.py +153 -0
- pulse_engine/pipeline/router_modules.py +66 -0
- pulse_engine/pipeline/router_pipelines.py +186 -0
- pulse_engine/pipeline/schemas.py +139 -0
- pulse_engine/pipeline/service.py +158 -0
- pulse_engine/pipeline/translators/__init__.py +44 -0
- pulse_engine/pipeline/translators/airflow_status.py +11 -0
- pulse_engine/pipeline/translators/airflow_translator.py +23 -0
- pulse_engine/pipeline/translators/base.py +43 -0
- pulse_engine/pipeline/translators/prefect_status.py +93 -0
- pulse_engine/pipeline/translators/prefect_translator.py +135 -0
- pulse_engine/processor/__init__.py +0 -0
- pulse_engine/processor/base.py +36 -0
- pulse_engine/processor/core/__init__.py +0 -0
- pulse_engine/processor/core/analysis.py +148 -0
- pulse_engine/processor/core/chunking.py +158 -0
- pulse_engine/processor/core/prompts.py +340 -0
- pulse_engine/processor/core/topic_splitter.py +105 -0
- pulse_engine/processor/defaults/__init__.py +11 -0
- pulse_engine/processor/defaults/core_processor.py +12 -0
- pulse_engine/processor/defaults/postprocessor.py +12 -0
- pulse_engine/processor/defaults/preprocessor.py +12 -0
- pulse_engine/processor/llm/__init__.py +0 -0
- pulse_engine/processor/llm/provider.py +58 -0
- pulse_engine/processor/ocr/gemini.py +52 -0
- pulse_engine/processor/pipeline.py +107 -0
- pulse_engine/processor/postprocessor/__init__.py +0 -0
- pulse_engine/processor/postprocessor/embeddings.py +34 -0
- pulse_engine/processor/postprocessor/tasks.py +180 -0
- pulse_engine/processor/preprocessor/__init__.py +0 -0
- pulse_engine/processor/preprocessor/tasks.py +71 -0
- pulse_engine/processor/router.py +192 -0
- pulse_engine/processor/schemas.py +167 -0
- pulse_engine/registry.py +117 -0
- pulse_engine/runners/__init__.py +0 -0
- pulse_engine/runners/lambda_runner.py +26 -0
- pulse_engine/runners/pipeline_runner.py +43 -0
- pulse_engine/runners/prefect_pipeline_flow.py +677 -0
- pulse_engine/runners/prefect_runner.py +33 -0
- pulse_engine/s3.py +72 -0
- pulse_engine/services/__init__.py +0 -0
- pulse_engine/services/bootstrap.py +210 -0
- pulse_engine/services/opensearch.py +84 -0
- pulse_engine/storage/__init__.py +0 -0
- pulse_engine/storage/connectors/__init__.py +0 -0
- pulse_engine/storage/connectors/athena.py +226 -0
- pulse_engine/storage/connectors/base.py +32 -0
- pulse_engine/storage/connectors/opensearch.py +344 -0
- pulse_engine/storage/knowledge_base.py +68 -0
- pulse_engine/storage/router.py +78 -0
- pulse_engine/storage/schemas.py +93 -0
- pulse_engine/testing/__init__.py +13 -0
- pulse_engine/testing/fixtures.py +50 -0
- pulse_engine/testing/mocks.py +104 -0
- pulse_engine/worker.py +53 -0
- pulse_engine-0.2.0.dev20260407065251.dist-info/METADATA +563 -0
- pulse_engine-0.2.0.dev20260407065251.dist-info/RECORD +132 -0
- pulse_engine-0.2.0.dev20260407065251.dist-info/WHEEL +4 -0
- pulse_engine-0.2.0.dev20260407065251.dist-info/entry_points.txt +4 -0
pulse_engine/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Authentication endpoint — exchanges email/password for Cognito tokens."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import structlog
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
10
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
11
|
+
from jose import jwt as jose_jwt
|
|
12
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
13
|
+
|
|
14
|
+
from pulse_engine.config import get_settings
|
|
15
|
+
from pulse_engine.middleware.rate_limit import check_rate_limit
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/auth", tags=["Auth"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LoginRequest(BaseModel):
|
|
23
|
+
email: EmailStr
|
|
24
|
+
password: str = Field(min_length=8, max_length=128)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LoginResponse(BaseModel):
|
|
28
|
+
id_token: str
|
|
29
|
+
access_token: str
|
|
30
|
+
refresh_token: str
|
|
31
|
+
expires_in: int
|
|
32
|
+
token_type: str = "Bearer"
|
|
33
|
+
tenant_id: str
|
|
34
|
+
email: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compute_secret_hash(email: str, client_id: str, client_secret: str) -> str:
|
|
38
|
+
message = email + client_id
|
|
39
|
+
digest = hmac.new(
|
|
40
|
+
client_secret.encode("utf-8"),
|
|
41
|
+
message.encode("utf-8"),
|
|
42
|
+
hashlib.sha256,
|
|
43
|
+
).digest()
|
|
44
|
+
return base64.b64encode(digest).decode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/login", response_model=LoginResponse)
|
|
48
|
+
async def login(request: Request, body: LoginRequest) -> LoginResponse:
|
|
49
|
+
"""Authenticate with email and password, returns JWT tokens."""
|
|
50
|
+
# Strict per-IP rate limit: 5 attempts per 60 seconds
|
|
51
|
+
check_rate_limit(request, scope="login", max_requests=5, window_seconds=60)
|
|
52
|
+
settings = get_settings()
|
|
53
|
+
|
|
54
|
+
client_id = settings.cognito_app_client_id
|
|
55
|
+
client_secret = settings.cognito_app_client_secret
|
|
56
|
+
|
|
57
|
+
auth_params: dict[str, str] = {
|
|
58
|
+
"USERNAME": body.email,
|
|
59
|
+
"PASSWORD": body.password,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if client_secret:
|
|
63
|
+
auth_params["SECRET_HASH"] = _compute_secret_hash(
|
|
64
|
+
body.email, client_id, client_secret
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
cognito = boto3.client("cognito-idp", region_name=settings.aws_region)
|
|
69
|
+
result = cognito.initiate_auth(
|
|
70
|
+
ClientId=client_id,
|
|
71
|
+
AuthFlow="USER_PASSWORD_AUTH",
|
|
72
|
+
AuthParameters=auth_params,
|
|
73
|
+
)
|
|
74
|
+
except ClientError as e:
|
|
75
|
+
code = e.response["Error"]["Code"]
|
|
76
|
+
if code in ("NotAuthorizedException", "UserNotFoundException"):
|
|
77
|
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
|
78
|
+
logger.error("cognito_auth_error", error=str(e), code=code)
|
|
79
|
+
raise HTTPException(status_code=500, detail="Authentication service error")
|
|
80
|
+
|
|
81
|
+
auth = result["AuthenticationResult"]
|
|
82
|
+
# Decode id_token (without verification) to extract tenant_id
|
|
83
|
+
claims = jose_jwt.get_unverified_claims(auth["IdToken"])
|
|
84
|
+
return LoginResponse(
|
|
85
|
+
id_token=auth["IdToken"],
|
|
86
|
+
access_token=auth["AccessToken"],
|
|
87
|
+
refresh_token=auth["RefreshToken"],
|
|
88
|
+
expires_in=auth["ExpiresIn"],
|
|
89
|
+
tenant_id=claims.get("custom:tenant_id", ""),
|
|
90
|
+
email=claims.get("email", body.email),
|
|
91
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import structlog
|
|
3
|
+
from fastapi import APIRouter, Depends, Request
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from pulse_engine.config import Settings, get_settings
|
|
7
|
+
from pulse_engine.services.opensearch import OpenSearchService
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
logger = structlog.get_logger()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ServiceHealth(BaseModel):
|
|
14
|
+
opensearch: str
|
|
15
|
+
prefect: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HealthResponse(BaseModel):
|
|
19
|
+
status: str
|
|
20
|
+
version: str
|
|
21
|
+
environment: str
|
|
22
|
+
services: ServiceHealth
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _check_opensearch(opensearch: OpenSearchService) -> str:
|
|
26
|
+
if await opensearch.ping():
|
|
27
|
+
return "up"
|
|
28
|
+
return "down"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _check_prefect(url: str) -> str:
|
|
32
|
+
try:
|
|
33
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
34
|
+
resp = await client.get(f"{url}/health")
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
return "up"
|
|
37
|
+
except Exception:
|
|
38
|
+
logger.warning("prefect_health_check_failed", url=url)
|
|
39
|
+
return "down"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("/health", response_model=HealthResponse)
|
|
43
|
+
async def health_check(
|
|
44
|
+
request: Request,
|
|
45
|
+
settings: Settings = Depends(get_settings),
|
|
46
|
+
) -> HealthResponse:
|
|
47
|
+
opensearch: OpenSearchService = request.app.state.opensearch
|
|
48
|
+
opensearch_status = await _check_opensearch(opensearch)
|
|
49
|
+
prefect_status = await _check_prefect(settings.prefect_api_url)
|
|
50
|
+
|
|
51
|
+
all_up = opensearch_status == "up" and prefect_status == "up"
|
|
52
|
+
overall = "ok" if all_up else "degraded"
|
|
53
|
+
|
|
54
|
+
return HealthResponse(
|
|
55
|
+
status=overall,
|
|
56
|
+
version=settings.app_version,
|
|
57
|
+
environment=settings.app_env,
|
|
58
|
+
services=ServiceHealth(
|
|
59
|
+
opensearch=opensearch_status,
|
|
60
|
+
prefect=prefect_status,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from pulse_engine.api.v1.auth import router as auth_router
|
|
4
|
+
from pulse_engine.api.v1.health import router as health_router
|
|
5
|
+
from pulse_engine.deployment.router import router as deployments_router
|
|
6
|
+
from pulse_engine.extractor.router import router as jobs_router
|
|
7
|
+
from pulse_engine.processor.router import router as process_router
|
|
8
|
+
from pulse_engine.storage.router import router as kb_router
|
|
9
|
+
|
|
10
|
+
v1_router = APIRouter(prefix="/api/v1")
|
|
11
|
+
v1_router.include_router(auth_router)
|
|
12
|
+
v1_router.include_router(health_router)
|
|
13
|
+
v1_router.include_router(kb_router)
|
|
14
|
+
v1_router.include_router(jobs_router)
|
|
15
|
+
v1_router.include_router(process_router)
|
|
16
|
+
v1_router.include_router(deployments_router)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Background task that monitors Prefect for stalled or failed chained flows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pulse_engine.config import Settings
|
|
12
|
+
from pulse_engine.deployment.job_launcher import JobLauncher
|
|
13
|
+
from pulse_engine.extractor.orchestrator.base import BaseOrchestratorAdapter
|
|
14
|
+
from pulse_engine.extractor.repository import JobRepository
|
|
15
|
+
from pulse_engine.extractor.stage_repository import StageRepository
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
_NEXT_STAGE = {
|
|
20
|
+
"extraction": ("processor", "processing"),
|
|
21
|
+
"processing": ("storage", "storage"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChainRecoveryTask:
|
|
26
|
+
"""Polls Prefect for stalled chained flows and auto-recovers."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
stage_repo: StageRepository,
|
|
31
|
+
job_repo: JobRepository,
|
|
32
|
+
job_launcher: JobLauncher,
|
|
33
|
+
orchestrator: BaseOrchestratorAdapter,
|
|
34
|
+
settings: Settings,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._stage_repo = stage_repo
|
|
37
|
+
self._job_repo = job_repo
|
|
38
|
+
self._job_launcher = job_launcher
|
|
39
|
+
self._orchestrator = orchestrator
|
|
40
|
+
self._grace_seconds = settings.pulse_chain_grace_period_seconds
|
|
41
|
+
|
|
42
|
+
async def check_once(self) -> None:
|
|
43
|
+
"""Check running stages against Prefect for failures."""
|
|
44
|
+
stages = await self._stage_repo.get_running_stages()
|
|
45
|
+
for stage in stages:
|
|
46
|
+
if not stage.prefect_flow_run_id:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
run_status = await self._orchestrator.get_run_status(
|
|
50
|
+
stage.prefect_flow_run_id
|
|
51
|
+
)
|
|
52
|
+
if run_status.status in ("failed", "cancelled"):
|
|
53
|
+
await self._stage_repo.update_status(
|
|
54
|
+
stage.id,
|
|
55
|
+
run_status.status,
|
|
56
|
+
)
|
|
57
|
+
job = await self._job_repo.get_by_id(stage.job_id)
|
|
58
|
+
if job:
|
|
59
|
+
await self._job_repo.update_status(
|
|
60
|
+
stage.job_id, job.tenant_id, "failed"
|
|
61
|
+
)
|
|
62
|
+
logger.warning(
|
|
63
|
+
"chain_recovery_stage_failed",
|
|
64
|
+
job_id=stage.job_id,
|
|
65
|
+
stage=stage.stage,
|
|
66
|
+
flow_run_status=run_status.status,
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.warning(
|
|
70
|
+
"chain_recovery_check_error",
|
|
71
|
+
stage_id=stage.id,
|
|
72
|
+
exc_info=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def check_stalled_chains(self) -> None:
|
|
76
|
+
"""Auto-trigger next stage for completed stages where chain stalled."""
|
|
77
|
+
cutoff = datetime.now(UTC) - timedelta(
|
|
78
|
+
seconds=self._grace_seconds,
|
|
79
|
+
)
|
|
80
|
+
stages = await self._stage_repo.get_completed_unchained_stages()
|
|
81
|
+
|
|
82
|
+
for stage in stages:
|
|
83
|
+
if stage.completed_at and stage.completed_at < cutoff:
|
|
84
|
+
next_info = _NEXT_STAGE.get(stage.stage)
|
|
85
|
+
if not next_info:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
next_stage_key, next_stage_name = next_info
|
|
89
|
+
job = await self._job_repo.get_by_id(stage.job_id)
|
|
90
|
+
if not job:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
params = getattr(job, "parameters", {}) or {}
|
|
94
|
+
if not params.get("chain", False):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
orchestrator = params.get("orchestrator", "prefect")
|
|
98
|
+
compute = params.get("compute", "ecs")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = await self._job_launcher.launch(
|
|
102
|
+
job_id=job.job_id,
|
|
103
|
+
tenant_id=job.tenant_id,
|
|
104
|
+
product=job.product,
|
|
105
|
+
stage=next_stage_key,
|
|
106
|
+
orchestrator=orchestrator,
|
|
107
|
+
compute=compute,
|
|
108
|
+
chain=True,
|
|
109
|
+
config=params.get("config", {}),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
await self._stage_repo.create(
|
|
113
|
+
job_id=job.job_id,
|
|
114
|
+
stage=next_stage_name,
|
|
115
|
+
prefect_flow_run_id=result.flow_run_id,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
logger.info(
|
|
119
|
+
"chain_recovery_triggered_next_stage",
|
|
120
|
+
job_id=job.job_id,
|
|
121
|
+
from_stage=stage.stage,
|
|
122
|
+
to_stage=next_stage_name,
|
|
123
|
+
flow_run_id=result.flow_run_id,
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"chain_recovery_trigger_error",
|
|
128
|
+
job_id=stage.job_id,
|
|
129
|
+
stage=stage.stage,
|
|
130
|
+
exc_info=True,
|
|
131
|
+
)
|
|
File without changes
|
pulse_engine/cli/main.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Pulse Engine CLI — scaffold, validate, and run products."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(name="pulse", help="Pulse Engine CLI", no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# pulse init
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def init(
|
|
21
|
+
product_name: Annotated[
|
|
22
|
+
str, typer.Argument(help="Name of the new product (e.g. 'political')")
|
|
23
|
+
],
|
|
24
|
+
output_dir: Annotated[
|
|
25
|
+
Path, typer.Option("--output-dir", "-o", help="Where to scaffold")
|
|
26
|
+
] = Path("."),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Scaffold a new Pulse product from the built-in template."""
|
|
29
|
+
from cookiecutter.main import cookiecutter
|
|
30
|
+
|
|
31
|
+
template_dir = str(Path(__file__).parent / "templates")
|
|
32
|
+
|
|
33
|
+
# Normalise name: lowercase, underscores for Python
|
|
34
|
+
slug = product_name.lower().replace("-", "_").replace(" ", "_")
|
|
35
|
+
display = slug.replace("_", "-")
|
|
36
|
+
|
|
37
|
+
result = cookiecutter(
|
|
38
|
+
template_dir,
|
|
39
|
+
output_dir=str(output_dir),
|
|
40
|
+
no_input=True,
|
|
41
|
+
extra_context={
|
|
42
|
+
"product_name": display,
|
|
43
|
+
"product_slug": slug,
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
typer.echo(f"Scaffolded product at {result}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# pulse validate
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def validate(
|
|
56
|
+
module: Annotated[
|
|
57
|
+
str,
|
|
58
|
+
typer.Argument(
|
|
59
|
+
help="Dotted import path to the manifest (e.g. 'pulse_political.manifest')"
|
|
60
|
+
),
|
|
61
|
+
] = "",
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Import a product manifest and run validation checks."""
|
|
64
|
+
from pulse_engine.registry import discover_products, validate_manifest
|
|
65
|
+
|
|
66
|
+
manifests = []
|
|
67
|
+
|
|
68
|
+
if module:
|
|
69
|
+
mod = importlib.import_module(module)
|
|
70
|
+
manifest = getattr(mod, "manifest", None)
|
|
71
|
+
if manifest is None:
|
|
72
|
+
typer.echo(f"No 'manifest' attribute found in {module}", err=True)
|
|
73
|
+
raise typer.Exit(code=1)
|
|
74
|
+
manifests = [manifest]
|
|
75
|
+
else:
|
|
76
|
+
manifests = discover_products()
|
|
77
|
+
if not manifests:
|
|
78
|
+
typer.echo("No products discovered via entry points.", err=True)
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
all_ok = True
|
|
82
|
+
for m in manifests:
|
|
83
|
+
errors = validate_manifest(m)
|
|
84
|
+
if errors:
|
|
85
|
+
all_ok = False
|
|
86
|
+
typer.echo(f"[FAIL] {m.name} v{m.version}:")
|
|
87
|
+
for e in errors:
|
|
88
|
+
typer.echo(f" - {e}")
|
|
89
|
+
else:
|
|
90
|
+
typer.echo(f"[OK] {m.name} v{m.version}")
|
|
91
|
+
|
|
92
|
+
if not all_ok:
|
|
93
|
+
raise typer.Exit(code=1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# pulse run
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def run(
|
|
103
|
+
host: Annotated[str, typer.Option(help="Bind host")] = "0.0.0.0",
|
|
104
|
+
port: Annotated[int, typer.Option(help="Bind port")] = 8000,
|
|
105
|
+
reload: Annotated[bool, typer.Option(help="Enable auto-reload")] = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Discover the product manifest and start the FastAPI server."""
|
|
108
|
+
import uvicorn
|
|
109
|
+
|
|
110
|
+
from pulse_engine.registry import discover_products
|
|
111
|
+
|
|
112
|
+
products = discover_products()
|
|
113
|
+
manifest = products[0] if products else None
|
|
114
|
+
|
|
115
|
+
if manifest:
|
|
116
|
+
typer.echo(f"Starting {manifest.name} v{manifest.version}")
|
|
117
|
+
|
|
118
|
+
# We use a factory string so uvicorn can import and call create_app
|
|
119
|
+
uvicorn.run(
|
|
120
|
+
"pulse_engine.main:create_app",
|
|
121
|
+
factory=True,
|
|
122
|
+
host=host,
|
|
123
|
+
port=port,
|
|
124
|
+
reload=reload,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# pulse run-worker
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def run_worker() -> None:
|
|
135
|
+
"""Discover the product manifest and start the Celery worker."""
|
|
136
|
+
from pulse_engine.config import get_settings
|
|
137
|
+
from pulse_engine.registry import discover_products
|
|
138
|
+
from pulse_engine.worker import create_celery_app
|
|
139
|
+
|
|
140
|
+
products = discover_products()
|
|
141
|
+
manifest = products[0] if products else None
|
|
142
|
+
|
|
143
|
+
settings = get_settings()
|
|
144
|
+
celery_app = create_celery_app(settings, manifest)
|
|
145
|
+
|
|
146
|
+
celery_app.worker_main(["worker", "--loglevel=info"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# pulse run-mcp
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def run_mcp() -> None:
|
|
156
|
+
"""Discover the product manifest and start the MCP server."""
|
|
157
|
+
import asyncio
|
|
158
|
+
|
|
159
|
+
from pulse_engine.mcp.server import run_mcp_server
|
|
160
|
+
from pulse_engine.registry import discover_products
|
|
161
|
+
|
|
162
|
+
products = discover_products()
|
|
163
|
+
manifest = products[0] if products else None
|
|
164
|
+
|
|
165
|
+
asyncio.run(run_mcp_server(manifest))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
app()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
FROM python:3.12-slim AS builder
|
|
2
|
+
|
|
3
|
+
WORKDIR /build
|
|
4
|
+
|
|
5
|
+
RUN pip install --no-cache-dir poetry==1.8.5
|
|
6
|
+
|
|
7
|
+
COPY pyproject.toml ./
|
|
8
|
+
RUN poetry config virtualenvs.in-project true && \
|
|
9
|
+
poetry install --only main --no-interaction --no-ansi
|
|
10
|
+
|
|
11
|
+
COPY src/ src/
|
|
12
|
+
|
|
13
|
+
FROM python:3.12-slim AS runtime
|
|
14
|
+
|
|
15
|
+
RUN groupadd -g 1001 appuser && \
|
|
16
|
+
useradd -u 1001 -g appuser -s /bin/false -m appuser
|
|
17
|
+
|
|
18
|
+
WORKDIR /app
|
|
19
|
+
|
|
20
|
+
COPY --from=builder /build/.venv .venv
|
|
21
|
+
COPY --from=builder /build/src src
|
|
22
|
+
|
|
23
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
24
|
+
|
|
25
|
+
USER appuser
|
|
26
|
+
|
|
27
|
+
EXPOSE 8000
|
|
28
|
+
|
|
29
|
+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
|
30
|
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health')"
|
|
31
|
+
|
|
32
|
+
CMD ["pulse", "run", "--host", "0.0.0.0", "--port", "8000"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: {{cookiecutter.product_name}}
|
|
2
|
+
version: "1.0"
|
|
3
|
+
modules:
|
|
4
|
+
- name: extractor
|
|
5
|
+
compute: ecs
|
|
6
|
+
retries: 2
|
|
7
|
+
timeout: 900
|
|
8
|
+
- name: storage
|
|
9
|
+
compute: ecs
|
|
10
|
+
|
|
11
|
+
dag:
|
|
12
|
+
- step: extract
|
|
13
|
+
module: extractor
|
|
14
|
+
- step: store
|
|
15
|
+
module: storage
|
|
16
|
+
depends_on:
|
|
17
|
+
- step: extract
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pulse-{{cookiecutter.product_name}}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pulse product: {{cookiecutter.product_name}}"
|
|
5
|
+
authors = ["Pulse Team"]
|
|
6
|
+
packages = [{include = "pulse_{{cookiecutter.product_slug}}", from = "src"}]
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = ">=3.11,<3.13"
|
|
10
|
+
pulse-engine = "*"
|
|
11
|
+
|
|
12
|
+
[tool.poetry.group.dev.dependencies]
|
|
13
|
+
pytest = "^8.3.0"
|
|
14
|
+
pytest-asyncio = "^0.25.0"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.plugins."pulse_engine.products"]
|
|
17
|
+
{{cookiecutter.product_slug}} = "pulse_{{cookiecutter.product_slug}}:manifest"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
asyncio_mode = "auto"
|
|
25
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Tests for the product manifest."""
|
|
2
|
+
|
|
3
|
+
from pulse_engine.registry import validate_manifest
|
|
4
|
+
|
|
5
|
+
from pulse_{{cookiecutter.product_slug}} import manifest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_manifest_is_valid() -> None:
|
|
9
|
+
errors = validate_manifest(manifest)
|
|
10
|
+
assert errors == [], f"Manifest validation failed: {errors}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_manifest_metadata() -> None:
|
|
14
|
+
assert manifest.name == "{{cookiecutter.product_name}}"
|
|
15
|
+
assert manifest.version == "0.1.0"
|