1313
1414
1515class 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