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.
Files changed (132) hide show
  1. pulse_engine/__init__.py +0 -0
  2. pulse_engine/api/__init__.py +0 -0
  3. pulse_engine/api/v1/__init__.py +0 -0
  4. pulse_engine/api/v1/auth.py +91 -0
  5. pulse_engine/api/v1/health.py +62 -0
  6. pulse_engine/api/v1/router.py +16 -0
  7. pulse_engine/chain_recovery.py +131 -0
  8. pulse_engine/cli/__init__.py +0 -0
  9. pulse_engine/cli/main.py +169 -0
  10. pulse_engine/cli/templates/cookiecutter.json +4 -0
  11. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
  12. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
  13. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
  14. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
  15. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
  16. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
  17. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
  18. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
  19. pulse_engine/client.py +95 -0
  20. pulse_engine/config.py +153 -0
  21. pulse_engine/core/__init__.py +0 -0
  22. pulse_engine/core/error_handlers.py +64 -0
  23. pulse_engine/core/exceptions.py +67 -0
  24. pulse_engine/core/job_token.py +109 -0
  25. pulse_engine/core/logging.py +45 -0
  26. pulse_engine/core/scope.py +23 -0
  27. pulse_engine/core/security.py +130 -0
  28. pulse_engine/database.py +30 -0
  29. pulse_engine/dependencies.py +166 -0
  30. pulse_engine/deployment/__init__.py +0 -0
  31. pulse_engine/deployment/backend_deployment_repository.py +83 -0
  32. pulse_engine/deployment/backends/__init__.py +0 -0
  33. pulse_engine/deployment/backends/base.py +50 -0
  34. pulse_engine/deployment/backends/exceptions.py +20 -0
  35. pulse_engine/deployment/backends/native_lambda.py +125 -0
  36. pulse_engine/deployment/backends/prefect_ecs.py +116 -0
  37. pulse_engine/deployment/backends/prefect_k8s.py +131 -0
  38. pulse_engine/deployment/backends/registry.py +50 -0
  39. pulse_engine/deployment/infra_provisioner.py +278 -0
  40. pulse_engine/deployment/job_launcher.py +178 -0
  41. pulse_engine/deployment/models.py +48 -0
  42. pulse_engine/deployment/repository.py +54 -0
  43. pulse_engine/deployment/router.py +22 -0
  44. pulse_engine/deployment/schemas.py +18 -0
  45. pulse_engine/deployment/service.py +65 -0
  46. pulse_engine/extractor/__init__.py +0 -0
  47. pulse_engine/extractor/base.py +48 -0
  48. pulse_engine/extractor/models.py +50 -0
  49. pulse_engine/extractor/orchestrator/__init__.py +15 -0
  50. pulse_engine/extractor/orchestrator/base.py +34 -0
  51. pulse_engine/extractor/orchestrator/noop.py +37 -0
  52. pulse_engine/extractor/orchestrator/prefect.py +163 -0
  53. pulse_engine/extractor/repository.py +163 -0
  54. pulse_engine/extractor/router.py +102 -0
  55. pulse_engine/extractor/schemas.py +93 -0
  56. pulse_engine/extractor/service.py +431 -0
  57. pulse_engine/extractor/stage_models.py +36 -0
  58. pulse_engine/extractor/stage_repository.py +109 -0
  59. pulse_engine/main.py +195 -0
  60. pulse_engine/mcp/__init__.py +0 -0
  61. pulse_engine/mcp/__main__.py +5 -0
  62. pulse_engine/mcp/server.py +103 -0
  63. pulse_engine/mcp/tools_jobs.py +159 -0
  64. pulse_engine/mcp/tools_kb.py +88 -0
  65. pulse_engine/mcp/tools_processor.py +208 -0
  66. pulse_engine/middleware/__init__.py +0 -0
  67. pulse_engine/middleware/rate_limit.py +144 -0
  68. pulse_engine/middleware/request_id.py +16 -0
  69. pulse_engine/middleware/security_headers.py +25 -0
  70. pulse_engine/middleware/tenant.py +90 -0
  71. pulse_engine/pipeline/__init__.py +0 -0
  72. pulse_engine/pipeline/config_parser.py +120 -0
  73. pulse_engine/pipeline/models.py +67 -0
  74. pulse_engine/pipeline/repositories.py +153 -0
  75. pulse_engine/pipeline/router_modules.py +66 -0
  76. pulse_engine/pipeline/router_pipelines.py +186 -0
  77. pulse_engine/pipeline/schemas.py +139 -0
  78. pulse_engine/pipeline/service.py +158 -0
  79. pulse_engine/pipeline/translators/__init__.py +44 -0
  80. pulse_engine/pipeline/translators/airflow_status.py +11 -0
  81. pulse_engine/pipeline/translators/airflow_translator.py +23 -0
  82. pulse_engine/pipeline/translators/base.py +43 -0
  83. pulse_engine/pipeline/translators/prefect_status.py +93 -0
  84. pulse_engine/pipeline/translators/prefect_translator.py +135 -0
  85. pulse_engine/processor/__init__.py +0 -0
  86. pulse_engine/processor/base.py +36 -0
  87. pulse_engine/processor/core/__init__.py +0 -0
  88. pulse_engine/processor/core/analysis.py +148 -0
  89. pulse_engine/processor/core/chunking.py +158 -0
  90. pulse_engine/processor/core/prompts.py +340 -0
  91. pulse_engine/processor/core/topic_splitter.py +105 -0
  92. pulse_engine/processor/defaults/__init__.py +11 -0
  93. pulse_engine/processor/defaults/core_processor.py +12 -0
  94. pulse_engine/processor/defaults/postprocessor.py +12 -0
  95. pulse_engine/processor/defaults/preprocessor.py +12 -0
  96. pulse_engine/processor/llm/__init__.py +0 -0
  97. pulse_engine/processor/llm/provider.py +58 -0
  98. pulse_engine/processor/ocr/gemini.py +52 -0
  99. pulse_engine/processor/pipeline.py +107 -0
  100. pulse_engine/processor/postprocessor/__init__.py +0 -0
  101. pulse_engine/processor/postprocessor/embeddings.py +34 -0
  102. pulse_engine/processor/postprocessor/tasks.py +180 -0
  103. pulse_engine/processor/preprocessor/__init__.py +0 -0
  104. pulse_engine/processor/preprocessor/tasks.py +71 -0
  105. pulse_engine/processor/router.py +192 -0
  106. pulse_engine/processor/schemas.py +167 -0
  107. pulse_engine/registry.py +117 -0
  108. pulse_engine/runners/__init__.py +0 -0
  109. pulse_engine/runners/lambda_runner.py +26 -0
  110. pulse_engine/runners/pipeline_runner.py +43 -0
  111. pulse_engine/runners/prefect_pipeline_flow.py +677 -0
  112. pulse_engine/runners/prefect_runner.py +33 -0
  113. pulse_engine/s3.py +72 -0
  114. pulse_engine/services/__init__.py +0 -0
  115. pulse_engine/services/bootstrap.py +210 -0
  116. pulse_engine/services/opensearch.py +84 -0
  117. pulse_engine/storage/__init__.py +0 -0
  118. pulse_engine/storage/connectors/__init__.py +0 -0
  119. pulse_engine/storage/connectors/athena.py +226 -0
  120. pulse_engine/storage/connectors/base.py +32 -0
  121. pulse_engine/storage/connectors/opensearch.py +344 -0
  122. pulse_engine/storage/knowledge_base.py +68 -0
  123. pulse_engine/storage/router.py +78 -0
  124. pulse_engine/storage/schemas.py +93 -0
  125. pulse_engine/testing/__init__.py +13 -0
  126. pulse_engine/testing/fixtures.py +50 -0
  127. pulse_engine/testing/mocks.py +104 -0
  128. pulse_engine/worker.py +53 -0
  129. pulse_engine-0.2.0.dev20260407065251.dist-info/METADATA +563 -0
  130. pulse_engine-0.2.0.dev20260407065251.dist-info/RECORD +132 -0
  131. pulse_engine-0.2.0.dev20260407065251.dist-info/WHEEL +4 -0
  132. pulse_engine-0.2.0.dev20260407065251.dist-info/entry_points.txt +4 -0
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
@@ -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,4 @@
1
+ {
2
+ "product_name": "my-product",
3
+ "product_slug": "my_product"
4
+ }
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .mypy_cache/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ *.egg
@@ -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"]
@@ -0,0 +1,8 @@
1
+ """Pulse product: {{cookiecutter.product_name}}."""
2
+
3
+ from pulse_engine.registry import ProductManifest
4
+
5
+ manifest = ProductManifest(
6
+ name="{{cookiecutter.product_name}}",
7
+ version="0.1.0",
8
+ )
@@ -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"