prefect-client 2.19.4__py3-none-any.whl → 3.0.0rc2__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.
Files changed (242) hide show
  1. prefect/__init__.py +8 -56
  2. prefect/_internal/compatibility/deprecated.py +6 -115
  3. prefect/_internal/compatibility/experimental.py +4 -79
  4. prefect/_internal/concurrency/api.py +0 -34
  5. prefect/_internal/concurrency/calls.py +0 -6
  6. prefect/_internal/concurrency/cancellation.py +0 -3
  7. prefect/_internal/concurrency/event_loop.py +0 -20
  8. prefect/_internal/concurrency/inspection.py +3 -3
  9. prefect/_internal/concurrency/threads.py +35 -0
  10. prefect/_internal/concurrency/waiters.py +0 -28
  11. prefect/_internal/pydantic/__init__.py +0 -45
  12. prefect/_internal/pydantic/v1_schema.py +21 -22
  13. prefect/_internal/pydantic/v2_schema.py +0 -2
  14. prefect/_internal/pydantic/v2_validated_func.py +18 -23
  15. prefect/_internal/schemas/bases.py +44 -177
  16. prefect/_internal/schemas/fields.py +1 -43
  17. prefect/_internal/schemas/validators.py +60 -158
  18. prefect/artifacts.py +161 -14
  19. prefect/automations.py +39 -4
  20. prefect/blocks/abstract.py +1 -1
  21. prefect/blocks/core.py +268 -148
  22. prefect/blocks/fields.py +2 -57
  23. prefect/blocks/kubernetes.py +8 -12
  24. prefect/blocks/notifications.py +40 -20
  25. prefect/blocks/redis.py +168 -0
  26. prefect/blocks/system.py +22 -11
  27. prefect/blocks/webhook.py +2 -9
  28. prefect/client/base.py +4 -4
  29. prefect/client/cloud.py +8 -13
  30. prefect/client/orchestration.py +362 -340
  31. prefect/client/schemas/actions.py +92 -86
  32. prefect/client/schemas/filters.py +20 -40
  33. prefect/client/schemas/objects.py +158 -152
  34. prefect/client/schemas/responses.py +16 -24
  35. prefect/client/schemas/schedules.py +47 -35
  36. prefect/client/subscriptions.py +2 -2
  37. prefect/client/utilities.py +5 -2
  38. prefect/concurrency/asyncio.py +4 -2
  39. prefect/concurrency/events.py +1 -1
  40. prefect/concurrency/services.py +7 -4
  41. prefect/context.py +195 -27
  42. prefect/deployments/__init__.py +5 -6
  43. prefect/deployments/base.py +7 -5
  44. prefect/deployments/flow_runs.py +185 -0
  45. prefect/deployments/runner.py +50 -45
  46. prefect/deployments/schedules.py +28 -23
  47. prefect/deployments/steps/__init__.py +0 -1
  48. prefect/deployments/steps/core.py +1 -0
  49. prefect/deployments/steps/pull.py +7 -21
  50. prefect/engine.py +12 -2422
  51. prefect/events/actions.py +17 -23
  52. prefect/events/cli/automations.py +19 -6
  53. prefect/events/clients.py +14 -37
  54. prefect/events/filters.py +14 -18
  55. prefect/events/related.py +2 -2
  56. prefect/events/schemas/__init__.py +0 -5
  57. prefect/events/schemas/automations.py +55 -46
  58. prefect/events/schemas/deployment_triggers.py +7 -197
  59. prefect/events/schemas/events.py +36 -65
  60. prefect/events/schemas/labelling.py +10 -14
  61. prefect/events/utilities.py +2 -3
  62. prefect/events/worker.py +2 -3
  63. prefect/filesystems.py +6 -517
  64. prefect/{new_flow_engine.py → flow_engine.py} +315 -74
  65. prefect/flow_runs.py +379 -7
  66. prefect/flows.py +248 -165
  67. prefect/futures.py +187 -345
  68. prefect/infrastructure/__init__.py +0 -27
  69. prefect/infrastructure/provisioners/__init__.py +5 -3
  70. prefect/infrastructure/provisioners/cloud_run.py +11 -6
  71. prefect/infrastructure/provisioners/container_instance.py +11 -7
  72. prefect/infrastructure/provisioners/ecs.py +6 -4
  73. prefect/infrastructure/provisioners/modal.py +8 -5
  74. prefect/input/actions.py +2 -4
  75. prefect/input/run_input.py +9 -9
  76. prefect/logging/formatters.py +0 -2
  77. prefect/logging/handlers.py +3 -11
  78. prefect/logging/loggers.py +2 -2
  79. prefect/manifests.py +2 -1
  80. prefect/records/__init__.py +1 -0
  81. prefect/records/cache_policies.py +179 -0
  82. prefect/records/result_store.py +42 -0
  83. prefect/records/store.py +9 -0
  84. prefect/results.py +43 -39
  85. prefect/runner/runner.py +9 -9
  86. prefect/runner/server.py +6 -10
  87. prefect/runner/storage.py +3 -8
  88. prefect/runner/submit.py +2 -2
  89. prefect/runner/utils.py +2 -2
  90. prefect/serializers.py +24 -35
  91. prefect/server/api/collections_data/views/aggregate-worker-metadata.json +5 -14
  92. prefect/settings.py +76 -136
  93. prefect/states.py +22 -50
  94. prefect/task_engine.py +666 -56
  95. prefect/task_runners.py +272 -300
  96. prefect/task_runs.py +203 -0
  97. prefect/{task_server.py → task_worker.py} +89 -60
  98. prefect/tasks.py +358 -341
  99. prefect/transactions.py +224 -0
  100. prefect/types/__init__.py +61 -82
  101. prefect/utilities/asyncutils.py +195 -136
  102. prefect/utilities/callables.py +121 -41
  103. prefect/utilities/collections.py +23 -38
  104. prefect/utilities/dispatch.py +11 -3
  105. prefect/utilities/dockerutils.py +4 -0
  106. prefect/utilities/engine.py +140 -20
  107. prefect/utilities/importtools.py +26 -27
  108. prefect/utilities/pydantic.py +128 -38
  109. prefect/utilities/schema_tools/hydration.py +5 -1
  110. prefect/utilities/templating.py +12 -2
  111. prefect/variables.py +84 -62
  112. prefect/workers/__init__.py +0 -1
  113. prefect/workers/base.py +26 -18
  114. prefect/workers/process.py +3 -8
  115. prefect/workers/server.py +2 -2
  116. {prefect_client-2.19.4.dist-info → prefect_client-3.0.0rc2.dist-info}/METADATA +23 -21
  117. prefect_client-3.0.0rc2.dist-info/RECORD +179 -0
  118. prefect/_internal/pydantic/_base_model.py +0 -51
  119. prefect/_internal/pydantic/_compat.py +0 -82
  120. prefect/_internal/pydantic/_flags.py +0 -20
  121. prefect/_internal/pydantic/_types.py +0 -8
  122. prefect/_internal/pydantic/utilities/__init__.py +0 -0
  123. prefect/_internal/pydantic/utilities/config_dict.py +0 -72
  124. prefect/_internal/pydantic/utilities/field_validator.py +0 -150
  125. prefect/_internal/pydantic/utilities/model_construct.py +0 -56
  126. prefect/_internal/pydantic/utilities/model_copy.py +0 -55
  127. prefect/_internal/pydantic/utilities/model_dump.py +0 -136
  128. prefect/_internal/pydantic/utilities/model_dump_json.py +0 -112
  129. prefect/_internal/pydantic/utilities/model_fields.py +0 -50
  130. prefect/_internal/pydantic/utilities/model_fields_set.py +0 -29
  131. prefect/_internal/pydantic/utilities/model_json_schema.py +0 -82
  132. prefect/_internal/pydantic/utilities/model_rebuild.py +0 -80
  133. prefect/_internal/pydantic/utilities/model_validate.py +0 -75
  134. prefect/_internal/pydantic/utilities/model_validate_json.py +0 -68
  135. prefect/_internal/pydantic/utilities/model_validator.py +0 -87
  136. prefect/_internal/pydantic/utilities/type_adapter.py +0 -71
  137. prefect/_vendor/__init__.py +0 -0
  138. prefect/_vendor/fastapi/__init__.py +0 -25
  139. prefect/_vendor/fastapi/applications.py +0 -946
  140. prefect/_vendor/fastapi/background.py +0 -3
  141. prefect/_vendor/fastapi/concurrency.py +0 -44
  142. prefect/_vendor/fastapi/datastructures.py +0 -58
  143. prefect/_vendor/fastapi/dependencies/__init__.py +0 -0
  144. prefect/_vendor/fastapi/dependencies/models.py +0 -64
  145. prefect/_vendor/fastapi/dependencies/utils.py +0 -877
  146. prefect/_vendor/fastapi/encoders.py +0 -177
  147. prefect/_vendor/fastapi/exception_handlers.py +0 -40
  148. prefect/_vendor/fastapi/exceptions.py +0 -46
  149. prefect/_vendor/fastapi/logger.py +0 -3
  150. prefect/_vendor/fastapi/middleware/__init__.py +0 -1
  151. prefect/_vendor/fastapi/middleware/asyncexitstack.py +0 -25
  152. prefect/_vendor/fastapi/middleware/cors.py +0 -3
  153. prefect/_vendor/fastapi/middleware/gzip.py +0 -3
  154. prefect/_vendor/fastapi/middleware/httpsredirect.py +0 -3
  155. prefect/_vendor/fastapi/middleware/trustedhost.py +0 -3
  156. prefect/_vendor/fastapi/middleware/wsgi.py +0 -3
  157. prefect/_vendor/fastapi/openapi/__init__.py +0 -0
  158. prefect/_vendor/fastapi/openapi/constants.py +0 -2
  159. prefect/_vendor/fastapi/openapi/docs.py +0 -203
  160. prefect/_vendor/fastapi/openapi/models.py +0 -480
  161. prefect/_vendor/fastapi/openapi/utils.py +0 -485
  162. prefect/_vendor/fastapi/param_functions.py +0 -340
  163. prefect/_vendor/fastapi/params.py +0 -453
  164. prefect/_vendor/fastapi/requests.py +0 -4
  165. prefect/_vendor/fastapi/responses.py +0 -40
  166. prefect/_vendor/fastapi/routing.py +0 -1331
  167. prefect/_vendor/fastapi/security/__init__.py +0 -15
  168. prefect/_vendor/fastapi/security/api_key.py +0 -98
  169. prefect/_vendor/fastapi/security/base.py +0 -6
  170. prefect/_vendor/fastapi/security/http.py +0 -172
  171. prefect/_vendor/fastapi/security/oauth2.py +0 -227
  172. prefect/_vendor/fastapi/security/open_id_connect_url.py +0 -34
  173. prefect/_vendor/fastapi/security/utils.py +0 -10
  174. prefect/_vendor/fastapi/staticfiles.py +0 -1
  175. prefect/_vendor/fastapi/templating.py +0 -3
  176. prefect/_vendor/fastapi/testclient.py +0 -1
  177. prefect/_vendor/fastapi/types.py +0 -3
  178. prefect/_vendor/fastapi/utils.py +0 -235
  179. prefect/_vendor/fastapi/websockets.py +0 -7
  180. prefect/_vendor/starlette/__init__.py +0 -1
  181. prefect/_vendor/starlette/_compat.py +0 -28
  182. prefect/_vendor/starlette/_exception_handler.py +0 -80
  183. prefect/_vendor/starlette/_utils.py +0 -88
  184. prefect/_vendor/starlette/applications.py +0 -261
  185. prefect/_vendor/starlette/authentication.py +0 -159
  186. prefect/_vendor/starlette/background.py +0 -43
  187. prefect/_vendor/starlette/concurrency.py +0 -59
  188. prefect/_vendor/starlette/config.py +0 -151
  189. prefect/_vendor/starlette/convertors.py +0 -87
  190. prefect/_vendor/starlette/datastructures.py +0 -707
  191. prefect/_vendor/starlette/endpoints.py +0 -130
  192. prefect/_vendor/starlette/exceptions.py +0 -60
  193. prefect/_vendor/starlette/formparsers.py +0 -276
  194. prefect/_vendor/starlette/middleware/__init__.py +0 -17
  195. prefect/_vendor/starlette/middleware/authentication.py +0 -52
  196. prefect/_vendor/starlette/middleware/base.py +0 -220
  197. prefect/_vendor/starlette/middleware/cors.py +0 -176
  198. prefect/_vendor/starlette/middleware/errors.py +0 -265
  199. prefect/_vendor/starlette/middleware/exceptions.py +0 -74
  200. prefect/_vendor/starlette/middleware/gzip.py +0 -113
  201. prefect/_vendor/starlette/middleware/httpsredirect.py +0 -19
  202. prefect/_vendor/starlette/middleware/sessions.py +0 -82
  203. prefect/_vendor/starlette/middleware/trustedhost.py +0 -64
  204. prefect/_vendor/starlette/middleware/wsgi.py +0 -147
  205. prefect/_vendor/starlette/requests.py +0 -328
  206. prefect/_vendor/starlette/responses.py +0 -347
  207. prefect/_vendor/starlette/routing.py +0 -933
  208. prefect/_vendor/starlette/schemas.py +0 -154
  209. prefect/_vendor/starlette/staticfiles.py +0 -248
  210. prefect/_vendor/starlette/status.py +0 -199
  211. prefect/_vendor/starlette/templating.py +0 -231
  212. prefect/_vendor/starlette/testclient.py +0 -804
  213. prefect/_vendor/starlette/types.py +0 -30
  214. prefect/_vendor/starlette/websockets.py +0 -193
  215. prefect/agent.py +0 -698
  216. prefect/deployments/deployments.py +0 -1042
  217. prefect/deprecated/__init__.py +0 -0
  218. prefect/deprecated/data_documents.py +0 -350
  219. prefect/deprecated/packaging/__init__.py +0 -12
  220. prefect/deprecated/packaging/base.py +0 -96
  221. prefect/deprecated/packaging/docker.py +0 -146
  222. prefect/deprecated/packaging/file.py +0 -92
  223. prefect/deprecated/packaging/orion.py +0 -80
  224. prefect/deprecated/packaging/serializers.py +0 -171
  225. prefect/events/instrument.py +0 -135
  226. prefect/infrastructure/base.py +0 -323
  227. prefect/infrastructure/container.py +0 -818
  228. prefect/infrastructure/kubernetes.py +0 -920
  229. prefect/infrastructure/process.py +0 -289
  230. prefect/new_task_engine.py +0 -423
  231. prefect/pydantic/__init__.py +0 -76
  232. prefect/pydantic/main.py +0 -39
  233. prefect/software/__init__.py +0 -2
  234. prefect/software/base.py +0 -50
  235. prefect/software/conda.py +0 -199
  236. prefect/software/pip.py +0 -122
  237. prefect/software/python.py +0 -52
  238. prefect/workers/block.py +0 -218
  239. prefect_client-2.19.4.dist-info/RECORD +0 -292
  240. {prefect_client-2.19.4.dist-info → prefect_client-3.0.0rc2.dist-info}/LICENSE +0 -0
  241. {prefect_client-2.19.4.dist-info → prefect_client-3.0.0rc2.dist-info}/WHEEL +0 -0
  242. {prefect_client-2.19.4.dist-info → prefect_client-3.0.0rc2.dist-info}/top_level.txt +0 -0
@@ -3,15 +3,13 @@ Utilities for interoperability with async functions and workers from various con
3
3
  """
4
4
 
5
5
  import asyncio
6
- import ctypes
7
6
  import inspect
8
7
  import threading
9
8
  import warnings
10
9
  from concurrent.futures import ThreadPoolExecutor
11
10
  from contextlib import asynccontextmanager
12
- from contextvars import copy_context
11
+ from contextvars import ContextVar, copy_context
13
12
  from functools import partial, wraps
14
- from threading import Thread
15
13
  from typing import (
16
14
  Any,
17
15
  Awaitable,
@@ -20,7 +18,6 @@ from typing import (
20
18
  Dict,
21
19
  List,
22
20
  Optional,
23
- Type,
24
21
  TypeVar,
25
22
  Union,
26
23
  cast,
@@ -29,9 +26,16 @@ from uuid import UUID, uuid4
29
26
 
30
27
  import anyio
31
28
  import anyio.abc
29
+ import anyio.from_thread
30
+ import anyio.to_thread
32
31
  import sniffio
33
32
  from typing_extensions import Literal, ParamSpec, TypeGuard
34
33
 
34
+ from prefect._internal.concurrency.api import _cast_to_call, from_sync
35
+ from prefect._internal.concurrency.threads import (
36
+ get_run_sync_loop,
37
+ in_run_sync_loop,
38
+ )
35
39
  from prefect.logging import get_logger
36
40
 
37
41
  T = TypeVar("T")
@@ -46,6 +50,14 @@ EVENT_LOOP_GC_REFS = {}
46
50
 
47
51
  PREFECT_THREAD_LIMITER: Optional[anyio.CapacityLimiter] = None
48
52
 
53
+ RUNNING_IN_RUN_SYNC_LOOP_FLAG = ContextVar("running_in_run_sync_loop", default=False)
54
+ RUNNING_ASYNC_FLAG = ContextVar("run_async", default=False)
55
+ BACKGROUND_TASKS: set[asyncio.Task] = set()
56
+ background_task_lock = threading.Lock()
57
+
58
+ # Thread-local storage to keep track of worker thread state
59
+ _thread_local = threading.local()
60
+
49
61
  logger = get_logger()
50
62
 
51
63
 
@@ -82,12 +94,47 @@ def is_async_gen_fn(func):
82
94
  return inspect.isasyncgenfunction(func)
83
95
 
84
96
 
85
- def run_sync(coroutine: Coroutine[Any, Any, T]) -> T:
97
+ def create_task(coroutine: Coroutine) -> asyncio.Task:
98
+ """
99
+ Replacement for asyncio.create_task that will ensure that tasks aren't
100
+ garbage collected before they complete. Allows for "fire and forget"
101
+ behavior in which tasks can be created and the application can move on.
102
+ Tasks can also be awaited normally.
103
+
104
+ See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task
105
+ for details (and essentially this implementation)
86
106
  """
87
- Runs a coroutine from a synchronous context. A thread will be spawned
88
- to run the event loop if necessary, which allows coroutines to run in
89
- environments like Jupyter notebooks where the event loop runs on the main
90
- thread.
107
+
108
+ task = asyncio.create_task(coroutine)
109
+
110
+ # Add task to the set. This creates a strong reference.
111
+ # Take a lock because this might be done from multiple threads.
112
+ with background_task_lock:
113
+ BACKGROUND_TASKS.add(task)
114
+
115
+ # To prevent keeping references to finished tasks forever,
116
+ # make each task remove its own reference from the set after
117
+ # completion:
118
+ task.add_done_callback(BACKGROUND_TASKS.discard)
119
+
120
+ return task
121
+
122
+
123
+ def _run_sync_in_new_thread(coroutine: Coroutine[Any, Any, T]) -> T:
124
+ """
125
+ Note: this is an OLD implementation of `run_coro_as_sync` which liberally created
126
+ new threads and new loops. This works, but prevents sharing any objects
127
+ across coroutines, in particular httpx clients, which are very expensive to
128
+ instantiate.
129
+
130
+ This is here for historical purposes and can be removed if/when it is no
131
+ longer needed for reference.
132
+
133
+ ---
134
+
135
+ Runs a coroutine from a synchronous context. A thread will be spawned to run
136
+ the event loop if necessary, which allows coroutines to run in environments
137
+ like Jupyter notebooks where the event loop runs on the main thread.
91
138
 
92
139
  Args:
93
140
  coroutine: The coroutine to run.
@@ -96,15 +143,25 @@ def run_sync(coroutine: Coroutine[Any, Any, T]) -> T:
96
143
  The return value of the coroutine.
97
144
 
98
145
  Example:
99
- Basic usage:
100
- ```python
101
- async def my_async_function(x: int) -> int:
146
+ Basic usage: ```python async def my_async_function(x: int) -> int:
102
147
  return x + 1
103
148
 
104
- run_sync(my_async_function(1))
105
- ```
149
+ run_sync(my_async_function(1)) ```
106
150
  """
151
+
107
152
  # ensure context variables are properly copied to the async frame
153
+ async def context_local_wrapper():
154
+ """
155
+ Wrapper that is submitted using copy_context().run to ensure
156
+ the RUNNING_ASYNC_FLAG mutations are tightly scoped to this coroutine's frame.
157
+ """
158
+ token = RUNNING_ASYNC_FLAG.set(True)
159
+ try:
160
+ result = await coroutine
161
+ finally:
162
+ RUNNING_ASYNC_FLAG.reset(token)
163
+ return result
164
+
108
165
  context = copy_context()
109
166
  try:
110
167
  loop = asyncio.get_running_loop()
@@ -113,102 +170,99 @@ def run_sync(coroutine: Coroutine[Any, Any, T]) -> T:
113
170
 
114
171
  if loop and loop.is_running():
115
172
  with ThreadPoolExecutor() as executor:
116
- future = executor.submit(context.run, asyncio.run, coroutine)
117
- return cast(T, future.result())
173
+ future = executor.submit(context.run, asyncio.run, context_local_wrapper())
174
+ result = cast(T, future.result())
118
175
  else:
119
- return context.run(asyncio.run, coroutine)
176
+ result = context.run(asyncio.run, context_local_wrapper())
177
+ return result
120
178
 
121
179
 
122
- async def run_sync_in_worker_thread(
123
- __fn: Callable[..., T], *args: Any, **kwargs: Any
124
- ) -> T:
180
+ def run_coro_as_sync(
181
+ coroutine: Awaitable[R],
182
+ force_new_thread: bool = False,
183
+ wait_for_result: bool = True,
184
+ ) -> R:
125
185
  """
126
- Runs a sync function in a new worker thread so that the main thread's event loop
127
- is not blocked
186
+ Runs a coroutine from a synchronous context, as if it were a synchronous
187
+ function.
128
188
 
129
- Unlike the anyio function, this defaults to a cancellable thread and does not allow
130
- passing arguments to the anyio function so users can pass kwargs to their function.
189
+ The coroutine is scheduled to run in the "run sync" event loop, which is
190
+ running in its own thread and is started the first time it is needed. This
191
+ allows us to share objects like async httpx clients among all coroutines
192
+ running in the loop.
131
193
 
132
- Note that cancellation of threads will not result in interrupted computation, the
133
- thread may continue running the outcome will just be ignored.
134
- """
135
- call = partial(__fn, *args, **kwargs)
136
- return await anyio.to_thread.run_sync(
137
- call, cancellable=True, limiter=get_thread_limiter()
138
- )
194
+ If run_sync is called from within the run_sync loop, it will run the
195
+ coroutine in a new thread, because otherwise a deadlock would occur. Note
196
+ that this behavior should not appear anywhere in the Prefect codebase or in
197
+ user code.
139
198
 
199
+ Args:
200
+ coroutine (Awaitable): The coroutine to be run as a synchronous function.
201
+ force_new_thread (bool, optional): If True, the coroutine will always be run in a new thread.
202
+ Defaults to False.
203
+ wait_for_result (bool, optional): If True, the function will wait for the coroutine to complete
204
+ and return the result. If False, the function will submit the coroutine to the "run sync"
205
+ event loop and return immediately, where it will eventually be run. Defaults to True.
140
206
 
141
- def raise_async_exception_in_thread(thread: Thread, exc_type: Type[BaseException]):
207
+ Returns:
208
+ The result of the coroutine if wait_for_result is True, otherwise None.
142
209
  """
143
- Raise an exception in a thread asynchronously.
144
210
 
145
- This will not interrupt long-running system calls like `sleep` or `wait`.
146
- """
147
- ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
148
- ctypes.c_long(thread.ident), ctypes.py_object(exc_type)
149
- )
150
- if ret == 0:
151
- raise ValueError("Thread not found.")
211
+ async def coroutine_wrapper():
212
+ """
213
+ Set flags so that children (and grandchildren...) of this task know they are running in a new
214
+ thread and do not try to run on the run_sync thread, which would cause a
215
+ deadlock.
216
+ """
217
+ token1 = RUNNING_IN_RUN_SYNC_LOOP_FLAG.set(True)
218
+ token2 = RUNNING_ASYNC_FLAG.set(True)
219
+ try:
220
+ # use `asyncio.create_task` because it copies context variables automatically
221
+ task = create_task(coroutine)
222
+ if wait_for_result:
223
+ return await task
224
+ finally:
225
+ RUNNING_IN_RUN_SYNC_LOOP_FLAG.reset(token1)
226
+ RUNNING_ASYNC_FLAG.reset(token2)
227
+
228
+ # if we are already in the run_sync loop, or a descendent of a coroutine
229
+ # that is running in the run_sync loop, we need to run this coroutine in a
230
+ # new thread
231
+ if in_run_sync_loop() or RUNNING_IN_RUN_SYNC_LOOP_FLAG.get() or force_new_thread:
232
+ return from_sync.call_in_new_thread(coroutine_wrapper)
233
+
234
+ # otherwise, we can run the coroutine in the run_sync loop
235
+ # and wait for the result
236
+ else:
237
+ call = _cast_to_call(coroutine_wrapper)
238
+ runner = get_run_sync_loop()
239
+ runner.submit(call)
240
+ return call.result()
152
241
 
153
242
 
154
- async def run_sync_in_interruptible_worker_thread(
243
+ async def run_sync_in_worker_thread(
155
244
  __fn: Callable[..., T], *args: Any, **kwargs: Any
156
245
  ) -> T:
157
246
  """
158
- Runs a sync function in a new interruptible worker thread so that the main
159
- thread's event loop is not blocked
160
-
161
- Unlike the anyio function, this performs best-effort cancellation of the
162
- thread using the C API. Cancellation will not interrupt system calls like
163
- `sleep`.
164
- """
165
-
166
- class NotSet:
167
- pass
247
+ Runs a sync function in a new worker thread so that the main thread's event loop
248
+ is not blocked.
168
249
 
169
- thread: Thread = None
170
- result = NotSet
171
- event = asyncio.Event()
172
- loop = asyncio.get_running_loop()
250
+ Unlike the anyio function, this defaults to a cancellable thread and does not allow
251
+ passing arguments to the anyio function so users can pass kwargs to their function.
173
252
 
174
- def capture_worker_thread_and_result():
175
- # Captures the worker thread that AnyIO is using to execute the function so
176
- # the main thread can perform actions on it
177
- nonlocal thread, result
178
- try:
179
- thread = threading.current_thread()
180
- result = __fn(*args, **kwargs)
181
- except BaseException as exc:
182
- result = exc
183
- raise
184
- finally:
185
- loop.call_soon_threadsafe(event.set)
253
+ Note that cancellation of threads will not result in interrupted computation, the
254
+ thread may continue running the outcome will just be ignored.
255
+ """
256
+ call = partial(__fn, *args, **kwargs)
257
+ result = await anyio.to_thread.run_sync(
258
+ call_with_mark, call, abandon_on_cancel=True, limiter=get_thread_limiter()
259
+ )
260
+ return result
186
261
 
187
- async def send_interrupt_to_thread():
188
- # This task waits until the result is returned from the thread, if cancellation
189
- # occurs during that time, we will raise the exception in the thread as well
190
- try:
191
- await event.wait()
192
- except anyio.get_cancelled_exc_class():
193
- # NOTE: We could send a SIGINT here which allow us to interrupt system
194
- # calls but the interrupt bubbles from the child thread into the main thread
195
- # and there is not a clear way to prevent it.
196
- raise_async_exception_in_thread(thread, anyio.get_cancelled_exc_class())
197
- raise
198
-
199
- async with anyio.create_task_group() as tg:
200
- tg.start_soon(send_interrupt_to_thread)
201
- tg.start_soon(
202
- partial(
203
- anyio.to_thread.run_sync,
204
- capture_worker_thread_and_result,
205
- cancellable=True,
206
- limiter=get_thread_limiter(),
207
- )
208
- )
209
262
 
210
- assert result is not NotSet
211
- return result
263
+ def call_with_mark(call):
264
+ mark_as_worker_thread()
265
+ return call()
212
266
 
213
267
 
214
268
  def run_async_from_worker_thread(
@@ -226,13 +280,12 @@ def run_async_in_new_loop(__fn: Callable[..., Awaitable[T]], *args: Any, **kwarg
226
280
  return anyio.run(partial(__fn, *args, **kwargs))
227
281
 
228
282
 
283
+ def mark_as_worker_thread():
284
+ _thread_local.is_worker_thread = True
285
+
286
+
229
287
  def in_async_worker_thread() -> bool:
230
- try:
231
- anyio.from_thread.threadlocals.current_async_module
232
- except AttributeError:
233
- return False
234
- else:
235
- return True
288
+ return getattr(_thread_local, "is_worker_thread", False)
236
289
 
237
290
 
238
291
  def in_async_main_thread() -> bool:
@@ -245,7 +298,7 @@ def in_async_main_thread() -> bool:
245
298
  return not in_async_worker_thread()
246
299
 
247
300
 
248
- def sync_compatible(async_fn: T) -> T:
301
+ def sync_compatible(async_fn: T, force_sync: bool = False) -> T:
249
302
  """
250
303
  Converts an async function into a dual async and sync function.
251
304
 
@@ -261,47 +314,53 @@ def sync_compatible(async_fn: T) -> T:
261
314
  """
262
315
 
263
316
  @wraps(async_fn)
264
- def coroutine_wrapper(*args, **kwargs):
265
- from prefect._internal.concurrency.api import create_call, from_sync
266
- from prefect._internal.concurrency.calls import get_current_call, logger
267
- from prefect._internal.concurrency.event_loop import get_running_loop
268
- from prefect._internal.concurrency.threads import get_global_loop
269
- from prefect.settings import PREFECT_EXPERIMENTAL_DISABLE_SYNC_COMPAT
270
-
271
- if PREFECT_EXPERIMENTAL_DISABLE_SYNC_COMPAT:
272
- return async_fn(*args, **kwargs)
273
-
274
- global_thread_portal = get_global_loop()
275
- current_thread = threading.current_thread()
276
- current_call = get_current_call()
277
- current_loop = get_running_loop()
317
+ def coroutine_wrapper(*args, _sync: bool = None, **kwargs):
318
+ from prefect.context import MissingContextError, get_run_context
319
+ from prefect.settings import (
320
+ PREFECT_EXPERIMENTAL_DISABLE_SYNC_COMPAT,
321
+ )
278
322
 
279
- if current_thread.ident == global_thread_portal.thread.ident:
280
- logger.debug(f"{async_fn} --> return coroutine for internal await")
281
- # In the prefect async context; return the coro for us to await
323
+ if PREFECT_EXPERIMENTAL_DISABLE_SYNC_COMPAT or _sync is False:
282
324
  return async_fn(*args, **kwargs)
283
- elif in_async_main_thread() and (
284
- not current_call or is_async_fn(current_call.fn)
285
- ):
286
- # In the main async context; return the coro for them to await
287
- logger.debug(f"{async_fn} --> return coroutine for user await")
288
- return async_fn(*args, **kwargs)
289
- elif in_async_worker_thread():
290
- # In a sync context but we can access the event loop thread; send the async
291
- # call to the parent
292
- return run_async_from_worker_thread(async_fn, *args, **kwargs)
293
- elif current_loop is not None:
294
- logger.debug(f"{async_fn} --> run async in global loop portal")
295
- # An event loop is already present but we are in a sync context, run the
296
- # call in Prefect's event loop thread
297
- return from_sync.call_soon_in_loop_thread(
298
- create_call(async_fn, *args, **kwargs)
299
- ).result()
325
+
326
+ is_async = True
327
+
328
+ # if _sync is set, we do as we're told
329
+ # otherwise, we make some determinations
330
+ if _sync is None:
331
+ try:
332
+ run_ctx = get_run_context()
333
+ parent_obj = getattr(run_ctx, "task", None)
334
+ if not parent_obj:
335
+ parent_obj = getattr(run_ctx, "flow", None)
336
+ is_async = getattr(parent_obj, "isasync", True)
337
+ except MissingContextError:
338
+ # not in an execution context, make best effort to
339
+ # decide whether to syncify
340
+ try:
341
+ asyncio.get_running_loop()
342
+ is_async = True
343
+ except RuntimeError:
344
+ is_async = False
345
+
346
+ async def ctx_call():
347
+ """
348
+ Wrapper that is submitted using copy_context().run to ensure
349
+ mutations of RUNNING_ASYNC_FLAG are tightly scoped to this coroutine's frame.
350
+ """
351
+ token = RUNNING_ASYNC_FLAG.set(True)
352
+ try:
353
+ result = await async_fn(*args, **kwargs)
354
+ finally:
355
+ RUNNING_ASYNC_FLAG.reset(token)
356
+ return result
357
+
358
+ if _sync is True:
359
+ return run_coro_as_sync(ctx_call())
360
+ elif _sync is False or RUNNING_ASYNC_FLAG.get() or is_async:
361
+ return ctx_call()
300
362
  else:
301
- logger.debug(f"{async_fn} --> run async in new loop")
302
- # Run in a new event loop, but use a `Call` for nested context detection
303
- call = create_call(async_fn, *args, **kwargs)
304
- return call()
363
+ return run_coro_as_sync(ctx_call())
305
364
 
306
365
  # TODO: This is breaking type hints on the callable... mypy is behind the curve
307
366
  # on argument annotations. We can still fix this for editors though.
@@ -5,36 +5,33 @@ Utilities for working with Python callables.
5
5
  import ast
6
6
  import importlib.util
7
7
  import inspect
8
+ import warnings
8
9
  from functools import partial
9
10
  from pathlib import Path
10
11
  from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
11
12
 
12
13
  import cloudpickle
13
-
14
- from prefect._internal.pydantic import HAS_PYDANTIC_V2
15
- from prefect._internal.pydantic.v1_schema import has_v1_type_as_param
16
-
17
- if HAS_PYDANTIC_V2:
18
- import pydantic.v1 as pydantic
19
-
20
- from prefect._internal.pydantic.v2_schema import (
21
- create_v2_schema,
22
- process_v2_params,
23
- )
24
- else:
25
- import pydantic
26
-
14
+ import pydantic
27
15
  from griffe.dataclasses import Docstring
28
16
  from griffe.docstrings.dataclasses import DocstringSectionKind
29
17
  from griffe.docstrings.parsers import Parser, parse
30
18
  from typing_extensions import Literal
31
19
 
20
+ from prefect._internal.pydantic.v1_schema import has_v1_type_as_param
21
+ from prefect._internal.pydantic.v2_schema import (
22
+ create_v2_schema,
23
+ process_v2_params,
24
+ )
32
25
  from prefect.exceptions import (
26
+ MappingLengthMismatch,
27
+ MappingMissingIterable,
33
28
  ParameterBindError,
34
29
  ReservedArgumentError,
35
30
  SignatureMismatchError,
36
31
  )
37
32
  from prefect.logging.loggers import disable_logger, get_logger
33
+ from prefect.utilities.annotations import allow_failure, quote, unmapped
34
+ from prefect.utilities.collections import isiterable
38
35
  from prefect.utilities.importtools import safe_load_namespace
39
36
 
40
37
  logger = get_logger(__name__)
@@ -230,15 +227,14 @@ class ParameterSchema(pydantic.BaseModel):
230
227
  title: Literal["Parameters"] = "Parameters"
231
228
  type: Literal["object"] = "object"
232
229
  properties: Dict[str, Any] = pydantic.Field(default_factory=dict)
233
- required: List[str] = None
234
- definitions: Optional[Dict[str, Any]] = None
230
+ required: List[str] = pydantic.Field(default_factory=list)
231
+ definitions: Dict[str, Any] = pydantic.Field(default_factory=dict)
235
232
 
236
- def dict(self, *args, **kwargs):
237
- """Exclude `None` fields by default to comply with
238
- the OpenAPI spec.
239
- """
240
- kwargs.setdefault("exclude_none", True)
241
- return super().dict(*args, **kwargs)
233
+ def model_dump_for_openapi(self) -> Dict[str, Any]:
234
+ result = self.model_dump(mode="python", exclude_none=True)
235
+ if "required" in result and not result["required"]:
236
+ del result["required"]
237
+ return result
242
238
 
243
239
 
244
240
  def parameter_docstrings(docstring: Optional[str]) -> Dict[str, str]:
@@ -286,21 +282,31 @@ def process_v1_params(
286
282
  name = param.name
287
283
 
288
284
  type_ = Any if param.annotation is inspect._empty else param.annotation
289
- field = pydantic.Field(
290
- default=... if param.default is param.empty else param.default,
291
- title=param.name,
292
- description=docstrings.get(param.name, None),
293
- alias=aliases.get(name),
294
- position=position,
295
- )
285
+
286
+ with warnings.catch_warnings():
287
+ warnings.filterwarnings(
288
+ "ignore", category=pydantic.warnings.PydanticDeprecatedSince20
289
+ )
290
+ field = pydantic.Field(
291
+ default=... if param.default is param.empty else param.default,
292
+ title=param.name,
293
+ description=docstrings.get(param.name, None),
294
+ alias=aliases.get(name),
295
+ position=position,
296
+ )
296
297
  return name, type_, field
297
298
 
298
299
 
299
300
  def create_v1_schema(name_: str, model_cfg, **model_fields):
300
- model: "pydantic.BaseModel" = pydantic.create_model(
301
- name_, __config__=model_cfg, **model_fields
302
- )
303
- return model.schema(by_alias=True)
301
+ with warnings.catch_warnings():
302
+ warnings.filterwarnings(
303
+ "ignore", category=pydantic.warnings.PydanticDeprecatedSince20
304
+ )
305
+
306
+ model: "pydantic.BaseModel" = pydantic.create_model(
307
+ name_, __config__=model_cfg, **model_fields
308
+ )
309
+ return model.schema(by_alias=True)
304
310
 
305
311
 
306
312
  def parameter_schema(fn: Callable) -> ParameterSchema:
@@ -381,16 +387,20 @@ def generate_parameter_schema(
381
387
  model_fields = {}
382
388
  aliases = {}
383
389
 
384
- class ModelConfig:
385
- arbitrary_types_allowed = True
386
-
387
- if HAS_PYDANTIC_V2 and not has_v1_type_as_param(signature):
390
+ if not has_v1_type_as_param(signature):
388
391
  create_schema = create_v2_schema
389
392
  process_params = process_v2_params
393
+
394
+ config = pydantic.ConfigDict(arbitrary_types_allowed=True)
390
395
  else:
391
396
  create_schema = create_v1_schema
392
397
  process_params = process_v1_params
393
398
 
399
+ class ModelConfig:
400
+ arbitrary_types_allowed = True
401
+
402
+ config = ModelConfig
403
+
394
404
  for position, param in enumerate(signature.parameters.values()):
395
405
  name, type_, field = process_params(
396
406
  param, position=position, docstrings=docstrings, aliases=aliases
@@ -398,16 +408,14 @@ def generate_parameter_schema(
398
408
  # Generate a Pydantic model at each step so we can check if this parameter
399
409
  # type supports schema generation
400
410
  try:
401
- create_schema(
402
- "CheckParameter", model_cfg=ModelConfig, **{name: (type_, field)}
403
- )
411
+ create_schema("CheckParameter", model_cfg=config, **{name: (type_, field)})
404
412
  except (ValueError, TypeError):
405
413
  # This field's type is not valid for schema creation, update it to `Any`
406
414
  type_ = Any
407
415
  model_fields[name] = (type_, field)
408
416
 
409
417
  # Generate the final model and schema
410
- schema = create_schema("Parameters", model_cfg=ModelConfig, **model_fields)
418
+ schema = create_schema("Parameters", model_cfg=config, **model_fields)
411
419
  return ParameterSchema(**schema)
412
420
 
413
421
 
@@ -621,3 +629,75 @@ def _get_docstring_from_source(source_code: str, func_name: str) -> Optional[str
621
629
  ):
622
630
  return func_def.body[0].value.value
623
631
  return None
632
+
633
+
634
+ def expand_mapping_parameters(
635
+ func: Callable, parameters: Dict[str, Any]
636
+ ) -> List[Dict[str, Any]]:
637
+ """
638
+ Generates a list of call parameters to be used for individual calls in a mapping
639
+ operation.
640
+
641
+ Args:
642
+ func: The function to be called
643
+ parameters: A dictionary of parameters with iterables to be mapped over
644
+
645
+ Returns:
646
+ List: A list of dictionaries to be used as parameters for each
647
+ call in the mapping operation
648
+ """
649
+ # Ensure that any parameters in kwargs are expanded before this check
650
+ parameters = explode_variadic_parameter(func, parameters)
651
+
652
+ iterable_parameters = {}
653
+ static_parameters = {}
654
+ annotated_parameters = {}
655
+ for key, val in parameters.items():
656
+ if isinstance(val, (allow_failure, quote)):
657
+ # Unwrap annotated parameters to determine if they are iterable
658
+ annotated_parameters[key] = val
659
+ val = val.unwrap()
660
+
661
+ if isinstance(val, unmapped):
662
+ static_parameters[key] = val.value
663
+ elif isiterable(val):
664
+ iterable_parameters[key] = list(val)
665
+ else:
666
+ static_parameters[key] = val
667
+
668
+ if not len(iterable_parameters):
669
+ raise MappingMissingIterable(
670
+ "No iterable parameters were received. Parameters for map must "
671
+ f"include at least one iterable. Parameters: {parameters}"
672
+ )
673
+
674
+ iterable_parameter_lengths = {
675
+ key: len(val) for key, val in iterable_parameters.items()
676
+ }
677
+ lengths = set(iterable_parameter_lengths.values())
678
+ if len(lengths) > 1:
679
+ raise MappingLengthMismatch(
680
+ "Received iterable parameters with different lengths. Parameters for map"
681
+ f" must all be the same length. Got lengths: {iterable_parameter_lengths}"
682
+ )
683
+
684
+ map_length = list(lengths)[0]
685
+
686
+ call_parameters_list = []
687
+ for i in range(map_length):
688
+ call_parameters = {key: value[i] for key, value in iterable_parameters.items()}
689
+ call_parameters.update({key: value for key, value in static_parameters.items()})
690
+
691
+ # Add default values for parameters; these are skipped earlier since they should
692
+ # not be mapped over
693
+ for key, value in get_parameter_defaults(func).items():
694
+ call_parameters.setdefault(key, value)
695
+
696
+ # Re-apply annotations to each key again
697
+ for key, annotation in annotated_parameters.items():
698
+ call_parameters[key] = annotation.rewrap(call_parameters[key])
699
+
700
+ # Collapse any previously exploded kwargs
701
+ call_parameters_list.append(collapse_variadic_parameters(func, call_parameters))
702
+
703
+ return call_parameters_list