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.
@@ -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})"