Skip to content

Commit 5835843

Browse files
committed
RecipeWrapper: Add type annotations and expand docstrings
Split *args/**kwargs on send, send_to, start, and checkpoint into explicit message/header/mangle_for_sending parameters, with **kwargs retained for transport pass-through. As a side effect, mangle_for_sending is now actually plumbed through to the recipe-level mangling path on start and checkpoint (previously it was silently swallowed into transport kwargs).
1 parent 6558a80 commit 5835843

1 file changed

Lines changed: 262 additions & 35 deletions

File tree

‎src/workflows/recipe/wrapper.py‎

Lines changed: 262 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,50 @@
1313

1414

1515
class RecipeWrapper:
16-
"""A wrapper object which contains a recipe and a number of functions to make
17-
life easier for recipe users.
16+
"""
17+
Represent a "Live" recipe, including the current state and history.
18+
19+
Makes it possible to keep track of the current state of the recipe
20+
and any parameters passed between services. If provided with a
21+
transport class, then the transport convenience methods can be used
22+
to manage all the bookkeeping to send onward messages/messages
23+
between services.
24+
25+
Services normally do not construct wrappers directly. Instead, the
26+
:func:`~workflows.recipe.wrap_subscribe` and
27+
:func:`~workflows.recipe.wrap_subscribe_broadcast` helpers intercept
28+
incoming messages, build a wrapper positioned at the relevant recipe
29+
step, and pass it to the service's callback. The callback then uses
30+
:meth:`send` or :meth:`send_to` to dispatch results onward without
31+
needing to know the names of the next services in the chain.
32+
33+
Wrappers can also be constructed from a bare recipe (rather than a
34+
received message) to kick off a new recipe via :meth:`start`.
35+
36+
Attributes:
37+
recipe: The underlying :class:`Recipe` object.
38+
recipe_pointer: Index of the current step within ``recipe``, or
39+
``None`` if the wrapper was built from a recipe that has not
40+
yet been started.
41+
recipe_step: The recipe node at ``recipe_pointer``, or ``None``
42+
before the recipe has started. Inspect this to read output
43+
channel definitions for the current step.
44+
recipe_path: History of nodes passed through to reach this node.
45+
environment: Dictionary of contextual information carried with
46+
the recipe (e.g. a recipe ``ID`` used for log correlation).
47+
Propagated unchanged to every downstream message.
48+
payload: The message payload delivered to this step, or ``None``
49+
when the wrapper was built from a bare recipe.
50+
default_channel: Named output channel treated as the target for
51+
:meth:`send` when the current step has named outputs. Set via
52+
:meth:`set_default_channel`.
53+
transport: The transport layer used to dispatch messages.
54+
Accessing this attribute raises :class:`RuntimeError` if no
55+
transport was supplied at construction time.
1856
"""
1957

2058
recipe_pointer: int | None
59+
default_channel: str | None
2160

2261
@overload
2362
def __init__(
@@ -51,7 +90,17 @@ def __init__(
5190
5291
transport:
5392
References to the transport layer, required to make use of
54-
methods that send directly to connected downstream processes.
93+
methods that send directly to downstream processes, such as
94+
:meth:`send`, :meth:`start`, and :meth:`checkpoint`. If not
95+
provided, then the RecipeWrapper can only be inspected.
96+
environment:
97+
Optional environment dictionary propagated to all downstream
98+
messages. Used for Recipe-workflow global information, such
99+
as `ID`.
100+
recipe:
101+
A :class:`Recipe` instance, or a raw recipe dictionary that
102+
will be validated and wrapped in one. Used for construction
103+
of a wrapper that has not yet been started.
55104
"""
56105
if message:
57106
self.recipe = workflows.recipe.Recipe(message["recipe"])
@@ -88,11 +137,53 @@ def transport(self) -> CommonTransport:
88137
)
89138
return self._transport
90139

91-
def send(self, *args, **kwargs):
92-
"""Send messages to another service that is connected to the currently
93-
running service via the recipe. The 'send' method will either use a
94-
default channel name, set via the set_default_channel method, or an
95-
unnamed output definition.
140+
def send(
141+
self,
142+
message: Any,
143+
header: dict[str, Any] | None = None,
144+
*,
145+
mangle_for_sending: Callable[[Any], Any] | None = None,
146+
**kwargs: Any,
147+
) -> None:
148+
"""
149+
Send a message to the current step's downstream services.
150+
151+
This is another service that is connected to the currently
152+
running service via the recipe, specified by the "output" field
153+
of the current step. There are three behaviours:
154+
155+
- If ``output`` is unset or the step has no ``output`` entry,
156+
then the message will be discarded.
157+
- If ``output`` is a single integer or an array of integers
158+
naming destination nodes in the recipe, then a copy of the
159+
message will be delivered to each one of them.
160+
- If ``output`` is a dict of named target nodes; If a default
161+
channel name has been set with :meth:`set_default_channel`
162+
then the message will only be sent to any destinations listed
163+
there.
164+
165+
For general sending to named output channels, use the
166+
:meth:`send_to` method instead.
167+
168+
Args:
169+
message:
170+
The payload to deliver. Wrapped in the standard recipe
171+
envelope (recipe, pointer, path, environment) before
172+
handing off to the transport.
173+
header:
174+
Optional dictionary of transport headers. This is merged
175+
with any set by the transport (e.g. the ``workflows-recipe``
176+
header flag is automatically added).
177+
mangle_for_sending:
178+
Optional callable applied to the fully formed message
179+
right before it is handed to the transport. If specified
180+
here, then the default transport serialization is not
181+
applied. The default mangling function for transports is
182+
usually "encode to JSON".
183+
**kwargs:
184+
Any additional keyword arguments forwarded to the
185+
transport's ``send``/``broadcast`` calls, in addition
186+
to any declared in the recipe.
96187
"""
97188
if not self.recipe_step:
98189
raise ValueError(
@@ -104,21 +195,72 @@ def send(self, *args, **kwargs):
104195
# The current recipe step does not have output channels.
105196
return
106197

198+
# Merge down the annotated function call to bare kwargs
199+
kwargs["message"] = message
200+
if header:
201+
kwargs["header"] = header
202+
if mangle_for_sending:
203+
kwargs["mangle_for_sending"] = mangle_for_sending
204+
107205
if isinstance(self.recipe_step["output"], dict):
108206
# The current recipe step does have named output channels.
109207
if self.default_channel:
110208
# Use named output channel
111-
self.send_to(self.default_channel, *args, **kwargs)
209+
self.send_to(self.default_channel, **kwargs)
112210

113211
else:
114212
# The current recipe step does have unnamed output channels.
115-
self._send_to_destinations(self.recipe_step["output"], *args, **kwargs)
213+
self._send_to_destinations(self.recipe_step["output"], **kwargs)
116214

117-
def send_to(self, channel, *args, **kwargs):
118-
"""Send messages to another service that is connected to the currently
119-
running service via the recipe. Discard messages if the recipe does
120-
not have anything connected to the specified output channel.
215+
def send_to(
216+
self,
217+
channel: str,
218+
message: Any,
219+
header: dict[str, Any] | None = None,
220+
*,
221+
mangle_for_sending: Callable[[Any], Any] | None = None,
222+
**kwargs: Any,
223+
) -> None:
121224
"""
225+
Send a message to a service named by this steps' output channel.
226+
227+
This is another service that is connected to the currently
228+
running service via the recipe, specified by the "output" field
229+
of the current step. There are three behaviours:
230+
231+
- If ``output`` is unset or the step has no ``output`` entry,
232+
then the message will be discarded.
233+
- If ``output`` is *NOT* a dictionary, then the message is sent
234+
to all services specified *ONLY IF* the passed channel name is
235+
the same as the default channel name.
236+
- The channel name is looked up in the ``outputs`` dictionary
237+
and a copy of the message is sent to every destination
238+
specified there.
239+
240+
Args:
241+
channel:
242+
The name of the output channel, corresponding to an
243+
entry in the ``outputs`` dictionary.
244+
message:
245+
The payload to deliver. Wrapped in the standard recipe
246+
envelope (recipe, pointer, path, environment) before
247+
handing off to the transport.
248+
header:
249+
Optional dictionary of transport headers. This is merged
250+
with any set by the transport (e.g. the ``workflows-recipe``
251+
header flag is automatically added).
252+
mangle_for_sending:
253+
Optional callable applied to the fully formed message
254+
right before it is handed to the transport. If specified
255+
here, then the default transport serialization is not
256+
applied. The default mangling function for transports is
257+
usually "encode to JSON".
258+
**kwargs:
259+
Any additional keyword arguments forwarded to the
260+
transport's ``send``/``broadcast`` calls, in addition
261+
to any declared in the recipe.
262+
"""
263+
122264
if not self.recipe_step:
123265
raise ValueError(
124266
"This RecipeWrapper object does not contain "
@@ -129,44 +271,109 @@ def send_to(self, channel, *args, **kwargs):
129271
# The current recipe step does not have output channels.
130272
return
131273

274+
# Merge down the annotated function call to bare kwargs
275+
kwargs["message"] = message
276+
if header:
277+
kwargs["header"] = header
278+
if mangle_for_sending:
279+
kwargs["mangle_for_sending"] = mangle_for_sending
280+
132281
if not isinstance(self.recipe_step["output"], dict):
133282
# The current recipe step does not have named output channels.
134283
if self.default_channel == channel:
135284
# Use unnamed output channels
136-
self.send(*args, **kwargs)
285+
self.send(**kwargs)
137286
return
138287

139288
if channel not in self.recipe_step["output"]:
140289
# The current recipe step does not have an output channel with this name.
141290
return
142291

143-
self._send_to_destinations(self.recipe_step["output"][channel], *args, **kwargs)
292+
self._send_to_destinations(self.recipe_step["output"][channel], **kwargs)
144293

145-
def set_default_channel(self, channel):
294+
def set_default_channel(self, channel: str) -> None:
146295
"""Define one named output channel to be equivalent to unnamed output
147296
channels. For this channel send() and send_to() will be identical."""
148297
self.default_channel = channel
149298

150-
def start(self, header=None, **kwargs):
151-
"""Trigger the start of a recipe, sending the defined payloads to the
152-
recipients set in the recipe. Any parameters to this function are
153-
passed to the transport send/broadcast methods.
154-
If the wrapped recipe has already been started then a ValueError will
155-
be raised.
299+
def start(
300+
self,
301+
header: dict[str, Any] | None = None,
302+
*,
303+
mangle_for_sending: Callable[[Any], Any] | None = None,
304+
**kwargs,
305+
) -> None:
306+
"""
307+
Trigger the start of a recipe.
308+
309+
Dispatches the payloads defined in the recipe's ``start`` node
310+
to their respective recipients via the transport layer. Only
311+
valid on a wrapper constructed from a bare recipe; calling
312+
:meth:`start` on a wrapper that already has a selected step
313+
raises :class:`ValueError`.
314+
315+
Args:
316+
header:
317+
Optional dictionary of transport headers. This is merged
318+
with any set by the transport (e.g. the ``workflows-recipe``
319+
header flag is automatically added).
320+
mangle_for_sending:
321+
Optional callable applied to the fully formed message
322+
right before it is handed to the transport. If specified
323+
here, then the default transport serialization is not
324+
applied. The default mangling function for transports is
325+
usually "encode to JSON".
326+
**kwargs: Keywords passed on to the transport.
327+
328+
Raises:
329+
ValueError: If the wrapped recipe has already been started.
156330
"""
157331

158332
if self.recipe_step:
159333
raise ValueError("This recipe has already been started.")
160334

161335
for destination, payload in self.recipe["start"]:
162-
self._send_to_destination(destination, header, payload, kwargs)
336+
self._send_to_destination(
337+
destination,
338+
header=header,
339+
payload=payload,
340+
transport_kwargs=kwargs,
341+
mangle_for_sending=mangle_for_sending,
342+
)
343+
344+
def checkpoint(
345+
self,
346+
message: Any,
347+
header: dict[str, Any] | None = None,
348+
delay: float = 0.0,
349+
*,
350+
mangle_for_sending: Callable[[Any], Any] | None = None,
351+
**kwargs: Any,
352+
) -> None:
353+
"""
354+
Send a message back to "yourself" e.g. the current recipe destination.
163355
164-
def checkpoint(self, message, header=None, delay=0, **kwargs):
165-
"""Send a message to the current recipe destination. This can be used to
166-
keep a state for longer processing tasks.
356+
This can be used to store state in the rabbitmq queue for
357+
processing of longer-term tasks, without making individual
358+
services stateful.
167359
168360
Args:
169-
delay: Delay transport of message by this many seconds
361+
message:
362+
The payload to deliver. Wrapped in the standard recipe
363+
envelope (recipe, pointer, path, environment) before
364+
handing off to the transport.
365+
header:
366+
Optional dictionary of transport headers. This is merged
367+
with any set by the transport (e.g. the ``workflows-recipe``
368+
header flag is automatically added).
369+
delay: Time, in seconds, to delay delivery of this message.
370+
mangle_for_sending:
371+
Optional callable applied to the fully formed message
372+
right before it is handed to the transport. If specified
373+
here, then the default transport serialization is not
374+
applied. The default mangling function for transports is
375+
usually "encode to JSON".
376+
**kwargs: Keywords passed on to the transport.
170377
"""
171378
if not self.recipe_step:
172379
raise ValueError(
@@ -177,15 +384,35 @@ def checkpoint(self, message, header=None, delay=0, **kwargs):
177384
kwargs["delay"] = delay
178385

179386
self._send_to_destination(
180-
self.recipe_pointer, header, message, kwargs, add_path_step=False
387+
self.recipe_pointer,
388+
header,
389+
message,
390+
kwargs,
391+
add_path_step=False,
392+
mangle_for_sending=mangle_for_sending,
181393
)
182394

183-
def apply_parameters(self, parameters):
184-
"""Recursively apply parameter replacement (see recipe.py) to the wrapped
185-
recipe, updating internal references afterwards.
186-
While this operation is useful for testing it should not be used in
187-
production. Replacing parameters means that the recipe changes as it is
188-
passed down the chain of services. This makes debugging very difficult.
395+
def apply_parameters(self, parameters: dict[str, Any]) -> None:
396+
"""
397+
Recursively substitute parameter values into the wrapped recipe.
398+
399+
Delegates to :meth:`Recipe.apply_parameters` and then refreshes
400+
:attr:`recipe_step` so subsequent :meth:`send` / :meth:`send_to`
401+
calls observe the substituted outputs and queue names.
402+
403+
Note:
404+
This is primarily useful in tests. Mutating the recipe as it
405+
is passed down the chain of services means each hop sees a
406+
different recipe, which makes failures very difficult to
407+
diagnose; prefer baking parameters in before dispatch in
408+
production.
409+
410+
Args:
411+
parameters: Mapping of parameter names to replacement values.
412+
Keys are referenced from the recipe as ``{name}``, or as
413+
``{$REPLACE:name}`` to substitute an entire data
414+
structure in place of the string. See
415+
:meth:`Recipe.apply_parameters` for the full grammar.
189416
"""
190417
self.recipe.apply_parameters(parameters)
191418
assert self.recipe_pointer is not None

0 commit comments

Comments
 (0)