prefect-client 3.3.5.dev3__py3-none-any.whl → 3.3.6__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.
prefect/flows.py CHANGED
@@ -40,6 +40,7 @@ from typing import (
40
40
  from uuid import UUID
41
41
 
42
42
  import pydantic
43
+ from exceptiongroup import BaseExceptionGroup, ExceptionGroup
43
44
  from pydantic.v1 import BaseModel as V1BaseModel
44
45
  from pydantic.v1.decorator import ValidatedFunction as V1ValidatedFunction
45
46
  from pydantic.v1.errors import ConfigError # TODO
@@ -64,7 +65,7 @@ from prefect.exceptions import (
64
65
  UnspecifiedFlowError,
65
66
  )
66
67
  from prefect.filesystems import LocalFileSystem, ReadableDeploymentStorage
67
- from prefect.futures import PrefectFuture
68
+ from prefect.futures import PrefectFlowRunFuture, PrefectFuture
68
69
  from prefect.logging import get_logger
69
70
  from prefect.logging.loggers import flow_run_logger
70
71
  from prefect.results import ResultSerializer, ResultStorage
@@ -105,6 +106,9 @@ from ._internal.pydantic.v2_validated_func import (
105
106
  V2ValidatedFunction as ValidatedFunction,
106
107
  )
107
108
 
109
+ if TYPE_CHECKING:
110
+ from prefect.workers.base import BaseWorker
111
+
108
112
  T = TypeVar("T") # Generic type var for capturing the inner return type of async funcs
109
113
  R = TypeVar("R") # The return type of the user's function
110
114
  P = ParamSpec("P") # The parameters of the flow
@@ -1984,6 +1988,241 @@ class FlowDecorator:
1984
1988
  flow: FlowDecorator = FlowDecorator()
1985
1989
 
1986
1990
 
1991
+ class InfrastructureBoundFlow(Flow[P, R]):
1992
+ """
1993
+ EXPERIMENTAL: This class is experimental and may be removed or changed in future
1994
+ releases.
1995
+
1996
+ A flow that is bound to running on a specific infrastructure.
1997
+
1998
+ Attributes:
1999
+ work_pool: The name of the work pool to run the flow on. The base job
2000
+ configuration of the work pool will determine the configuration of the
2001
+ infrastructure the flow will run on.
2002
+ job_variables: Infrastructure configuration that will override the base job
2003
+ configuration of the work pool.
2004
+ worker_cls: The class of the worker to use to spin up infrastructure and submit
2005
+ the flow to it.
2006
+ """
2007
+
2008
+ def __init__(
2009
+ self,
2010
+ *args: Any,
2011
+ work_pool: str,
2012
+ job_variables: dict[str, Any],
2013
+ worker_cls: type["BaseWorker[Any, Any, Any]"],
2014
+ **kwargs: Any,
2015
+ ):
2016
+ super().__init__(*args, **kwargs)
2017
+ self.work_pool = work_pool
2018
+ self.job_variables = job_variables
2019
+ self.worker_cls = worker_cls
2020
+
2021
+ @overload
2022
+ def __call__(self: "Flow[P, NoReturn]", *args: P.args, **kwargs: P.kwargs) -> None:
2023
+ # `NoReturn` matches if a type can't be inferred for the function which stops a
2024
+ # sync function from matching the `Coroutine` overload
2025
+ ...
2026
+
2027
+ @overload
2028
+ def __call__(
2029
+ self: "Flow[P, Coroutine[Any, Any, T]]",
2030
+ *args: P.args,
2031
+ **kwargs: P.kwargs,
2032
+ ) -> Coroutine[Any, Any, T]: ...
2033
+
2034
+ @overload
2035
+ def __call__(
2036
+ self: "Flow[P, T]",
2037
+ *args: P.args,
2038
+ **kwargs: P.kwargs,
2039
+ ) -> T: ...
2040
+
2041
+ @overload
2042
+ def __call__(
2043
+ self: "Flow[P, Coroutine[Any, Any, T]]",
2044
+ *args: P.args,
2045
+ return_state: Literal[True],
2046
+ **kwargs: P.kwargs,
2047
+ ) -> Awaitable[State[T]]: ...
2048
+
2049
+ @overload
2050
+ def __call__(
2051
+ self: "Flow[P, T]",
2052
+ *args: P.args,
2053
+ return_state: Literal[True],
2054
+ **kwargs: P.kwargs,
2055
+ ) -> State[T]: ...
2056
+
2057
+ def __call__(
2058
+ self,
2059
+ *args: "P.args",
2060
+ return_state: bool = False,
2061
+ wait_for: Optional[Iterable[PrefectFuture[Any]]] = None,
2062
+ **kwargs: "P.kwargs",
2063
+ ):
2064
+ async def modified_call(
2065
+ *args: P.args,
2066
+ return_state: bool = False,
2067
+ # TODO: Handle wait_for once we have an asynchronous way to wait for futures
2068
+ # We should wait locally for futures to resolve before spinning up
2069
+ # infrastructure.
2070
+ wait_for: Optional[Iterable[PrefectFuture[Any]]] = None,
2071
+ **kwargs: P.kwargs,
2072
+ ) -> R | State[R]:
2073
+ try:
2074
+ async with self.worker_cls(work_pool_name=self.work_pool) as worker:
2075
+ parameters = get_call_parameters(self, args, kwargs)
2076
+ future = await worker.submit(
2077
+ flow=self,
2078
+ parameters=parameters,
2079
+ job_variables=self.job_variables,
2080
+ )
2081
+ if return_state:
2082
+ await future.wait_async()
2083
+ return future.state
2084
+ return await future.aresult()
2085
+ except (ExceptionGroup, BaseExceptionGroup) as exc:
2086
+ # For less verbose tracebacks
2087
+ exceptions = exc.exceptions
2088
+ if len(exceptions) == 1:
2089
+ raise exceptions[0] from None
2090
+ else:
2091
+ raise
2092
+
2093
+ if inspect.iscoroutinefunction(self.fn):
2094
+ return modified_call(
2095
+ *args, return_state=return_state, wait_for=wait_for, **kwargs
2096
+ )
2097
+ else:
2098
+ return run_coro_as_sync(
2099
+ modified_call(
2100
+ *args,
2101
+ return_state=return_state,
2102
+ wait_for=wait_for,
2103
+ **kwargs,
2104
+ )
2105
+ )
2106
+
2107
+ def submit(self, *args: P.args, **kwargs: P.kwargs) -> PrefectFlowRunFuture[R]:
2108
+ """
2109
+ EXPERIMENTAL: This method is experimental and may be removed or changed in future
2110
+ releases.
2111
+
2112
+ Submit the flow to run on remote infrastructure.
2113
+
2114
+ Args:
2115
+ *args: Positional arguments to pass to the flow.
2116
+ **kwargs: Keyword arguments to pass to the flow.
2117
+
2118
+ Returns:
2119
+ A `PrefectFlowRunFuture` that can be used to retrieve the result of the flow run.
2120
+
2121
+ Examples:
2122
+ Submit a flow to run on Kubernetes:
2123
+
2124
+ ```python
2125
+ from prefect import flow
2126
+ from prefect_kubernetes.experimental import kubernetes
2127
+
2128
+ @kubernetes(work_pool="my-kubernetes-work-pool")
2129
+ @flow
2130
+ def my_flow(x: int, y: int):
2131
+ return x + y
2132
+
2133
+ future = my_flow.submit(x=1, y=2)
2134
+ result = future.result()
2135
+ print(result)
2136
+ ```
2137
+ """
2138
+
2139
+ async def submit_func():
2140
+ async with self.worker_cls(work_pool_name=self.work_pool) as worker:
2141
+ parameters = get_call_parameters(self, args, kwargs)
2142
+ return await worker.submit(
2143
+ flow=self,
2144
+ parameters=parameters,
2145
+ job_variables=self.job_variables,
2146
+ )
2147
+
2148
+ return run_coro_as_sync(submit_func())
2149
+
2150
+ def with_options(
2151
+ self,
2152
+ *,
2153
+ name: Optional[str] = None,
2154
+ version: Optional[str] = None,
2155
+ retries: Optional[int] = None,
2156
+ retry_delay_seconds: Optional[Union[int, float]] = None,
2157
+ description: Optional[str] = None,
2158
+ flow_run_name: Optional[Union[Callable[[], str], str]] = None,
2159
+ task_runner: Union[
2160
+ Type[TaskRunner[PrefectFuture[Any]]], TaskRunner[PrefectFuture[Any]], None
2161
+ ] = None,
2162
+ timeout_seconds: Union[int, float, None] = None,
2163
+ validate_parameters: Optional[bool] = None,
2164
+ persist_result: Optional[bool] = NotSet, # type: ignore
2165
+ result_storage: Optional[ResultStorage] = NotSet, # type: ignore
2166
+ result_serializer: Optional[ResultSerializer] = NotSet, # type: ignore
2167
+ cache_result_in_memory: Optional[bool] = None,
2168
+ log_prints: Optional[bool] = NotSet, # type: ignore
2169
+ on_completion: Optional[list[FlowStateHook[P, R]]] = None,
2170
+ on_failure: Optional[list[FlowStateHook[P, R]]] = None,
2171
+ on_cancellation: Optional[list[FlowStateHook[P, R]]] = None,
2172
+ on_crashed: Optional[list[FlowStateHook[P, R]]] = None,
2173
+ on_running: Optional[list[FlowStateHook[P, R]]] = None,
2174
+ job_variables: Optional[dict[str, Any]] = None,
2175
+ ) -> "InfrastructureBoundFlow[P, R]":
2176
+ new_flow = super().with_options(
2177
+ name=name,
2178
+ version=version,
2179
+ retries=retries,
2180
+ retry_delay_seconds=retry_delay_seconds,
2181
+ description=description,
2182
+ flow_run_name=flow_run_name,
2183
+ task_runner=task_runner,
2184
+ timeout_seconds=timeout_seconds,
2185
+ validate_parameters=validate_parameters,
2186
+ persist_result=persist_result,
2187
+ result_storage=result_storage,
2188
+ result_serializer=result_serializer,
2189
+ cache_result_in_memory=cache_result_in_memory,
2190
+ log_prints=log_prints,
2191
+ on_completion=on_completion,
2192
+ on_failure=on_failure,
2193
+ on_cancellation=on_cancellation,
2194
+ on_crashed=on_crashed,
2195
+ on_running=on_running,
2196
+ )
2197
+ new_infrastructure_bound_flow = bind_flow_to_infrastructure(
2198
+ new_flow,
2199
+ self.work_pool,
2200
+ self.worker_cls,
2201
+ job_variables=job_variables
2202
+ if job_variables is not None
2203
+ else self.job_variables,
2204
+ )
2205
+ return new_infrastructure_bound_flow
2206
+
2207
+
2208
+ def bind_flow_to_infrastructure(
2209
+ flow: Flow[P, R],
2210
+ work_pool: str,
2211
+ worker_cls: type["BaseWorker[Any, Any, Any]"],
2212
+ job_variables: dict[str, Any] | None = None,
2213
+ ) -> InfrastructureBoundFlow[P, R]:
2214
+ new = InfrastructureBoundFlow[P, R](
2215
+ flow.fn,
2216
+ work_pool=work_pool,
2217
+ job_variables=job_variables or {},
2218
+ worker_cls=worker_cls,
2219
+ )
2220
+ # Copy all attributes from the original flow
2221
+ for attr, value in flow.__dict__.items():
2222
+ setattr(new, attr, value)
2223
+ return new
2224
+
2225
+
1987
2226
  def _raise_on_name_with_banned_characters(name: Optional[str]) -> Optional[str]:
1988
2227
  """
1989
2228
  Raise an InvalidNameError if the given name contains any invalid
prefect/futures.py CHANGED
@@ -612,19 +612,44 @@ def resolve_futures_to_states(
612
612
 
613
613
  Unsupported object types will be returned without modification.
614
614
  """
615
- futures: set[PrefectFuture[R]] = set()
616
615
 
617
- def _collect_futures(
618
- futures: set[PrefectFuture[R]], expr: Any | PrefectFuture[R], context: Any
619
- ) -> Any | PrefectFuture[R]:
620
- # Expressions inside quotes should not be traversed
621
- if isinstance(context.get("annotation"), quote):
622
- raise StopVisiting()
616
+ def _resolve_state(future: PrefectFuture[R]):
617
+ future.wait()
618
+ return future.state
623
619
 
624
- if isinstance(expr, PrefectFuture):
625
- futures.add(expr)
620
+ return _resolve_futures(
621
+ expr,
622
+ resolve_fn=_resolve_state,
623
+ )
626
624
 
627
- return expr
625
+
626
+ def resolve_futures_to_results(
627
+ expr: PrefectFuture[R] | Any,
628
+ ) -> Any:
629
+ """
630
+ Given a Python built-in collection, recursively find `PrefectFutures` and build a
631
+ new collection with the same structure with futures resolved to their final results.
632
+ Resolving futures to their final result may wait for execution to complete.
633
+
634
+ Unsupported object types will be returned without modification.
635
+ """
636
+
637
+ def _resolve_result(future: PrefectFuture[R]) -> Any:
638
+ future.wait()
639
+ if future.state.is_completed():
640
+ return future.result()
641
+ else:
642
+ raise Exception("At least one result did not complete successfully")
643
+
644
+ return _resolve_futures(expr, resolve_fn=_resolve_result)
645
+
646
+
647
+ def _resolve_futures(
648
+ expr: PrefectFuture[R] | Any,
649
+ resolve_fn: Callable[[PrefectFuture[R]], Any],
650
+ ) -> Any:
651
+ """Helper function to resolve PrefectFutures in a collection."""
652
+ futures: set[PrefectFuture[R]] = set()
628
653
 
629
654
  visit_collection(
630
655
  expr,
@@ -633,31 +658,39 @@ def resolve_futures_to_states(
633
658
  context={},
634
659
  )
635
660
 
636
- # if no futures were found, return the original expression
661
+ # If no futures were found, return the original expression
637
662
  if not futures:
638
663
  return expr
639
664
 
640
- # Get final states for each future
641
- states: list[State] = []
642
- for future in futures:
643
- future.wait()
644
- states.append(future.state)
645
-
646
- states_by_future = dict(zip(futures, states))
665
+ # Resolve each future using the provided resolve function
666
+ resolved_values = {future: resolve_fn(future) for future in futures}
647
667
 
648
- def replace_futures_with_states(expr: Any, context: Any) -> Any:
668
+ def replace_futures(expr: Any, context: Any) -> Any:
649
669
  # Expressions inside quotes should not be modified
650
670
  if isinstance(context.get("annotation"), quote):
651
671
  raise StopVisiting()
652
672
 
653
673
  if isinstance(expr, PrefectFuture):
654
- return states_by_future[expr]
674
+ return resolved_values[expr]
655
675
  else:
656
676
  return expr
657
677
 
658
678
  return visit_collection(
659
679
  expr,
660
- visit_fn=replace_futures_with_states,
680
+ visit_fn=replace_futures,
661
681
  return_data=True,
662
682
  context={},
663
683
  )
684
+
685
+
686
+ def _collect_futures(
687
+ futures: set[PrefectFuture[R]], expr: Any | PrefectFuture[R], context: Any
688
+ ) -> Any | PrefectFuture[R]:
689
+ # Expressions inside quotes should not be traversed
690
+ if isinstance(context.get("annotation"), quote):
691
+ raise StopVisiting()
692
+
693
+ if isinstance(expr, PrefectFuture):
694
+ futures.add(expr)
695
+
696
+ return expr
prefect/runner/runner.py CHANGED
@@ -636,7 +636,12 @@ class Runner:
636
636
 
637
637
  return process
638
638
 
639
- async def execute_bundle(self, bundle: SerializedBundle) -> None:
639
+ async def execute_bundle(
640
+ self,
641
+ bundle: SerializedBundle,
642
+ cwd: Path | str | None = None,
643
+ env: dict[str, str | None] | None = None,
644
+ ) -> None:
640
645
  """
641
646
  Executes a bundle in a subprocess.
642
647
  """
@@ -651,7 +656,7 @@ class Runner:
651
656
  if not self._acquire_limit_slot(flow_run.id):
652
657
  return
653
658
 
654
- process = execute_bundle_in_subprocess(bundle)
659
+ process = execute_bundle_in_subprocess(bundle, cwd=cwd, env=env)
655
660
 
656
661
  if process.pid is None:
657
662
  # This shouldn't happen because `execute_bundle_in_subprocess` starts the process
@@ -776,7 +781,7 @@ class Runner:
776
781
  if command is None:
777
782
  runner_command = [get_sys_executable(), "-m", "prefect.engine"]
778
783
  else:
779
- runner_command = shlex.split(command)
784
+ runner_command = shlex.split(command, posix=(os.name != "nt"))
780
785
 
781
786
  flow_run_logger = self._get_flow_run_logger(flow_run)
782
787
 
@@ -98,9 +98,31 @@ async def create_deployment(
98
98
  )
99
99
 
100
100
  # hydrate the input model into a full model
101
- deployment_dict = deployment.model_dump(
102
- exclude={"work_pool_name"}, exclude_unset=True
101
+ deployment_dict: dict = deployment.model_dump(
102
+ exclude={"work_pool_name"},
103
+ exclude_unset=True,
103
104
  )
105
+
106
+ requested_concurrency_limit = deployment_dict.pop(
107
+ "global_concurrency_limit_id", "unset"
108
+ )
109
+ if requested_concurrency_limit != "unset":
110
+ if requested_concurrency_limit:
111
+ concurrency_limit = (
112
+ await models.concurrency_limits_v2.read_concurrency_limit(
113
+ session=session,
114
+ concurrency_limit_id=requested_concurrency_limit,
115
+ )
116
+ )
117
+
118
+ if not concurrency_limit:
119
+ raise HTTPException(
120
+ status_code=status.HTTP_404_NOT_FOUND,
121
+ detail="Concurrency limit not found",
122
+ )
123
+
124
+ deployment_dict["concurrency_limit_id"] = requested_concurrency_limit
125
+
104
126
  if deployment.work_pool_name and deployment.work_queue_name:
105
127
  # If a specific pool name/queue name combination was provided, get the
106
128
  # ID for that work pool queue.
@@ -300,8 +322,24 @@ async def update_deployment(
300
322
  detail="Invalid schema: Unable to validate schema with circular references.",
301
323
  )
302
324
 
325
+ if deployment.global_concurrency_limit_id:
326
+ concurrency_limit = (
327
+ await models.concurrency_limits_v2.read_concurrency_limit(
328
+ session=session,
329
+ concurrency_limit_id=deployment.global_concurrency_limit_id,
330
+ )
331
+ )
332
+
333
+ if not concurrency_limit:
334
+ raise HTTPException(
335
+ status_code=status.HTTP_404_NOT_FOUND,
336
+ detail="Concurrency limit not found",
337
+ )
338
+
303
339
  result = await models.deployments.update_deployment(
304
- session=session, deployment_id=deployment_id, deployment=deployment
340
+ session=session,
341
+ deployment_id=deployment_id,
342
+ deployment=deployment,
305
343
  )
306
344
 
307
345
  for schedule in schedules_to_patch:
@@ -0,0 +1,117 @@
1
+ import ast
2
+ import math
3
+ from typing import TYPE_CHECKING, Literal
4
+
5
+ import anyio
6
+ from typing_extensions import TypeAlias
7
+
8
+ from prefect.logging.loggers import get_logger
9
+ from prefect.settings import get_current_settings
10
+ from prefect.utilities.asyncutils import LazySemaphore
11
+ from prefect.utilities.filesystem import get_open_file_limit
12
+
13
+ # Only allow half of the open file limit to be open at once to allow for other
14
+ # actors to open files.
15
+ OPEN_FILE_SEMAPHORE = LazySemaphore(lambda: math.floor(get_open_file_limit() * 0.5))
16
+
17
+ # this potentially could be a TypedDict, but you
18
+ # need some way to convince the type checker that
19
+ # Literal["flow_name", "task_name"] are being provided
20
+ DecoratedFnMetadata: TypeAlias = dict[str, str]
21
+
22
+
23
+ async def find_prefect_decorated_functions_in_file(
24
+ path: anyio.Path, decorator_module: str, decorator_name: Literal["flow", "task"]
25
+ ) -> list[DecoratedFnMetadata]:
26
+ logger = get_logger()
27
+ decorator_name_key = f"{decorator_name}_name"
28
+ decorated_functions: list[DecoratedFnMetadata] = []
29
+
30
+ async with OPEN_FILE_SEMAPHORE:
31
+ try:
32
+ async with await anyio.open_file(path) as f:
33
+ try:
34
+ tree = ast.parse(await f.read())
35
+ except SyntaxError:
36
+ if get_current_settings().debug_mode:
37
+ logger.debug(
38
+ f"Could not parse {path} as a Python file. Skipping."
39
+ )
40
+ return decorated_functions
41
+ except Exception as exc:
42
+ if get_current_settings().debug_mode:
43
+ logger.debug(f"Could not open {path}: {exc}. Skipping.")
44
+ return decorated_functions
45
+
46
+ for node in ast.walk(tree):
47
+ if isinstance(
48
+ node,
49
+ (
50
+ ast.FunctionDef,
51
+ ast.AsyncFunctionDef,
52
+ ),
53
+ ):
54
+ for decorator in node.decorator_list:
55
+ # handles @decorator_name
56
+ is_name_match = (
57
+ isinstance(decorator, ast.Name) and decorator.id == decorator_name
58
+ )
59
+ # handles @decorator_name()
60
+ is_func_name_match = (
61
+ isinstance(decorator, ast.Call)
62
+ and isinstance(decorator.func, ast.Name)
63
+ and decorator.func.id == decorator_name
64
+ )
65
+ # handles @decorator_module.decorator_name
66
+ is_module_attribute_match = (
67
+ isinstance(decorator, ast.Attribute)
68
+ and isinstance(decorator.value, ast.Name)
69
+ and decorator.value.id == decorator_module
70
+ and decorator.attr == decorator_name
71
+ )
72
+ # handles @decorator_module.decorator_name()
73
+ is_module_attribute_func_match = (
74
+ isinstance(decorator, ast.Call)
75
+ and isinstance(decorator.func, ast.Attribute)
76
+ and decorator.func.attr == decorator_name
77
+ and isinstance(decorator.func.value, ast.Name)
78
+ and decorator.func.value.id == decorator_module
79
+ )
80
+ if is_name_match or is_module_attribute_match:
81
+ decorated_functions.append(
82
+ {
83
+ decorator_name_key: node.name,
84
+ "function_name": node.name,
85
+ "filepath": str(path),
86
+ }
87
+ )
88
+ if is_func_name_match or is_module_attribute_func_match:
89
+ name_kwarg_node = None
90
+ if TYPE_CHECKING:
91
+ assert isinstance(decorator, ast.Call)
92
+ for kw in decorator.keywords:
93
+ if kw.arg == "name":
94
+ name_kwarg_node = kw
95
+ break
96
+ if name_kwarg_node is not None and isinstance(
97
+ name_kwarg_node.value, ast.Constant
98
+ ):
99
+ decorated_fn_name = name_kwarg_node.value.value
100
+ else:
101
+ decorated_fn_name = node.name
102
+ decorated_functions.append(
103
+ {
104
+ decorator_name_key: decorated_fn_name,
105
+ "function_name": node.name,
106
+ "filepath": str(path),
107
+ }
108
+ )
109
+ return decorated_functions
110
+
111
+
112
+ async def find_flow_functions_in_file(path: anyio.Path) -> list[DecoratedFnMetadata]:
113
+ return await find_prefect_decorated_functions_in_file(path, "prefect", "flow")
114
+
115
+
116
+ async def find_task_functions_in_file(path: anyio.Path) -> list[DecoratedFnMetadata]:
117
+ return await find_prefect_decorated_functions_in_file(path, "prefect", "task")