moveset 0.1.0__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.
moveset-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ella Inng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
moveset-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: moveset
3
+ Version: 0.1.0
4
+ Summary: Explicit linear composition of selected Python capabilities.
5
+ Author: ellainng
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Dist: hidden-moves>=0.1.0
9
+ Requires-Dist: func-seq>=0.1.0 ; python_full_version >= '3.14' and extra == 'sequence'
10
+ Requires-Python: >=3.11
11
+ Project-URL: Source, https://github.com/wispsy/moveset
12
+ Project-URL: Issues, https://github.com/wispsy/moveset/issues
13
+ Project-URL: Project, https://github.com/orgs/wispsy/projects/23
14
+ Provides-Extra: sequence
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Moveset
18
+
19
+ Explicit linear composition of selected Python capabilities. Python 3.11+.
20
+
21
+ ```python
22
+ from hidden_moves import Moves
23
+ from hidden_moves.adapters import CapabilityCatalog
24
+ from moveset import Input, Pipeline, Ref, Step
25
+
26
+ def normalize(text: str) -> str:
27
+ return " ".join(text.lower().split())
28
+
29
+ moves = Moves()
30
+ moves.learn(normalize)
31
+ selected = CapabilityCatalog(moves, ["normalize"])
32
+ definition = Pipeline("example", version="1", steps=(
33
+ Step("clean", "normalize", {"text": Input("text")}),
34
+ ), outputs={"text": Ref("clean")})
35
+ bound = definition.bind(selected, catalog_version="text-v1")
36
+ assert bound.run({"text": " Hello WORLD "}).outputs == {"text": "hello world"}
37
+ ```
38
+
39
+ Definitions snapshot nested arguments and outputs. `Input("payload")["key"][0]`
40
+ selects run data; `Ref("clean")` selects an earlier result. Mappings are explicit:
41
+ scalar results are never automatically wrapped in objects. Binding validates
42
+ selected capability names and known argument shapes. Run preflight validates all
43
+ known inputs before effects; dynamic result paths and values are checked before
44
+ the dependent call. Hidden Moves owns its documented schema subset and callable
45
+ binding. No provider is activated by creating or executing a pipeline.
46
+
47
+ `arun(inputs)` awaits mixed sync/async steps in order. `run` rejects declared async
48
+ steps before executing anything. Cancellation propagates; ordinary runs start
49
+ fresh. Results are caller-owned finite JSON snapshots, bounded to 1 MiB with depth
50
+ 32; definitions have at most 256 steps. StepError identifies the step/capability
51
+ and retains the original cause. There is no retry, persistence or concurrency in
52
+ the core.
53
+
54
+ The bound fingerprint includes mappings, schemas, async flags and catalog version.
55
+ Change `catalog_version` when callable behavior, resources, defaults or configuration
56
+ change; this is not source authentication. The optional lifecycle adapter belongs
57
+ in the `sequence` profile and delegates durable execution to func_seq.
58
+
59
+ This source development profile pins Hidden Moves to an immutable revision. It is
60
+ independently built and versioned; release requirements and publication are tracked
61
+ separately in the [Wispsy Composition Project](https://github.com/orgs/wispsy/projects/23).
62
+
63
+ Build with `uv build --out-dir dist`; `python scripts/qualify.py dist` installs the
64
+ wheel and source archive outside the checkout and runs the contract tests. CI
65
+ qualifies Python 3.11 on Linux/Windows and Python 3.14 on Linux/macOS/Windows.
@@ -0,0 +1,49 @@
1
+ # Moveset
2
+
3
+ Explicit linear composition of selected Python capabilities. Python 3.11+.
4
+
5
+ ```python
6
+ from hidden_moves import Moves
7
+ from hidden_moves.adapters import CapabilityCatalog
8
+ from moveset import Input, Pipeline, Ref, Step
9
+
10
+ def normalize(text: str) -> str:
11
+ return " ".join(text.lower().split())
12
+
13
+ moves = Moves()
14
+ moves.learn(normalize)
15
+ selected = CapabilityCatalog(moves, ["normalize"])
16
+ definition = Pipeline("example", version="1", steps=(
17
+ Step("clean", "normalize", {"text": Input("text")}),
18
+ ), outputs={"text": Ref("clean")})
19
+ bound = definition.bind(selected, catalog_version="text-v1")
20
+ assert bound.run({"text": " Hello WORLD "}).outputs == {"text": "hello world"}
21
+ ```
22
+
23
+ Definitions snapshot nested arguments and outputs. `Input("payload")["key"][0]`
24
+ selects run data; `Ref("clean")` selects an earlier result. Mappings are explicit:
25
+ scalar results are never automatically wrapped in objects. Binding validates
26
+ selected capability names and known argument shapes. Run preflight validates all
27
+ known inputs before effects; dynamic result paths and values are checked before
28
+ the dependent call. Hidden Moves owns its documented schema subset and callable
29
+ binding. No provider is activated by creating or executing a pipeline.
30
+
31
+ `arun(inputs)` awaits mixed sync/async steps in order. `run` rejects declared async
32
+ steps before executing anything. Cancellation propagates; ordinary runs start
33
+ fresh. Results are caller-owned finite JSON snapshots, bounded to 1 MiB with depth
34
+ 32; definitions have at most 256 steps. StepError identifies the step/capability
35
+ and retains the original cause. There is no retry, persistence or concurrency in
36
+ the core.
37
+
38
+ The bound fingerprint includes mappings, schemas, async flags and catalog version.
39
+ Change `catalog_version` when callable behavior, resources, defaults or configuration
40
+ change; this is not source authentication. The optional lifecycle adapter belongs
41
+ in the `sequence` profile and delegates durable execution to func_seq.
42
+
43
+ This source development profile pins Hidden Moves to an immutable revision. It is
44
+ independently built and versioned; release requirements and publication are tracked
45
+ separately in the [Wispsy Composition Project](https://github.com/orgs/wispsy/projects/23).
46
+
47
+ Build with `uv build --out-dir dist`; `python scripts/qualify.py dist` installs the
48
+ wheel and source archive outside the checkout and runs the contract tests. CI
49
+ qualifies Python 3.11 on Linux/Windows and Python 3.14 on Linux/macOS/Windows.
@@ -0,0 +1,26 @@
1
+ # Optional durable execution
2
+
3
+ Install the `sequence` profile on Python 3.14+. The base import does not load
4
+ func_seq and remains usable on Python 3.11+.
5
+
6
+ ```python
7
+ from moveset.sequence import compile_script
8
+ script = compile_script(bound, inputs)
9
+ progress = script.run(checkpoint="progress.json", pace="function")
10
+ progress = script.run(checkpoint="progress.json")
11
+ if progress.status == "completed":
12
+ result = bound.finish(inputs, progress.results)
13
+ ```
14
+
15
+ `compile_script(..., asynchronous=True)` supports `await script.arun()` for mixed
16
+ sync/async or sync-returned-awaitable operations. Execution mode is part of identity.
17
+ All mappings translate to func_seq values/references with the original step IDs.
18
+ The Script version hashes the bound fingerprint, run inputs and execution mode.
19
+ Mapping, schema, catalog version, output, input or mode changes reject old progress.
20
+ Callable/environment changes require the host to change catalog_version.
21
+
22
+ Use func_seq.Control, observers, pace, retry, restart and checkpoint directly.
23
+ Cooperative pause occurs at boundaries; cancellation propagates through arun.
24
+ Cancellation or failure may follow an effect: an uncertain action requires explicit
25
+ retry=True or restart=True. Completed steps do not replay on ordinary resume.
26
+ No new checkpoint format, executor, signal hook or exactly-once guarantee is added.
@@ -0,0 +1,115 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+
8
+ from func_seq import Control, DefinitionMismatchError, RecoveryRequiredError, StepFailedError
9
+ from hidden_moves import Moves
10
+ from hidden_moves.adapters import CapabilityCatalog
11
+ from moveset import Input, Pipeline, Ref, Step
12
+ from moveset.sequence import compile_script
13
+
14
+
15
+ def bind(function, *, value=None, version="1"):
16
+ moves = Moves()
17
+ moves.learn(function, name="operation")
18
+ return Pipeline("sequence", version="1", steps=(
19
+ Step("first", "operation", {"value": Input("value")}),
20
+ Step("second", "operation", {"value": Ref("first") if value is None else value}),
21
+ ), outputs={"value": Ref("second")}).bind(CapabilityCatalog(moves, ["operation"]), catalog_version=version)
22
+
23
+
24
+ class LifecycleTests(unittest.TestCase):
25
+ def test_fresh_process_resume_and_identity(self):
26
+ with tempfile.TemporaryDirectory() as directory:
27
+ checkpoint, receipt = str(Path(directory) / "checkpoint.json"), str(Path(directory) / "receipt.txt")
28
+ def run(mode, version="v1"):
29
+ return subprocess.run([sys.executable, "-I", str(Path(__file__).with_name("worker.py")), checkpoint, receipt, mode, version], capture_output=True, text=True)
30
+ first = run("pause")
31
+ self.assertEqual(first.returncode, 0, first.stderr)
32
+ self.assertIn('"paused"', first.stdout)
33
+ for mode, version in (("input", "v1"), ("run", "v2")):
34
+ mismatch = run(mode, version)
35
+ self.assertNotEqual(mismatch.returncode, 0)
36
+ self.assertIn("DefinitionMismatchError", mismatch.stderr)
37
+ resumed = run("run")
38
+ self.assertEqual(resumed.returncode, 0, resumed.stderr)
39
+ self.assertEqual(Path(receipt).read_text().splitlines(), ["hello", "hello"])
40
+ self.assertEqual(run("run").returncode, 0)
41
+ self.assertEqual(Path(receipt).read_text().splitlines(), ["hello", "hello"])
42
+
43
+ def test_mapping_mismatch_pause_retry_restart_and_outputs(self):
44
+ calls = []
45
+
46
+ def operation(value: str) -> str:
47
+ calls.append(value)
48
+ return value
49
+
50
+ bound = bind(operation)
51
+ with tempfile.TemporaryDirectory() as directory:
52
+ checkpoint = Path(directory) / "checkpoint.json"
53
+ control = Control()
54
+ def observer(event):
55
+ if event.kind == "finished":
56
+ control.request_pause()
57
+ first = compile_script(bound, {"value": "hi"}).run(checkpoint=checkpoint, control=control, observer=observer)
58
+ self.assertEqual(first.status, "paused")
59
+ with self.assertRaises(DefinitionMismatchError):
60
+ compile_script(bind(operation, value="different"), {"value": "hi"}).run(checkpoint=checkpoint)
61
+ result = compile_script(bound, {"value": "hi"}).run(checkpoint=checkpoint)
62
+ self.assertEqual(bound.finish({"value": "hi"}, result.results).outputs, {"value": "hi"})
63
+ self.assertEqual(calls, ["hi", "hi"])
64
+ compile_script(bound, {"value": "hi"}).run(checkpoint=checkpoint, restart=True)
65
+ self.assertEqual(len(calls), 4)
66
+
67
+ def test_failed_effect_requires_explicit_retry(self):
68
+ calls = []
69
+
70
+ def effect(value: str) -> str:
71
+ calls.append(value)
72
+ if len(calls) == 1:
73
+ raise RuntimeError("after effect")
74
+ return value
75
+
76
+ script = compile_script(bind(effect), {"value": "x"})
77
+ with self.assertRaises(StepFailedError):
78
+ script.run()
79
+ with self.assertRaises(RecoveryRequiredError):
80
+ script.run()
81
+ self.assertEqual(len(calls), 1)
82
+ self.assertEqual(script.run(retry=True).status, "completed")
83
+ self.assertEqual(len(calls), 3)
84
+
85
+
86
+ class AsyncLifecycleTests(unittest.IsolatedAsyncioTestCase):
87
+ async def test_mixed_async_and_cancellation_uncertainty(self):
88
+ calls = []
89
+
90
+ def first(value: str) -> str:
91
+ calls.append("sync")
92
+ return value
93
+
94
+ async def second(value: str) -> str:
95
+ await asyncio.sleep(0)
96
+ calls.append("async")
97
+ return value.upper()
98
+
99
+ moves = Moves()
100
+ moves.learn(first)
101
+ moves.learn(second)
102
+ bound = Pipeline("mixed", version="1", steps=(Step("a", "first", {"value": "hello"}),
103
+ Step("b", "second", {"value": Ref("a")})), outputs={"value": Ref("b")}).bind(CapabilityCatalog(moves, ["first", "second"]), catalog_version="1")
104
+ result = await compile_script(bound, {}, asynchronous=True).arun()
105
+ self.assertEqual(calls, ["sync", "async"])
106
+ self.assertEqual(result.results["b"], "HELLO")
107
+
108
+ async def cancel(value: str) -> str:
109
+ raise asyncio.CancelledError
110
+
111
+ script = compile_script(bind(cancel), {"value": "x"}, asynchronous=True)
112
+ with self.assertRaises(asyncio.CancelledError):
113
+ await script.arun()
114
+ with self.assertRaises(RecoveryRequiredError):
115
+ await script.arun()
@@ -0,0 +1,33 @@
1
+ """Synthetic effects for actual independent-process checkpoint tests."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ from hidden_moves import Moves
8
+ from hidden_moves.adapters import CapabilityCatalog
9
+ from moveset import Input, Pipeline, Ref, Step
10
+ from moveset.sequence import compile_script
11
+
12
+
13
+ def effect(value: str, receipt: str) -> str:
14
+ with Path(receipt).open("a", encoding="utf-8") as output:
15
+ output.write(value + "\n")
16
+ return value
17
+
18
+
19
+ def main():
20
+ checkpoint, receipt, mode, catalog_version = sys.argv[1:]
21
+ moves = Moves()
22
+ moves.learn(effect)
23
+ bound = Pipeline("processes", version="1", steps=(
24
+ Step("first", "effect", {"value": Input("text"), "receipt": receipt}),
25
+ Step("second", "effect", {"value": Ref("first"), "receipt": receipt}),
26
+ ), outputs={"text": Ref("second")}).bind(CapabilityCatalog(moves, ["effect"]), catalog_version=catalog_version)
27
+ script = compile_script(bound, {"text": "changed" if mode == "input" else "hello"})
28
+ result = script.run(checkpoint=checkpoint, pace="function" if mode == "pause" else "auto")
29
+ print(json.dumps({"status": result.status, "completed": result.completed}))
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "moveset"
3
+ version = "0.1.0"
4
+ license = "MIT"
5
+ license-files = ["LICENSE"]
6
+ description = "Explicit linear composition of selected Python capabilities."
7
+ readme = "README.md"
8
+ requires-python = ">=3.11"
9
+ dependencies = ["hidden-moves>=0.1.0"]
10
+
11
+ [[project.authors]]
12
+ name = "ellainng"
13
+
14
+ [project.optional-dependencies]
15
+ sequence = ["func-seq>=0.1.0 ; python_version >= '3.14'"]
16
+
17
+ [project.urls]
18
+ Source = "https://github.com/wispsy/moveset"
19
+ Issues = "https://github.com/wispsy/moveset/issues"
20
+ Project = "https://github.com/orgs/wispsy/projects/23"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.19,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv.build-backend]
27
+ source-include = [
28
+ "LICENSE",
29
+ "tests/*.py",
30
+ "scripts/*.py",
31
+ "docs/*.md",
32
+ "lifecycle_tests/*.py",
33
+ ]
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "moveset"
3
+ version = "0.1.0"
4
+ license = "MIT"
5
+ license-files = ["LICENSE"]
6
+ description = "Explicit linear composition of selected Python capabilities."
7
+ readme = "README.md"
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "ellainng" }]
10
+ dependencies = ["hidden-moves>=0.1.0"]
11
+
12
+ [project.optional-dependencies]
13
+ sequence = ["func-seq>=0.1.0 ; python_version >= '3.14'"]
14
+
15
+ [project.urls]
16
+ Source = "https://github.com/wispsy/moveset"
17
+ Issues = "https://github.com/wispsy/moveset/issues"
18
+ Project = "https://github.com/orgs/wispsy/projects/23"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.19,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.build-backend]
25
+ source-include = ["LICENSE", "tests/*.py", "scripts/*.py", "docs/*.md", "lifecycle_tests/*.py"]
@@ -0,0 +1,42 @@
1
+ """Install both artifacts outside the checkout and run public contract tests."""
2
+
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import zipfile
8
+ from email.parser import BytesParser
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def main():
14
+ directory = Path(sys.argv[1]).resolve()
15
+ artifacts = sorted([*directory.glob("*.whl"), *directory.glob("*.tar.gz")])
16
+ assert len(artifacts) == 2
17
+ wheel, = directory.glob("*.whl")
18
+ with zipfile.ZipFile(wheel) as archive:
19
+ assert "moveset/py.typed" in archive.namelist()
20
+ metadata, = [name for name in archive.namelist() if name.endswith("/METADATA")]
21
+ message = BytesParser().parsebytes(archive.read(metadata))
22
+ assert message["Name"] == "moveset"
23
+ assert message["Requires-Python"] == ">=3.11"
24
+ assert all("func-seq" not in item or "extra ==" in item for item in message.get_all("Requires-Dist", []))
25
+ for artifact in artifacts:
26
+ with tempfile.TemporaryDirectory() as temporary:
27
+ outside = Path(temporary)
28
+ environment = outside / "env"
29
+ subprocess.run(["uv", "venv", str(environment), "--python", sys.executable], check=True)
30
+ python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
31
+ subprocess.run(["uv", "pip", "install", "--python", str(python), str(artifact)], check=True, cwd=outside)
32
+ subprocess.run([str(python), "-I", "-c", "import importlib.util, moveset; assert importlib.util.find_spec('func_seq') is None"], check=True, cwd=outside)
33
+ subprocess.run([str(python), "-I", "-m", "unittest", "discover", "-s", str(ROOT / "tests"), "-v"], check=True, cwd=outside)
34
+ lifecycle = ROOT / "lifecycle_tests"
35
+ if sys.version_info >= (3, 14) and lifecycle.exists():
36
+ subprocess.run(["uv", "pip", "install", "--python", str(python), f"{artifact}[sequence]"], check=True, cwd=outside)
37
+ subprocess.run([str(python), "-I", "-m", "unittest", "discover", "-s", str(lifecycle), "-v"], check=True, cwd=outside)
38
+ subprocess.run(["uv", "pip", "check", "--python", str(python)], check=True, cwd=outside)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,5 @@
1
+ """Immutable linear pipelines over explicitly selected capability catalogs."""
2
+
3
+ from .core import BoundPipeline, DefinitionError, Input, Pipeline, PipelineError, Ref, Result, Step, StepError
4
+
5
+ __all__ = ["BoundPipeline", "DefinitionError", "Input", "Pipeline", "PipelineError", "Ref", "Result", "Step", "StepError"]
@@ -0,0 +1,121 @@
1
+ """Fallback JSON validation when hidden_moves does not export validate_json_value."""
2
+ from __future__ import annotations
3
+ import math
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+ from hidden_moves.adapters.errors import CapabilityExposureError
7
+
8
+ _KEYWORDS = frozenset({
9
+ "$schema", "title", "description", "default", "examples", "type", "enum",
10
+ "anyOf", "properties", "required", "additionalProperties", "items",
11
+ })
12
+ _TYPES = frozenset({"null", "boolean", "integer", "number", "string", "array", "object"})
13
+
14
+ class ValueValidationError(ValueError):
15
+ """A finite JSON value does not match the declared subset."""
16
+
17
+ def check_schema(schema: Mapping[str, Any]) -> None:
18
+ if not isinstance(schema, Mapping):
19
+ raise CapabilityExposureError("Schema nodes must be objects.")
20
+ unknown = set(schema) - _KEYWORDS
21
+ if unknown:
22
+ raise CapabilityExposureError(f"Unsupported schema keywords: {", ".join(sorted(unknown))}.")
23
+ if "type" in schema:
24
+ types = schema["type"]
25
+ if isinstance(types, str):
26
+ types = (types,)
27
+ if not isinstance(types, (list, tuple)) or not types or any(
28
+ not isinstance(item, str) or item not in _TYPES for item in types
29
+ ):
30
+ raise CapabilityExposureError("Schema type must be a supported JSON type or nonempty type list.")
31
+ if "enum" in schema and (not isinstance(schema["enum"], (list, tuple)) or not schema["enum"]):
32
+ raise CapabilityExposureError("Schema enum must be a nonempty array.")
33
+ if "anyOf" in schema:
34
+ branches = schema["anyOf"]
35
+ if not isinstance(branches, (list, tuple)) or not branches:
36
+ raise CapabilityExposureError("Schema anyOf must be a nonempty array.")
37
+ for branch in branches:
38
+ check_schema(branch)
39
+ properties = schema.get("properties", {})
40
+ if not isinstance(properties, Mapping):
41
+ raise CapabilityExposureError("Schema properties must be an object.")
42
+ for child in properties.values():
43
+ check_schema(child)
44
+ if "required" in schema:
45
+ required = schema["required"]
46
+ if not isinstance(required, (list, tuple)) or any(not isinstance(item, str) for item in required):
47
+ raise CapabilityExposureError("Schema required must be an array of property names.")
48
+ if len(required) != len(set(required)):
49
+ raise CapabilityExposureError("Schema required names must be unique.")
50
+ additional = schema.get("additionalProperties", True)
51
+ if not isinstance(additional, bool):
52
+ check_schema(additional)
53
+ if additional is False and any(name not in properties for name in schema.get("required", ())):
54
+ raise CapabilityExposureError("A closed schema requires a property it does not declare.")
55
+ if "items" in schema:
56
+ check_schema(schema["items"])
57
+
58
+ def _matches(value: Any, kind: str) -> bool:
59
+ if kind == "null":
60
+ return value is None
61
+ if kind == "boolean":
62
+ return isinstance(value, bool)
63
+ if kind == "integer":
64
+ return type(value) is int or (type(value) is float and math.isfinite(value) and value.is_integer())
65
+ if kind == "number":
66
+ return type(value) is int or (type(value) is float and math.isfinite(value))
67
+ return isinstance(value, {"string": str, "array": list, "object": dict}[kind])
68
+
69
+ def _equal(first: Any, second: Any) -> bool:
70
+ if isinstance(first, bool) != isinstance(second, bool):
71
+ return False
72
+ if isinstance(first, Mapping) and isinstance(second, Mapping):
73
+ return first.keys() == second.keys() and all(_equal(first[key], second[key]) for key in first)
74
+ if isinstance(first, (tuple, list)) and isinstance(second, (tuple, list)):
75
+ return len(first) == len(second) and all(_equal(a, b) for a, b in zip(first, second))
76
+ return first == second
77
+
78
+ def validate_value(schema: Mapping[str, Any], value: Any, path: str = "$arguments") -> None:
79
+ if "type" in schema:
80
+ types = schema["type"]
81
+ types = (types,) if isinstance(types, str) else types
82
+ if not any(_matches(value, kind) for kind in types):
83
+ raise ValueValidationError(f"{path}: expected {" or ".join(types)}.")
84
+ if "enum" in schema and not any(_equal(value, item) for item in schema["enum"]):
85
+ raise ValueValidationError(f"{path}: value is outside the declared enum.")
86
+ if "anyOf" in schema:
87
+ for branch in schema["anyOf"]:
88
+ try:
89
+ validate_value(branch, value, path)
90
+ break
91
+ except ValueValidationError:
92
+ pass
93
+ else:
94
+ raise ValueValidationError(f"{path}: value does not match any declared type.")
95
+ if isinstance(value, dict):
96
+ properties = schema.get("properties", {})
97
+ for name in schema.get("required", ()):
98
+ if name not in value:
99
+ raise ValueValidationError(f"{path}.{name}: required argument is missing.")
100
+ additional = schema.get("additionalProperties", True)
101
+ for name, item in value.items():
102
+ if name in properties:
103
+ validate_value(properties[name], item, f"{path}.{name}")
104
+ elif additional is False:
105
+ raise ValueValidationError(f"{path}.{name}: unexpected argument.")
106
+ elif isinstance(additional, Mapping):
107
+ validate_value(additional, item, f"{path}.{name}")
108
+ if isinstance(value, list) and "items" in schema:
109
+ for index, item in enumerate(value):
110
+ validate_value(schema["items"], item, f"{path}[{index}]")
111
+
112
+ def fallback_validate_json_value(schema: Mapping[str, Any], value: Any, path: str = "$value") -> Any:
113
+ from hidden_moves import MoveSchemaError
114
+ from hidden_moves.core.schema import json_value
115
+ check_schema(schema)
116
+ try:
117
+ copied = json_value(value)
118
+ except (MoveSchemaError, RecursionError) as error:
119
+ raise ValueValidationError(f"{path}: {error}") from error
120
+ validate_value(schema, copied, path)
121
+ return copied
@@ -0,0 +1,380 @@
1
+ """Explicit data mappings; capability and lifecycle behavior belongs to their owners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import inspect
8
+ import json
9
+ import math
10
+ import re
11
+ from typing import Any
12
+
13
+ from hidden_moves.adapters import CapabilityCatalog
14
+ try:
15
+ from hidden_moves.adapters import validate_json_value
16
+ except ImportError:
17
+ from ._validation import fallback_validate_json_value as validate_json_value
18
+
19
+ MAX_BYTES = 1_048_576
20
+ MAX_DEPTH = 32
21
+ MAX_STEPS = 256
22
+
23
+
24
+ class PipelineError(ValueError):
25
+ """A definition or value cannot be used by this pipeline."""
26
+
27
+
28
+ class DefinitionError(PipelineError):
29
+ """A passive definition is invalid."""
30
+
31
+
32
+ class StepError(PipelineError):
33
+ """A selected operation or one of its mappings failed."""
34
+
35
+ def __init__(self, step: Step, message: str):
36
+ self.step_id = step.id
37
+ self.capability = step.capability
38
+ super().__init__(f"step {step.id!r} ({step.capability}): {message}")
39
+
40
+
41
+ def identifier(value: str) -> str:
42
+ if type(value) is not str or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value):
43
+ raise DefinitionError("Identifiers require 1–128 ASCII letters, digits, dots, underscores or hyphens")
44
+ return value
45
+
46
+
47
+ def canonical(value: Any) -> bytes:
48
+ try:
49
+ data = json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True,
50
+ separators=(",", ":")).encode("utf-8")
51
+ except (TypeError, ValueError, UnicodeError, RecursionError) as error:
52
+ raise PipelineError("Expected finite UTF-8 JSON") from error
53
+ if len(data) > MAX_BYTES:
54
+ raise PipelineError(f"JSON exceeds {MAX_BYTES} bytes")
55
+ return data
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Input:
60
+ name: str
61
+ path: tuple[str | int, ...] = ()
62
+
63
+ def __post_init__(self):
64
+ identifier(self.name)
65
+ _path(self.path)
66
+
67
+ def __getitem__(self, part: str | int) -> Input:
68
+ return Input(self.name, (*self.path, part))
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Ref:
73
+ step: str
74
+ path: tuple[str | int, ...] = ()
75
+
76
+ def __post_init__(self):
77
+ identifier(self.step)
78
+ _path(self.path)
79
+
80
+ def __getitem__(self, part: str | int) -> Ref:
81
+ return Ref(self.step, (*self.path, part))
82
+
83
+
84
+ def _path(path):
85
+ if type(path) is not tuple or len(path) > MAX_DEPTH or any(
86
+ type(part) not in (str, int) or (type(part) is int and part < 0) for part in path
87
+ ):
88
+ raise DefinitionError("Paths must be bounded tuples of string keys or nonnegative indexes")
89
+ canonical(list(path))
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class _Object:
94
+ items: tuple
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class _Array:
99
+ items: tuple
100
+
101
+
102
+ def freeze(value, depth=0, *, expressions=True):
103
+ if depth > MAX_DEPTH:
104
+ raise DefinitionError(f"Values exceed depth {MAX_DEPTH}")
105
+ if expressions and isinstance(value, (Input, Ref)):
106
+ return value
107
+ if type(value) is dict:
108
+ if any(type(key) is not str for key in value):
109
+ raise DefinitionError("Object keys must be strings")
110
+ return _Object(tuple((key, freeze(item, depth + 1, expressions=expressions)) for key, item in value.items()))
111
+ if type(value) is list:
112
+ return _Array(tuple(freeze(item, depth + 1, expressions=expressions) for item in value))
113
+ if value is None or type(value) in (str, bool, int) or (type(value) is float and math.isfinite(value)):
114
+ canonical(value)
115
+ return value
116
+ raise DefinitionError("Values must be finite JSON or explicit Input/Ref expressions")
117
+
118
+
119
+ def thaw(value):
120
+ if isinstance(value, _Object):
121
+ return {key: thaw(item) for key, item in value.items}
122
+ if isinstance(value, _Array):
123
+ return [thaw(item) for item in value.items]
124
+ return value
125
+
126
+
127
+ def copy_json(value):
128
+ copied = thaw(freeze(value, expressions=False))
129
+ canonical(copied)
130
+ return copied
131
+
132
+
133
+ def expression(value):
134
+ if isinstance(value, Input):
135
+ return {"input": {"name": value.name, "path": list(value.path)}}
136
+ if isinstance(value, Ref):
137
+ return {"ref": {"step": value.step, "path": list(value.path)}}
138
+ if isinstance(value, _Object):
139
+ return {"object": {key: expression(item) for key, item in value.items}}
140
+ if isinstance(value, _Array):
141
+ return {"array": [expression(item) for item in value.items]}
142
+ return {"value": value}
143
+
144
+
145
+ def references(value):
146
+ if isinstance(value, (Ref, Input)):
147
+ yield value
148
+ elif isinstance(value, (_Object, _Array)):
149
+ for item in (item for _, item in value.items) if isinstance(value, _Object) else value.items:
150
+ yield from references(item)
151
+
152
+
153
+ def resolve(value, inputs, results):
154
+ if isinstance(value, (Input, Ref)):
155
+ selected = inputs if isinstance(value, Input) else results
156
+ key = value.name if isinstance(value, Input) else value.step
157
+ try:
158
+ selected = selected[key]
159
+ for part in value.path:
160
+ if (type(part) is str and type(selected) is dict) or (type(part) is int and type(selected) is list):
161
+ selected = selected[part]
162
+ else:
163
+ raise KeyError(part)
164
+ except (KeyError, IndexError) as error:
165
+ raise PipelineError(f"Cannot resolve {expression(value)}") from error
166
+ return copy_json(selected)
167
+ if isinstance(value, _Object):
168
+ return {key: resolve(item, inputs, results) for key, item in value.items}
169
+ if isinstance(value, _Array):
170
+ return [resolve(item, inputs, results) for item in value.items]
171
+ return value
172
+
173
+
174
+ @dataclass(frozen=True, init=False)
175
+ class Step:
176
+ id: str
177
+ capability: str
178
+ _arguments: _Object
179
+
180
+ def __init__(self, id: str, capability: str, arguments: dict):
181
+ identifier(id)
182
+ if type(capability) is not str or not capability or len(capability) > 512:
183
+ raise DefinitionError("A step requires a bounded capability name")
184
+ if type(arguments) is not dict:
185
+ raise DefinitionError("Step arguments must be an object")
186
+ object.__setattr__(self, "id", id)
187
+ object.__setattr__(self, "capability", capability)
188
+ object.__setattr__(self, "_arguments", freeze(arguments))
189
+ canonical(expression(self._arguments))
190
+
191
+ @property
192
+ def arguments(self):
193
+ return thaw(self._arguments)
194
+
195
+
196
+ @dataclass(frozen=True, init=False)
197
+ class Pipeline:
198
+ name: str
199
+ version: str
200
+ steps: tuple[Step, ...]
201
+ _outputs: _Object
202
+
203
+ def __init__(self, name: str, *, version: str, steps: tuple[Step, ...], outputs: dict):
204
+ identifier(name)
205
+ identifier(version)
206
+ if type(steps) is not tuple or not 1 <= len(steps) <= MAX_STEPS or any(type(step) is not Step for step in steps):
207
+ raise DefinitionError(f"A pipeline requires a tuple of 1–{MAX_STEPS} Steps")
208
+ previous = set()
209
+ for step in steps:
210
+ if step.id in previous:
211
+ raise DefinitionError(f"Duplicate step {step.id!r}")
212
+ for ref in references(step._arguments):
213
+ if isinstance(ref, Ref) and ref.step not in previous:
214
+ raise DefinitionError(f"step {step.id!r}: reference must name an earlier step: {ref.step!r}")
215
+ previous.add(step.id)
216
+ if type(outputs) is not dict:
217
+ raise DefinitionError("Outputs must be an explicit object")
218
+ frozen = freeze(outputs)
219
+ if any(isinstance(ref, Ref) and ref.step not in previous for ref in references(frozen)):
220
+ raise DefinitionError("Output reference names an unknown step")
221
+ for key, value in (("name", name), ("version", version), ("steps", steps), ("_outputs", frozen)):
222
+ object.__setattr__(self, key, value)
223
+ canonical(self.manifest)
224
+
225
+ @property
226
+ def outputs(self):
227
+ return thaw(self._outputs)
228
+
229
+ @property
230
+ def manifest(self):
231
+ return {"schema": 1, "name": self.name, "version": self.version,
232
+ "steps": [{"id": step.id, "capability": step.capability,
233
+ "arguments": expression(step._arguments)} for step in self.steps],
234
+ "outputs": expression(self._outputs)}
235
+
236
+ def bind(self, catalog: CapabilityCatalog, *, catalog_version: str) -> BoundPipeline:
237
+ return BoundPipeline(self, catalog, catalog_version)
238
+
239
+
240
+ def _partial(schema, value, inputs=None):
241
+ """Validate known portions; unknown producer values are checked at invocation."""
242
+ if isinstance(value, Ref):
243
+ return
244
+ if isinstance(value, Input):
245
+ if inputs is not None:
246
+ validate_json_value(schema, resolve(value, inputs, {}))
247
+ return
248
+ if "anyOf" in schema:
249
+ for branch in schema["anyOf"]:
250
+ try:
251
+ _partial(branch, value, inputs)
252
+ break
253
+ except (ValueError, TypeError):
254
+ pass
255
+ else:
256
+ raise DefinitionError("Mapping does not match any declared type")
257
+ if isinstance(value, _Object):
258
+ values = dict(value.items)
259
+ if "type" in schema:
260
+ validate_json_value({"type": schema["type"]}, {})
261
+ if any(name not in values for name in schema.get("required", ())):
262
+ raise DefinitionError("A required argument is missing")
263
+ properties = schema.get("properties", {})
264
+ additional = schema.get("additionalProperties", True)
265
+ for key, item in value.items:
266
+ if key not in properties and additional is False:
267
+ raise DefinitionError(f"Unexpected argument {key!r}")
268
+ _partial(properties.get(key, additional if isinstance(additional, dict) else {}), item, inputs)
269
+ elif isinstance(value, _Array):
270
+ if "type" in schema:
271
+ validate_json_value({"type": schema["type"]}, [])
272
+ for item in value.items:
273
+ _partial(schema.get("items", {}), item, inputs)
274
+ else:
275
+ validate_json_value(schema, value)
276
+ if not tuple(references(value)):
277
+ validate_json_value(schema, thaw(value))
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class Result:
282
+ """A run's caller-owned JSON snapshots."""
283
+
284
+ results: dict
285
+ outputs: dict
286
+
287
+
288
+ @dataclass(frozen=True, init=False)
289
+ class BoundPipeline:
290
+ definition: Pipeline
291
+ catalog: CapabilityCatalog
292
+ catalog_version: str
293
+
294
+ def __init__(self, definition, catalog, catalog_version):
295
+ if not isinstance(catalog, CapabilityCatalog):
296
+ raise DefinitionError("Bind an explicitly selected CapabilityCatalog")
297
+ identifier(catalog_version)
298
+ for step in definition.steps:
299
+ try:
300
+ schema = _schema_json(catalog.describe(step.capability).input_schema)
301
+ _partial(schema, step._arguments)
302
+ except Exception as error:
303
+ raise StepError(step, str(error)) from error
304
+ object.__setattr__(self, "definition", definition)
305
+ object.__setattr__(self, "catalog", catalog)
306
+ object.__setattr__(self, "catalog_version", catalog_version)
307
+ canonical(self.manifest)
308
+
309
+ @property
310
+ def manifest(self):
311
+ names = sorted({step.capability for step in self.definition.steps})
312
+ return {"definition": self.definition.manifest, "catalog_version": self.catalog_version,
313
+ "capabilities": [{"name": name, "input": _schema_json(self.catalog.describe(name).input_schema),
314
+ "output": _schema_json(self.catalog.describe(name).output_schema),
315
+ "async": self.catalog.describe(name).is_async} for name in names]}
316
+
317
+ @property
318
+ def fingerprint(self):
319
+ return hashlib.sha256(canonical(self.manifest)).hexdigest()
320
+
321
+ def prepare(self, inputs: dict):
322
+ if type(inputs) is not dict:
323
+ raise PipelineError("Run inputs must be a JSON object")
324
+ inputs = copy_json(inputs)
325
+ for step in self.definition.steps:
326
+ try:
327
+ _partial(_schema_json(self.catalog.describe(step.capability).input_schema), step._arguments, inputs)
328
+ except Exception as error:
329
+ raise StepError(step, str(error)) from error
330
+ for ref in references(self.definition._outputs):
331
+ if isinstance(ref, Input):
332
+ resolve(ref, inputs, {})
333
+ return inputs
334
+
335
+ def finish(self, inputs, results):
336
+ return Result(copy_json(results), resolve(self.definition._outputs, inputs, results))
337
+
338
+ def _invoke(self, step, inputs, results):
339
+ return self.catalog.invoke(step.capability, resolve(step._arguments, inputs, results))
340
+
341
+ def run(self, inputs: dict) -> Result:
342
+ inputs = self.prepare(inputs)
343
+ if any(self.catalog.describe(step.capability).is_async for step in self.definition.steps):
344
+ raise DefinitionError("Pipeline contains async capabilities; use arun()")
345
+ results = {}
346
+ for step in self.definition.steps:
347
+ try:
348
+ result = self._invoke(step, inputs, results)
349
+ if inspect.isawaitable(result):
350
+ if inspect.iscoroutine(result):
351
+ result.close()
352
+ raise DefinitionError("Capability returned an awaitable; use arun()")
353
+ results[step.id] = copy_json(self.catalog.serialize_result(step.capability, result))
354
+ except Exception as error:
355
+ raise StepError(step, str(error)) from error
356
+ return self.finish(inputs, results)
357
+
358
+ async def arun(self, inputs: dict) -> Result:
359
+ inputs = self.prepare(inputs)
360
+ results = {}
361
+ for step in self.definition.steps:
362
+ try:
363
+ result = self._invoke(step, inputs, results)
364
+ if inspect.isawaitable(result):
365
+ result = await result
366
+ results[step.id] = copy_json(self.catalog.serialize_result(step.capability, result))
367
+ except Exception as error:
368
+ raise StepError(step, str(error)) from error
369
+ return self.finish(inputs, results)
370
+
371
+
372
+ def _schema_json(schema):
373
+ """Catalog metadata uses frozen mappings/tuples; normalize without changing values."""
374
+ from collections.abc import Mapping
375
+
376
+ if isinstance(schema, Mapping):
377
+ return {key: _schema_json(item) for key, item in schema.items()}
378
+ if isinstance(schema, (list, tuple)):
379
+ return [_schema_json(item) for item in schema]
380
+ return schema
File without changes
@@ -0,0 +1,70 @@
1
+ """Optional compilation into func_seq's public Script and lifecycle contract."""
2
+
3
+ import sys
4
+
5
+ if sys.version_info < (3, 14):
6
+ raise ImportError("The moveset sequence adapter requires Python 3.14 or newer")
7
+
8
+ import hashlib
9
+ import inspect
10
+
11
+ from func_seq import Ref as SequenceRef, Script
12
+
13
+ from .core import BoundPipeline, DefinitionError, Input, Ref, StepError, _Array, _Object, canonical, copy_json, resolve
14
+
15
+
16
+ def _mapping(value, inputs):
17
+ if isinstance(value, Input):
18
+ return resolve(value, inputs, {})
19
+ if isinstance(value, Ref):
20
+ return SequenceRef(value.step, value.path)
21
+ if isinstance(value, _Object):
22
+ return {key: _mapping(item, inputs) for key, item in value.items}
23
+ if isinstance(value, _Array):
24
+ return [_mapping(item, inputs) for item in value.items]
25
+ return value
26
+
27
+
28
+ def _wrapper(bound, step, asynchronous):
29
+ def invoke(arguments):
30
+ try:
31
+ result = bound.catalog.invoke(step.capability, arguments)
32
+ if inspect.isawaitable(result):
33
+ if inspect.iscoroutine(result):
34
+ result.close()
35
+ raise DefinitionError("Capability returned an awaitable; compile with asynchronous=True")
36
+ return copy_json(bound.catalog.serialize_result(step.capability, result))
37
+ except Exception as error:
38
+ raise StepError(step, str(error)) from error
39
+
40
+ async def ainvoke(arguments):
41
+ try:
42
+ result = bound.catalog.invoke(step.capability, arguments)
43
+ if inspect.isawaitable(result):
44
+ result = await result
45
+ return copy_json(bound.catalog.serialize_result(step.capability, result))
46
+ except Exception as error:
47
+ raise StepError(step, str(error)) from error
48
+
49
+ return ainvoke if asynchronous else invoke
50
+
51
+
52
+ def compile_script(bound: BoundPipeline, inputs: dict, *, asynchronous: bool = False) -> Script:
53
+ """Compile validated mappings; return func_seq's executor without wrapping state.
54
+
55
+ Use asynchronous=True with Script.arun for mixed/awaitable capabilities. The
56
+ execution mode is part of compatibility identity. Control, pace, observers,
57
+ checkpoint, retry and restart are passed directly to Script.run/arun.
58
+ """
59
+ if type(asynchronous) is not bool:
60
+ raise DefinitionError("asynchronous must be a boolean")
61
+ inputs = bound.prepare(inputs)
62
+ if not asynchronous and any(bound.catalog.describe(step.capability).is_async for step in bound.definition.steps):
63
+ raise DefinitionError("Async capabilities require asynchronous=True and Script.arun")
64
+ version = hashlib.sha256(canonical({"pipeline": bound.fingerprint, "inputs": inputs,
65
+ "asynchronous": asynchronous})).hexdigest()
66
+ script = Script(bound.definition.name, version=version)
67
+ for step in bound.definition.steps:
68
+ script.add_function(_wrapper(bound, step, asynchronous),
69
+ _mapping(step._arguments, inputs), step_id=step.id)
70
+ return script
@@ -0,0 +1,170 @@
1
+ import asyncio
2
+ from dataclasses import FrozenInstanceError
3
+ import unittest
4
+
5
+ from hidden_moves import Moves
6
+ from hidden_moves.adapters import CapabilityCatalog
7
+ from moveset import DefinitionError, Input, Pipeline, PipelineError, Ref, Step, StepError
8
+
9
+
10
+ def catalog(**functions):
11
+ moves = Moves()
12
+ for name, function in functions.items():
13
+ moves.learn(function, name=name)
14
+ return CapabilityCatalog(moves, functions)
15
+
16
+
17
+ def normalize(text: str) -> str:
18
+ return " ".join(text.lower().split())
19
+
20
+
21
+ def report(text: str) -> dict[str, str]:
22
+ return {"text": text}
23
+
24
+
25
+ def definition():
26
+ return Pipeline("text", version="1", steps=(
27
+ Step("normalize", "normalize", {"text": Input("text")}),
28
+ Step("report", "report", {"text": Ref("normalize")}),
29
+ ), outputs={"report": Ref("report")})
30
+
31
+
32
+ class PipelineTests(unittest.TestCase):
33
+ def test_reuses_ordinary_functions_and_preserves_scalar_records(self):
34
+ bound = definition().bind(catalog(normalize=normalize, report=report), catalog_version="v1")
35
+ self.assertEqual(normalize(" Hi THERE "), "hi there")
36
+ result = bound.run({"text": " Hi THERE "})
37
+ self.assertEqual(result.results, {"normalize": "hi there", "report": {"text": "hi there"}})
38
+ self.assertEqual(result.outputs, {"report": {"text": "hi there"}})
39
+ result.results["report"]["text"] = "changed"
40
+ self.assertEqual(result.outputs["report"]["text"], "hi there")
41
+ self.assertEqual(bound.run({"text": "x"}).outputs["report"]["text"], "x")
42
+
43
+ def test_definition_is_deeply_immutable(self):
44
+ arguments = {"text": "before"}
45
+ step = Step("first", "normalize", arguments)
46
+ pipeline = Pipeline("copy", version="1", steps=(step,), outputs={"result": Ref("first")})
47
+ arguments["text"] = "after"
48
+ step.arguments["text"] = "also after"
49
+ pipeline.outputs["new"] = 2
50
+ pipeline.manifest["steps"].clear()
51
+ self.assertEqual(step.arguments["text"], "before")
52
+ self.assertEqual(pipeline.outputs, {"result": Ref("first")})
53
+ with self.assertRaises(FrozenInstanceError):
54
+ step.id = "other"
55
+
56
+ def test_static_errors_happen_without_invocation(self):
57
+ calls = []
58
+
59
+ def effect(text: str) -> str:
60
+ calls.append(text)
61
+ return text
62
+
63
+ selected = catalog(effect=effect)
64
+ for arguments in ({}, {"text": 1}, {"text": "x", "extra": Ref("first")}):
65
+ with self.subTest(arguments=arguments), self.assertRaises(PipelineError):
66
+ Pipeline("bad", version="1", steps=(Step("first", "effect", {"text": "x"}),
67
+ Step("second", "effect", arguments)), outputs={}).bind(selected, catalog_version="1")
68
+ bound = Pipeline("inputs", version="1", steps=(Step("first", "effect", {"text": "x"}),
69
+ Step("second", "effect", {"text": Input("missing")})), outputs={}).bind(selected, catalog_version="1")
70
+ with self.assertRaises(StepError):
71
+ bound.run({})
72
+ with self.assertRaises(StepError):
73
+ bound.run({"missing": False})
74
+ self.assertEqual(calls, [])
75
+ with self.assertRaises(StepError):
76
+ definition().bind(selected, catalog_version="1")
77
+
78
+ def test_names_references_and_limits(self):
79
+ for name in ("", "a b", "a" * 129, True):
80
+ with self.assertRaises(DefinitionError):
81
+ Step(name, "normalize", {})
82
+ for path in ((True,), (-1,), ["x"]):
83
+ with self.assertRaises(DefinitionError):
84
+ Ref("first", path)
85
+ with self.assertRaises(DefinitionError):
86
+ Pipeline("bad", version="1", steps=(Step("first", "normalize", {"text": Ref("later")}),), outputs={})
87
+ with self.assertRaises(DefinitionError):
88
+ Pipeline("bad", version="1", steps=(Step("same", "normalize", {}), Step("same", "normalize", {})), outputs={})
89
+ for value in (float("nan"), object(), {1: "bad"}, "\ud800", "x" * 1_048_576):
90
+ with self.subTest(value=type(value)), self.assertRaises(PipelineError):
91
+ Step("bad", "normalize", {"text": value})
92
+
93
+ def test_missing_path_and_invalid_reference_type_never_call_consumer(self):
94
+ calls = []
95
+
96
+ def producer() -> dict[str, list[int]]:
97
+ calls.append("producer")
98
+ return {"items": [3]}
99
+
100
+ def consume(text: str) -> str:
101
+ calls.append("consumer")
102
+ return text
103
+
104
+ for ref in (Ref("first")["missing"], Ref("first")["items"][0], Ref("first")[0]):
105
+ calls.clear()
106
+ bound = Pipeline("paths", version="1", steps=(Step("first", "producer", {}),
107
+ Step("second", "consume", {"text": ref})), outputs={}).bind(catalog(producer=producer, consume=consume), catalog_version="1")
108
+ with self.assertRaises(StepError) as caught:
109
+ bound.run({})
110
+ self.assertEqual(caught.exception.step_id, "second")
111
+ self.assertEqual(calls, ["producer"])
112
+
113
+ def test_nested_mapping_copy_and_input_paths(self):
114
+ def mutate(items: list[dict[str, int]]) -> list[dict[str, int]]:
115
+ items[0]["x"] += 1
116
+ return items
117
+
118
+ inputs = {"payload": {"x": 2}}
119
+ bound = Pipeline("nested", version="1", steps=(Step("one", "mutate", {"items": [Input("payload")]}),
120
+ Step("two", "mutate", {"items": Ref("one")})), outputs={"first": Ref("one")[0]["x"], "second": Ref("two")}).bind(catalog(mutate=mutate), catalog_version="1")
121
+ result = bound.run(inputs)
122
+ self.assertEqual(result.outputs, {"first": 3, "second": [{"x": 4}]})
123
+ self.assertEqual(inputs, {"payload": {"x": 2}})
124
+
125
+ def test_failures_keep_context_and_cause(self):
126
+ def fail() -> str:
127
+ raise RuntimeError("failed")
128
+
129
+ bound = Pipeline("failure", version="1", steps=(Step("one", "fail", {}),), outputs={}).bind(catalog(fail=fail), catalog_version="1")
130
+ with self.assertRaises(StepError) as caught:
131
+ bound.run({})
132
+ self.assertIsInstance(caught.exception.__cause__, RuntimeError)
133
+
134
+ def test_fingerprint_covers_definition_mapping_schemas_and_catalog_version(self):
135
+ selected = catalog(normalize=normalize, report=report)
136
+ bound = definition().bind(selected, catalog_version="v1")
137
+ self.assertEqual(bound.fingerprint, definition().bind(selected, catalog_version="v1").fingerprint)
138
+ self.assertNotEqual(bound.fingerprint, definition().bind(selected, catalog_version="v2").fingerprint)
139
+ bound.manifest.clear()
140
+ self.assertEqual(len(bound.fingerprint), 64)
141
+
142
+
143
+ class AsyncTests(unittest.IsolatedAsyncioTestCase):
144
+ async def test_mixed_order_and_sync_preflight(self):
145
+ calls = []
146
+
147
+ def first(text: str) -> str:
148
+ calls.append("first")
149
+ return text
150
+
151
+ async def second(text: str) -> str:
152
+ await asyncio.sleep(0)
153
+ calls.append("second")
154
+ return text.upper()
155
+
156
+ bound = Pipeline("mixed", version="1", steps=(Step("a", "first", {"text": "hello"}),
157
+ Step("b", "second", {"text": Ref("a")})), outputs={"text": Ref("b")}).bind(catalog(first=first, second=second), catalog_version="1")
158
+ with self.assertRaises(DefinitionError):
159
+ bound.run({})
160
+ self.assertEqual(calls, [])
161
+ self.assertEqual((await bound.arun({})).outputs, {"text": "HELLO"})
162
+ self.assertEqual(calls, ["first", "second"])
163
+
164
+ async def test_cancellation_propagates(self):
165
+ async def stop() -> str:
166
+ raise asyncio.CancelledError
167
+
168
+ bound = Pipeline("cancel", version="1", steps=(Step("one", "stop", {}),), outputs={}).bind(catalog(stop=stop), catalog_version="1")
169
+ with self.assertRaises(asyncio.CancelledError):
170
+ await bound.arun({})