prefect-client 2.18.1__py3-none-any.whl → 2.18.2__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.
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
- from contextlib import asynccontextmanager
2
+ import inspect
3
+ from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
3
4
  from dataclasses import dataclass
4
5
  from typing import (
5
6
  Any,
@@ -15,6 +16,8 @@ from typing import (
15
16
  )
16
17
 
17
18
  import anyio
19
+ import anyio._backends._asyncio
20
+ from sniffio import AsyncLibraryNotFoundError
18
21
  from typing_extensions import ParamSpec
19
22
 
20
23
  from prefect import Flow, Task, get_client
@@ -33,7 +36,8 @@ from prefect.states import (
33
36
  exception_to_failed_state,
34
37
  return_value_to_state,
35
38
  )
36
- from prefect.utilities.asyncutils import A, Async
39
+ from prefect.utilities.asyncutils import A, Async, run_sync
40
+ from prefect.utilities.callables import parameters_to_args_kwargs
37
41
  from prefect.utilities.engine import (
38
42
  _dynamic_key_for_task_run,
39
43
  _resolve_custom_flow_run_name,
@@ -47,7 +51,7 @@ R = TypeVar("R")
47
51
 
48
52
  @dataclass
49
53
  class FlowRunEngine(Generic[P, R]):
50
- flow: Flow[P, Coroutine[Any, Any, R]]
54
+ flow: Union[Flow[P, R], Flow[P, Coroutine[Any, Any, R]]]
51
55
  parameters: Optional[Dict[str, Any]] = None
52
56
  flow_run: Optional[FlowRun] = None
53
57
  _is_started: bool = False
@@ -89,10 +93,17 @@ class FlowRunEngine(Generic[P, R]):
89
93
  return state
90
94
 
91
95
  async def result(self, raise_on_failure: bool = True) -> "Union[R, State, None]":
92
- return await self.state.result(raise_on_failure=raise_on_failure, fetch=True)
96
+ _result = self.state.result(raise_on_failure=raise_on_failure, fetch=True) # type: ignore
97
+ # state.result is a `sync_compatible` function that may or may not return an awaitable
98
+ # depending on whether the parent frame is sync or not
99
+ if inspect.isawaitable(_result):
100
+ _result = await _result
101
+ return _result
93
102
 
94
103
  async def handle_success(self, result: R) -> R:
95
104
  result_factory = getattr(FlowRunContext.get(), "result_factory", None)
105
+ if result_factory is None:
106
+ raise ValueError("Result factory is not set")
96
107
  terminal_state = await return_value_to_state(
97
108
  await resolve_futures_to_states(result),
98
109
  result_factory=result_factory,
@@ -117,71 +128,102 @@ class FlowRunEngine(Generic[P, R]):
117
128
  state = await self.set_state(Running())
118
129
  return state
119
130
 
131
+ async def load_subflow_run(
132
+ self, parent_task_run: TaskRun, client: PrefectClient, context: FlowRunContext
133
+ ) -> Union[FlowRun, None]:
134
+ """
135
+ This method attempts to load an existing flow run for a subflow task
136
+ run, if appropriate.
137
+
138
+ If the parent task run is in a final but not COMPLETED state, and not
139
+ being rerun, then we attempt to load an existing flow run instead of
140
+ creating a new one. This will prevent the engine from running the
141
+ subflow again.
142
+
143
+ If no existing flow run is found, or if the subflow should be rerun,
144
+ then no flow run is returned.
145
+ """
146
+
147
+ # check if the parent flow run is rerunning
148
+ rerunning = (
149
+ context.flow_run.run_count > 1
150
+ if getattr(context, "flow_run", None)
151
+ and isinstance(context.flow_run, FlowRun)
152
+ else False
153
+ )
154
+
155
+ # if the parent task run is in a final but not completed state, and
156
+ # not rerunning, then retrieve the most recent flow run instead of
157
+ # creating a new one. This effectively loads a cached flow run for
158
+ # situations where we are confident the flow should not be run
159
+ # again.
160
+ assert isinstance(parent_task_run.state, State)
161
+ if parent_task_run.state.is_final() and not (
162
+ rerunning and not parent_task_run.state.is_completed()
163
+ ):
164
+ # return the most recent flow run, if it exists
165
+ flow_runs = await client.read_flow_runs(
166
+ flow_run_filter=FlowRunFilter(
167
+ parent_task_run_id={"any_": [parent_task_run.id]}
168
+ ),
169
+ sort=FlowRunSort.EXPECTED_START_TIME_ASC,
170
+ limit=1,
171
+ )
172
+ if flow_runs:
173
+ return flow_runs[-1]
174
+
120
175
  async def create_subflow_task_run(
121
176
  self, client: PrefectClient, context: FlowRunContext
122
177
  ) -> TaskRun:
178
+ """
179
+ Adds a task to a parent flow run that represents the execution of a subflow run.
180
+
181
+ The task run is referred to as the "parent task run" of the subflow and will be kept
182
+ in sync with the subflow run's state by the orchestration engine.
183
+ """
123
184
  dummy_task = Task(
124
185
  name=self.flow.name, fn=self.flow.fn, version=self.flow.version
125
186
  )
126
187
  task_inputs = {
127
- k: await collect_task_run_inputs(v) for k, v in self.parameters.items()
188
+ k: await collect_task_run_inputs(v)
189
+ for k, v in (self.parameters or {}).items()
128
190
  }
129
191
  parent_task_run = await client.create_task_run(
130
192
  task=dummy_task,
131
193
  flow_run_id=(
132
- context.flow_run.id if getattr(context, "flow_run", None) else None
194
+ context.flow_run.id
195
+ if getattr(context, "flow_run", None)
196
+ and isinstance(context.flow_run, FlowRun)
197
+ else None
133
198
  ),
134
- dynamic_key=_dynamic_key_for_task_run(context, dummy_task),
135
- task_inputs=task_inputs,
199
+ dynamic_key=_dynamic_key_for_task_run(context, dummy_task), # type: ignore
200
+ task_inputs=task_inputs, # type: ignore
136
201
  state=Pending(),
137
202
  )
138
203
  return parent_task_run
139
204
 
140
- async def get_most_recent_flow_run_for_parent_task_run(
141
- self, client: PrefectClient, parent_task_run: TaskRun
142
- ) -> "Union[FlowRun, None]":
143
- """
144
- Get the most recent flow run associated with the provided parent task run.
145
-
146
- Args:
147
- - An orchestration client
148
- - The parent task run to get the most recent flow run for
149
-
150
- Returns:
151
- The most recent flow run associated with the parent task run or `None` if
152
- no flow runs are found
153
- """
154
- flow_runs = await client.read_flow_runs(
155
- flow_run_filter=FlowRunFilter(
156
- parent_task_run_id={"any_": [parent_task_run.id]}
157
- ),
158
- sort=FlowRunSort.EXPECTED_START_TIME_ASC,
159
- )
160
- return flow_runs[-1] if flow_runs else None
161
-
162
205
  async def create_flow_run(self, client: PrefectClient) -> FlowRun:
163
206
  flow_run_ctx = FlowRunContext.get()
207
+ parameters = self.parameters or {}
164
208
 
165
209
  parent_task_run = None
210
+
166
211
  # this is a subflow run
167
212
  if flow_run_ctx:
213
+ # get the parent task run
168
214
  parent_task_run = await self.create_subflow_task_run(
169
215
  client=client, context=flow_run_ctx
170
216
  )
171
- # If the parent task run already completed, return the last flow run
172
- # associated with the parent task run. This prevents rerunning a completed
173
- # flow run when the parent task run is rerun.
174
- most_recent_flow_run = (
175
- await self.get_most_recent_flow_run_for_parent_task_run(
176
- client=client, parent_task_run=parent_task_run
177
- )
178
- )
179
- if most_recent_flow_run:
180
- return most_recent_flow_run
217
+
218
+ # check if there is already a flow run for this subflow
219
+ if subflow_run := await self.load_subflow_run(
220
+ parent_task_run=parent_task_run, client=client, context=flow_run_ctx
221
+ ):
222
+ return subflow_run
181
223
 
182
224
  try:
183
225
  flow_run_name = _resolve_custom_flow_run_name(
184
- flow=self.flow, parameters=self.parameters
226
+ flow=self.flow, parameters=parameters
185
227
  )
186
228
  except TypeError:
187
229
  flow_run_name = None
@@ -189,7 +231,7 @@ class FlowRunEngine(Generic[P, R]):
189
231
  flow_run = await client.create_flow_run(
190
232
  flow=self.flow,
191
233
  name=flow_run_name,
192
- parameters=self.flow.serialize_parameters(self.parameters),
234
+ parameters=self.flow.serialize_parameters(parameters),
193
235
  state=Pending(),
194
236
  parent_task_run_id=getattr(parent_task_run, "id", None),
195
237
  )
@@ -199,8 +241,46 @@ class FlowRunEngine(Generic[P, R]):
199
241
  async def enter_run_context(self, client: Optional[PrefectClient] = None):
200
242
  if client is None:
201
243
  client = self.client
244
+ if not self.flow_run:
245
+ raise ValueError("Flow run not set")
202
246
 
203
247
  self.flow_run = await client.read_flow_run(self.flow_run.id)
248
+ task_runner = self.flow.task_runner.duplicate()
249
+
250
+ async with AsyncExitStack() as stack:
251
+ task_runner = await stack.enter_async_context(
252
+ self.flow.task_runner.duplicate().start()
253
+ )
254
+ stack.enter_context(
255
+ FlowRunContext(
256
+ flow=self.flow,
257
+ log_prints=self.flow.log_prints or False,
258
+ flow_run=self.flow_run,
259
+ parameters=self.parameters,
260
+ client=client,
261
+ background_tasks=anyio.create_task_group(),
262
+ result_factory=await ResultFactory.from_flow(self.flow),
263
+ task_runner=task_runner,
264
+ )
265
+ )
266
+ self.logger = flow_run_logger(flow_run=self.flow_run, flow=self.flow)
267
+ yield
268
+
269
+ @contextmanager
270
+ def enter_run_context_sync(self, client: Optional[PrefectClient] = None):
271
+ if client is None:
272
+ client = self.client
273
+ if not self.flow_run:
274
+ raise ValueError("Flow run not set")
275
+
276
+ self.flow_run = run_sync(client.read_flow_run(self.flow_run.id))
277
+
278
+ # if running in a completely synchronous frame, anyio will not detect the
279
+ # backend to use for the task group
280
+ try:
281
+ task_group = anyio.create_task_group()
282
+ except AsyncLibraryNotFoundError:
283
+ task_group = anyio._backends._asyncio.TaskGroup()
204
284
 
205
285
  with FlowRunContext(
206
286
  flow=self.flow,
@@ -208,8 +288,8 @@ class FlowRunEngine(Generic[P, R]):
208
288
  flow_run=self.flow_run,
209
289
  parameters=self.parameters,
210
290
  client=client,
211
- background_tasks=anyio.create_task_group(),
212
- result_factory=await ResultFactory.from_flow(self.flow),
291
+ background_tasks=task_group,
292
+ result_factory=run_sync(ResultFactory.from_flow(self.flow)),
213
293
  task_runner=self.flow.task_runner,
214
294
  ):
215
295
  self.logger = flow_run_logger(flow_run=self.flow_run, flow=self.flow)
@@ -230,7 +310,9 @@ class FlowRunEngine(Generic[P, R]):
230
310
  # validate prior to context so that context receives validated params
231
311
  if self.flow.should_validate_parameters:
232
312
  try:
233
- self.parameters = self.flow.validate_parameters(self.parameters)
313
+ self.parameters = self.flow.validate_parameters(
314
+ self.parameters or {}
315
+ )
234
316
  except Exception as exc:
235
317
  await self.handle_exception(
236
318
  exc,
@@ -238,11 +320,46 @@ class FlowRunEngine(Generic[P, R]):
238
320
  result_factory=await ResultFactory.from_flow(self.flow),
239
321
  )
240
322
  self.short_circuit = True
323
+ try:
324
+ yield self
325
+ finally:
326
+ self._is_started = False
327
+ self._client = None
328
+
329
+ @contextmanager
330
+ def start_sync(self):
331
+ """
332
+ Enters a client context and creates a flow run if needed.
333
+ """
241
334
 
335
+ client = get_client()
336
+ run_sync(client.__aenter__())
337
+ self._client = client
338
+ self._is_started = True
339
+
340
+ if not self.flow_run:
341
+ self.flow_run = run_sync(self.create_flow_run(client))
342
+
343
+ # validate prior to context so that context receives validated params
344
+ if self.flow.should_validate_parameters:
345
+ try:
346
+ self.parameters = self.flow.validate_parameters(self.parameters or {})
347
+ except Exception as exc:
348
+ run_sync(
349
+ self.handle_exception(
350
+ exc,
351
+ msg="Validation of flow parameters failed with error",
352
+ result_factory=run_sync(ResultFactory.from_flow(self.flow)),
353
+ )
354
+ )
355
+ self.short_circuit = True
356
+ try:
242
357
  yield self
243
-
244
- self._is_started = False
245
- self._client = None
358
+ finally:
359
+ # quickly close client
360
+ run_sync(client.__aexit__(None, None, None))
361
+ self._is_started = False
362
+ self._client = None
246
363
 
247
364
  def is_running(self) -> bool:
248
365
  if getattr(self, "flow_run", None) is None:
@@ -256,12 +373,12 @@ class FlowRunEngine(Generic[P, R]):
256
373
 
257
374
 
258
375
  async def run_flow(
259
- flow: Task[P, Coroutine[Any, Any, R]],
376
+ flow: Flow[P, Coroutine[Any, Any, R]],
260
377
  flow_run: Optional[FlowRun] = None,
261
378
  parameters: Optional[Dict[str, Any]] = None,
262
379
  wait_for: Optional[Iterable[PrefectFuture[A, Async]]] = None,
263
380
  return_type: Literal["state", "result"] = "result",
264
- ) -> "Union[R, None]":
381
+ ) -> Union[R, None]:
265
382
  """
266
383
  Runs a flow against the API.
267
384
 
@@ -269,18 +386,19 @@ async def run_flow(
269
386
  """
270
387
 
271
388
  engine = FlowRunEngine[P, R](flow, parameters, flow_run)
389
+
390
+ # This is a context manager that keeps track of the state of the flow run.
272
391
  async with engine.start() as run:
273
- # This is a context manager that keeps track of the state of the flow run.
274
392
  await run.begin_run()
275
393
 
276
394
  while run.is_running():
277
395
  async with run.enter_run_context():
278
396
  try:
279
397
  # This is where the flow is actually run.
280
- if flow.isasync:
281
- result = cast(R, await flow.fn(**(run.parameters or {}))) # type: ignore
282
- else:
283
- result = cast(R, flow.fn(**(run.parameters or {}))) # type: ignore
398
+ call_args, call_kwargs = parameters_to_args_kwargs(
399
+ flow.fn, run.parameters or {}
400
+ )
401
+ result = cast(R, await flow.fn(*call_args, **call_kwargs)) # type: ignore
284
402
  # If the flow run is successful, finalize it.
285
403
  await run.handle_success(result)
286
404
 
@@ -291,3 +409,36 @@ async def run_flow(
291
409
  if return_type == "state":
292
410
  return run.state
293
411
  return await run.result()
412
+
413
+
414
+ def run_flow_sync(
415
+ flow: Flow[P, R],
416
+ flow_run: Optional[FlowRun] = None,
417
+ parameters: Optional[Dict[str, Any]] = None,
418
+ wait_for: Optional[Iterable[PrefectFuture[A, Async]]] = None,
419
+ return_type: Literal["state", "result"] = "result",
420
+ ) -> Union[R, State, None]:
421
+ engine = FlowRunEngine[P, R](flow, parameters, flow_run)
422
+
423
+ # This is a context manager that keeps track of the state of the flow run.
424
+ with engine.start_sync() as run:
425
+ run_sync(run.begin_run())
426
+
427
+ while run.is_running():
428
+ with run.enter_run_context_sync():
429
+ try:
430
+ # This is where the flow is actually run.
431
+ call_args, call_kwargs = parameters_to_args_kwargs(
432
+ flow.fn, run.parameters or {}
433
+ )
434
+ result = cast(R, flow.fn(*call_args, **call_kwargs)) # type: ignore
435
+ # If the flow run is successful, finalize it.
436
+ run_sync(run.handle_success(result))
437
+
438
+ except Exception as exc:
439
+ # If the flow fails, and we have retries left, set the flow to retrying.
440
+ run_sync(run.handle_exception(exc))
441
+
442
+ if return_type == "state":
443
+ return run.state
444
+ return run_sync(run.result())