prefect-client 2.14.20__py3-none-any.whl → 2.15.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 (108) hide show
  1. prefect/_internal/concurrency/api.py +37 -2
  2. prefect/_internal/concurrency/calls.py +9 -0
  3. prefect/_internal/concurrency/cancellation.py +3 -1
  4. prefect/_internal/concurrency/event_loop.py +2 -2
  5. prefect/_internal/concurrency/threads.py +3 -2
  6. prefect/_internal/pydantic/annotations/pendulum.py +4 -4
  7. prefect/_internal/pydantic/v2_schema.py +2 -2
  8. prefect/_vendor/fastapi/__init__.py +1 -1
  9. prefect/_vendor/fastapi/applications.py +13 -13
  10. prefect/_vendor/fastapi/background.py +3 -1
  11. prefect/_vendor/fastapi/concurrency.py +7 -3
  12. prefect/_vendor/fastapi/datastructures.py +9 -7
  13. prefect/_vendor/fastapi/dependencies/utils.py +12 -7
  14. prefect/_vendor/fastapi/encoders.py +1 -1
  15. prefect/_vendor/fastapi/exception_handlers.py +7 -4
  16. prefect/_vendor/fastapi/exceptions.py +4 -2
  17. prefect/_vendor/fastapi/middleware/__init__.py +1 -1
  18. prefect/_vendor/fastapi/middleware/asyncexitstack.py +1 -1
  19. prefect/_vendor/fastapi/middleware/cors.py +3 -1
  20. prefect/_vendor/fastapi/middleware/gzip.py +3 -1
  21. prefect/_vendor/fastapi/middleware/httpsredirect.py +1 -1
  22. prefect/_vendor/fastapi/middleware/trustedhost.py +1 -1
  23. prefect/_vendor/fastapi/middleware/wsgi.py +3 -1
  24. prefect/_vendor/fastapi/openapi/docs.py +1 -1
  25. prefect/_vendor/fastapi/openapi/utils.py +3 -3
  26. prefect/_vendor/fastapi/requests.py +4 -2
  27. prefect/_vendor/fastapi/responses.py +13 -7
  28. prefect/_vendor/fastapi/routing.py +15 -15
  29. prefect/_vendor/fastapi/security/api_key.py +3 -3
  30. prefect/_vendor/fastapi/security/http.py +2 -2
  31. prefect/_vendor/fastapi/security/oauth2.py +2 -2
  32. prefect/_vendor/fastapi/security/open_id_connect_url.py +3 -3
  33. prefect/_vendor/fastapi/staticfiles.py +1 -1
  34. prefect/_vendor/fastapi/templating.py +3 -1
  35. prefect/_vendor/fastapi/testclient.py +1 -1
  36. prefect/_vendor/fastapi/utils.py +3 -3
  37. prefect/_vendor/fastapi/websockets.py +7 -3
  38. prefect/_vendor/starlette/__init__.py +1 -0
  39. prefect/_vendor/starlette/_compat.py +28 -0
  40. prefect/_vendor/starlette/_exception_handler.py +80 -0
  41. prefect/_vendor/starlette/_utils.py +88 -0
  42. prefect/_vendor/starlette/applications.py +261 -0
  43. prefect/_vendor/starlette/authentication.py +159 -0
  44. prefect/_vendor/starlette/background.py +43 -0
  45. prefect/_vendor/starlette/concurrency.py +59 -0
  46. prefect/_vendor/starlette/config.py +151 -0
  47. prefect/_vendor/starlette/convertors.py +87 -0
  48. prefect/_vendor/starlette/datastructures.py +707 -0
  49. prefect/_vendor/starlette/endpoints.py +130 -0
  50. prefect/_vendor/starlette/exceptions.py +60 -0
  51. prefect/_vendor/starlette/formparsers.py +276 -0
  52. prefect/_vendor/starlette/middleware/__init__.py +17 -0
  53. prefect/_vendor/starlette/middleware/authentication.py +52 -0
  54. prefect/_vendor/starlette/middleware/base.py +220 -0
  55. prefect/_vendor/starlette/middleware/cors.py +176 -0
  56. prefect/_vendor/starlette/middleware/errors.py +265 -0
  57. prefect/_vendor/starlette/middleware/exceptions.py +74 -0
  58. prefect/_vendor/starlette/middleware/gzip.py +113 -0
  59. prefect/_vendor/starlette/middleware/httpsredirect.py +19 -0
  60. prefect/_vendor/starlette/middleware/sessions.py +82 -0
  61. prefect/_vendor/starlette/middleware/trustedhost.py +64 -0
  62. prefect/_vendor/starlette/middleware/wsgi.py +147 -0
  63. prefect/_vendor/starlette/requests.py +328 -0
  64. prefect/_vendor/starlette/responses.py +347 -0
  65. prefect/_vendor/starlette/routing.py +933 -0
  66. prefect/_vendor/starlette/schemas.py +154 -0
  67. prefect/_vendor/starlette/staticfiles.py +248 -0
  68. prefect/_vendor/starlette/status.py +199 -0
  69. prefect/_vendor/starlette/templating.py +231 -0
  70. prefect/_vendor/starlette/testclient.py +805 -0
  71. prefect/_vendor/starlette/types.py +30 -0
  72. prefect/_vendor/starlette/websockets.py +193 -0
  73. prefect/blocks/core.py +3 -3
  74. prefect/blocks/notifications.py +10 -9
  75. prefect/client/base.py +1 -1
  76. prefect/client/cloud.py +1 -1
  77. prefect/client/orchestration.py +1 -1
  78. prefect/client/schemas/objects.py +11 -0
  79. prefect/client/subscriptions.py +19 -12
  80. prefect/concurrency/services.py +1 -1
  81. prefect/context.py +4 -4
  82. prefect/deployments/deployments.py +3 -3
  83. prefect/engine.py +89 -17
  84. prefect/events/clients.py +1 -1
  85. prefect/events/utilities.py +4 -1
  86. prefect/events/worker.py +10 -6
  87. prefect/filesystems.py +9 -9
  88. prefect/flow_runs.py +5 -1
  89. prefect/futures.py +1 -1
  90. prefect/infrastructure/container.py +3 -3
  91. prefect/infrastructure/kubernetes.py +4 -6
  92. prefect/infrastructure/process.py +3 -3
  93. prefect/input/run_input.py +1 -1
  94. prefect/logging/formatters.py +1 -1
  95. prefect/results.py +3 -6
  96. prefect/runner/server.py +4 -4
  97. prefect/settings.py +23 -3
  98. prefect/software/pip.py +1 -1
  99. prefect/task_engine.py +14 -11
  100. prefect/task_server.py +69 -35
  101. prefect/utilities/asyncutils.py +12 -2
  102. prefect/utilities/collections.py +1 -1
  103. prefect/utilities/filesystem.py +10 -5
  104. {prefect_client-2.14.20.dist-info → prefect_client-2.15.0.dist-info}/METADATA +4 -2
  105. {prefect_client-2.14.20.dist-info → prefect_client-2.15.0.dist-info}/RECORD +108 -73
  106. {prefect_client-2.14.20.dist-info → prefect_client-2.15.0.dist-info}/LICENSE +0 -0
  107. {prefect_client-2.14.20.dist-info → prefect_client-2.15.0.dist-info}/WHEEL +0 -0
  108. {prefect_client-2.14.20.dist-info → prefect_client-2.15.0.dist-info}/top_level.txt +0 -0
prefect/task_engine.py CHANGED
@@ -1,3 +1,4 @@
1
+ import threading
1
2
  from contextlib import AsyncExitStack
2
3
  from typing import (
3
4
  Any,
@@ -8,7 +9,7 @@ from typing import (
8
9
  )
9
10
 
10
11
  import anyio
11
- from anyio import start_blocking_portal
12
+ import greenback
12
13
  from typing_extensions import Literal
13
14
 
14
15
  from prefect._internal.concurrency.api import create_call, from_async, from_sync
@@ -21,7 +22,7 @@ from prefect.engine import (
21
22
  )
22
23
  from prefect.futures import PrefectFuture
23
24
  from prefect.results import ResultFactory
24
- from prefect.task_runners import BaseTaskRunner, SequentialTaskRunner
25
+ from prefect.task_runners import BaseTaskRunner
25
26
  from prefect.tasks import Task
26
27
  from prefect.utilities.asyncutils import sync_compatible
27
28
 
@@ -32,28 +33,28 @@ EngineReturnType = Literal["future", "state", "result"]
32
33
  async def submit_autonomous_task_to_engine(
33
34
  task: Task,
34
35
  task_run: TaskRun,
36
+ task_runner: Type[BaseTaskRunner],
35
37
  parameters: Optional[Dict] = None,
36
38
  wait_for: Optional[Iterable[PrefectFuture]] = None,
37
39
  mapped: bool = False,
38
40
  return_type: EngineReturnType = "future",
39
- task_runner: Optional[Type[BaseTaskRunner]] = None,
41
+ client=None,
40
42
  ) -> Any:
41
- parameters = parameters or {}
42
43
  async with AsyncExitStack() as stack:
44
+ if not task_runner._started:
45
+ task_runner_ctx = await stack.enter_async_context(task_runner.start())
46
+ else:
47
+ task_runner_ctx = task_runner
48
+ parameters = parameters or {}
43
49
  with EngineContext(
44
50
  flow=None,
45
51
  flow_run=None,
46
52
  autonomous_task_run=task_run,
47
- task_runner=await stack.enter_async_context(
48
- (task_runner if task_runner else SequentialTaskRunner()).start()
49
- ),
50
- client=await stack.enter_async_context(get_client()),
53
+ task_runner=task_runner_ctx,
54
+ client=client or await stack.enter_async_context(get_client()),
51
55
  parameters=parameters,
52
56
  result_factory=await ResultFactory.from_task(task),
53
57
  background_tasks=await stack.enter_async_context(anyio.create_task_group()),
54
- sync_portal=(
55
- stack.enter_context(start_blocking_portal()) if task.isasync else None
56
- ),
57
58
  ) as flow_run_context:
58
59
  begin_run = create_call(
59
60
  begin_task_map if mapped else get_task_call_return_value,
@@ -63,8 +64,10 @@ async def submit_autonomous_task_to_engine(
63
64
  wait_for=wait_for,
64
65
  return_type=return_type,
65
66
  task_runner=task_runner,
67
+ user_thread=threading.current_thread(),
66
68
  )
67
69
  if task.isasync:
68
70
  return await from_async.wait_for_call_in_loop_thread(begin_run)
69
71
  else:
72
+ await greenback.ensure_portal()
70
73
  return from_sync.wait_for_call_in_loop_thread(begin_run)
prefect/task_server.py CHANGED
@@ -1,58 +1,67 @@
1
1
  import asyncio
2
2
  import signal
3
3
  import sys
4
+ from contextlib import AsyncExitStack
4
5
  from functools import partial
5
- from typing import Iterable, Optional
6
+ from typing import Optional, Type
6
7
 
7
8
  import anyio
8
9
  import anyio.abc
9
- import pendulum
10
10
 
11
11
  from prefect import Task, get_client
12
12
  from prefect._internal.concurrency.api import create_call, from_sync
13
13
  from prefect.client.schemas.objects import TaskRun
14
14
  from prefect.client.subscriptions import Subscription
15
+ from prefect.engine import propose_state
15
16
  from prefect.logging.loggers import get_logger
16
17
  from prefect.results import ResultFactory
17
18
  from prefect.settings import (
18
19
  PREFECT_EXPERIMENTAL_ENABLE_TASK_SCHEDULING,
19
20
  PREFECT_TASK_SCHEDULING_DELETE_FAILED_SUBMISSIONS,
20
21
  )
22
+ from prefect.states import Pending
21
23
  from prefect.task_engine import submit_autonomous_task_to_engine
24
+ from prefect.task_runners import BaseTaskRunner, ConcurrentTaskRunner
22
25
  from prefect.utilities.asyncutils import asyncnullcontext, sync_compatible
23
- from prefect.utilities.collections import distinct
24
26
  from prefect.utilities.processutils import _register_signal
25
27
 
26
28
  logger = get_logger("task_server")
27
29
 
28
30
 
31
+ class StopTaskServer(Exception):
32
+ """Raised when the task server is stopped."""
33
+
34
+ pass
35
+
36
+
29
37
  class TaskServer:
30
- """This class is responsible for serving tasks that may be executed autonomously
31
- (i.e., without a parent flow run).
38
+ """This class is responsible for serving tasks that may be executed autonomously by a
39
+ task runner in the engine.
32
40
 
33
- When `start()` is called, the task server will subscribe to the task run scheduling
34
- topic and poll for scheduled task runs. When a scheduled task run is found, it
35
- will submit the task run to the engine for execution, using `submit_autonomous_task_to_engine`
36
- to construct a minimal `EngineContext` for the task run.
41
+ When `start()` is called, the task server will open a websocket connection to a
42
+ server-side queue of scheduled task runs. When a scheduled task run is found, the
43
+ scheduled task run is submitted to the engine for execution with a minimal `EngineContext`
44
+ so that the task run can be governed by orchestration rules.
37
45
 
38
46
  Args:
39
47
  - tasks: A list of tasks to serve. These tasks will be submitted to the engine
40
48
  when a scheduled task run is found.
41
- - tags: A list of tags to apply to the task server. Defaults to `["autonomous"]`.
49
+ - task_runner: The task runner to use for executing the tasks. Defaults to
50
+ `ConcurrentTaskRunner`.
42
51
  """
43
52
 
44
53
  def __init__(
45
54
  self,
46
55
  *tasks: Task,
47
- tags: Optional[Iterable[str]] = None,
56
+ task_runner: Optional[Type[BaseTaskRunner]] = None,
48
57
  ):
49
58
  self.tasks: list[Task] = tasks
50
- self.tags: Iterable[str] = tags or ["autonomous"]
51
- self.last_polled: Optional[pendulum.DateTime] = None
52
- self.started = False
53
- self.stopping = False
59
+ self.task_runner: Type[BaseTaskRunner] = task_runner or ConcurrentTaskRunner()
60
+ self.started: bool = False
61
+ self.stopping: bool = False
54
62
 
55
63
  self._client = get_client()
64
+ self._exit_stack = AsyncExitStack()
56
65
 
57
66
  if not asyncio.get_event_loop().is_running():
58
67
  raise RuntimeError(
@@ -89,18 +98,21 @@ class TaskServer:
89
98
  " calling .start()"
90
99
  )
91
100
 
92
- logger.info("Stopping task server...")
93
101
  self.started = False
94
102
  self.stopping = True
95
103
 
104
+ raise StopTaskServer
105
+
96
106
  async def _subscribe_to_task_scheduling(self):
97
- subscription = Subscription(TaskRun, "/task_runs/subscriptions/scheduled")
98
- logger.debug(f"Created: {subscription}")
99
- async for task_run in subscription:
107
+ async for task_run in Subscription(
108
+ TaskRun,
109
+ "/task_runs/subscriptions/scheduled",
110
+ [task.task_key for task in self.tasks],
111
+ ):
100
112
  logger.info(f"Received task run: {task_run.id} - {task_run.name}")
101
- await self._submit_pending_task_run(task_run)
113
+ await self._submit_scheduled_task_run(task_run)
102
114
 
103
- async def _submit_pending_task_run(self, task_run: TaskRun):
115
+ async def _submit_scheduled_task_run(self, task_run: TaskRun):
104
116
  logger.debug(
105
117
  f"Found task run: {task_run.name!r} in state: {task_run.state.name!r}"
106
118
  )
@@ -142,7 +154,18 @@ class TaskServer:
142
154
  f"Submitting run {task_run.name!r} of task {task.name!r} to engine"
143
155
  )
144
156
 
145
- task_run.tags = distinct(task_run.tags + list(self.tags))
157
+ state = await propose_state(
158
+ client=get_client(), # TODO prove that we cannot use self._client here
159
+ state=Pending(),
160
+ task_run_id=task_run.id,
161
+ )
162
+
163
+ if not state.is_pending():
164
+ logger.warning(
165
+ f"Aborted task run {task_run.id!r} -"
166
+ f" server returned a non-pending state {state.type.value!r}."
167
+ " Task run may have already begun execution."
168
+ )
146
169
 
147
170
  self._runs_task_group.start_soon(
148
171
  partial(
@@ -150,14 +173,17 @@ class TaskServer:
150
173
  task=task,
151
174
  task_run=task_run,
152
175
  parameters=parameters,
176
+ task_runner=self.task_runner,
177
+ client=self._client,
153
178
  )
154
179
  )
155
180
 
156
181
  async def __aenter__(self):
157
182
  logger.debug("Starting task server...")
158
- self._client = get_client()
159
- await self._client.__aenter__()
160
- await self._runs_task_group.__aenter__()
183
+
184
+ self._client = await self._exit_stack.enter_async_context(get_client())
185
+ await self._exit_stack.enter_async_context(self._runs_task_group)
186
+ await self._exit_stack.enter_async_context(self.task_runner.start())
161
187
 
162
188
  self.started = True
163
189
  return self
@@ -165,20 +191,21 @@ class TaskServer:
165
191
  async def __aexit__(self, *exc_info):
166
192
  logger.debug("Stopping task server...")
167
193
  self.started = False
168
- if self._runs_task_group:
169
- await self._runs_task_group.__aexit__(*exc_info)
170
- if self._client:
171
- await self._client.__aexit__(*exc_info)
194
+
195
+ await self._exit_stack.__aexit__(*exc_info)
172
196
 
173
197
 
174
198
  @sync_compatible
175
- async def serve(*tasks: Task, tags: Optional[Iterable[str]] = None):
176
- """Serve the provided tasks so that they may be executed autonomously.
199
+ async def serve(*tasks: Task, task_runner: Optional[Type[BaseTaskRunner]] = None):
200
+ """Serve the provided tasks so that their runs may be submitted to and executed.
201
+ in the engine. Tasks do not need to be within a flow run context to be submitted.
202
+ You must `.submit` the same task object that you pass to `serve`.
177
203
 
178
204
  Args:
179
205
  - tasks: A list of tasks to serve. When a scheduled task run is found for a
180
206
  given task, the task run will be submitted to the engine for execution.
181
- - tags: A list of tags to apply to the task server. Defaults to `["autonomous"]`.
207
+ - task_runner: The task runner to use for executing the tasks. Defaults to
208
+ `ConcurrentTaskRunner`.
182
209
 
183
210
  Example:
184
211
  ```python
@@ -193,7 +220,7 @@ async def serve(*tasks: Task, tags: Optional[Iterable[str]] = None):
193
220
  def yell(message: str):
194
221
  print(message.upper())
195
222
 
196
- # starts a long-lived process that listens scheduled runs of these tasks
223
+ # starts a long-lived process that listens for scheduled runs of these tasks
197
224
  if __name__ == "__main__":
198
225
  serve(say, yell)
199
226
  ```
@@ -204,5 +231,12 @@ async def serve(*tasks: Task, tags: Optional[Iterable[str]] = None):
204
231
  " to True."
205
232
  )
206
233
 
207
- task_server = TaskServer(*tasks, tags=tags)
208
- await task_server.start()
234
+ task_server = TaskServer(*tasks, task_runner=task_runner)
235
+ try:
236
+ await task_server.start()
237
+
238
+ except StopTaskServer:
239
+ logger.info("Task server stopped.")
240
+
241
+ except asyncio.CancelledError:
242
+ logger.info("Task server interrupted, stopping...")
@@ -28,6 +28,8 @@ import anyio.abc
28
28
  import sniffio
29
29
  from typing_extensions import Literal, ParamSpec, TypeGuard
30
30
 
31
+ from prefect.logging import get_logger
32
+
31
33
  T = TypeVar("T")
32
34
  P = ParamSpec("P")
33
35
  R = TypeVar("R")
@@ -40,6 +42,8 @@ EVENT_LOOP_GC_REFS = {}
40
42
 
41
43
  PREFECT_THREAD_LIMITER: Optional[anyio.CapacityLimiter] = None
42
44
 
45
+ logger = get_logger()
46
+
43
47
 
44
48
  def get_thread_limiter():
45
49
  global PREFECT_THREAD_LIMITER
@@ -51,7 +55,7 @@ def get_thread_limiter():
51
55
 
52
56
 
53
57
  def is_async_fn(
54
- func: Union[Callable[P, R], Callable[P, Awaitable[R]]]
58
+ func: Union[Callable[P, R], Callable[P, Awaitable[R]]],
55
59
  ) -> TypeGuard[Callable[P, Awaitable[R]]]:
56
60
  """
57
61
  Returns `True` if a function returns a coroutine.
@@ -334,7 +338,13 @@ async def add_event_loop_shutdown_callback(coroutine_fn: Callable[[], Awaitable]
334
338
  EVENT_LOOP_GC_REFS[key] = on_shutdown(key)
335
339
 
336
340
  # Begin iterating so it will be cleaned up as an incomplete generator
337
- await EVENT_LOOP_GC_REFS[key].__anext__()
341
+ try:
342
+ await EVENT_LOOP_GC_REFS[key].__anext__()
343
+ # There is a poorly understood edge case we've seen in CI where the key is
344
+ # removed from the dict before we begin generator iteration.
345
+ except KeyError:
346
+ logger.warn("The event loop shutdown callback was not properly registered. ")
347
+ pass
338
348
 
339
349
 
340
350
  class GatherIncomplete(RuntimeError):
@@ -106,7 +106,7 @@ def dict_to_flatdict(
106
106
 
107
107
 
108
108
  def flatdict_to_dict(
109
- dct: Dict[Tuple[KT, ...], VT]
109
+ dct: Dict[Tuple[KT, ...], VT],
110
110
  ) -> Dict[KT, Union[VT, Dict[KT, VT]]]:
111
111
  """Converts a flattened dictionary back to a nested dictionary.
112
112
 
@@ -3,6 +3,7 @@ Utilities for working with file systems
3
3
  """
4
4
  import os
5
5
  import pathlib
6
+ import threading
6
7
  from contextlib import contextmanager
7
8
  from pathlib import Path, PureWindowsPath
8
9
  from typing import Union
@@ -51,6 +52,9 @@ def filter_files(
51
52
  return included_files
52
53
 
53
54
 
55
+ chdir_lock = threading.Lock()
56
+
57
+
54
58
  @contextmanager
55
59
  def tmpchdir(path: str):
56
60
  """
@@ -62,11 +66,12 @@ def tmpchdir(path: str):
62
66
 
63
67
  owd = os.getcwd()
64
68
 
65
- try:
66
- os.chdir(path)
67
- yield path
68
- finally:
69
- os.chdir(owd)
69
+ with chdir_lock:
70
+ try:
71
+ os.chdir(path)
72
+ yield path
73
+ finally:
74
+ os.chdir(owd)
70
75
 
71
76
 
72
77
  def filename(path: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prefect-client
3
- Version: 2.14.20
3
+ Version: 2.15.0
4
4
  Summary: Workflow orchestration and management.
5
5
  Home-page: https://www.prefect.io
6
6
  Author: Prefect Technologies, Inc.
@@ -31,6 +31,7 @@ Requires-Dist: coolname <3.0.0,>=1.0.4
31
31
  Requires-Dist: croniter <3.0.0,>=1.0.12
32
32
  Requires-Dist: fsspec >=2022.5.0
33
33
  Requires-Dist: graphviz >=0.20.1
34
+ Requires-Dist: greenback >=1.2.0
34
35
  Requires-Dist: griffe >=0.20.0
35
36
  Requires-Dist: httpcore <2.0.0,>=0.15.0
36
37
  Requires-Dist: httpx[http2] !=0.23.2,>=0.23
@@ -46,12 +47,13 @@ Requires-Dist: pyyaml <7.0.0,>=5.4.1
46
47
  Requires-Dist: rich <14.0,>=11.0
47
48
  Requires-Dist: ruamel.yaml >=0.17.0
48
49
  Requires-Dist: sniffio <2.0.0,>=1.3.0
49
- Requires-Dist: starlette <0.33.0,>=0.27.0
50
50
  Requires-Dist: toml >=0.10.0
51
51
  Requires-Dist: typing-extensions <5.0.0,>=4.5.0
52
52
  Requires-Dist: ujson <6.0.0,>=5.8.0
53
53
  Requires-Dist: uvicorn >=0.14.0
54
54
  Requires-Dist: websockets <13.0,>=10.4
55
+ Requires-Dist: itsdangerous
56
+ Requires-Dist: python-multipart >=0.0.7
55
57
  Requires-Dist: importlib-metadata >=4.4 ; python_version < "3.10"
56
58
  Requires-Dist: pendulum <3.0 ; python_version < "3.12"
57
59
  Requires-Dist: pendulum <4,>=3.0.0 ; python_version >= "3.12"