perd-worker 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.
- perd_worker-0.1.0/.gitignore +24 -0
- perd_worker-0.1.0/PKG-INFO +9 -0
- perd_worker-0.1.0/pyproject.toml +26 -0
- perd_worker-0.1.0/src/perd_worker/__init__.py +32 -0
- perd_worker-0.1.0/src/perd_worker/compiler.py +178 -0
- perd_worker-0.1.0/src/perd_worker/registry.py +252 -0
- perd_worker-0.1.0/src/perd_worker/runtime.py +488 -0
- perd_worker-0.1.0/src/perd_worker/serve.py +103 -0
- perd_worker-0.1.0/tests/conftest.py +21 -0
- perd_worker-0.1.0/tests/test_serve_entrypoint.py +90 -0
- perd_worker-0.1.0/tests/test_worker_sdk.py +435 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
node_modules/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.coverage
|
|
7
|
+
.coverage.*
|
|
8
|
+
# Spike codegen: regenerated by conftest.py on every run.
|
|
9
|
+
spikes/**/echo_pb2.py
|
|
10
|
+
spikes/**/echo_pb2.pyi
|
|
11
|
+
# Next.js build output
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
*.tsbuildinfo
|
|
15
|
+
|
|
16
|
+
# Build artifacts
|
|
17
|
+
dist/
|
|
18
|
+
coverage.xml
|
|
19
|
+
|
|
20
|
+
# terraform: state and plugin cache never committed; .terraform.lock.hcl IS.
|
|
21
|
+
**/.terraform/
|
|
22
|
+
*.tfplan
|
|
23
|
+
*.tfstate
|
|
24
|
+
*.tfstate.*
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perd-worker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Author PERD workflows: decorators, contract compiler, WorkerService runtime
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: grpcio-tools>=1.60
|
|
7
|
+
Requires-Dist: grpcio>=1.60
|
|
8
|
+
Requires-Dist: perd-contracts~=0.1.0
|
|
9
|
+
Requires-Dist: protobuf>=6
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "perd-worker"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Author PERD workflows: decorators, contract compiler, WorkerService runtime"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
# Compatible-release pin: the generated perd.v1 modules must match this
|
|
8
|
+
# wheel; release-py.yml enforces one version across all published dists.
|
|
9
|
+
"perd-contracts~=0.1.0",
|
|
10
|
+
"protobuf>=6",
|
|
11
|
+
"grpcio>=1.60",
|
|
12
|
+
# grpcio-tools is a build-time need of workflow images (compile_contract
|
|
13
|
+
# runs protoc when the developer builds), carried as a runtime dep so a
|
|
14
|
+
# worker image is always able to compile the workflow it ships.
|
|
15
|
+
"grpcio-tools>=1.60",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
perd-contracts = { workspace = true }
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/perd_worker"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
r"""perd_worker — the SDK workflow developers build against.
|
|
2
|
+
|
|
3
|
+
Authoring surface:
|
|
4
|
+
|
|
5
|
+
.. code-block:: python
|
|
6
|
+
|
|
7
|
+
from perd_worker import workflow, WorkflowStreamInput, WorkflowStreamOutput
|
|
8
|
+
|
|
9
|
+
Build time: :func:`perd_worker.compiler.compile_contract` projects the
|
|
10
|
+
registered signatures into the workflow's ``FileDescriptorSet``; its SHA-256
|
|
11
|
+
is the contract identity every other component pins.
|
|
12
|
+
|
|
13
|
+
Runtime: :class:`perd_worker.runtime.WorkerServicer` serves the uniform
|
|
14
|
+
``perd.v1.WorkerService`` over gRPC.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .registry import (
|
|
18
|
+
WorkflowDefinitionError,
|
|
19
|
+
WorkflowStreamInput,
|
|
20
|
+
WorkflowStreamOutput,
|
|
21
|
+
workflow,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"workflow",
|
|
28
|
+
"WorkflowStreamInput",
|
|
29
|
+
"WorkflowStreamOutput",
|
|
30
|
+
"WorkflowDefinitionError",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
r"""Build-time projection: registered signatures -> protobuf contract.
|
|
2
|
+
|
|
3
|
+
Runs ONCE, at image build. The output — a serialized ``FileDescriptorSet`` —
|
|
4
|
+
is the workflow's entire public identity:
|
|
5
|
+
|
|
6
|
+
.. math::
|
|
7
|
+
|
|
8
|
+
\mathrm{contract\_hash} = \operatorname{SHA256}\!\left(D\right)
|
|
9
|
+
|
|
10
|
+
where:
|
|
11
|
+
|
|
12
|
+
- :math:`D`: the ``FileDescriptorSet`` bytes embedded in the worker image and returned verbatim by ``WorkerService.Describe``
|
|
13
|
+
- :math:`\mathrm{contract\_hash}`: pinned by the store at publish, by the client at resolve, and re-verified by the orchestrator at registration — one value, three enforcement points
|
|
14
|
+
|
|
15
|
+
Never runs at request time. v1 downloaded protos from GCS and shelled out to
|
|
16
|
+
``protoc`` on first use of every workflow, inside the orchestrator; here the
|
|
17
|
+
runtime path only ever *reads* the embedded bytes.
|
|
18
|
+
|
|
19
|
+
Field rules (all consumed by ``contracts/tools/manifest_from_descriptor``):
|
|
20
|
+
|
|
21
|
+
- every field is proto3 ``optional`` — presence-tracked, so a returned
|
|
22
|
+
:math:`0`, :math:`0.0`, ``""`` or ``False`` is distinguishable from absent,
|
|
23
|
+
and an omitted defaulted parameter is distinguishable from an explicit zero
|
|
24
|
+
- input-stream items are ``item_1..item_N`` numbered from 1
|
|
25
|
+
- per-call parameters keep their Python names, numbered from 20 upward so
|
|
26
|
+
item fields and parameter fields can never collide
|
|
27
|
+
- non-streaming results use the single field ``result``; streaming outputs
|
|
28
|
+
use ``item_1..item_N``
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from .registry import (
|
|
42
|
+
MODE_BI_DI,
|
|
43
|
+
MODE_INPUT_STREAM,
|
|
44
|
+
MODE_OUTPUT_STREAM,
|
|
45
|
+
SCALAR_TYPES,
|
|
46
|
+
RegisteredOp,
|
|
47
|
+
WorkflowRegistry,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: First field number for per-call parameters; items occupy 1..19.
|
|
51
|
+
PARAM_FIELD_BASE = 20
|
|
52
|
+
|
|
53
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CompileError(RuntimeError):
|
|
57
|
+
"""Raised when the registry cannot be projected into a valid contract."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pascal(name: str) -> str:
|
|
61
|
+
return "".join(part[:1].upper() + part[1:] for part in name.split("_") if part)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class CompiledContract:
|
|
66
|
+
"""The build artifact: proto source, descriptor bytes, and identity."""
|
|
67
|
+
|
|
68
|
+
workflow_name: str
|
|
69
|
+
proto_source: str
|
|
70
|
+
descriptor_set: bytes
|
|
71
|
+
contract_hash: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_proto(workflow_name: str, ops: dict[str, RegisteredOp]) -> str:
|
|
75
|
+
"""Render the registry as a .proto source file."""
|
|
76
|
+
if not _NAME_RE.match(workflow_name):
|
|
77
|
+
raise CompileError(
|
|
78
|
+
f"workflow name '{workflow_name}' must be snake_case "
|
|
79
|
+
"([a-z][a-z0-9_]*) — it becomes a protobuf package segment."
|
|
80
|
+
)
|
|
81
|
+
if not ops:
|
|
82
|
+
raise CompileError(
|
|
83
|
+
"No operations registered. Decorate at least one function with "
|
|
84
|
+
"@workflow.unary / .input_stream / .output_stream / .bi_di."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
lines = [
|
|
88
|
+
"// Generated by perd_worker.compiler — DO NOT EDIT.",
|
|
89
|
+
'syntax = "proto3";',
|
|
90
|
+
"",
|
|
91
|
+
f"package perd.workflow.{workflow_name};",
|
|
92
|
+
"",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
rpcs = []
|
|
96
|
+
for op in ops.values():
|
|
97
|
+
rpc = _pascal(op.function_name)
|
|
98
|
+
req, res = f"{rpc}Request", f"{rpc}Response"
|
|
99
|
+
|
|
100
|
+
lines.append(f"message {req} {{")
|
|
101
|
+
for i, t in enumerate(op.input_item_types, start=1):
|
|
102
|
+
lines.append(f" optional {SCALAR_TYPES[t]} item_{i} = {i};")
|
|
103
|
+
if len(op.input_item_types) >= PARAM_FIELD_BASE:
|
|
104
|
+
raise CompileError(
|
|
105
|
+
f"{op.function_name}: at most {PARAM_FIELD_BASE - 1} stream "
|
|
106
|
+
"item fields are supported."
|
|
107
|
+
)
|
|
108
|
+
for j, (name, t, _hd, _d) in enumerate(op.params, start=PARAM_FIELD_BASE):
|
|
109
|
+
if re.fullmatch(r"item_\d+", name):
|
|
110
|
+
raise CompileError(
|
|
111
|
+
f"{op.function_name}: parameter name '{name}' collides "
|
|
112
|
+
"with the reserved stream-item naming (item_N)."
|
|
113
|
+
)
|
|
114
|
+
lines.append(f" optional {SCALAR_TYPES[t]} {name} = {j};")
|
|
115
|
+
lines.append("}")
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
lines.append(f"message {res} {{")
|
|
119
|
+
if op.mode in (MODE_OUTPUT_STREAM, MODE_BI_DI):
|
|
120
|
+
for i, t in enumerate(op.output_item_types, start=1):
|
|
121
|
+
lines.append(f" optional {SCALAR_TYPES[t]} item_{i} = {i};")
|
|
122
|
+
else:
|
|
123
|
+
lines.append(
|
|
124
|
+
f" optional {SCALAR_TYPES[op.output_item_types[0]]} result = 1;"
|
|
125
|
+
)
|
|
126
|
+
lines.append("}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
|
|
129
|
+
in_stream = "stream " if op.mode in (MODE_INPUT_STREAM, MODE_BI_DI) else ""
|
|
130
|
+
out_stream = "stream " if op.mode in (MODE_OUTPUT_STREAM, MODE_BI_DI) else ""
|
|
131
|
+
rpcs.append(f" rpc {rpc}({in_stream}{req}) returns ({out_stream}{res});")
|
|
132
|
+
|
|
133
|
+
lines.append("service Workflow {")
|
|
134
|
+
lines.extend(rpcs)
|
|
135
|
+
lines.append("}")
|
|
136
|
+
return "\n".join(lines) + "\n"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def compile_contract(
|
|
140
|
+
workflow_name: str, registry: WorkflowRegistry
|
|
141
|
+
) -> CompiledContract:
|
|
142
|
+
"""Project the registry into a serialized ``FileDescriptorSet``.
|
|
143
|
+
|
|
144
|
+
Uses ``grpc_tools.protoc`` in a temp dir — acceptable because this is a
|
|
145
|
+
build step, executed exactly once per image, never per request.
|
|
146
|
+
"""
|
|
147
|
+
proto_source = render_proto(workflow_name, registry.ops)
|
|
148
|
+
|
|
149
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
150
|
+
root = Path(tmp)
|
|
151
|
+
(root / "workflow.proto").write_text(proto_source)
|
|
152
|
+
out = root / "descriptor.binpb"
|
|
153
|
+
result = subprocess.run(
|
|
154
|
+
[
|
|
155
|
+
sys.executable,
|
|
156
|
+
"-m",
|
|
157
|
+
"grpc_tools.protoc",
|
|
158
|
+
f"-I{root}",
|
|
159
|
+
f"--descriptor_set_out={out}",
|
|
160
|
+
"--include_imports",
|
|
161
|
+
str(root / "workflow.proto"),
|
|
162
|
+
],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
)
|
|
166
|
+
if result.returncode != 0:
|
|
167
|
+
raise CompileError(
|
|
168
|
+
f"protoc rejected the generated schema:\n{result.stderr}\n"
|
|
169
|
+
f"--- generated source ---\n{proto_source}"
|
|
170
|
+
)
|
|
171
|
+
descriptor_set = out.read_bytes()
|
|
172
|
+
|
|
173
|
+
return CompiledContract(
|
|
174
|
+
workflow_name=workflow_name,
|
|
175
|
+
proto_source=proto_source,
|
|
176
|
+
descriptor_set=descriptor_set,
|
|
177
|
+
contract_hash=hashlib.sha256(descriptor_set).hexdigest(),
|
|
178
|
+
)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
r"""Workflow authoring surface: decorators and stream type markers.
|
|
2
|
+
|
|
3
|
+
A workflow developer writes ordinary Python and marks entry points:
|
|
4
|
+
|
|
5
|
+
.. code-block:: python
|
|
6
|
+
|
|
7
|
+
from perd_worker import workflow, WorkflowStreamInput, WorkflowStreamOutput
|
|
8
|
+
|
|
9
|
+
@workflow.unary
|
|
10
|
+
def add(a: float, b: float) -> float: ...
|
|
11
|
+
|
|
12
|
+
@workflow.bi_di
|
|
13
|
+
async def train(
|
|
14
|
+
inputStream: WorkflowStreamInput[float, float, float],
|
|
15
|
+
batch_size: int = 4,
|
|
16
|
+
) -> WorkflowStreamOutput[int, float]: ...
|
|
17
|
+
|
|
18
|
+
The decorators only *register*; all schema work happens at build time in
|
|
19
|
+
:mod:`perd_worker.compiler`, which projects these signatures into a protobuf
|
|
20
|
+
service and embeds its ``FileDescriptorSet`` in the image. The four mode names
|
|
21
|
+
here are the only spellings that exist anywhere in the system — v1 carried
|
|
22
|
+
three independent normalisation tables plus legacy aliases, which is why they
|
|
23
|
+
drifted.
|
|
24
|
+
|
|
25
|
+
where:
|
|
26
|
+
|
|
27
|
+
- :math:`\text{unary}`: one request message, one response message
|
|
28
|
+
- :math:`\text{input\_stream}`: a stream of item messages plus trailing per-call parameters, one response
|
|
29
|
+
- :math:`\text{output\_stream}`: one request, a stream of responses
|
|
30
|
+
- :math:`\text{bi\_di}`: both, fully interleaved
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import inspect
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Callable
|
|
38
|
+
|
|
39
|
+
MODE_UNARY = "unary"
|
|
40
|
+
MODE_INPUT_STREAM = "input_stream"
|
|
41
|
+
MODE_OUTPUT_STREAM = "output_stream"
|
|
42
|
+
MODE_BI_DI = "bi_di"
|
|
43
|
+
|
|
44
|
+
#: Python annotation -> proto scalar type. The ONLY types a workflow
|
|
45
|
+
#: signature may use. Matches manifest_from_descriptor._PROTO_TYPE_NAMES.
|
|
46
|
+
SCALAR_TYPES: dict[type, str] = {
|
|
47
|
+
float: "double",
|
|
48
|
+
int: "int64",
|
|
49
|
+
str: "string",
|
|
50
|
+
bool: "bool",
|
|
51
|
+
bytes: "bytes",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _StreamMarker:
|
|
56
|
+
"""Base for the generic stream annotations; carries the item types."""
|
|
57
|
+
|
|
58
|
+
item_types: tuple[type, ...] = ()
|
|
59
|
+
|
|
60
|
+
def __class_getitem__(cls, item: type | tuple[type, ...]) -> type[_StreamMarker]:
|
|
61
|
+
types = item if isinstance(item, tuple) else (item,)
|
|
62
|
+
marker = type(cls.__name__, (cls,), {"item_types": tuple(types)})
|
|
63
|
+
return marker
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WorkflowStreamInput(_StreamMarker):
|
|
67
|
+
r"""Annotates the (first) parameter that receives the input stream.
|
|
68
|
+
|
|
69
|
+
``WorkflowStreamInput[float, int]`` declares two-field stream items; the
|
|
70
|
+
generated request message carries them as ``item_1: double, item_2: int64``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WorkflowStreamOutput(_StreamMarker):
|
|
75
|
+
r"""Annotates a streaming return type.
|
|
76
|
+
|
|
77
|
+
``WorkflowStreamOutput[int, float]`` declares two-field output items,
|
|
78
|
+
carried as ``item_1: int64, item_2: double`` on the response message.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WorkflowDefinitionError(TypeError):
|
|
83
|
+
"""Raised at registration time for a signature the SDK cannot serve."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class RegisteredOp:
|
|
88
|
+
"""One decorated function plus everything introspected from it."""
|
|
89
|
+
|
|
90
|
+
function_name: str
|
|
91
|
+
mode: str
|
|
92
|
+
fn: Callable[..., Any]
|
|
93
|
+
#: (name, python type, has_default, default) for per-call parameters.
|
|
94
|
+
params: tuple[tuple[str, type, bool, Any], ...]
|
|
95
|
+
#: item types of the input stream ('' when the mode has none).
|
|
96
|
+
input_item_types: tuple[type, ...]
|
|
97
|
+
#: item types of the output stream / the single result type for
|
|
98
|
+
#: non-streaming outputs (always at least one entry).
|
|
99
|
+
output_item_types: tuple[type, ...]
|
|
100
|
+
docstring: str | None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_scalar(t: Any, where: str) -> type:
|
|
104
|
+
if t not in SCALAR_TYPES:
|
|
105
|
+
allowed = ", ".join(x.__name__ for x in SCALAR_TYPES)
|
|
106
|
+
raise WorkflowDefinitionError(
|
|
107
|
+
f"{where}: type {t!r} is not supported. "
|
|
108
|
+
f"Workflow signatures may only use: {allowed}."
|
|
109
|
+
)
|
|
110
|
+
assert isinstance(t, type) # SCALAR_TYPES membership guarantees this
|
|
111
|
+
return t
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_hints(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
115
|
+
"""Resolve annotations to real objects.
|
|
116
|
+
|
|
117
|
+
``fn.__annotations__`` holds *strings* under ``from __future__ import
|
|
118
|
+
annotations`` (PEP 563), which every modern workflow module uses —
|
|
119
|
+
evaluate them in the function's own namespace.
|
|
120
|
+
"""
|
|
121
|
+
import typing
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return typing.get_type_hints(fn)
|
|
125
|
+
except NameError as exc:
|
|
126
|
+
raise WorkflowDefinitionError(
|
|
127
|
+
f"{fn.__name__}: annotation references an undefined name ({exc})."
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _introspect(fn: Callable[..., Any], mode: str) -> RegisteredOp:
|
|
132
|
+
sig = inspect.signature(fn)
|
|
133
|
+
hints = _resolve_hints(fn)
|
|
134
|
+
params: list[tuple[str, type, bool, Any]] = []
|
|
135
|
+
input_items: tuple[type, ...] = ()
|
|
136
|
+
|
|
137
|
+
parameters = list(sig.parameters.values())
|
|
138
|
+
input_streaming = mode in (MODE_INPUT_STREAM, MODE_BI_DI)
|
|
139
|
+
output_streaming = mode in (MODE_OUTPUT_STREAM, MODE_BI_DI)
|
|
140
|
+
|
|
141
|
+
if input_streaming:
|
|
142
|
+
if not parameters:
|
|
143
|
+
raise WorkflowDefinitionError(
|
|
144
|
+
f"{fn.__name__}: {mode} requires a first WorkflowStreamInput parameter."
|
|
145
|
+
)
|
|
146
|
+
first = parameters[0]
|
|
147
|
+
ann = hints.get(first.name)
|
|
148
|
+
if not (isinstance(ann, type) and issubclass(ann, WorkflowStreamInput)):
|
|
149
|
+
raise WorkflowDefinitionError(
|
|
150
|
+
f"{fn.__name__}: first parameter of a {mode} workflow must be "
|
|
151
|
+
f"annotated WorkflowStreamInput[...], got {ann!r}."
|
|
152
|
+
)
|
|
153
|
+
if not ann.item_types:
|
|
154
|
+
raise WorkflowDefinitionError(
|
|
155
|
+
f"{fn.__name__}: WorkflowStreamInput needs at least one item type, "
|
|
156
|
+
"e.g. WorkflowStreamInput[float]."
|
|
157
|
+
)
|
|
158
|
+
input_items = tuple(
|
|
159
|
+
_check_scalar(t, f"{fn.__name__} stream item {i + 1}")
|
|
160
|
+
for i, t in enumerate(ann.item_types)
|
|
161
|
+
)
|
|
162
|
+
parameters = parameters[1:]
|
|
163
|
+
|
|
164
|
+
for p in parameters:
|
|
165
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
166
|
+
raise WorkflowDefinitionError(
|
|
167
|
+
f"{fn.__name__}: *args/**kwargs are not representable in a "
|
|
168
|
+
"workflow contract; declare explicit parameters."
|
|
169
|
+
)
|
|
170
|
+
if p.name.startswith("_"):
|
|
171
|
+
raise WorkflowDefinitionError(
|
|
172
|
+
f"{fn.__name__}: parameter '{p.name}' is invalid — names "
|
|
173
|
+
"beginning with '_' are reserved for client call options "
|
|
174
|
+
"(e.g. _deadline_ms)."
|
|
175
|
+
)
|
|
176
|
+
ann = hints.get(p.name)
|
|
177
|
+
if ann is None:
|
|
178
|
+
raise WorkflowDefinitionError(
|
|
179
|
+
f"{fn.__name__}: parameter '{p.name}' needs a type annotation."
|
|
180
|
+
)
|
|
181
|
+
_check_scalar(ann, f"{fn.__name__}.{p.name}")
|
|
182
|
+
has_default = p.default is not inspect.Parameter.empty
|
|
183
|
+
params.append((p.name, ann, has_default, p.default if has_default else None))
|
|
184
|
+
|
|
185
|
+
ret = hints.get("return")
|
|
186
|
+
if output_streaming:
|
|
187
|
+
if not (isinstance(ret, type) and issubclass(ret, WorkflowStreamOutput)):
|
|
188
|
+
raise WorkflowDefinitionError(
|
|
189
|
+
f"{fn.__name__}: a {mode} workflow must return "
|
|
190
|
+
f"WorkflowStreamOutput[...], got {ret!r}."
|
|
191
|
+
)
|
|
192
|
+
out_items = tuple(
|
|
193
|
+
_check_scalar(t, f"{fn.__name__} output item {i + 1}")
|
|
194
|
+
for i, t in enumerate(ret.item_types)
|
|
195
|
+
)
|
|
196
|
+
if not out_items:
|
|
197
|
+
raise WorkflowDefinitionError(
|
|
198
|
+
f"{fn.__name__}: WorkflowStreamOutput needs at least one item type."
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
if ret is None:
|
|
202
|
+
raise WorkflowDefinitionError(
|
|
203
|
+
f"{fn.__name__}: a return annotation is required."
|
|
204
|
+
)
|
|
205
|
+
out_items = (_check_scalar(ret, f"{fn.__name__} return"),)
|
|
206
|
+
|
|
207
|
+
return RegisteredOp(
|
|
208
|
+
function_name=fn.__name__,
|
|
209
|
+
mode=mode,
|
|
210
|
+
fn=fn,
|
|
211
|
+
params=tuple(params),
|
|
212
|
+
input_item_types=input_items,
|
|
213
|
+
output_item_types=out_items,
|
|
214
|
+
docstring=inspect.getdoc(fn),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class WorkflowRegistry:
|
|
220
|
+
"""Collects decorated operations for one workflow module."""
|
|
221
|
+
|
|
222
|
+
ops: dict[str, RegisteredOp] = field(default_factory=dict)
|
|
223
|
+
|
|
224
|
+
def _register(self, fn: Callable[..., Any], mode: str) -> Callable[..., Any]:
|
|
225
|
+
op = _introspect(fn, mode)
|
|
226
|
+
if op.function_name in self.ops:
|
|
227
|
+
raise WorkflowDefinitionError(
|
|
228
|
+
f"Duplicate workflow operation '{op.function_name}'."
|
|
229
|
+
)
|
|
230
|
+
self.ops[op.function_name] = op
|
|
231
|
+
return fn
|
|
232
|
+
|
|
233
|
+
# The four decorators. Names are the contract; never alias them.
|
|
234
|
+
def unary(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
235
|
+
"""Register a request/response operation."""
|
|
236
|
+
return self._register(fn, MODE_UNARY)
|
|
237
|
+
|
|
238
|
+
def input_stream(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
239
|
+
"""Register a client-streaming operation (stream in, one result out)."""
|
|
240
|
+
return self._register(fn, MODE_INPUT_STREAM)
|
|
241
|
+
|
|
242
|
+
def output_stream(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
243
|
+
"""Register a server-streaming operation (one request, stream out)."""
|
|
244
|
+
return self._register(fn, MODE_OUTPUT_STREAM)
|
|
245
|
+
|
|
246
|
+
def bi_di(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
247
|
+
"""Register a fully bidirectional streaming operation."""
|
|
248
|
+
return self._register(fn, MODE_BI_DI)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
#: The module-level registry workflow authors decorate against.
|
|
252
|
+
workflow = WorkflowRegistry()
|