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.
@@ -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()