pulse-engine 0.2.0__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/adapters/__init__.py +58 -0
- pulse_engine/adapters/audio_transcription.py +167 -0
- pulse_engine/adapters/batcher.py +36 -0
- pulse_engine/adapters/digital_news.py +128 -0
- pulse_engine/adapters/digital_news_metadata.py +536 -0
- pulse_engine/adapters/exceptions.py +10 -0
- pulse_engine/adapters/models.py +134 -0
- pulse_engine/adapters/opensearch_storage.py +160 -0
- pulse_engine/adapters/speech_content.py +130 -0
- pulse_engine/adapters/speech_metadata.py +374 -0
- pulse_engine/adapters/twitter.py +423 -0
- pulse_engine/adapters/youtube_downloader.py +186 -0
- pulse_engine/adapters/youtube_metadata.py +261 -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 +157 -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 +285 -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/adapters/__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 +108 -0
- pulse_engine/mcp/tools_jobs.py +159 -0
- pulse_engine/mcp/tools_kb.py +88 -0
- pulse_engine/mcp/tools_modules.py +115 -0
- pulse_engine/mcp/tools_pipelines.py +215 -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 +148 -0
- pulse_engine/pipeline/expression.py +268 -0
- pulse_engine/pipeline/models.py +98 -0
- pulse_engine/pipeline/repositories.py +224 -0
- pulse_engine/pipeline/router_modules.py +66 -0
- pulse_engine/pipeline/router_pipelines.py +198 -0
- pulse_engine/pipeline/schemas.py +200 -0
- pulse_engine/pipeline/service.py +250 -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 +22 -0
- pulse_engine/pipeline/translators/base.py +42 -0
- pulse_engine/pipeline/translators/prefect_status.py +93 -0
- pulse_engine/pipeline/translators/prefect_translator.py +195 -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 +904 -0
- pulse_engine/runners/prefect_runner.py +33 -0
- pulse_engine/s3.py +72 -0
- pulse_engine/secrets.py +46 -0
- pulse_engine/services/__init__.py +0 -0
- pulse_engine/services/bootstrap.py +211 -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.dist-info/METADATA +654 -0
- pulse_engine-0.2.0.dist-info/RECORD +150 -0
- pulse_engine-0.2.0.dist-info/WHEEL +4 -0
- pulse_engine-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import yaml # type: ignore[import-untyped]
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from pulse_engine.pipeline.expression import parse_expression
|
|
7
|
+
from pulse_engine.pipeline.schemas import PipelineConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PipelineConfigError(Exception):
|
|
11
|
+
"""Raised when pipeline config is invalid."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, errors: list[str]) -> None:
|
|
14
|
+
self.errors = errors
|
|
15
|
+
super().__init__(f"Pipeline config validation failed: {'; '.join(errors)}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_pipeline_config(yaml_content: str) -> PipelineConfig:
|
|
19
|
+
"""Parse YAML string into validated PipelineConfig."""
|
|
20
|
+
try:
|
|
21
|
+
raw = yaml.safe_load(yaml_content)
|
|
22
|
+
except yaml.YAMLError as e:
|
|
23
|
+
raise PipelineConfigError([f"YAML parse error: {e}"]) from e
|
|
24
|
+
|
|
25
|
+
if not isinstance(raw, dict):
|
|
26
|
+
raise PipelineConfigError(["YAML content must be a mapping"])
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
config = PipelineConfig(**raw)
|
|
30
|
+
except ValidationError as e:
|
|
31
|
+
errors = [f"{err['loc']}: {err['msg']}" for err in e.errors()]
|
|
32
|
+
raise PipelineConfigError(errors) from e
|
|
33
|
+
|
|
34
|
+
validate_dag(config)
|
|
35
|
+
return config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_dag(config: PipelineConfig) -> None:
|
|
39
|
+
"""Validate DAG structure for v2 pipelines."""
|
|
40
|
+
errors: list[str] = []
|
|
41
|
+
module_names = {m.name for m in config.modules}
|
|
42
|
+
module_map = {m.name: m for m in config.modules}
|
|
43
|
+
step_names: set[str] = set()
|
|
44
|
+
|
|
45
|
+
# Pass 1: duplicate steps + module reference check
|
|
46
|
+
for s in config.dag:
|
|
47
|
+
if s.step in step_names:
|
|
48
|
+
errors.append(f"Duplicate step: '{s.step}'")
|
|
49
|
+
step_names.add(s.step)
|
|
50
|
+
|
|
51
|
+
if s.module not in module_names:
|
|
52
|
+
errors.append(f"Step '{s.step}' references unknown module '{s.module}'")
|
|
53
|
+
|
|
54
|
+
# Pass 2: expression + collect_from validation
|
|
55
|
+
for s in config.dag:
|
|
56
|
+
# Validate collect_from references
|
|
57
|
+
if s.collect_from is not None:
|
|
58
|
+
refs = (
|
|
59
|
+
[s.collect_from]
|
|
60
|
+
if isinstance(s.collect_from, str)
|
|
61
|
+
else list(s.collect_from)
|
|
62
|
+
)
|
|
63
|
+
for ref in refs:
|
|
64
|
+
if ref not in step_names:
|
|
65
|
+
errors.append(
|
|
66
|
+
f"Step '{s.step}' collect_from references unknown step '{ref}'"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Validate for_each expression
|
|
70
|
+
if s.for_each is not None:
|
|
71
|
+
module = module_map.get(s.module)
|
|
72
|
+
try:
|
|
73
|
+
expr = parse_expression(s.for_each)
|
|
74
|
+
|
|
75
|
+
if expr.source == "args":
|
|
76
|
+
# $args.X requires module to have arg X
|
|
77
|
+
if module is not None and expr.field not in module.args:
|
|
78
|
+
errors.append(
|
|
79
|
+
f"Step '{s.step}' for_each '{s.for_each}' but "
|
|
80
|
+
f"module has no arg '{expr.field}'"
|
|
81
|
+
)
|
|
82
|
+
# $args fan-out cannot be combined with collect_from
|
|
83
|
+
if s.collect_from is not None:
|
|
84
|
+
errors.append(
|
|
85
|
+
f"Step '{s.step}' cannot combine '$args' fan-out "
|
|
86
|
+
f"with collect_from"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
elif expr.source == "steps":
|
|
90
|
+
if expr.step not in step_names:
|
|
91
|
+
errors.append(
|
|
92
|
+
f"Step '{s.step}' for_each references unknown step "
|
|
93
|
+
f"'{expr.step}'"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
elif expr.source == "collect":
|
|
97
|
+
if s.collect_from is None:
|
|
98
|
+
errors.append(
|
|
99
|
+
f"Step '{s.step}' uses '$collect.{expr.field}' "
|
|
100
|
+
f"but has no collect_from"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
errors.append(f"Step '{s.step}' has invalid for_each expression: {exc}")
|
|
105
|
+
|
|
106
|
+
# Cycle detection via Kahn's algorithm
|
|
107
|
+
# Edges: collect_from deps + for_each $steps.S.* deps
|
|
108
|
+
in_degree: dict[str, int] = {s.step: 0 for s in config.dag}
|
|
109
|
+
adjacency: dict[str, list[str]] = {s.step: [] for s in config.dag}
|
|
110
|
+
|
|
111
|
+
for s in config.dag:
|
|
112
|
+
deps: set[str] = set()
|
|
113
|
+
|
|
114
|
+
if s.collect_from is not None:
|
|
115
|
+
refs = (
|
|
116
|
+
[s.collect_from]
|
|
117
|
+
if isinstance(s.collect_from, str)
|
|
118
|
+
else list(s.collect_from)
|
|
119
|
+
)
|
|
120
|
+
deps.update(r for r in refs if r in step_names)
|
|
121
|
+
|
|
122
|
+
if s.for_each is not None:
|
|
123
|
+
try:
|
|
124
|
+
expr = parse_expression(s.for_each)
|
|
125
|
+
if expr.source == "steps" and expr.step and expr.step in step_names:
|
|
126
|
+
deps.add(expr.step)
|
|
127
|
+
except ValueError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
for dep in deps:
|
|
131
|
+
adjacency[dep].append(s.step)
|
|
132
|
+
in_degree[s.step] += 1
|
|
133
|
+
|
|
134
|
+
queue = [name for name, deg in in_degree.items() if deg == 0]
|
|
135
|
+
visited = 0
|
|
136
|
+
while queue:
|
|
137
|
+
node = queue.pop(0)
|
|
138
|
+
visited += 1
|
|
139
|
+
for neighbor in adjacency[node]:
|
|
140
|
+
in_degree[neighbor] -= 1
|
|
141
|
+
if in_degree[neighbor] == 0:
|
|
142
|
+
queue.append(neighbor)
|
|
143
|
+
|
|
144
|
+
if visited != len(config.dag):
|
|
145
|
+
errors.append("Circular dependency detected in DAG")
|
|
146
|
+
|
|
147
|
+
if errors:
|
|
148
|
+
raise PipelineConfigError(errors)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Pure expression parser and resolver for v2 pipeline for_each expressions.
|
|
2
|
+
|
|
3
|
+
No Prefect, no AWS. All functions are synchronous and unit-testable in isolation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ParsedExpression:
|
|
16
|
+
source: str # "args", "steps", or "collect"
|
|
17
|
+
step: str | None # for source="steps": the referenced step name
|
|
18
|
+
# for "args": arg key; for "steps"/"collect": optional output field
|
|
19
|
+
field: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_expression(expr: str) -> ParsedExpression:
|
|
23
|
+
"""Parse a for_each expression string.
|
|
24
|
+
|
|
25
|
+
Valid forms:
|
|
26
|
+
$args.FIELD
|
|
27
|
+
$steps.STEP_NAME.output
|
|
28
|
+
$steps.STEP_NAME.output.FIELD
|
|
29
|
+
$collect.FIELD
|
|
30
|
+
|
|
31
|
+
Step names may contain hyphens (e.g. $steps.batch-yt.output.batches).
|
|
32
|
+
"""
|
|
33
|
+
m = re.match(r"^\$args\.(\w+)$", expr)
|
|
34
|
+
if m:
|
|
35
|
+
return ParsedExpression(source="args", step=None, field=m.group(1))
|
|
36
|
+
|
|
37
|
+
m = re.match(r"^\$steps\.([\w-]+)\.output(?:\.(\w+))?$", expr)
|
|
38
|
+
if m:
|
|
39
|
+
return ParsedExpression(source="steps", step=m.group(1), field=m.group(2))
|
|
40
|
+
|
|
41
|
+
m = re.match(r"^\$collect\.(\w+)$", expr)
|
|
42
|
+
if m:
|
|
43
|
+
return ParsedExpression(source="collect", step=None, field=m.group(1))
|
|
44
|
+
|
|
45
|
+
raise ValueError(f"Invalid for_each expression: {expr!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_for_each_items(
|
|
49
|
+
expr: ParsedExpression,
|
|
50
|
+
module_args: dict[str, Any],
|
|
51
|
+
step_results: dict[str, Any],
|
|
52
|
+
collect_from: list[str] | None,
|
|
53
|
+
) -> list[Any]:
|
|
54
|
+
"""Return the list of items to fan out over for the given expression.
|
|
55
|
+
|
|
56
|
+
Raises ValueError if an $args source is not a list.
|
|
57
|
+
Returns [] for missing/empty sources rather than raising.
|
|
58
|
+
"""
|
|
59
|
+
if expr.source == "args":
|
|
60
|
+
assert expr.field is not None # guaranteed by parse_expression for "args"
|
|
61
|
+
items = module_args.get(expr.field, [])
|
|
62
|
+
if not isinstance(items, list):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"module.args['{expr.field}'] must be a list for fan-out, "
|
|
65
|
+
f"got {type(items).__name__}"
|
|
66
|
+
)
|
|
67
|
+
return items
|
|
68
|
+
|
|
69
|
+
if expr.source == "steps":
|
|
70
|
+
assert expr.step is not None # guaranteed by parse_expression for "steps"
|
|
71
|
+
output = step_results.get(expr.step)
|
|
72
|
+
if output is None:
|
|
73
|
+
return []
|
|
74
|
+
if expr.field:
|
|
75
|
+
return list(output.get(expr.field, [])) if isinstance(output, dict) else []
|
|
76
|
+
return output if isinstance(output, list) else [output]
|
|
77
|
+
|
|
78
|
+
if expr.source == "collect":
|
|
79
|
+
field = expr.field
|
|
80
|
+
assert field is not None # guaranteed by parse_expression for "collect"
|
|
81
|
+
sources = collect_from or []
|
|
82
|
+
collected: list[Any] = []
|
|
83
|
+
for step_name in sources:
|
|
84
|
+
result = step_results.get(step_name)
|
|
85
|
+
if result is None:
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(result, list):
|
|
88
|
+
for r in result:
|
|
89
|
+
if isinstance(r, dict):
|
|
90
|
+
collected.extend(r.get(field, []))
|
|
91
|
+
elif isinstance(result, dict):
|
|
92
|
+
collected.extend(result.get(field, []))
|
|
93
|
+
return collected
|
|
94
|
+
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_task_args(
|
|
99
|
+
expr: ParsedExpression,
|
|
100
|
+
module_args: dict[str, Any],
|
|
101
|
+
item: Any,
|
|
102
|
+
) -> tuple[dict[str, Any], Any]:
|
|
103
|
+
"""Build (task_args, inputs) for a single fan-out task.
|
|
104
|
+
|
|
105
|
+
task_args is always module_args unchanged — args stays static across all shards.
|
|
106
|
+
inputs is the fan-out item — the runtime value this shard processes.
|
|
107
|
+
"""
|
|
108
|
+
return dict(module_args), item
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _unwrap_item(item: Any) -> list[Any]:
|
|
112
|
+
"""Unwrap a step output item into a flat list of data items.
|
|
113
|
+
|
|
114
|
+
If item is a dict with exactly ONE key and that value is a list, return
|
|
115
|
+
that list — this handles the common pattern where a module returns
|
|
116
|
+
{"videos": [...]} or {"speeches": [...]} and the caller wants the inner list.
|
|
117
|
+
|
|
118
|
+
Dicts with multiple keys are never unwrapped to avoid accidentally extracting
|
|
119
|
+
a list field (e.g. "segments") from a rich data object like a video item.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(item, dict) and len(item) == 1:
|
|
122
|
+
(val,) = item.values()
|
|
123
|
+
if isinstance(val, list):
|
|
124
|
+
return val
|
|
125
|
+
return [item]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def collect_inputs(
|
|
129
|
+
collect_from: list[str],
|
|
130
|
+
step_results: dict[str, Any],
|
|
131
|
+
) -> list[Any]:
|
|
132
|
+
"""Flatten outputs from multiple steps into a single list for inputs (collect_from).
|
|
133
|
+
|
|
134
|
+
Each collected item is unwrapped via _unwrap_item so that modules returning
|
|
135
|
+
{"videos": [...]} or {"speeches": [...]} contribute their inner list items
|
|
136
|
+
rather than the wrapper dict.
|
|
137
|
+
"""
|
|
138
|
+
result: list[Any] = []
|
|
139
|
+
for step_name in collect_from:
|
|
140
|
+
val = step_results.get(step_name)
|
|
141
|
+
if val is None:
|
|
142
|
+
continue
|
|
143
|
+
items = val if isinstance(val, list) else [val]
|
|
144
|
+
for item in items:
|
|
145
|
+
result.extend(_unwrap_item(item))
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# when: expression evaluator
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_when_references(
|
|
155
|
+
expr: str,
|
|
156
|
+
step_results: dict[str, Any],
|
|
157
|
+
step_statuses: dict[str, str],
|
|
158
|
+
item: Any,
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Replace $steps.* and $item.* with repr() of their resolved values."""
|
|
161
|
+
|
|
162
|
+
def _steps_sub(m: re.Match[str]) -> str:
|
|
163
|
+
step = m.group(1)
|
|
164
|
+
rest = m.group(2)
|
|
165
|
+
if rest == "status":
|
|
166
|
+
return repr(step_statuses.get(step, "unknown"))
|
|
167
|
+
if rest == "skipped":
|
|
168
|
+
return repr(step_statuses.get(step) == "skipped")
|
|
169
|
+
if rest.startswith("output"):
|
|
170
|
+
output = step_results.get(step, {})
|
|
171
|
+
field_path = rest[len("output") :].lstrip(".")
|
|
172
|
+
if field_path and isinstance(output, dict):
|
|
173
|
+
val = output.get(field_path)
|
|
174
|
+
else:
|
|
175
|
+
val = output
|
|
176
|
+
return repr(val)
|
|
177
|
+
return repr(None)
|
|
178
|
+
|
|
179
|
+
def _item_sub(m: re.Match[str]) -> str:
|
|
180
|
+
field = m.group(1)
|
|
181
|
+
if isinstance(item, dict):
|
|
182
|
+
return repr(item.get(field))
|
|
183
|
+
return repr(None)
|
|
184
|
+
|
|
185
|
+
expr = re.sub(r"\$steps\.([\w-]+)\.([\w.]+)", _steps_sub, expr)
|
|
186
|
+
expr = re.sub(r"\$item\.(\w+)", _item_sub, expr)
|
|
187
|
+
expr = re.sub(r"\bnull\b", "None", expr)
|
|
188
|
+
expr = re.sub(r"\btrue\b", "True", expr)
|
|
189
|
+
expr = re.sub(r"\bfalse\b", "False", expr)
|
|
190
|
+
return expr
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _SafeEval(ast.NodeVisitor):
|
|
194
|
+
"""Evaluate a boolean AST over constants, comparisons, and bool ops only."""
|
|
195
|
+
|
|
196
|
+
def visit(self, node: ast.AST) -> Any:
|
|
197
|
+
return super().visit(node)
|
|
198
|
+
|
|
199
|
+
def visit_Expression(self, node: ast.Expression) -> Any:
|
|
200
|
+
return self.visit(node.body)
|
|
201
|
+
|
|
202
|
+
def visit_Constant(self, node: ast.Constant) -> Any:
|
|
203
|
+
return node.value
|
|
204
|
+
|
|
205
|
+
def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
|
|
206
|
+
if isinstance(node.op, ast.Not):
|
|
207
|
+
return not self.visit(node.operand)
|
|
208
|
+
raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
|
|
209
|
+
|
|
210
|
+
def visit_BoolOp(self, node: ast.BoolOp) -> Any:
|
|
211
|
+
vals = [self.visit(v) for v in node.values]
|
|
212
|
+
if isinstance(node.op, ast.And):
|
|
213
|
+
return all(vals)
|
|
214
|
+
if isinstance(node.op, ast.Or):
|
|
215
|
+
return any(vals)
|
|
216
|
+
raise ValueError(f"Unsupported bool op: {type(node.op).__name__}")
|
|
217
|
+
|
|
218
|
+
def visit_Compare(self, node: ast.Compare) -> Any:
|
|
219
|
+
left = self.visit(node.left)
|
|
220
|
+
for op, comp in zip(node.ops, node.comparators):
|
|
221
|
+
right = self.visit(comp)
|
|
222
|
+
try:
|
|
223
|
+
if isinstance(op, ast.Eq):
|
|
224
|
+
result = left == right
|
|
225
|
+
elif isinstance(op, ast.NotEq):
|
|
226
|
+
result = left != right
|
|
227
|
+
elif isinstance(op, ast.Lt):
|
|
228
|
+
result = left < right
|
|
229
|
+
elif isinstance(op, ast.LtE):
|
|
230
|
+
result = left <= right
|
|
231
|
+
elif isinstance(op, ast.Gt):
|
|
232
|
+
result = left > right
|
|
233
|
+
elif isinstance(op, ast.GtE):
|
|
234
|
+
result = left >= right
|
|
235
|
+
elif isinstance(op, ast.Is):
|
|
236
|
+
result = left is right
|
|
237
|
+
elif isinstance(op, ast.IsNot):
|
|
238
|
+
result = left is not right
|
|
239
|
+
else:
|
|
240
|
+
raise ValueError(f"Unsupported compare op: {type(op).__name__}")
|
|
241
|
+
except TypeError:
|
|
242
|
+
return False
|
|
243
|
+
if not result:
|
|
244
|
+
return False
|
|
245
|
+
left = right
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
def generic_visit(self, node: ast.AST) -> Any:
|
|
249
|
+
raise ValueError(f"Unsupported AST node: {type(node).__name__}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def evaluate_when(
|
|
253
|
+
expr: str,
|
|
254
|
+
step_results: dict[str, Any],
|
|
255
|
+
step_statuses: dict[str, str],
|
|
256
|
+
item: Any = None,
|
|
257
|
+
) -> bool:
|
|
258
|
+
"""Evaluate a when: expression. Returns True if the step/item should proceed.
|
|
259
|
+
|
|
260
|
+
On eval error (malformed expression, type mismatch) returns True so that
|
|
261
|
+
steps are never silently skipped due to a bad expression string.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
resolved = _resolve_when_references(expr, step_results, step_statuses, item)
|
|
265
|
+
tree = ast.parse(resolved, mode="eval")
|
|
266
|
+
return bool(_SafeEval().visit(tree))
|
|
267
|
+
except Exception:
|
|
268
|
+
return True
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import JSON, DateTime, Index, String, Text, func
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
from pulse_engine.database import Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModuleRegistryModel(Base):
|
|
14
|
+
__tablename__ = "module_registry"
|
|
15
|
+
|
|
16
|
+
id: Mapped[str] = mapped_column(
|
|
17
|
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
18
|
+
)
|
|
19
|
+
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
20
|
+
product: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
21
|
+
module_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
22
|
+
image: Mapped[str] = mapped_column(Text, nullable=False)
|
|
23
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
24
|
+
DateTime(timezone=True), server_default=func.now()
|
|
25
|
+
)
|
|
26
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
27
|
+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__table_args__ = (
|
|
31
|
+
Index(
|
|
32
|
+
"uq_module_registry_product_module",
|
|
33
|
+
"tenant_id",
|
|
34
|
+
"product",
|
|
35
|
+
"module_name",
|
|
36
|
+
unique=True,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PipelineRunModel(Base):
|
|
42
|
+
__tablename__ = "pipeline_runs"
|
|
43
|
+
|
|
44
|
+
id: Mapped[str] = mapped_column(
|
|
45
|
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
46
|
+
)
|
|
47
|
+
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
48
|
+
product: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
49
|
+
orchestrator: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
50
|
+
orchestrator_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
51
|
+
config_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
|
52
|
+
global_config: Mapped[dict[str, Any]] = mapped_column(
|
|
53
|
+
JSON, nullable=False, default=dict
|
|
54
|
+
)
|
|
55
|
+
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
|
56
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
57
|
+
DateTime(timezone=True), server_default=func.now()
|
|
58
|
+
)
|
|
59
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
60
|
+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__table_args__ = (
|
|
64
|
+
Index("ix_pipeline_runs_tenant_status", "tenant_id", "status"),
|
|
65
|
+
Index("ix_pipeline_runs_tenant_product", "tenant_id", "product"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PipelineStepRunModel(Base):
|
|
70
|
+
__tablename__ = "pipeline_step_runs"
|
|
71
|
+
|
|
72
|
+
id: Mapped[str] = mapped_column(
|
|
73
|
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
74
|
+
)
|
|
75
|
+
pipeline_run_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
76
|
+
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
77
|
+
step_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
78
|
+
module_type: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
79
|
+
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
|
80
|
+
started_at: Mapped[datetime | None] = mapped_column(
|
|
81
|
+
DateTime(timezone=True), nullable=True
|
|
82
|
+
)
|
|
83
|
+
completed_at: Mapped[datetime | None] = mapped_column(
|
|
84
|
+
DateTime(timezone=True), nullable=True
|
|
85
|
+
)
|
|
86
|
+
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
87
|
+
output_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
88
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
89
|
+
DateTime(timezone=True), server_default=func.now()
|
|
90
|
+
)
|
|
91
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
92
|
+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
__table_args__ = (
|
|
96
|
+
Index("ix_pipeline_step_runs_run_id", "pipeline_run_id"),
|
|
97
|
+
Index("ix_pipeline_step_runs_tenant_status", "tenant_id", "status"),
|
|
98
|
+
)
|