prefect-client 2.20.2__py3-none-any.whl → 3.0.0__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 (288) hide show
  1. prefect/__init__.py +74 -110
  2. prefect/_internal/compatibility/deprecated.py +6 -115
  3. prefect/_internal/compatibility/experimental.py +4 -79
  4. prefect/_internal/compatibility/migration.py +166 -0
  5. prefect/_internal/concurrency/__init__.py +2 -2
  6. prefect/_internal/concurrency/api.py +1 -35
  7. prefect/_internal/concurrency/calls.py +0 -6
  8. prefect/_internal/concurrency/cancellation.py +0 -3
  9. prefect/_internal/concurrency/event_loop.py +0 -20
  10. prefect/_internal/concurrency/inspection.py +3 -3
  11. prefect/_internal/concurrency/primitives.py +1 -0
  12. prefect/_internal/concurrency/services.py +23 -0
  13. prefect/_internal/concurrency/threads.py +35 -0
  14. prefect/_internal/concurrency/waiters.py +0 -28
  15. prefect/_internal/integrations.py +7 -0
  16. prefect/_internal/pydantic/__init__.py +0 -45
  17. prefect/_internal/pydantic/annotations/pendulum.py +2 -2
  18. prefect/_internal/pydantic/v1_schema.py +21 -22
  19. prefect/_internal/pydantic/v2_schema.py +0 -2
  20. prefect/_internal/pydantic/v2_validated_func.py +18 -23
  21. prefect/_internal/pytz.py +1 -1
  22. prefect/_internal/retries.py +61 -0
  23. prefect/_internal/schemas/bases.py +45 -177
  24. prefect/_internal/schemas/fields.py +1 -43
  25. prefect/_internal/schemas/validators.py +47 -233
  26. prefect/agent.py +3 -695
  27. prefect/artifacts.py +173 -14
  28. prefect/automations.py +39 -4
  29. prefect/blocks/abstract.py +1 -1
  30. prefect/blocks/core.py +423 -164
  31. prefect/blocks/fields.py +2 -57
  32. prefect/blocks/notifications.py +43 -28
  33. prefect/blocks/redis.py +168 -0
  34. prefect/blocks/system.py +67 -20
  35. prefect/blocks/webhook.py +2 -9
  36. prefect/cache_policies.py +239 -0
  37. prefect/client/__init__.py +4 -0
  38. prefect/client/base.py +33 -27
  39. prefect/client/cloud.py +65 -20
  40. prefect/client/collections.py +1 -1
  41. prefect/client/orchestration.py +667 -440
  42. prefect/client/schemas/actions.py +115 -100
  43. prefect/client/schemas/filters.py +46 -52
  44. prefect/client/schemas/objects.py +228 -178
  45. prefect/client/schemas/responses.py +18 -36
  46. prefect/client/schemas/schedules.py +55 -36
  47. prefect/client/schemas/sorting.py +2 -0
  48. prefect/client/subscriptions.py +8 -7
  49. prefect/client/types/flexible_schedule_list.py +11 -0
  50. prefect/client/utilities.py +9 -6
  51. prefect/concurrency/asyncio.py +60 -11
  52. prefect/concurrency/context.py +24 -0
  53. prefect/concurrency/events.py +2 -2
  54. prefect/concurrency/services.py +46 -16
  55. prefect/concurrency/sync.py +51 -7
  56. prefect/concurrency/v1/asyncio.py +143 -0
  57. prefect/concurrency/v1/context.py +27 -0
  58. prefect/concurrency/v1/events.py +61 -0
  59. prefect/concurrency/v1/services.py +116 -0
  60. prefect/concurrency/v1/sync.py +92 -0
  61. prefect/context.py +246 -149
  62. prefect/deployments/__init__.py +33 -18
  63. prefect/deployments/base.py +10 -15
  64. prefect/deployments/deployments.py +2 -1048
  65. prefect/deployments/flow_runs.py +178 -0
  66. prefect/deployments/runner.py +72 -173
  67. prefect/deployments/schedules.py +31 -25
  68. prefect/deployments/steps/__init__.py +0 -1
  69. prefect/deployments/steps/core.py +7 -0
  70. prefect/deployments/steps/pull.py +15 -21
  71. prefect/deployments/steps/utility.py +2 -1
  72. prefect/docker/__init__.py +20 -0
  73. prefect/docker/docker_image.py +82 -0
  74. prefect/engine.py +15 -2466
  75. prefect/events/actions.py +17 -23
  76. prefect/events/cli/automations.py +20 -7
  77. prefect/events/clients.py +142 -80
  78. prefect/events/filters.py +14 -18
  79. prefect/events/related.py +74 -75
  80. prefect/events/schemas/__init__.py +0 -5
  81. prefect/events/schemas/automations.py +55 -46
  82. prefect/events/schemas/deployment_triggers.py +7 -197
  83. prefect/events/schemas/events.py +46 -65
  84. prefect/events/schemas/labelling.py +10 -14
  85. prefect/events/utilities.py +4 -5
  86. prefect/events/worker.py +23 -8
  87. prefect/exceptions.py +15 -0
  88. prefect/filesystems.py +30 -529
  89. prefect/flow_engine.py +827 -0
  90. prefect/flow_runs.py +379 -7
  91. prefect/flows.py +470 -360
  92. prefect/futures.py +382 -331
  93. prefect/infrastructure/__init__.py +5 -26
  94. prefect/infrastructure/base.py +3 -320
  95. prefect/infrastructure/provisioners/__init__.py +5 -3
  96. prefect/infrastructure/provisioners/cloud_run.py +13 -8
  97. prefect/infrastructure/provisioners/container_instance.py +14 -9
  98. prefect/infrastructure/provisioners/ecs.py +10 -8
  99. prefect/infrastructure/provisioners/modal.py +8 -5
  100. prefect/input/__init__.py +4 -0
  101. prefect/input/actions.py +2 -4
  102. prefect/input/run_input.py +9 -9
  103. prefect/logging/formatters.py +2 -4
  104. prefect/logging/handlers.py +9 -14
  105. prefect/logging/loggers.py +5 -5
  106. prefect/main.py +72 -0
  107. prefect/plugins.py +2 -64
  108. prefect/profiles.toml +16 -2
  109. prefect/records/__init__.py +1 -0
  110. prefect/records/base.py +223 -0
  111. prefect/records/filesystem.py +207 -0
  112. prefect/records/memory.py +178 -0
  113. prefect/records/result_store.py +64 -0
  114. prefect/results.py +577 -504
  115. prefect/runner/runner.py +124 -51
  116. prefect/runner/server.py +32 -34
  117. prefect/runner/storage.py +3 -12
  118. prefect/runner/submit.py +2 -10
  119. prefect/runner/utils.py +2 -2
  120. prefect/runtime/__init__.py +1 -0
  121. prefect/runtime/deployment.py +1 -0
  122. prefect/runtime/flow_run.py +40 -5
  123. prefect/runtime/task_run.py +1 -0
  124. prefect/serializers.py +28 -39
  125. prefect/server/api/collections_data/views/aggregate-worker-metadata.json +5 -14
  126. prefect/settings.py +209 -332
  127. prefect/states.py +160 -63
  128. prefect/task_engine.py +1478 -57
  129. prefect/task_runners.py +383 -287
  130. prefect/task_runs.py +240 -0
  131. prefect/task_worker.py +463 -0
  132. prefect/tasks.py +684 -374
  133. prefect/transactions.py +410 -0
  134. prefect/types/__init__.py +72 -86
  135. prefect/types/entrypoint.py +13 -0
  136. prefect/utilities/annotations.py +4 -3
  137. prefect/utilities/asyncutils.py +227 -148
  138. prefect/utilities/callables.py +138 -48
  139. prefect/utilities/collections.py +134 -86
  140. prefect/utilities/dispatch.py +27 -14
  141. prefect/utilities/dockerutils.py +11 -4
  142. prefect/utilities/engine.py +186 -32
  143. prefect/utilities/filesystem.py +4 -5
  144. prefect/utilities/importtools.py +26 -27
  145. prefect/utilities/pydantic.py +128 -38
  146. prefect/utilities/schema_tools/hydration.py +18 -1
  147. prefect/utilities/schema_tools/validation.py +30 -0
  148. prefect/utilities/services.py +35 -9
  149. prefect/utilities/templating.py +12 -2
  150. prefect/utilities/timeout.py +20 -5
  151. prefect/utilities/urls.py +195 -0
  152. prefect/utilities/visualization.py +1 -0
  153. prefect/variables.py +78 -59
  154. prefect/workers/__init__.py +0 -1
  155. prefect/workers/base.py +237 -244
  156. prefect/workers/block.py +5 -226
  157. prefect/workers/cloud.py +6 -0
  158. prefect/workers/process.py +265 -12
  159. prefect/workers/server.py +29 -11
  160. {prefect_client-2.20.2.dist-info → prefect_client-3.0.0.dist-info}/METADATA +30 -26
  161. prefect_client-3.0.0.dist-info/RECORD +201 -0
  162. {prefect_client-2.20.2.dist-info → prefect_client-3.0.0.dist-info}/WHEEL +1 -1
  163. prefect/_internal/pydantic/_base_model.py +0 -51
  164. prefect/_internal/pydantic/_compat.py +0 -82
  165. prefect/_internal/pydantic/_flags.py +0 -20
  166. prefect/_internal/pydantic/_types.py +0 -8
  167. prefect/_internal/pydantic/utilities/config_dict.py +0 -72
  168. prefect/_internal/pydantic/utilities/field_validator.py +0 -150
  169. prefect/_internal/pydantic/utilities/model_construct.py +0 -56
  170. prefect/_internal/pydantic/utilities/model_copy.py +0 -55
  171. prefect/_internal/pydantic/utilities/model_dump.py +0 -136
  172. prefect/_internal/pydantic/utilities/model_dump_json.py +0 -112
  173. prefect/_internal/pydantic/utilities/model_fields.py +0 -50
  174. prefect/_internal/pydantic/utilities/model_fields_set.py +0 -29
  175. prefect/_internal/pydantic/utilities/model_json_schema.py +0 -82
  176. prefect/_internal/pydantic/utilities/model_rebuild.py +0 -80
  177. prefect/_internal/pydantic/utilities/model_validate.py +0 -75
  178. prefect/_internal/pydantic/utilities/model_validate_json.py +0 -68
  179. prefect/_internal/pydantic/utilities/model_validator.py +0 -87
  180. prefect/_internal/pydantic/utilities/type_adapter.py +0 -71
  181. prefect/_vendor/fastapi/__init__.py +0 -25
  182. prefect/_vendor/fastapi/applications.py +0 -946
  183. prefect/_vendor/fastapi/background.py +0 -3
  184. prefect/_vendor/fastapi/concurrency.py +0 -44
  185. prefect/_vendor/fastapi/datastructures.py +0 -58
  186. prefect/_vendor/fastapi/dependencies/__init__.py +0 -0
  187. prefect/_vendor/fastapi/dependencies/models.py +0 -64
  188. prefect/_vendor/fastapi/dependencies/utils.py +0 -877
  189. prefect/_vendor/fastapi/encoders.py +0 -177
  190. prefect/_vendor/fastapi/exception_handlers.py +0 -40
  191. prefect/_vendor/fastapi/exceptions.py +0 -46
  192. prefect/_vendor/fastapi/logger.py +0 -3
  193. prefect/_vendor/fastapi/middleware/__init__.py +0 -1
  194. prefect/_vendor/fastapi/middleware/asyncexitstack.py +0 -25
  195. prefect/_vendor/fastapi/middleware/cors.py +0 -3
  196. prefect/_vendor/fastapi/middleware/gzip.py +0 -3
  197. prefect/_vendor/fastapi/middleware/httpsredirect.py +0 -3
  198. prefect/_vendor/fastapi/middleware/trustedhost.py +0 -3
  199. prefect/_vendor/fastapi/middleware/wsgi.py +0 -3
  200. prefect/_vendor/fastapi/openapi/__init__.py +0 -0
  201. prefect/_vendor/fastapi/openapi/constants.py +0 -2
  202. prefect/_vendor/fastapi/openapi/docs.py +0 -203
  203. prefect/_vendor/fastapi/openapi/models.py +0 -480
  204. prefect/_vendor/fastapi/openapi/utils.py +0 -485
  205. prefect/_vendor/fastapi/param_functions.py +0 -340
  206. prefect/_vendor/fastapi/params.py +0 -453
  207. prefect/_vendor/fastapi/py.typed +0 -0
  208. prefect/_vendor/fastapi/requests.py +0 -4
  209. prefect/_vendor/fastapi/responses.py +0 -40
  210. prefect/_vendor/fastapi/routing.py +0 -1331
  211. prefect/_vendor/fastapi/security/__init__.py +0 -15
  212. prefect/_vendor/fastapi/security/api_key.py +0 -98
  213. prefect/_vendor/fastapi/security/base.py +0 -6
  214. prefect/_vendor/fastapi/security/http.py +0 -172
  215. prefect/_vendor/fastapi/security/oauth2.py +0 -227
  216. prefect/_vendor/fastapi/security/open_id_connect_url.py +0 -34
  217. prefect/_vendor/fastapi/security/utils.py +0 -10
  218. prefect/_vendor/fastapi/staticfiles.py +0 -1
  219. prefect/_vendor/fastapi/templating.py +0 -3
  220. prefect/_vendor/fastapi/testclient.py +0 -1
  221. prefect/_vendor/fastapi/types.py +0 -3
  222. prefect/_vendor/fastapi/utils.py +0 -235
  223. prefect/_vendor/fastapi/websockets.py +0 -7
  224. prefect/_vendor/starlette/__init__.py +0 -1
  225. prefect/_vendor/starlette/_compat.py +0 -28
  226. prefect/_vendor/starlette/_exception_handler.py +0 -80
  227. prefect/_vendor/starlette/_utils.py +0 -88
  228. prefect/_vendor/starlette/applications.py +0 -261
  229. prefect/_vendor/starlette/authentication.py +0 -159
  230. prefect/_vendor/starlette/background.py +0 -43
  231. prefect/_vendor/starlette/concurrency.py +0 -59
  232. prefect/_vendor/starlette/config.py +0 -151
  233. prefect/_vendor/starlette/convertors.py +0 -87
  234. prefect/_vendor/starlette/datastructures.py +0 -707
  235. prefect/_vendor/starlette/endpoints.py +0 -130
  236. prefect/_vendor/starlette/exceptions.py +0 -60
  237. prefect/_vendor/starlette/formparsers.py +0 -276
  238. prefect/_vendor/starlette/middleware/__init__.py +0 -17
  239. prefect/_vendor/starlette/middleware/authentication.py +0 -52
  240. prefect/_vendor/starlette/middleware/base.py +0 -220
  241. prefect/_vendor/starlette/middleware/cors.py +0 -176
  242. prefect/_vendor/starlette/middleware/errors.py +0 -265
  243. prefect/_vendor/starlette/middleware/exceptions.py +0 -74
  244. prefect/_vendor/starlette/middleware/gzip.py +0 -113
  245. prefect/_vendor/starlette/middleware/httpsredirect.py +0 -19
  246. prefect/_vendor/starlette/middleware/sessions.py +0 -82
  247. prefect/_vendor/starlette/middleware/trustedhost.py +0 -64
  248. prefect/_vendor/starlette/middleware/wsgi.py +0 -147
  249. prefect/_vendor/starlette/py.typed +0 -0
  250. prefect/_vendor/starlette/requests.py +0 -328
  251. prefect/_vendor/starlette/responses.py +0 -347
  252. prefect/_vendor/starlette/routing.py +0 -933
  253. prefect/_vendor/starlette/schemas.py +0 -154
  254. prefect/_vendor/starlette/staticfiles.py +0 -248
  255. prefect/_vendor/starlette/status.py +0 -199
  256. prefect/_vendor/starlette/templating.py +0 -231
  257. prefect/_vendor/starlette/testclient.py +0 -804
  258. prefect/_vendor/starlette/types.py +0 -30
  259. prefect/_vendor/starlette/websockets.py +0 -193
  260. prefect/blocks/kubernetes.py +0 -119
  261. prefect/deprecated/__init__.py +0 -0
  262. prefect/deprecated/data_documents.py +0 -350
  263. prefect/deprecated/packaging/__init__.py +0 -12
  264. prefect/deprecated/packaging/base.py +0 -96
  265. prefect/deprecated/packaging/docker.py +0 -146
  266. prefect/deprecated/packaging/file.py +0 -92
  267. prefect/deprecated/packaging/orion.py +0 -80
  268. prefect/deprecated/packaging/serializers.py +0 -171
  269. prefect/events/instrument.py +0 -135
  270. prefect/infrastructure/container.py +0 -824
  271. prefect/infrastructure/kubernetes.py +0 -920
  272. prefect/infrastructure/process.py +0 -289
  273. prefect/manifests.py +0 -20
  274. prefect/new_flow_engine.py +0 -449
  275. prefect/new_task_engine.py +0 -423
  276. prefect/pydantic/__init__.py +0 -76
  277. prefect/pydantic/main.py +0 -39
  278. prefect/software/__init__.py +0 -2
  279. prefect/software/base.py +0 -50
  280. prefect/software/conda.py +0 -199
  281. prefect/software/pip.py +0 -122
  282. prefect/software/python.py +0 -52
  283. prefect/task_server.py +0 -322
  284. prefect_client-2.20.2.dist-info/RECORD +0 -294
  285. /prefect/{_internal/pydantic/utilities → client/types}/__init__.py +0 -0
  286. /prefect/{_vendor → concurrency/v1}/__init__.py +0 -0
  287. {prefect_client-2.20.2.dist-info → prefect_client-3.0.0.dist-info}/LICENSE +0 -0
  288. {prefect_client-2.20.2.dist-info → prefect_client-3.0.0.dist-info}/top_level.txt +0 -0
@@ -2,39 +2,53 @@ import asyncio
2
2
  import concurrent.futures
3
3
  from contextlib import asynccontextmanager
4
4
  from typing import (
5
+ TYPE_CHECKING,
6
+ AsyncGenerator,
5
7
  FrozenSet,
6
8
  Optional,
7
9
  Tuple,
8
10
  )
9
11
 
10
12
  import httpx
11
- from prefect._vendor.starlette import status
13
+ from starlette import status
12
14
 
13
- from prefect import get_client
14
15
  from prefect._internal.concurrency import logger
15
16
  from prefect._internal.concurrency.services import QueueService
16
- from prefect.client.orchestration import PrefectClient
17
+ from prefect.client.orchestration import get_client
17
18
  from prefect.utilities.timeout import timeout_async
18
19
 
20
+ if TYPE_CHECKING:
21
+ from prefect.client.orchestration import PrefectClient
22
+
19
23
 
20
24
  class ConcurrencySlotAcquisitionService(QueueService):
21
25
  def __init__(self, concurrency_limit_names: FrozenSet[str]):
22
26
  super().__init__(concurrency_limit_names)
23
- self._client: PrefectClient
27
+ self._client: "PrefectClient"
24
28
  self.concurrency_limit_names = sorted(list(concurrency_limit_names))
25
29
 
26
30
  @asynccontextmanager
27
- async def _lifespan(self):
31
+ async def _lifespan(self) -> AsyncGenerator[None, None]:
28
32
  async with get_client() as client:
29
33
  self._client = client
30
34
  yield
31
35
 
32
36
  async def _handle(
33
- self, item: Tuple[int, str, Optional[float], concurrent.futures.Future]
34
- ):
35
- occupy, mode, timeout_seconds, future = item
37
+ self,
38
+ item: Tuple[
39
+ int,
40
+ str,
41
+ Optional[float],
42
+ concurrent.futures.Future,
43
+ Optional[bool],
44
+ Optional[int],
45
+ ],
46
+ ) -> None:
47
+ occupy, mode, timeout_seconds, future, create_if_missing, max_retries = item
36
48
  try:
37
- response = await self.acquire_slots(occupy, mode, timeout_seconds)
49
+ response = await self.acquire_slots(
50
+ occupy, mode, timeout_seconds, create_if_missing, max_retries
51
+ )
38
52
  except Exception as exc:
39
53
  # If the request to the increment endpoint fails in a non-standard
40
54
  # way, we need to set the future's result so that the caller can
@@ -45,27 +59,41 @@ class ConcurrencySlotAcquisitionService(QueueService):
45
59
  future.set_result(response)
46
60
 
47
61
  async def acquire_slots(
48
- self, slots: int, mode: str, timeout_seconds: Optional[float] = None
49
- ):
50
- with timeout_async(timeout_seconds):
62
+ self,
63
+ slots: int,
64
+ mode: str,
65
+ timeout_seconds: Optional[float] = None,
66
+ create_if_missing: Optional[bool] = False,
67
+ max_retries: Optional[int] = None,
68
+ ) -> httpx.Response:
69
+ with timeout_async(seconds=timeout_seconds):
51
70
  while True:
52
71
  try:
53
72
  response = await self._client.increment_concurrency_slots(
54
- names=self.concurrency_limit_names, slots=slots, mode=mode
73
+ names=self.concurrency_limit_names,
74
+ slots=slots,
75
+ mode=mode,
76
+ create_if_missing=create_if_missing,
55
77
  )
56
78
  except Exception as exc:
57
79
  if (
58
80
  isinstance(exc, httpx.HTTPStatusError)
59
81
  and exc.response.status_code == status.HTTP_423_LOCKED
60
82
  ):
83
+ if max_retries is not None and max_retries <= 0:
84
+ raise exc
61
85
  retry_after = float(exc.response.headers["Retry-After"])
62
86
  await asyncio.sleep(retry_after)
87
+ if max_retries is not None:
88
+ max_retries -= 1
63
89
  else:
64
90
  raise exc
65
91
  else:
66
92
  return response
67
93
 
68
- def send(self, item: Tuple[int, str, Optional[float]]) -> concurrent.futures.Future:
94
+ def send(
95
+ self, item: Tuple[int, str, Optional[float], Optional[bool], Optional[int]]
96
+ ) -> concurrent.futures.Future:
69
97
  with self._lock:
70
98
  if self._stopped:
71
99
  raise RuntimeError("Cannot put items in a stopped service instance.")
@@ -73,7 +101,9 @@ class ConcurrencySlotAcquisitionService(QueueService):
73
101
  logger.debug("Service %r enqueuing item %r", self, item)
74
102
  future: concurrent.futures.Future = concurrent.futures.Future()
75
103
 
76
- occupy, mode, timeout_seconds = item
77
- self._queue.put_nowait((occupy, mode, timeout_seconds, future))
104
+ occupy, mode, timeout_seconds, create_if_missing, max_retries = item
105
+ self._queue.put_nowait(
106
+ (occupy, mode, timeout_seconds, future, create_if_missing, max_retries)
107
+ )
78
108
 
79
109
  return future
@@ -1,5 +1,15 @@
1
1
  from contextlib import contextmanager
2
- from typing import List, Optional, Union, cast
2
+ from typing import (
3
+ Any,
4
+ Awaitable,
5
+ Callable,
6
+ Generator,
7
+ List,
8
+ Optional,
9
+ TypeVar,
10
+ Union,
11
+ cast,
12
+ )
3
13
 
4
14
  import pendulum
5
15
 
@@ -22,13 +32,17 @@ from .events import (
22
32
  _emit_concurrency_release_events,
23
33
  )
24
34
 
35
+ T = TypeVar("T")
36
+
25
37
 
26
38
  @contextmanager
27
39
  def concurrency(
28
40
  names: Union[str, List[str]],
29
41
  occupy: int = 1,
30
42
  timeout_seconds: Optional[float] = None,
31
- ):
43
+ create_if_missing: bool = True,
44
+ max_retries: Optional[int] = None,
45
+ ) -> Generator[None, None, None]:
32
46
  """A context manager that acquires and releases concurrency slots from the
33
47
  given concurrency limits.
34
48
 
@@ -37,6 +51,8 @@ def concurrency(
37
51
  occupy: The number of slots to acquire and hold from each limit.
38
52
  timeout_seconds: The number of seconds to wait for the slots to be acquired before
39
53
  raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
54
+ create_if_missing: Whether to create the concurrency limits if they do not exist.
55
+ max_retries: The maximum number of retries to acquire the concurrency slots.
40
56
 
41
57
  Raises:
42
58
  TimeoutError: If the slots are not acquired within the given timeout.
@@ -54,10 +70,19 @@ def concurrency(
54
70
  resource_heavy()
55
71
  ```
56
72
  """
73
+ if not names:
74
+ yield
75
+ return
76
+
57
77
  names = names if isinstance(names, list) else [names]
58
78
 
59
79
  limits: List[MinimalConcurrencyLimitResponse] = _call_async_function_from_sync(
60
- _acquire_concurrency_slots, names, occupy, timeout_seconds=timeout_seconds
80
+ _acquire_concurrency_slots,
81
+ names,
82
+ occupy,
83
+ timeout_seconds=timeout_seconds,
84
+ create_if_missing=create_if_missing,
85
+ max_retries=max_retries,
61
86
  )
62
87
  acquisition_time = pendulum.now("UTC")
63
88
  emitted_events = _emit_concurrency_acquisition_events(limits, occupy)
@@ -75,7 +100,12 @@ def concurrency(
75
100
  _emit_concurrency_release_events(limits, occupy, emitted_events)
76
101
 
77
102
 
78
- def rate_limit(names: Union[str, List[str]], occupy: int = 1):
103
+ def rate_limit(
104
+ names: Union[str, List[str]],
105
+ occupy: int = 1,
106
+ timeout_seconds: Optional[float] = None,
107
+ create_if_missing: Optional[bool] = True,
108
+ ) -> None:
79
109
  """Block execution until an `occupy` number of slots of the concurrency
80
110
  limits given in `names` are acquired. Requires that all given concurrency
81
111
  limits have a slot decay.
@@ -83,19 +113,33 @@ def rate_limit(names: Union[str, List[str]], occupy: int = 1):
83
113
  Args:
84
114
  names: The names of the concurrency limits to acquire slots from.
85
115
  occupy: The number of slots to acquire and hold from each limit.
116
+ timeout_seconds: The number of seconds to wait for the slots to be acquired before
117
+ raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
118
+ create_if_missing: Whether to create the concurrency limits if they do not exist.
86
119
  """
120
+ if not names:
121
+ return
122
+
87
123
  names = names if isinstance(names, list) else [names]
124
+
88
125
  limits = _call_async_function_from_sync(
89
- _acquire_concurrency_slots, names, occupy, mode="rate_limit"
126
+ _acquire_concurrency_slots,
127
+ names,
128
+ occupy,
129
+ mode="rate_limit",
130
+ timeout_seconds=timeout_seconds,
131
+ create_if_missing=create_if_missing,
90
132
  )
91
133
  _emit_concurrency_acquisition_events(limits, occupy)
92
134
 
93
135
 
94
- def _call_async_function_from_sync(fn, *args, **kwargs):
136
+ def _call_async_function_from_sync(
137
+ fn: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any
138
+ ) -> T:
95
139
  loop = get_running_loop()
96
140
  call = create_call(fn, *args, **kwargs)
97
141
 
98
142
  if loop is not None:
99
143
  return from_sync.call_soon_in_loop_thread(call).result()
100
144
  else:
101
- return call()
145
+ return call() # type: ignore [return-value]
@@ -0,0 +1,143 @@
1
+ import asyncio
2
+ from contextlib import asynccontextmanager
3
+ from typing import AsyncGenerator, List, Optional, Union, cast
4
+ from uuid import UUID
5
+
6
+ import anyio
7
+ import httpx
8
+ import pendulum
9
+
10
+ from ...client.schemas.responses import MinimalConcurrencyLimitResponse
11
+
12
+ try:
13
+ from pendulum import Interval
14
+ except ImportError:
15
+ # pendulum < 3
16
+ from pendulum.period import Period as Interval # type: ignore
17
+
18
+ from prefect.client.orchestration import get_client
19
+
20
+ from .context import ConcurrencyContext
21
+ from .events import (
22
+ _emit_concurrency_acquisition_events,
23
+ _emit_concurrency_release_events,
24
+ )
25
+ from .services import ConcurrencySlotAcquisitionService
26
+
27
+
28
+ class ConcurrencySlotAcquisitionError(Exception):
29
+ """Raised when an unhandlable occurs while acquiring concurrency slots."""
30
+
31
+
32
+ class AcquireConcurrencySlotTimeoutError(TimeoutError):
33
+ """Raised when acquiring a concurrency slot times out."""
34
+
35
+
36
+ @asynccontextmanager
37
+ async def concurrency(
38
+ names: Union[str, List[str]],
39
+ task_run_id: UUID,
40
+ timeout_seconds: Optional[float] = None,
41
+ ) -> AsyncGenerator[None, None]:
42
+ """A context manager that acquires and releases concurrency slots from the
43
+ given concurrency limits.
44
+
45
+ Args:
46
+ names: The names of the concurrency limits to acquire slots from.
47
+ task_run_id: The name of the task_run_id that is incrementing the slots.
48
+ timeout_seconds: The number of seconds to wait for the slots to be acquired before
49
+ raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
50
+
51
+ Raises:
52
+ TimeoutError: If the slots are not acquired within the given timeout.
53
+
54
+ Example:
55
+ A simple example of using the async `concurrency` context manager:
56
+ ```python
57
+ from prefect.concurrency.v1.asyncio import concurrency
58
+
59
+ async def resource_heavy():
60
+ async with concurrency("test", task_run_id):
61
+ print("Resource heavy task")
62
+
63
+ async def main():
64
+ await resource_heavy()
65
+ ```
66
+ """
67
+ if not names:
68
+ yield
69
+ return
70
+
71
+ names_normalized: List[str] = names if isinstance(names, list) else [names]
72
+
73
+ limits = await _acquire_concurrency_slots(
74
+ names_normalized,
75
+ task_run_id=task_run_id,
76
+ timeout_seconds=timeout_seconds,
77
+ )
78
+ acquisition_time = pendulum.now("UTC")
79
+ emitted_events = _emit_concurrency_acquisition_events(limits, task_run_id)
80
+
81
+ try:
82
+ yield
83
+ finally:
84
+ occupancy_period = cast(Interval, (pendulum.now("UTC") - acquisition_time))
85
+ try:
86
+ await _release_concurrency_slots(
87
+ names_normalized, task_run_id, occupancy_period.total_seconds()
88
+ )
89
+ except anyio.get_cancelled_exc_class():
90
+ # The task was cancelled before it could release the slots. Add the
91
+ # slots to the cleanup list so they can be released when the
92
+ # concurrency context is exited.
93
+ if ctx := ConcurrencyContext.get():
94
+ ctx.cleanup_slots.append(
95
+ (names_normalized, occupancy_period.total_seconds(), task_run_id)
96
+ )
97
+
98
+ _emit_concurrency_release_events(limits, emitted_events, task_run_id)
99
+
100
+
101
+ async def _acquire_concurrency_slots(
102
+ names: List[str],
103
+ task_run_id: UUID,
104
+ timeout_seconds: Optional[float] = None,
105
+ ) -> List[MinimalConcurrencyLimitResponse]:
106
+ service = ConcurrencySlotAcquisitionService.instance(frozenset(names))
107
+ future = service.send((task_run_id, timeout_seconds))
108
+ response_or_exception = await asyncio.wrap_future(future)
109
+
110
+ if isinstance(response_or_exception, Exception):
111
+ if isinstance(response_or_exception, TimeoutError):
112
+ raise AcquireConcurrencySlotTimeoutError(
113
+ f"Attempt to acquire concurrency limits timed out after {timeout_seconds} second(s)"
114
+ ) from response_or_exception
115
+
116
+ raise ConcurrencySlotAcquisitionError(
117
+ f"Unable to acquire concurrency limits {names!r}"
118
+ ) from response_or_exception
119
+
120
+ return _response_to_concurrency_limit_response(response_or_exception)
121
+
122
+
123
+ async def _release_concurrency_slots(
124
+ names: List[str],
125
+ task_run_id: UUID,
126
+ occupancy_seconds: float,
127
+ ) -> List[MinimalConcurrencyLimitResponse]:
128
+ async with get_client() as client:
129
+ response = await client.decrement_v1_concurrency_slots(
130
+ names=names,
131
+ task_run_id=task_run_id,
132
+ occupancy_seconds=occupancy_seconds,
133
+ )
134
+ return _response_to_concurrency_limit_response(response)
135
+
136
+
137
+ def _response_to_concurrency_limit_response(
138
+ response: httpx.Response,
139
+ ) -> List[MinimalConcurrencyLimitResponse]:
140
+ data = response.json() or []
141
+ return [
142
+ MinimalConcurrencyLimitResponse.model_validate(limit) for limit in data if data
143
+ ]
@@ -0,0 +1,27 @@
1
+ from contextvars import ContextVar
2
+ from typing import List, Tuple
3
+ from uuid import UUID
4
+
5
+ from prefect.client.orchestration import get_client
6
+ from prefect.context import ContextModel, Field
7
+
8
+
9
+ class ConcurrencyContext(ContextModel):
10
+ __var__: ContextVar = ContextVar("concurrency_v1")
11
+
12
+ # Track the limits that have been acquired but were not able to be released
13
+ # due to cancellation or some other error. These limits are released when
14
+ # the context manager exits.
15
+ cleanup_slots: List[Tuple[List[str], float, UUID]] = Field(default_factory=list)
16
+
17
+ def __exit__(self, *exc_info):
18
+ if self.cleanup_slots:
19
+ with get_client(sync_client=True) as client:
20
+ for names, occupancy_seconds, task_run_id in self.cleanup_slots:
21
+ client.decrement_v1_concurrency_slots(
22
+ names=names,
23
+ occupancy_seconds=occupancy_seconds,
24
+ task_run_id=task_run_id,
25
+ )
26
+
27
+ return super().__exit__(*exc_info)
@@ -0,0 +1,61 @@
1
+ from typing import Dict, List, Literal, Optional, Union
2
+ from uuid import UUID
3
+
4
+ from prefect.client.schemas.responses import MinimalConcurrencyLimitResponse
5
+ from prefect.events import Event, RelatedResource, emit_event
6
+
7
+
8
+ def _emit_concurrency_event(
9
+ phase: Union[Literal["acquired"], Literal["released"]],
10
+ primary_limit: MinimalConcurrencyLimitResponse,
11
+ related_limits: List[MinimalConcurrencyLimitResponse],
12
+ task_run_id: UUID,
13
+ follows: Union[Event, None] = None,
14
+ ) -> Union[Event, None]:
15
+ resource: Dict[str, str] = {
16
+ "prefect.resource.id": f"prefect.concurrency-limit.v1.{primary_limit.id}",
17
+ "prefect.resource.name": primary_limit.name,
18
+ "limit": str(primary_limit.limit),
19
+ "task_run_id": str(task_run_id),
20
+ }
21
+
22
+ related = [
23
+ RelatedResource.model_validate(
24
+ {
25
+ "prefect.resource.id": f"prefect.concurrency-limit.v1.{limit.id}",
26
+ "prefect.resource.role": "concurrency-limit",
27
+ }
28
+ )
29
+ for limit in related_limits
30
+ if limit.id != primary_limit.id
31
+ ]
32
+
33
+ return emit_event(
34
+ f"prefect.concurrency-limit.v1.{phase}",
35
+ resource=resource,
36
+ related=related,
37
+ follows=follows,
38
+ )
39
+
40
+
41
+ def _emit_concurrency_acquisition_events(
42
+ limits: List[MinimalConcurrencyLimitResponse],
43
+ task_run_id: UUID,
44
+ ) -> Dict[UUID, Optional[Event]]:
45
+ events = {}
46
+ for limit in limits:
47
+ event = _emit_concurrency_event("acquired", limit, limits, task_run_id)
48
+ events[limit.id] = event
49
+
50
+ return events
51
+
52
+
53
+ def _emit_concurrency_release_events(
54
+ limits: List[MinimalConcurrencyLimitResponse],
55
+ events: Dict[UUID, Optional[Event]],
56
+ task_run_id: UUID,
57
+ ) -> None:
58
+ for limit in limits:
59
+ _emit_concurrency_event(
60
+ "released", limit, limits, task_run_id, events[limit.id]
61
+ )
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import concurrent.futures
3
+ from contextlib import asynccontextmanager
4
+ from json import JSONDecodeError
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ AsyncGenerator,
8
+ FrozenSet,
9
+ Optional,
10
+ Tuple,
11
+ )
12
+ from uuid import UUID
13
+
14
+ import httpx
15
+ from starlette import status
16
+
17
+ from prefect._internal.concurrency import logger
18
+ from prefect._internal.concurrency.services import QueueService
19
+ from prefect.client.orchestration import get_client
20
+ from prefect.utilities.timeout import timeout_async
21
+
22
+ if TYPE_CHECKING:
23
+ from prefect.client.orchestration import PrefectClient
24
+
25
+
26
+ class ConcurrencySlotAcquisitionServiceError(Exception):
27
+ """Raised when an error occurs while acquiring concurrency slots."""
28
+
29
+
30
+ class ConcurrencySlotAcquisitionService(QueueService):
31
+ def __init__(self, concurrency_limit_names: FrozenSet[str]):
32
+ super().__init__(concurrency_limit_names)
33
+ self._client: "PrefectClient"
34
+ self.concurrency_limit_names = sorted(list(concurrency_limit_names))
35
+
36
+ @asynccontextmanager
37
+ async def _lifespan(self) -> AsyncGenerator[None, None]:
38
+ async with get_client() as client:
39
+ self._client = client
40
+ yield
41
+
42
+ async def _handle(
43
+ self,
44
+ item: Tuple[
45
+ UUID,
46
+ concurrent.futures.Future,
47
+ Optional[float],
48
+ ],
49
+ ) -> None:
50
+ task_run_id, future, timeout_seconds = item
51
+ try:
52
+ response = await self.acquire_slots(task_run_id, timeout_seconds)
53
+ except Exception as exc:
54
+ # If the request to the increment endpoint fails in a non-standard
55
+ # way, we need to set the future's result so that the caller can
56
+ # handle the exception and then re-raise.
57
+ future.set_result(exc)
58
+ raise exc
59
+ else:
60
+ future.set_result(response)
61
+
62
+ async def acquire_slots(
63
+ self,
64
+ task_run_id: UUID,
65
+ timeout_seconds: Optional[float] = None,
66
+ ) -> httpx.Response:
67
+ with timeout_async(seconds=timeout_seconds):
68
+ while True:
69
+ try:
70
+ response = await self._client.increment_v1_concurrency_slots(
71
+ task_run_id=task_run_id,
72
+ names=self.concurrency_limit_names,
73
+ )
74
+ except Exception as exc:
75
+ if (
76
+ isinstance(exc, httpx.HTTPStatusError)
77
+ and exc.response.status_code == status.HTTP_423_LOCKED
78
+ ):
79
+ retry_after = exc.response.headers.get("Retry-After")
80
+ if retry_after:
81
+ retry_after = float(retry_after)
82
+ await asyncio.sleep(retry_after)
83
+ else:
84
+ # We received a 423 but no Retry-After header. This
85
+ # should indicate that the server told us to abort
86
+ # because the concurrency limit is set to 0, i.e.
87
+ # effectively disabled.
88
+ try:
89
+ reason = exc.response.json()["detail"]
90
+ except (JSONDecodeError, KeyError):
91
+ logger.error(
92
+ "Failed to parse response from concurrency limit 423 Locked response: %s",
93
+ exc.response.content,
94
+ )
95
+ reason = "Concurrency limit is locked (server did not specify the reason)"
96
+ raise ConcurrencySlotAcquisitionServiceError(
97
+ reason
98
+ ) from exc
99
+
100
+ else:
101
+ raise exc # type: ignore
102
+ else:
103
+ return response
104
+
105
+ def send(self, item: Tuple[UUID, Optional[float]]) -> concurrent.futures.Future:
106
+ with self._lock:
107
+ if self._stopped:
108
+ raise RuntimeError("Cannot put items in a stopped service instance.")
109
+
110
+ logger.debug("Service %r enqueuing item %r", self, item)
111
+ future: concurrent.futures.Future = concurrent.futures.Future()
112
+
113
+ task_run_id, timeout_seconds = item
114
+ self._queue.put_nowait((task_run_id, future, timeout_seconds))
115
+
116
+ return future
@@ -0,0 +1,92 @@
1
+ from contextlib import contextmanager
2
+ from typing import (
3
+ Generator,
4
+ List,
5
+ Optional,
6
+ TypeVar,
7
+ Union,
8
+ cast,
9
+ )
10
+ from uuid import UUID
11
+
12
+ import pendulum
13
+
14
+ from ...client.schemas.responses import MinimalConcurrencyLimitResponse
15
+ from ..sync import _call_async_function_from_sync
16
+
17
+ try:
18
+ from pendulum import Interval
19
+ except ImportError:
20
+ # pendulum < 3
21
+ from pendulum.period import Period as Interval # type: ignore
22
+
23
+ from .asyncio import (
24
+ _acquire_concurrency_slots,
25
+ _release_concurrency_slots,
26
+ )
27
+ from .events import (
28
+ _emit_concurrency_acquisition_events,
29
+ _emit_concurrency_release_events,
30
+ )
31
+
32
+ T = TypeVar("T")
33
+
34
+
35
+ @contextmanager
36
+ def concurrency(
37
+ names: Union[str, List[str]],
38
+ task_run_id: UUID,
39
+ timeout_seconds: Optional[float] = None,
40
+ ) -> Generator[None, None, None]:
41
+ """
42
+ A context manager that acquires and releases concurrency slots from the
43
+ given concurrency limits.
44
+
45
+ Args:
46
+ names: The names of the concurrency limits to acquire.
47
+ task_run_id: The task run ID acquiring the limits.
48
+ timeout_seconds: The number of seconds to wait to acquire the limits before
49
+ raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
50
+
51
+ Raises:
52
+ TimeoutError: If the limits are not acquired within the given timeout.
53
+
54
+ Example:
55
+ A simple example of using the sync `concurrency` context manager:
56
+ ```python
57
+ from prefect.concurrency.v1.sync import concurrency
58
+
59
+ def resource_heavy():
60
+ with concurrency("test"):
61
+ print("Resource heavy task")
62
+
63
+ def main():
64
+ resource_heavy()
65
+ ```
66
+ """
67
+ if not names:
68
+ yield
69
+ return
70
+
71
+ names = names if isinstance(names, list) else [names]
72
+
73
+ limits: List[MinimalConcurrencyLimitResponse] = _call_async_function_from_sync(
74
+ _acquire_concurrency_slots,
75
+ names,
76
+ timeout_seconds=timeout_seconds,
77
+ task_run_id=task_run_id,
78
+ )
79
+ acquisition_time = pendulum.now("UTC")
80
+ emitted_events = _emit_concurrency_acquisition_events(limits, task_run_id)
81
+
82
+ try:
83
+ yield
84
+ finally:
85
+ occupancy_period = cast(Interval, pendulum.now("UTC") - acquisition_time)
86
+ _call_async_function_from_sync(
87
+ _release_concurrency_slots,
88
+ names,
89
+ task_run_id,
90
+ occupancy_period.total_seconds(),
91
+ )
92
+ _emit_concurrency_release_events(limits, emitted_events, task_run_id)