pipeline-frame 0.0.1__tar.gz
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.
- pipeline_frame-0.0.1/PKG-INFO +103 -0
- pipeline_frame-0.0.1/README.md +87 -0
- pipeline_frame-0.0.1/pipeline_frame/__init__.py +9 -0
- pipeline_frame-0.0.1/pipeline_frame/engine.py +134 -0
- pipeline_frame-0.0.1/pipeline_frame/execution.py +316 -0
- pipeline_frame-0.0.1/pipeline_frame/execution_context.py +41 -0
- pipeline_frame-0.0.1/pipeline_frame/execution_result.py +229 -0
- pipeline_frame-0.0.1/pipeline_frame/execution_state.py +67 -0
- pipeline_frame-0.0.1/pipeline_frame/observability/__init__.py +12 -0
- pipeline_frame-0.0.1/pipeline_frame/observability/logger.py +15 -0
- pipeline_frame-0.0.1/pipeline_frame/observability/timer.py +32 -0
- pipeline_frame-0.0.1/pipeline_frame/processor.py +70 -0
- pipeline_frame-0.0.1/pipeline_frame/workflow.py +86 -0
- pipeline_frame-0.0.1/pipeline_frame.egg-info/PKG-INFO +103 -0
- pipeline_frame-0.0.1/pipeline_frame.egg-info/SOURCES.txt +18 -0
- pipeline_frame-0.0.1/pipeline_frame.egg-info/dependency_links.txt +1 -0
- pipeline_frame-0.0.1/pipeline_frame.egg-info/top_level.txt +1 -0
- pipeline_frame-0.0.1/pyproject.toml +29 -0
- pipeline_frame-0.0.1/setup.cfg +4 -0
- pipeline_frame-0.0.1/tests/test_execution.py +308 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipeline-frame
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A middleware framework for building data processing pipelines
|
|
5
|
+
Author-email: alan <al6nlee@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/al6nlee/pipeline-frame
|
|
7
|
+
Project-URL: Bug Reports, https://github.com/al6nlee/pipeline-frame/issues
|
|
8
|
+
Project-URL: Source, https://github.com/al6nlee/pipeline-frame
|
|
9
|
+
Keywords: middleware,pipeline,execution,processor
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Pipeline Frame
|
|
18
|
+
|
|
19
|
+
一个用于构建 middleware-style processing pipeline 的轻量 Python 框架。
|
|
20
|
+
每个 `Processor` 分别处理进入和返回阶段的数据,从而形成 U-shaped
|
|
21
|
+
执行路径:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
input: Processor A -> Processor B -> Processor C
|
|
25
|
+
output: Processor A <- Processor B <- Processor C
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
适用于 request/response middleware、数据校验与转换链、ETL 和 event
|
|
29
|
+
processing 等场景。应用启动入口创建一个 application-scoped `Engine`,
|
|
30
|
+
由它注册 processor 并构建可复用的 `Workflow`;每次调用 workflow 时,
|
|
31
|
+
再创建一个 one-shot `Execution`。
|
|
32
|
+
|
|
33
|
+
## 快速开始 / Quick Start
|
|
34
|
+
|
|
35
|
+
安装方式以及完整的 sync/async 示例,请参阅
|
|
36
|
+
[Quick Start](./docs/quick-start.md)。
|
|
37
|
+
|
|
38
|
+
## 架构概览 / Architecture
|
|
39
|
+
|
|
40
|
+
运行时分为两层:application-scoped `Engine` 注册并构建 frozen
|
|
41
|
+
`Workflow`;每次调用 workflow 时创建 one-shot `Execution`,由它拥有本次
|
|
42
|
+
调用的 processor instances、`ExecutionContext`、`ExecutionState` 和
|
|
43
|
+
`ExecutionResult`。完整的 ownership、生命周期、执行顺序和失败契约请参阅
|
|
44
|
+
[Architecture](./docs/architecture.md)。
|
|
45
|
+
|
|
46
|
+
## 项目结构 / Package Layout
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
pipeline_frame/
|
|
50
|
+
├── __init__.py
|
|
51
|
+
├── execution.py
|
|
52
|
+
├── execution_context.py
|
|
53
|
+
├── execution_result.py
|
|
54
|
+
├── execution_state.py
|
|
55
|
+
├── engine.py
|
|
56
|
+
├── processor.py
|
|
57
|
+
├── workflow.py
|
|
58
|
+
└── observability/
|
|
59
|
+
├── __init__.py
|
|
60
|
+
├── logger.py
|
|
61
|
+
└── timer.py
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 开发 / Development
|
|
65
|
+
|
|
66
|
+
在项目根目录运行测试:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python3 -m unittest discover -s tests -v
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
使用标准 Python build frontend 构建 distribution:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python3 -m pip install build
|
|
76
|
+
python3 -m build
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 发布 / Release
|
|
80
|
+
|
|
81
|
+
向 `main` push 包含 `pyproject.toml` 中 `project.version` 变更的提交,
|
|
82
|
+
即视为发布新版本。CI 会在同一次 workflow 中创建 annotated
|
|
83
|
+
`v<version>` tag、构建 distribution 并发布到 PyPI。也支持手动 push
|
|
84
|
+
`v*` tag。
|
|
85
|
+
|
|
86
|
+
如果之前的 release 已创建 tag,但在发布到 PyPI 前失败,后续 `main`
|
|
87
|
+
push 会在 PyPI 仍缺少对应版本时复用该 tag,无需再次修改 version。
|
|
88
|
+
|
|
89
|
+
version 可以写成 `0.2.0` 或 `v0.2.0`,CI 会统一生成 `v0.2.0` tag。
|
|
90
|
+
在 `pyproject.toml` 中省略 `v` 是更常规的写法。
|
|
91
|
+
|
|
92
|
+
启用自动发布前,需要在 GitHub 仓库设置中为 Actions 开启
|
|
93
|
+
**Read and write permissions**,并为该 workflow 配置 PyPI Trusted
|
|
94
|
+
Publishing。每次发布必须使用新版本,PyPI 不允许覆盖已有版本。
|
|
95
|
+
|
|
96
|
+
## 项目链接 / Project Links
|
|
97
|
+
|
|
98
|
+
- [Source repository](https://github.com/al6nlee/pipeline-frame)
|
|
99
|
+
- [Issue tracker](https://github.com/al6nlee/pipeline-frame/issues)
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Pipeline Frame
|
|
2
|
+
|
|
3
|
+
一个用于构建 middleware-style processing pipeline 的轻量 Python 框架。
|
|
4
|
+
每个 `Processor` 分别处理进入和返回阶段的数据,从而形成 U-shaped
|
|
5
|
+
执行路径:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
input: Processor A -> Processor B -> Processor C
|
|
9
|
+
output: Processor A <- Processor B <- Processor C
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
适用于 request/response middleware、数据校验与转换链、ETL 和 event
|
|
13
|
+
processing 等场景。应用启动入口创建一个 application-scoped `Engine`,
|
|
14
|
+
由它注册 processor 并构建可复用的 `Workflow`;每次调用 workflow 时,
|
|
15
|
+
再创建一个 one-shot `Execution`。
|
|
16
|
+
|
|
17
|
+
## 快速开始 / Quick Start
|
|
18
|
+
|
|
19
|
+
安装方式以及完整的 sync/async 示例,请参阅
|
|
20
|
+
[Quick Start](./docs/quick-start.md)。
|
|
21
|
+
|
|
22
|
+
## 架构概览 / Architecture
|
|
23
|
+
|
|
24
|
+
运行时分为两层:application-scoped `Engine` 注册并构建 frozen
|
|
25
|
+
`Workflow`;每次调用 workflow 时创建 one-shot `Execution`,由它拥有本次
|
|
26
|
+
调用的 processor instances、`ExecutionContext`、`ExecutionState` 和
|
|
27
|
+
`ExecutionResult`。完整的 ownership、生命周期、执行顺序和失败契约请参阅
|
|
28
|
+
[Architecture](./docs/architecture.md)。
|
|
29
|
+
|
|
30
|
+
## 项目结构 / Package Layout
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
pipeline_frame/
|
|
34
|
+
├── __init__.py
|
|
35
|
+
├── execution.py
|
|
36
|
+
├── execution_context.py
|
|
37
|
+
├── execution_result.py
|
|
38
|
+
├── execution_state.py
|
|
39
|
+
├── engine.py
|
|
40
|
+
├── processor.py
|
|
41
|
+
├── workflow.py
|
|
42
|
+
└── observability/
|
|
43
|
+
├── __init__.py
|
|
44
|
+
├── logger.py
|
|
45
|
+
└── timer.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 开发 / Development
|
|
49
|
+
|
|
50
|
+
在项目根目录运行测试:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python3 -m unittest discover -s tests -v
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
使用标准 Python build frontend 构建 distribution:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python3 -m pip install build
|
|
60
|
+
python3 -m build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 发布 / Release
|
|
64
|
+
|
|
65
|
+
向 `main` push 包含 `pyproject.toml` 中 `project.version` 变更的提交,
|
|
66
|
+
即视为发布新版本。CI 会在同一次 workflow 中创建 annotated
|
|
67
|
+
`v<version>` tag、构建 distribution 并发布到 PyPI。也支持手动 push
|
|
68
|
+
`v*` tag。
|
|
69
|
+
|
|
70
|
+
如果之前的 release 已创建 tag,但在发布到 PyPI 前失败,后续 `main`
|
|
71
|
+
push 会在 PyPI 仍缺少对应版本时复用该 tag,无需再次修改 version。
|
|
72
|
+
|
|
73
|
+
version 可以写成 `0.2.0` 或 `v0.2.0`,CI 会统一生成 `v0.2.0` tag。
|
|
74
|
+
在 `pyproject.toml` 中省略 `v` 是更常规的写法。
|
|
75
|
+
|
|
76
|
+
启用自动发布前,需要在 GitHub 仓库设置中为 Actions 开启
|
|
77
|
+
**Read and write permissions**,并为该 workflow 配置 PyPI Trusted
|
|
78
|
+
Publishing。每次发布必须使用新版本,PyPI 不允许覆盖已有版本。
|
|
79
|
+
|
|
80
|
+
## 项目链接 / Project Links
|
|
81
|
+
|
|
82
|
+
- [Source repository](https://github.com/al6nlee/pipeline-frame)
|
|
83
|
+
- [Issue tracker](https://github.com/al6nlee/pipeline-frame/issues)
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Package metadata for Pipeline Frame."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _distribution_version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _distribution_version("pipeline-frame")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
# Source checkouts are not always installed as a distribution.
|
|
9
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Application-scoped registries for processors and workflows."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
import inspect
|
|
5
|
+
from threading import RLock
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
|
|
8
|
+
from .processor import Processor, _validate_name
|
|
9
|
+
from .workflow import Workflow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Engine:
|
|
13
|
+
"""Registry and workflow factory for one application entrypoint.
|
|
14
|
+
|
|
15
|
+
``Engine`` deliberately has no process-wide singleton semantics. An
|
|
16
|
+
application can create independent engines for different entrypoints or
|
|
17
|
+
test fixtures; each instance owns its own processor and workflow registry.
|
|
18
|
+
Execution is performed by ``Execution`` instances created from workflows.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._processors: dict[str, type[Processor]] = {}
|
|
23
|
+
self._workflows: dict[str, Workflow] = {}
|
|
24
|
+
self._lock = RLock()
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def processors(self) -> Mapping[str, type[Processor]]:
|
|
28
|
+
"""A read-only view of registered processors."""
|
|
29
|
+
|
|
30
|
+
return MappingProxyType(self._processors)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def workflows(self) -> Mapping[str, Workflow]:
|
|
34
|
+
"""A read-only view of registered workflows."""
|
|
35
|
+
|
|
36
|
+
return MappingProxyType(self._workflows)
|
|
37
|
+
|
|
38
|
+
def register_processor(self, processor_cls: type[Processor]) -> type[Processor]:
|
|
39
|
+
"""Register a concrete ``Processor`` subclass.
|
|
40
|
+
|
|
41
|
+
The class's ``name`` is its canonical registry identity. Instances are
|
|
42
|
+
intentionally not accepted: ``Workflow`` must be able to create a fresh
|
|
43
|
+
instance for every ``Execution``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
if not inspect.isclass(processor_cls) or not issubclass(
|
|
47
|
+
processor_cls, Processor
|
|
48
|
+
):
|
|
49
|
+
raise TypeError("processor_cls must be a Processor subclass")
|
|
50
|
+
if inspect.isabstract(processor_cls):
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"Processor {processor_cls.__name__!r} must implement input() and output()"
|
|
53
|
+
)
|
|
54
|
+
name = _validate_name(processor_cls.name, "processor name")
|
|
55
|
+
|
|
56
|
+
with self._lock:
|
|
57
|
+
if name in self._processors:
|
|
58
|
+
raise ValueError(f"Processor {name!r} already exists")
|
|
59
|
+
self._processors[name] = processor_cls
|
|
60
|
+
return processor_cls
|
|
61
|
+
|
|
62
|
+
def register_workflow(self, workflow: Workflow) -> Workflow:
|
|
63
|
+
"""Register an existing immutable workflow under its canonical name."""
|
|
64
|
+
|
|
65
|
+
if not isinstance(workflow, Workflow):
|
|
66
|
+
raise TypeError("workflow must be a Workflow instance")
|
|
67
|
+
name = _validate_name(workflow.name, "workflow name")
|
|
68
|
+
|
|
69
|
+
with self._lock:
|
|
70
|
+
if name in self._workflows:
|
|
71
|
+
raise ValueError(f"Workflow {name!r} already exists")
|
|
72
|
+
self._workflows[name] = workflow
|
|
73
|
+
return workflow
|
|
74
|
+
|
|
75
|
+
def create_workflow(
|
|
76
|
+
self,
|
|
77
|
+
name: str,
|
|
78
|
+
processor_names: Iterable[str],
|
|
79
|
+
) -> Workflow:
|
|
80
|
+
"""Resolve registered processor names and register a new workflow.
|
|
81
|
+
|
|
82
|
+
``processor_names`` is consumed immediately and copied into the
|
|
83
|
+
immutable workflow definition. Repeated names are allowed, which is
|
|
84
|
+
useful when a processor intentionally appears at multiple stages.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
_validate_name(name, "workflow name")
|
|
88
|
+
if isinstance(processor_names, (str, bytes)):
|
|
89
|
+
raise TypeError("processor_names must be an iterable of names, not a string")
|
|
90
|
+
try:
|
|
91
|
+
names = tuple(processor_names)
|
|
92
|
+
except TypeError as exc:
|
|
93
|
+
raise TypeError("processor_names must be an iterable of names") from exc
|
|
94
|
+
|
|
95
|
+
with self._lock:
|
|
96
|
+
if name in self._workflows:
|
|
97
|
+
raise ValueError(f"Workflow {name!r} already exists")
|
|
98
|
+
|
|
99
|
+
definitions: list[type[Processor]] = []
|
|
100
|
+
for index, processor_name in enumerate(names):
|
|
101
|
+
if not isinstance(processor_name, str):
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"processor name at index {index} must be a string"
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
processor_cls = self._processors[processor_name]
|
|
107
|
+
except KeyError as exc:
|
|
108
|
+
raise KeyError(
|
|
109
|
+
f"Unknown processor {processor_name!r} while creating "
|
|
110
|
+
f"workflow {name!r}"
|
|
111
|
+
) from exc
|
|
112
|
+
definitions.append(processor_cls)
|
|
113
|
+
|
|
114
|
+
workflow = Workflow(name, tuple(definitions))
|
|
115
|
+
self._workflows[name] = workflow
|
|
116
|
+
return workflow
|
|
117
|
+
|
|
118
|
+
def get_processor(self, name: str) -> type[Processor]:
|
|
119
|
+
"""Return a registered processor class with contextual lookup errors."""
|
|
120
|
+
|
|
121
|
+
with self._lock:
|
|
122
|
+
try:
|
|
123
|
+
return self._processors[name]
|
|
124
|
+
except KeyError as exc:
|
|
125
|
+
raise KeyError(f"Unknown processor {name!r}") from exc
|
|
126
|
+
|
|
127
|
+
def get_workflow(self, name: str) -> Workflow:
|
|
128
|
+
"""Return a registered workflow with contextual lookup errors."""
|
|
129
|
+
|
|
130
|
+
with self._lock:
|
|
131
|
+
try:
|
|
132
|
+
return self._workflows[name]
|
|
133
|
+
except KeyError as exc:
|
|
134
|
+
raise KeyError(f"Unknown workflow {name!r}") from exc
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""One-shot execution of an immutable workflow definition."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import time
|
|
6
|
+
from threading import RLock
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .execution_context import ExecutionContext
|
|
10
|
+
from .execution_result import ExecutionResult, ExecutionStatus
|
|
11
|
+
from .execution_state import ExecutionState
|
|
12
|
+
from .observability.logger import logger as default_logger
|
|
13
|
+
from .observability.timer import timer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Execution:
|
|
17
|
+
"""Run a workflow once with fresh processor instances.
|
|
18
|
+
|
|
19
|
+
``Execution`` owns the processor instances and all three per-call
|
|
20
|
+
runtime objects. It deliberately has no public ``status`` property;
|
|
21
|
+
lifecycle information belongs to ``ExecutionResult.status`` while a
|
|
22
|
+
private admission flag only enforces the one-shot rule.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, workflow: Any, processors: Any = None):
|
|
26
|
+
self.workflow = workflow
|
|
27
|
+
if processors is None:
|
|
28
|
+
factory = getattr(workflow, "_create_processor_instances", None)
|
|
29
|
+
if not callable(factory):
|
|
30
|
+
raise TypeError("processors are required for this workflow")
|
|
31
|
+
processors = factory()
|
|
32
|
+
self.processors = tuple(processors)
|
|
33
|
+
self.context: ExecutionContext | None = None
|
|
34
|
+
self.state: ExecutionState | None = None
|
|
35
|
+
self._result = ExecutionResult()
|
|
36
|
+
|
|
37
|
+
self._started = False
|
|
38
|
+
self._lock = RLock()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def result(self) -> ExecutionResult:
|
|
42
|
+
"""The result accumulator owned by this one-shot execution."""
|
|
43
|
+
|
|
44
|
+
return self._result
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Public execution entry points
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
def run(self, input_data: Any) -> ExecutionResult:
|
|
50
|
+
"""Execute synchronously and return a frozen ``ExecutionResult``.
|
|
51
|
+
|
|
52
|
+
If a processor returns an awaitable, synchronous execution fails with
|
|
53
|
+
a clear instruction to use :meth:`arun`; the one-shot admission is
|
|
54
|
+
still consumed and the resulting failure is frozen.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
context, state, result = self._begin(input_data)
|
|
58
|
+
time_report: list[Any] = []
|
|
59
|
+
run_logger: Any = default_logger
|
|
60
|
+
total_t0 = time.perf_counter()
|
|
61
|
+
terminal_status = ExecutionStatus.SUCCESS
|
|
62
|
+
terminal_error: BaseException | None = None
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
run_logger = self._select_logger(context)
|
|
66
|
+
for processor in self.processors:
|
|
67
|
+
with timer(processor.name, "INPUT", time_report):
|
|
68
|
+
self._call_sync(
|
|
69
|
+
processor.input,
|
|
70
|
+
context,
|
|
71
|
+
state,
|
|
72
|
+
result,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for processor in reversed(self.processors):
|
|
76
|
+
with timer(processor.name, "OUTPUT", time_report):
|
|
77
|
+
self._call_sync(
|
|
78
|
+
processor.output,
|
|
79
|
+
context,
|
|
80
|
+
state,
|
|
81
|
+
result,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
except asyncio.CancelledError as exc:
|
|
86
|
+
# A synchronous caller can still deliberately raise
|
|
87
|
+
# CancelledError; preserve the same cancellation contract as arun.
|
|
88
|
+
terminal_status = ExecutionStatus.CANCELLED
|
|
89
|
+
terminal_error = exc
|
|
90
|
+
raise
|
|
91
|
+
except BaseException as exc:
|
|
92
|
+
terminal_status = ExecutionStatus.FAILED
|
|
93
|
+
terminal_error = exc
|
|
94
|
+
raise
|
|
95
|
+
finally:
|
|
96
|
+
self._finish(
|
|
97
|
+
result,
|
|
98
|
+
run_logger,
|
|
99
|
+
time_report,
|
|
100
|
+
total_t0,
|
|
101
|
+
terminal_status,
|
|
102
|
+
terminal_error,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def arun(self, input_data: Any) -> ExecutionResult:
|
|
106
|
+
"""Execute asynchronously, allowing sync and async hooks to mix."""
|
|
107
|
+
|
|
108
|
+
context, state, result = self._begin(input_data)
|
|
109
|
+
time_report: list[Any] = []
|
|
110
|
+
run_logger: Any = default_logger
|
|
111
|
+
total_t0 = time.perf_counter()
|
|
112
|
+
terminal_status = ExecutionStatus.SUCCESS
|
|
113
|
+
terminal_error: BaseException | None = None
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
run_logger = self._select_logger(context)
|
|
117
|
+
for processor in self.processors:
|
|
118
|
+
with timer(processor.name, "INPUT", time_report):
|
|
119
|
+
await self._call_async(
|
|
120
|
+
processor.input,
|
|
121
|
+
context,
|
|
122
|
+
state,
|
|
123
|
+
result,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
for processor in reversed(self.processors):
|
|
127
|
+
with timer(processor.name, "OUTPUT", time_report):
|
|
128
|
+
await self._call_async(
|
|
129
|
+
processor.output,
|
|
130
|
+
context,
|
|
131
|
+
state,
|
|
132
|
+
result,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
except asyncio.CancelledError as exc:
|
|
137
|
+
terminal_status = ExecutionStatus.CANCELLED
|
|
138
|
+
terminal_error = exc
|
|
139
|
+
raise
|
|
140
|
+
except BaseException as exc:
|
|
141
|
+
terminal_status = ExecutionStatus.FAILED
|
|
142
|
+
terminal_error = exc
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
self._finish(
|
|
146
|
+
result,
|
|
147
|
+
run_logger,
|
|
148
|
+
time_report,
|
|
149
|
+
total_t0,
|
|
150
|
+
terminal_status,
|
|
151
|
+
terminal_error,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Admission and finalization
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
def _begin(
|
|
158
|
+
self, input_data: Any
|
|
159
|
+
) -> tuple[ExecutionContext, ExecutionState, ExecutionResult]:
|
|
160
|
+
"""Admit one caller and construct its runtime objects.
|
|
161
|
+
|
|
162
|
+
The result is installed before context validation so malformed input
|
|
163
|
+
still leaves ``execution.result`` as a frozen failure and cannot be
|
|
164
|
+
retried through the same one-shot object.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
with self._lock:
|
|
168
|
+
if self._started:
|
|
169
|
+
raise RuntimeError("Execution is one-shot and has already started")
|
|
170
|
+
result = self.result
|
|
171
|
+
if result.frozen:
|
|
172
|
+
raise RuntimeError("ExecutionResult is already frozen")
|
|
173
|
+
# Reserve the accumulator before releasing the admission lock so
|
|
174
|
+
# an external ``freeze()`` cannot race the one-shot transition.
|
|
175
|
+
result._begin_execution()
|
|
176
|
+
self._started = True
|
|
177
|
+
# Keep one stable result object for the whole one-shot lifecycle.
|
|
178
|
+
# In particular, callers can retain ``execution.result`` before
|
|
179
|
+
# calling ``run`` and observe its terminal state afterwards.
|
|
180
|
+
|
|
181
|
+
total_t0 = time.perf_counter()
|
|
182
|
+
run_logger: Any = default_logger
|
|
183
|
+
time_report: list[Any] = []
|
|
184
|
+
try:
|
|
185
|
+
result.set_status(ExecutionStatus.RUNNING)
|
|
186
|
+
context = ExecutionContext(input_data)
|
|
187
|
+
state = ExecutionState()
|
|
188
|
+
self.context = context
|
|
189
|
+
self.state = state
|
|
190
|
+
run_logger = self._select_logger(context)
|
|
191
|
+
return context, state, result
|
|
192
|
+
except asyncio.CancelledError as exc:
|
|
193
|
+
self._finish(
|
|
194
|
+
result,
|
|
195
|
+
run_logger,
|
|
196
|
+
time_report,
|
|
197
|
+
total_t0,
|
|
198
|
+
ExecutionStatus.CANCELLED,
|
|
199
|
+
exc,
|
|
200
|
+
)
|
|
201
|
+
raise
|
|
202
|
+
except BaseException as exc:
|
|
203
|
+
self._finish(
|
|
204
|
+
result,
|
|
205
|
+
run_logger,
|
|
206
|
+
time_report,
|
|
207
|
+
total_t0,
|
|
208
|
+
ExecutionStatus.FAILED,
|
|
209
|
+
exc,
|
|
210
|
+
)
|
|
211
|
+
raise
|
|
212
|
+
|
|
213
|
+
def _finish(
|
|
214
|
+
self,
|
|
215
|
+
result: ExecutionResult,
|
|
216
|
+
run_logger: Any,
|
|
217
|
+
time_report: list[Any],
|
|
218
|
+
total_t0: float,
|
|
219
|
+
terminal_status: ExecutionStatus,
|
|
220
|
+
terminal_error: BaseException | None,
|
|
221
|
+
) -> None:
|
|
222
|
+
# Freeze before logging so observers see the same terminal object the
|
|
223
|
+
# caller receives. Logger/observer failures must never replace the
|
|
224
|
+
# processor exception (or turn a successful run into a failure).
|
|
225
|
+
try:
|
|
226
|
+
# Timing is kept in a local sink while hooks are running. The
|
|
227
|
+
# result keeps its active marker until the merge and freeze have
|
|
228
|
+
# both completed, preventing an external observer from freezing
|
|
229
|
+
# the result in between those operations.
|
|
230
|
+
result._finish_execution(
|
|
231
|
+
time_report,
|
|
232
|
+
status=terminal_status,
|
|
233
|
+
error=terminal_error,
|
|
234
|
+
)
|
|
235
|
+
except BaseException:
|
|
236
|
+
# Freezing is internal bookkeeping. A malformed custom report
|
|
237
|
+
# object must not replace the business exception or strand the
|
|
238
|
+
# execution in RUNNING.
|
|
239
|
+
pass
|
|
240
|
+
finally:
|
|
241
|
+
try:
|
|
242
|
+
workflow_name = getattr(self.workflow, "name", "workflow")
|
|
243
|
+
except BaseException:
|
|
244
|
+
workflow_name = "workflow"
|
|
245
|
+
self._safe_log_run(
|
|
246
|
+
run_logger,
|
|
247
|
+
result,
|
|
248
|
+
time_report,
|
|
249
|
+
total_t0,
|
|
250
|
+
workflow_name,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _select_logger(context: ExecutionContext) -> Any:
|
|
255
|
+
try:
|
|
256
|
+
candidate = context.input.get("logger", default_logger)
|
|
257
|
+
return (
|
|
258
|
+
candidate
|
|
259
|
+
if callable(getattr(candidate, "info", None))
|
|
260
|
+
else default_logger
|
|
261
|
+
)
|
|
262
|
+
except BaseException:
|
|
263
|
+
return default_logger
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _safe_log_run(
|
|
267
|
+
run_logger: Any,
|
|
268
|
+
result: ExecutionResult,
|
|
269
|
+
time_report: list[Any],
|
|
270
|
+
total_t0: float,
|
|
271
|
+
workflow_name: Any = "workflow",
|
|
272
|
+
) -> None:
|
|
273
|
+
try:
|
|
274
|
+
status = getattr(result.status, "value", result.status)
|
|
275
|
+
run_logger.info(f"{workflow_name} execution finished, status: {status}")
|
|
276
|
+
run_logger.info(
|
|
277
|
+
f"{workflow_name} execution total time: "
|
|
278
|
+
f"{time.perf_counter() - total_t0:.2f}s"
|
|
279
|
+
)
|
|
280
|
+
for stage, name, cost in time_report:
|
|
281
|
+
run_logger.info(f"{stage}: {name} elapsed: {cost:.2f}s")
|
|
282
|
+
except BaseException:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Processor hook invocation
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _call_sync(
|
|
290
|
+
action: Any,
|
|
291
|
+
context: ExecutionContext,
|
|
292
|
+
state: ExecutionState,
|
|
293
|
+
result: ExecutionResult,
|
|
294
|
+
) -> None:
|
|
295
|
+
outcome = action(context, state, result)
|
|
296
|
+
if inspect.isawaitable(outcome):
|
|
297
|
+
close = getattr(outcome, "close", None)
|
|
298
|
+
if callable(close):
|
|
299
|
+
try:
|
|
300
|
+
close()
|
|
301
|
+
except BaseException:
|
|
302
|
+
pass
|
|
303
|
+
raise RuntimeError(
|
|
304
|
+
"Async processor methods require calling Execution.arun()"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
async def _call_async(
|
|
309
|
+
action: Any,
|
|
310
|
+
context: ExecutionContext,
|
|
311
|
+
state: ExecutionState,
|
|
312
|
+
result: ExecutionResult,
|
|
313
|
+
) -> None:
|
|
314
|
+
outcome = action(context, state, result)
|
|
315
|
+
if inspect.isawaitable(outcome):
|
|
316
|
+
await outcome
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Read-only input context for a single pipeline execution."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExecutionContext(Mapping[str, Any]):
|
|
9
|
+
"""Immutable, per-execution view of the input mapping.
|
|
10
|
+
|
|
11
|
+
The mapping is copied at construction time and wrapped in a
|
|
12
|
+
:class:`~types.MappingProxyType`, so assigning top-level keys through the
|
|
13
|
+
context cannot alter either the context or the caller's mapping. Values
|
|
14
|
+
are intentionally not deep-copied; the first version of the runtime only
|
|
15
|
+
guarantees top-level read-only semantics.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("_input",)
|
|
19
|
+
|
|
20
|
+
def __init__(self, input_data: Mapping[str, Any]):
|
|
21
|
+
if not isinstance(input_data, Mapping):
|
|
22
|
+
raise TypeError("ExecutionContext input_data must be a Mapping")
|
|
23
|
+
self._input = MappingProxyType(dict(input_data))
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def input(self) -> Mapping[str, Any]:
|
|
27
|
+
"""The read-only input mapping for this execution."""
|
|
28
|
+
|
|
29
|
+
return self._input
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, key: str) -> Any:
|
|
32
|
+
return self._input[key]
|
|
33
|
+
|
|
34
|
+
def __iter__(self) -> Iterator[str]:
|
|
35
|
+
return iter(self._input)
|
|
36
|
+
|
|
37
|
+
def __len__(self) -> int:
|
|
38
|
+
return len(self._input)
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return f"{type(self).__name__}({dict(self._input)!r})"
|