temporal-parseable 0.1.0__py3-none-any.whl

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,82 @@
1
+ """
2
+ Public helper for emitting custom domain events from workflow code.
3
+
4
+ Import from workflow code only::
5
+
6
+ from temporal_parseable.workflow import workflow_event
7
+
8
+ This module must be safe to import inside the Temporal workflow sandbox
9
+ (no I/O, no threading, no non-deterministic calls at import time).
10
+
11
+ Usage::
12
+
13
+ @workflow.defn
14
+ class AgentWorkflow:
15
+ @workflow.run
16
+ async def run(self, input: AgentInput) -> AgentResult:
17
+ workflow_event("agent.started", {"user_id": input.user_id})
18
+
19
+ plan = await workflow.execute_activity(plan_activity, input)
20
+ workflow_event("agent.plan.chosen", {"steps": len(plan.steps)})
21
+
22
+ for step in plan.steps:
23
+ workflow_event("agent.step.start", {"tool": step.tool})
24
+ await workflow.execute_activity(run_step, step)
25
+
26
+ return result
27
+
28
+ Each call emits a record with:
29
+
30
+ type = "user_event"
31
+ event_name = the name argument
32
+ event_data = the data argument (arbitrary JSON-serialisable dict)
33
+ workflow_id, run_id, workflow_name — current workflow context
34
+
35
+ Records are emitted only during live execution (replay-safe: guarded by
36
+ ``workflow.unsafe.is_replaying()``), matching the TypeScript plugin's
37
+ ``callDuringReplay: false`` sink behaviour.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Any, Dict, Optional
43
+
44
+ from temporalio import workflow
45
+
46
+ # The emitter is injected at worker startup by ParseablePlugin.
47
+ # Workflow code must never import _emitter directly — that would break sandbox isolation.
48
+ _emitter: Any = None # ParseableEmitter | None
49
+
50
+
51
+ def _set_emitter(emitter: Any) -> None:
52
+ """Called by ParseablePlugin during worker initialisation. Not for user code."""
53
+ global _emitter
54
+ _emitter = emitter
55
+
56
+
57
+ def workflow_event(name: str, data: Optional[Dict[str, Any]] = None) -> None:
58
+ """
59
+ Emit a custom domain event from inside a Temporal workflow.
60
+
61
+ :param name: Dot-separated event name, e.g. ``"order.payment.captured"``.
62
+ :param data: Arbitrary JSON-serialisable payload. Defaults to ``{}``.
63
+
64
+ The call is a no-op when:
65
+ - the Temporal worker is replaying history (replay-safe)
66
+ - logs are disabled in the plugin config
67
+ - called outside a running workflow (e.g. in tests without the plugin)
68
+ """
69
+ if _emitter is None:
70
+ return
71
+ if workflow.unsafe.is_replaying():
72
+ return
73
+
74
+ info = workflow.info()
75
+ _emitter.emit({ # type: ignore[arg-type]
76
+ "type": "user_event",
77
+ "event_name": name,
78
+ "event_data": data or {},
79
+ "workflow_id": info.workflow_id,
80
+ "run_id": info.run_id,
81
+ "workflow_name": info.workflow_type,
82
+ })
@@ -0,0 +1,411 @@
1
+ """
2
+ Workflow interceptors.
3
+
4
+ Two classes:
5
+
6
+ ParseableWorkflowInboundInterceptor
7
+ Wraps:
8
+ execute_workflow → type=workflow started/completed/failed
9
+ handle_signal → type=signal direction=inbound started/completed/failed
10
+ handle_query → type=query direction=inbound started/completed/failed
11
+ handle_update → type=update direction=inbound started/completed/failed
12
+
13
+ ParseableWorkflowOutboundInterceptor
14
+ Wraps:
15
+ start_child_workflow → type=child_workflow direction=outbound started/completed/failed
16
+ signal_external_workflow → type=signal direction=outbound started/completed
17
+ signal_child_workflow → type=signal direction=outbound started/completed
18
+ continue_as_new → type=continue_as_new direction=outbound started (no completed)
19
+
20
+ Replay safety
21
+ -------------
22
+ The Python SDK has no equivalent of the TypeScript proxySinks
23
+ ``callDuringReplay: false`` mechanism. Instead, every emission is guarded by:
24
+
25
+ if not workflow.unsafe.is_replaying():
26
+ self._emitter.emit(...)
27
+
28
+ This ensures that when Temporal replays a workflow's history (worker crash,
29
+ cache eviction, manual replay via Worker.run_replay_history), no duplicate
30
+ records are emitted. Verified by the replay-safety test suite.
31
+
32
+ Important: workflow interceptors run inside the deterministic workflow isolate.
33
+ They must NEVER perform I/O directly. The _emitter.emit() call is safe
34
+ because OTel's BatchLogRecordProcessor offloads the actual network send to a
35
+ background thread outside the isolate.
36
+
37
+ Mirrors the TypeScript workflow-interceptor.ts.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import time
43
+ from typing import Any
44
+
45
+ from temporalio import workflow
46
+ from temporalio.worker import (
47
+ WorkflowInboundInterceptor,
48
+ WorkflowOutboundInterceptor,
49
+ ExecuteWorkflowInput,
50
+ HandleSignalInput,
51
+ HandleQueryInput,
52
+ HandleUpdateInput,
53
+ StartChildWorkflowInput,
54
+ SignalExternalWorkflowInput,
55
+ SignalChildWorkflowInput,
56
+ ContinueAsNewInput,
57
+ )
58
+
59
+ from ._emitter import ParseableEmitter, _now_iso
60
+
61
+
62
+ # ── helpers ──────────────────────────────────────────────────────────────────
63
+
64
+ def _wf_base() -> dict:
65
+ """Extract the current workflow identifiers (safe to call in WF context)."""
66
+ info = workflow.info()
67
+ return {
68
+ "workflow_id": info.workflow_id,
69
+ "run_id": info.run_id,
70
+ "workflow_name": info.workflow_type,
71
+ }
72
+
73
+
74
+ def _emit_if_live(emitter: ParseableEmitter, record: dict) -> None:
75
+ """Emit only during live execution, never during history replay."""
76
+ if not workflow.unsafe.is_replaying():
77
+ emitter.emit(record) # type: ignore[arg-type]
78
+
79
+
80
+ # ── inbound interceptor ───────────────────────────────────────────────────────
81
+
82
+ class ParseableWorkflowInboundInterceptor(WorkflowInboundInterceptor):
83
+ """
84
+ Intercepts inbound workflow calls and emits Parseable records.
85
+
86
+ Created once per workflow execution by
87
+ ``ParseableWorkerInterceptor.workflow_interceptor_class``.
88
+ """
89
+
90
+ def __init__(self, next: WorkflowInboundInterceptor) -> None:
91
+ super().__init__(next)
92
+ self._emitter: ParseableEmitter # set by init()
93
+ self._outbound: ParseableWorkflowOutboundInterceptor
94
+
95
+ def init(self, outbound: WorkflowOutboundInterceptor) -> None:
96
+ # Wrap the outbound interceptor with ours so we can observe outbound calls.
97
+ self._outbound = ParseableWorkflowOutboundInterceptor(outbound, None)
98
+ super().init(self._outbound)
99
+
100
+ def _set_emitter(self, emitter: ParseableEmitter) -> None:
101
+ """Called by the worker interceptor after construction."""
102
+ self._emitter = emitter
103
+ self._outbound._emitter = emitter
104
+
105
+ # ── execute_workflow ──────────────────────────────────────────────────────
106
+
107
+ async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any:
108
+ base = {**_wf_base(), "type": "workflow"}
109
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
110
+
111
+ start_ns = time.monotonic_ns()
112
+ try:
113
+ result = await self.next.execute_workflow(input)
114
+ except Exception as exc:
115
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
116
+ _emit_if_live(self._emitter, {
117
+ **base,
118
+ "status": "failed",
119
+ "timestamp": _now_iso(),
120
+ "duration_ms": round(duration_ms, 3),
121
+ "error": str(exc),
122
+ })
123
+ raise
124
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
125
+ _emit_if_live(self._emitter, {
126
+ **base,
127
+ "status": "completed",
128
+ "timestamp": _now_iso(),
129
+ "duration_ms": round(duration_ms, 3),
130
+ })
131
+ return result
132
+
133
+ # ── handle_signal ─────────────────────────────────────────────────────────
134
+
135
+ async def handle_signal(self, input: HandleSignalInput) -> None:
136
+ base = {
137
+ **_wf_base(),
138
+ "type": "signal",
139
+ "direction": "inbound",
140
+ "message_name": input.signal,
141
+ }
142
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
143
+
144
+ start_ns = time.monotonic_ns()
145
+ try:
146
+ await self.next.handle_signal(input)
147
+ except Exception as exc:
148
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
149
+ _emit_if_live(self._emitter, {
150
+ **base,
151
+ "status": "failed",
152
+ "timestamp": _now_iso(),
153
+ "duration_ms": round(duration_ms, 3),
154
+ "error": str(exc),
155
+ })
156
+ raise
157
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
158
+ _emit_if_live(self._emitter, {
159
+ **base,
160
+ "status": "completed",
161
+ "timestamp": _now_iso(),
162
+ "duration_ms": round(duration_ms, 3),
163
+ })
164
+
165
+ # ── handle_query ──────────────────────────────────────────────────────────
166
+
167
+ async def handle_query(self, input: HandleQueryInput) -> Any:
168
+ base = {
169
+ **_wf_base(),
170
+ "type": "query",
171
+ "direction": "inbound",
172
+ "message_name": input.query,
173
+ }
174
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
175
+
176
+ start_ns = time.monotonic_ns()
177
+ try:
178
+ result = await self.next.handle_query(input)
179
+ except Exception as exc:
180
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
181
+ _emit_if_live(self._emitter, {
182
+ **base,
183
+ "status": "failed",
184
+ "timestamp": _now_iso(),
185
+ "duration_ms": round(duration_ms, 3),
186
+ "error": str(exc),
187
+ })
188
+ raise
189
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
190
+ _emit_if_live(self._emitter, {
191
+ **base,
192
+ "status": "completed",
193
+ "timestamp": _now_iso(),
194
+ "duration_ms": round(duration_ms, 3),
195
+ })
196
+ return result
197
+
198
+ # ── handle_update ─────────────────────────────────────────────────────────
199
+
200
+ async def handle_update(self, input: HandleUpdateInput) -> Any:
201
+ base = {
202
+ **_wf_base(),
203
+ "type": "update",
204
+ "direction": "inbound",
205
+ "message_name": input.update,
206
+ }
207
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
208
+
209
+ start_ns = time.monotonic_ns()
210
+ try:
211
+ result = await self.next.handle_update(input)
212
+ except Exception as exc:
213
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
214
+ _emit_if_live(self._emitter, {
215
+ **base,
216
+ "status": "failed",
217
+ "timestamp": _now_iso(),
218
+ "duration_ms": round(duration_ms, 3),
219
+ "error": str(exc),
220
+ })
221
+ raise
222
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
223
+ _emit_if_live(self._emitter, {
224
+ **base,
225
+ "status": "completed",
226
+ "timestamp": _now_iso(),
227
+ "duration_ms": round(duration_ms, 3),
228
+ })
229
+ return result
230
+
231
+
232
+ # ── outbound interceptor ──────────────────────────────────────────────────────
233
+
234
+ class ParseableWorkflowOutboundInterceptor(WorkflowOutboundInterceptor):
235
+ """
236
+ Intercepts outbound workflow calls (child workflows, signals, continue-as-new).
237
+
238
+ The emitter is injected after construction by
239
+ ``ParseableWorkflowInboundInterceptor.init()``.
240
+ """
241
+
242
+ def __init__(
243
+ self,
244
+ next: WorkflowOutboundInterceptor,
245
+ emitter: ParseableEmitter | None,
246
+ ) -> None:
247
+ super().__init__(next)
248
+ self._emitter = emitter # type: ignore[assignment]
249
+
250
+ # ── start_child_workflow ──────────────────────────────────────────────────
251
+
252
+ async def start_child_workflow(self, input: StartChildWorkflowInput) -> Any:
253
+ base = {
254
+ **_wf_base(),
255
+ "type": "child_workflow",
256
+ "direction": "outbound",
257
+ "message_name": input.workflow,
258
+ "target_workflow_id": input.id or "",
259
+ }
260
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
261
+
262
+ start_ns = time.monotonic_ns()
263
+ try:
264
+ # next() returns a ChildWorkflowHandle; we await its result to
265
+ # track completion, matching the TS behaviour (completed fires when
266
+ # the child finishes, not when the start RPC returns).
267
+ handle = await self.next.start_child_workflow(input)
268
+ except Exception as exc:
269
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
270
+ _emit_if_live(self._emitter, {
271
+ **base,
272
+ "status": "failed",
273
+ "timestamp": _now_iso(),
274
+ "duration_ms": round(duration_ms, 3),
275
+ "error": str(exc),
276
+ })
277
+ raise
278
+
279
+ # Wrap the handle so we can observe when the child actually finishes.
280
+ return _ChildWorkflowHandleWrapper(handle, base, self._emitter, start_ns)
281
+
282
+ # ── signal_external_workflow ──────────────────────────────────────────────
283
+
284
+ async def signal_external_workflow(self, input: SignalExternalWorkflowInput) -> None:
285
+ base = {
286
+ **_wf_base(),
287
+ "type": "signal",
288
+ "direction": "outbound",
289
+ "message_name": input.signal,
290
+ "target_workflow_id": input.workflow_id,
291
+ }
292
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
293
+
294
+ start_ns = time.monotonic_ns()
295
+ try:
296
+ await self.next.signal_external_workflow(input)
297
+ except Exception as exc:
298
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
299
+ _emit_if_live(self._emitter, {
300
+ **base,
301
+ "status": "failed",
302
+ "timestamp": _now_iso(),
303
+ "duration_ms": round(duration_ms, 3),
304
+ "error": str(exc),
305
+ })
306
+ raise
307
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
308
+ _emit_if_live(self._emitter, {
309
+ **base,
310
+ "status": "completed",
311
+ "timestamp": _now_iso(),
312
+ "duration_ms": round(duration_ms, 3),
313
+ })
314
+
315
+ # ── signal_child_workflow ─────────────────────────────────────────────────
316
+
317
+ async def signal_child_workflow(self, input: SignalChildWorkflowInput) -> None:
318
+ base = {
319
+ **_wf_base(),
320
+ "type": "signal",
321
+ "direction": "outbound",
322
+ "message_name": input.signal,
323
+ "target_workflow_id": input.workflow_id,
324
+ }
325
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
326
+
327
+ start_ns = time.monotonic_ns()
328
+ try:
329
+ await self.next.signal_child_workflow(input)
330
+ except Exception as exc:
331
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
332
+ _emit_if_live(self._emitter, {
333
+ **base,
334
+ "status": "failed",
335
+ "timestamp": _now_iso(),
336
+ "duration_ms": round(duration_ms, 3),
337
+ "error": str(exc),
338
+ })
339
+ raise
340
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
341
+ _emit_if_live(self._emitter, {
342
+ **base,
343
+ "status": "completed",
344
+ "timestamp": _now_iso(),
345
+ "duration_ms": round(duration_ms, 3),
346
+ })
347
+
348
+ # ── continue_as_new ───────────────────────────────────────────────────────
349
+
350
+ def continue_as_new(self, input: ContinueAsNewInput) -> None:
351
+ base = {
352
+ **_wf_base(),
353
+ "type": "continue_as_new",
354
+ "direction": "outbound",
355
+ }
356
+ # Only a single "started" record — there is no "completed" because the
357
+ # current execution ends immediately.
358
+ _emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
359
+ self.next.continue_as_new(input)
360
+
361
+
362
+ # ── child workflow handle wrapper ─────────────────────────────────────────────
363
+
364
+ class _ChildWorkflowHandleWrapper:
365
+ """
366
+ Thin proxy around a ChildWorkflowHandle that emits completed/failed records
367
+ when the child finishes.
368
+
369
+ Delegating __getattr__ keeps this transparent to callers who use the handle
370
+ for signalling, querying, etc.
371
+ """
372
+
373
+ def __init__(
374
+ self,
375
+ handle: Any,
376
+ base_record: dict,
377
+ emitter: ParseableEmitter,
378
+ start_ns: int,
379
+ ) -> None:
380
+ self._handle = handle
381
+ self._base = base_record
382
+ self._emitter = emitter
383
+ self._start_ns = start_ns
384
+
385
+ def __getattr__(self, name: str) -> Any:
386
+ return getattr(self._handle, name)
387
+
388
+ def __await__(self):
389
+ return self._await_result().__await__()
390
+
391
+ async def _await_result(self) -> Any:
392
+ try:
393
+ result = await self._handle
394
+ except Exception as exc:
395
+ duration_ms = (time.monotonic_ns() - self._start_ns) / 1_000_000
396
+ _emit_if_live(self._emitter, {
397
+ **self._base,
398
+ "status": "failed",
399
+ "timestamp": _now_iso(),
400
+ "duration_ms": round(duration_ms, 3),
401
+ "error": str(exc),
402
+ })
403
+ raise
404
+ duration_ms = (time.monotonic_ns() - self._start_ns) / 1_000_000
405
+ _emit_if_live(self._emitter, {
406
+ **self._base,
407
+ "status": "completed",
408
+ "timestamp": _now_iso(),
409
+ "duration_ms": round(duration_ms, 3),
410
+ })
411
+ return result