prefect-client 3.2.7__py3-none-any.whl → 3.2.9__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 (40) hide show
  1. prefect/_build_info.py +3 -3
  2. prefect/_experimental/bundles.py +79 -0
  3. prefect/_waiters.py +254 -0
  4. prefect/client/subscriptions.py +2 -1
  5. prefect/events/clients.py +19 -17
  6. prefect/flow_runs.py +67 -35
  7. prefect/flows.py +3 -1
  8. prefect/futures.py +192 -22
  9. prefect/runner/runner.py +106 -39
  10. prefect/server/api/artifacts.py +5 -0
  11. prefect/server/api/automations.py +5 -0
  12. prefect/server/api/block_capabilities.py +5 -0
  13. prefect/server/api/block_documents.py +2 -0
  14. prefect/server/api/block_schemas.py +5 -0
  15. prefect/server/api/block_types.py +3 -1
  16. prefect/server/api/concurrency_limits.py +5 -0
  17. prefect/server/api/concurrency_limits_v2.py +5 -0
  18. prefect/server/api/deployments.py +2 -0
  19. prefect/server/api/events.py +5 -1
  20. prefect/server/api/flow_run_notification_policies.py +2 -0
  21. prefect/server/api/flow_run_states.py +2 -0
  22. prefect/server/api/flow_runs.py +2 -0
  23. prefect/server/api/flows.py +2 -0
  24. prefect/server/api/logs.py +5 -1
  25. prefect/server/api/task_run_states.py +2 -0
  26. prefect/server/api/task_runs.py +2 -0
  27. prefect/server/api/task_workers.py +5 -1
  28. prefect/server/api/variables.py +5 -0
  29. prefect/server/api/work_queues.py +2 -0
  30. prefect/server/api/workers.py +4 -0
  31. prefect/settings/profiles.py +6 -5
  32. prefect/task_worker.py +3 -3
  33. prefect/telemetry/instrumentation.py +2 -2
  34. prefect/utilities/templating.py +50 -11
  35. prefect/workers/base.py +3 -3
  36. prefect/workers/process.py +22 -319
  37. {prefect_client-3.2.7.dist-info → prefect_client-3.2.9.dist-info}/METADATA +2 -2
  38. {prefect_client-3.2.7.dist-info → prefect_client-3.2.9.dist-info}/RECORD +40 -39
  39. {prefect_client-3.2.7.dist-info → prefect_client-3.2.9.dist-info}/WHEEL +0 -0
  40. {prefect_client-3.2.7.dist-info → prefect_client-3.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -15,10 +15,13 @@ from typing import (
15
15
  )
16
16
 
17
17
  from prefect.client.utilities import inject_client
18
+ from prefect.logging.loggers import get_logger
18
19
  from prefect.utilities.annotations import NotSet
19
20
  from prefect.utilities.collections import get_from_dict
20
21
 
21
22
  if TYPE_CHECKING:
23
+ import logging
24
+
22
25
  from prefect.client.orchestration import PrefectClient
23
26
 
24
27
 
@@ -30,6 +33,9 @@ VARIABLE_PLACEHOLDER_PREFIX = "prefect.variables."
30
33
  ENV_VAR_PLACEHOLDER_PREFIX = "$"
31
34
 
32
35
 
36
+ logger: "logging.Logger" = get_logger("utilities.templating")
37
+
38
+
33
39
  class PlaceholderType(enum.Enum):
34
40
  STANDARD = "standard"
35
41
  BLOCK_DOCUMENT = "block_document"
@@ -92,24 +98,36 @@ def find_placeholders(template: T) -> set[Placeholder]:
92
98
 
93
99
  @overload
94
100
  def apply_values(
95
- template: T, values: dict[str, Any], remove_notset: Literal[True] = True
101
+ template: T,
102
+ values: dict[str, Any],
103
+ remove_notset: Literal[True] = True,
104
+ warn_on_notset: bool = False,
96
105
  ) -> T: ...
97
106
 
98
107
 
99
108
  @overload
100
109
  def apply_values(
101
- template: T, values: dict[str, Any], remove_notset: Literal[False] = False
110
+ template: T,
111
+ values: dict[str, Any],
112
+ remove_notset: Literal[False] = False,
113
+ warn_on_notset: bool = False,
102
114
  ) -> Union[T, type[NotSet]]: ...
103
115
 
104
116
 
105
117
  @overload
106
118
  def apply_values(
107
- template: T, values: dict[str, Any], remove_notset: bool = False
119
+ template: T,
120
+ values: dict[str, Any],
121
+ remove_notset: bool = False,
122
+ warn_on_notset: bool = False,
108
123
  ) -> Union[T, type[NotSet]]: ...
109
124
 
110
125
 
111
126
  def apply_values(
112
- template: T, values: dict[str, Any], remove_notset: bool = True
127
+ template: T,
128
+ values: dict[str, Any],
129
+ remove_notset: bool = True,
130
+ warn_on_notset: bool = False,
113
131
  ) -> Union[T, type[NotSet]]:
114
132
  """
115
133
  Replaces placeholders in a template with values from a supplied dictionary.
@@ -134,6 +152,7 @@ def apply_values(
134
152
  template: template to discover and replace values in
135
153
  values: The values to apply to placeholders in the template
136
154
  remove_notset: If True, remove keys with an unset value
155
+ warn_on_notset: If True, warn when a placeholder is not found in `values`
137
156
 
138
157
  Returns:
139
158
  The template with the values applied
@@ -153,7 +172,13 @@ def apply_values(
153
172
  # If there is only one variable with no surrounding text,
154
173
  # we can replace it. If there is no variable value, we
155
174
  # return NotSet to indicate that the value should not be included.
156
- return get_from_dict(values, list(placeholders)[0].name, NotSet)
175
+ value = get_from_dict(values, list(placeholders)[0].name, NotSet)
176
+ if value is NotSet and warn_on_notset:
177
+ logger.warning(
178
+ f"Value for placeholder {list(placeholders)[0].name!r} not found in provided values. Please ensure that "
179
+ "the placeholder is spelled correctly and that the corresponding value is provided.",
180
+ )
181
+ return value
157
182
  else:
158
183
  for full_match, name, placeholder_type in placeholders:
159
184
  if placeholder_type is PlaceholderType.STANDARD:
@@ -164,10 +189,14 @@ def apply_values(
164
189
  else:
165
190
  continue
166
191
 
167
- if value is NotSet and not remove_notset:
168
- continue
169
- elif value is NotSet:
170
- template = template.replace(full_match, "")
192
+ if value is NotSet:
193
+ if warn_on_notset:
194
+ logger.warning(
195
+ f"Value for placeholder {full_match!r} not found in provided values. Please ensure that "
196
+ "the placeholder is spelled correctly and that the corresponding value is provided.",
197
+ )
198
+ if remove_notset:
199
+ template = template.replace(full_match, "")
171
200
  else:
172
201
  template = template.replace(full_match, str(value))
173
202
 
@@ -175,7 +204,12 @@ def apply_values(
175
204
  elif isinstance(template, dict):
176
205
  updated_template: dict[str, Any] = {}
177
206
  for key, value in template.items():
178
- updated_value = apply_values(value, values, remove_notset=remove_notset)
207
+ updated_value = apply_values(
208
+ value,
209
+ values,
210
+ remove_notset=remove_notset,
211
+ warn_on_notset=warn_on_notset,
212
+ )
179
213
  if updated_value is not NotSet:
180
214
  updated_template[key] = updated_value
181
215
  elif not remove_notset:
@@ -185,7 +219,12 @@ def apply_values(
185
219
  elif isinstance(template, list):
186
220
  updated_list: list[Any] = []
187
221
  for value in template:
188
- updated_value = apply_values(value, values, remove_notset=remove_notset)
222
+ updated_value = apply_values(
223
+ value,
224
+ values,
225
+ remove_notset=remove_notset,
226
+ warn_on_notset=warn_on_notset,
227
+ )
189
228
  if updated_value is not NotSet:
190
229
  updated_list.append(updated_value)
191
230
  return cast(T, updated_list)
prefect/workers/base.py CHANGED
@@ -566,8 +566,6 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
566
566
  healthcheck_thread = None
567
567
  try:
568
568
  async with self as worker:
569
- # wait for an initial heartbeat to configure the worker
570
- await worker.sync_with_backend()
571
569
  # schedule the scheduled flow run polling loop
572
570
  async with anyio.create_task_group() as loops_task_group:
573
571
  loops_task_group.start_soon(
@@ -655,6 +653,8 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
655
653
  await self._exit_stack.enter_async_context(self._client)
656
654
  await self._exit_stack.enter_async_context(self._runs_task_group)
657
655
 
656
+ await self.sync_with_backend()
657
+
658
658
  self.is_setup = True
659
659
 
660
660
  async def teardown(self, *exc_info: Any) -> None:
@@ -1085,7 +1085,7 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
1085
1085
  self,
1086
1086
  flow_run: "FlowRun",
1087
1087
  deployment: Optional["DeploymentResponse"] = None,
1088
- ) -> BaseJobConfiguration:
1088
+ ) -> C:
1089
1089
  deployment = (
1090
1090
  deployment
1091
1091
  if deployment
@@ -16,17 +16,11 @@ checkout out the [Prefect docs](/concepts/work-pools/).
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import contextlib
20
19
  import os
21
- import signal
22
- import socket
23
- import subprocess
24
- import sys
25
- import tempfile
26
20
  import threading
27
21
  from functools import partial
28
22
  from pathlib import Path
29
- from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple
23
+ from typing import TYPE_CHECKING, Any, Callable, Optional
30
24
 
31
25
  import anyio
32
26
  import anyio.abc
@@ -34,26 +28,9 @@ from pydantic import Field, field_validator
34
28
 
35
29
  from prefect._internal.schemas.validators import validate_working_dir
36
30
  from prefect.client.schemas import FlowRun
37
- from prefect.client.schemas.filters import (
38
- FlowRunFilter,
39
- FlowRunFilterId,
40
- FlowRunFilterState,
41
- FlowRunFilterStateName,
42
- FlowRunFilterStateType,
43
- WorkPoolFilter,
44
- WorkPoolFilterName,
45
- WorkQueueFilter,
46
- WorkQueueFilterName,
47
- )
48
- from prefect.client.schemas.objects import StateType
49
- from prefect.events.utilities import emit_event
50
- from prefect.exceptions import (
51
- InfrastructureNotAvailable,
52
- InfrastructureNotFound,
53
- ObjectNotFound,
54
- )
31
+ from prefect.runner.runner import Runner
55
32
  from prefect.settings import PREFECT_WORKER_QUERY_SECONDS
56
- from prefect.utilities.processutils import get_sys_executable, run_process
33
+ from prefect.utilities.processutils import get_sys_executable
57
34
  from prefect.utilities.services import critical_service_loop
58
35
  from prefect.workers.base import (
59
36
  BaseJobConfiguration,
@@ -66,20 +43,6 @@ if TYPE_CHECKING:
66
43
  from prefect.client.schemas.objects import Flow
67
44
  from prefect.client.schemas.responses import DeploymentResponse
68
45
 
69
- if sys.platform == "win32":
70
- # exit code indicating that the process was terminated by Ctrl+C or Ctrl+Break
71
- STATUS_CONTROL_C_EXIT = 0xC000013A
72
-
73
-
74
- def _infrastructure_pid_from_process(process: anyio.abc.Process) -> str:
75
- hostname = socket.gethostname()
76
- return f"{hostname}:{process.pid}"
77
-
78
-
79
- def _parse_infrastructure_pid(infrastructure_pid: str) -> Tuple[str, int]:
80
- hostname, pid = infrastructure_pid.split(":")
81
- return hostname, int(pid)
82
-
83
46
 
84
47
  class ProcessJobConfiguration(BaseJobConfiguration):
85
48
  stream_output: bool = Field(default=True)
@@ -107,7 +70,8 @@ class ProcessJobConfiguration(BaseJobConfiguration):
107
70
  else self.command
108
71
  )
109
72
 
110
- def _base_flow_run_command(self) -> str:
73
+ @staticmethod
74
+ def _base_flow_run_command() -> str:
111
75
  """
112
76
  Override the base flow run command because enhanced cancellation doesn't
113
77
  work with the process worker.
@@ -205,16 +169,6 @@ class ProcessWorker(
205
169
  backoff=4,
206
170
  )
207
171
  )
208
- loops_task_group.start_soon(
209
- partial(
210
- critical_service_loop,
211
- workload=self.check_for_cancelled_flow_runs,
212
- interval=PREFECT_WORKER_QUERY_SECONDS.value() * 2,
213
- run_once=run_once,
214
- jitter_range=0.3,
215
- backoff=4,
216
- )
217
- )
218
172
 
219
173
  self._started_event = await self._emit_worker_started_event()
220
174
 
@@ -249,279 +203,28 @@ class ProcessWorker(
249
203
  configuration: ProcessJobConfiguration,
250
204
  task_status: Optional[anyio.abc.TaskStatus[int]] = None,
251
205
  ) -> ProcessWorkerResult:
252
- command = configuration.command
253
- if not command:
254
- command = f"{get_sys_executable()} -m prefect.engine"
255
-
256
- flow_run_logger = self.get_flow_run_logger(flow_run)
257
-
258
- # We must add creationflags to a dict so it is only passed as a function
259
- # parameter on Windows, because the presence of creationflags causes
260
- # errors on Unix even if set to None
261
- kwargs: Dict[str, object] = {}
262
- if sys.platform == "win32":
263
- kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
264
-
265
- flow_run_logger.info("Opening process...")
266
-
267
- working_dir_ctx = (
268
- tempfile.TemporaryDirectory(suffix="prefect")
269
- if not configuration.working_dir
270
- else contextlib.nullcontext(configuration.working_dir)
206
+ process = await self._runner.execute_flow_run(
207
+ flow_run_id=flow_run.id,
208
+ command=configuration.command,
209
+ cwd=configuration.working_dir,
210
+ env=configuration.env,
211
+ stream_output=configuration.stream_output,
212
+ task_status=task_status,
271
213
  )
272
- with working_dir_ctx as working_dir:
273
- flow_run_logger.debug(
274
- f"Process running command: {command} in {working_dir}"
275
- )
276
- process = await run_process(
277
- command.split(" "),
278
- stream_output=configuration.stream_output,
279
- task_status=task_status,
280
- task_status_handler=_infrastructure_pid_from_process,
281
- cwd=working_dir,
282
- env=configuration.env,
283
- **kwargs,
284
- )
285
-
286
- # Use the pid for display if no name was given
287
- display_name = f" {process.pid}"
288
-
289
- if process.returncode:
290
- help_message = None
291
- if process.returncode == -9:
292
- help_message = (
293
- "This indicates that the process exited due to a SIGKILL signal. "
294
- "Typically, this is either caused by manual cancellation or "
295
- "high memory usage causing the operating system to "
296
- "terminate the process."
297
- )
298
- if process.returncode == -15:
299
- help_message = (
300
- "This indicates that the process exited due to a SIGTERM signal. "
301
- "Typically, this is caused by manual cancellation."
302
- )
303
- elif process.returncode == 247:
304
- help_message = (
305
- "This indicates that the process was terminated due to high "
306
- "memory usage."
307
- )
308
- elif (
309
- sys.platform == "win32" and process.returncode == STATUS_CONTROL_C_EXIT
310
- ):
311
- help_message = (
312
- "Process was terminated due to a Ctrl+C or Ctrl+Break signal. "
313
- "Typically, this is caused by manual cancellation."
314
- )
315
-
316
- flow_run_logger.error(
317
- f"Process{display_name} exited with status code: {process.returncode}"
318
- + (f"; {help_message}" if help_message else "")
319
- )
320
- else:
321
- flow_run_logger.info(f"Process{display_name} exited cleanly.")
214
+
215
+ if process is None or process.returncode is None:
216
+ raise RuntimeError("Failed to start flow run process.")
322
217
 
323
218
  return ProcessWorkerResult(
324
219
  status_code=process.returncode, identifier=str(process.pid)
325
220
  )
326
221
 
327
- async def kill_process(
328
- self,
329
- infrastructure_pid: str,
330
- grace_seconds: int = 30,
331
- ) -> None:
332
- hostname, pid = _parse_infrastructure_pid(infrastructure_pid)
333
-
334
- if hostname != socket.gethostname():
335
- raise InfrastructureNotAvailable(
336
- f"Unable to kill process {pid!r}: The process is running on a different"
337
- f" host {hostname!r}."
338
- )
339
-
340
- # In a non-windows environment first send a SIGTERM, then, after
341
- # `grace_seconds` seconds have passed subsequent send SIGKILL. In
342
- # Windows we use CTRL_BREAK_EVENT as SIGTERM is useless:
343
- # https://bugs.python.org/issue26350
344
- if sys.platform == "win32":
345
- try:
346
- os.kill(pid, signal.CTRL_BREAK_EVENT)
347
- except (ProcessLookupError, WindowsError):
348
- raise InfrastructureNotFound(
349
- f"Unable to kill process {pid!r}: The process was not found."
350
- )
351
- else:
352
- try:
353
- os.kill(pid, signal.SIGTERM)
354
- except ProcessLookupError:
355
- raise InfrastructureNotFound(
356
- f"Unable to kill process {pid!r}: The process was not found."
357
- )
358
-
359
- # Throttle how often we check if the process is still alive to keep
360
- # from making too many system calls in a short period of time.
361
- check_interval = max(grace_seconds / 10, 1)
362
-
363
- with anyio.move_on_after(grace_seconds):
364
- while True:
365
- await anyio.sleep(check_interval)
366
-
367
- # Detect if the process is still alive. If not do an early
368
- # return as the process respected the SIGTERM from above.
369
- try:
370
- os.kill(pid, 0)
371
- except ProcessLookupError:
372
- return
373
-
374
- try:
375
- os.kill(pid, signal.SIGKILL)
376
- except OSError:
377
- # We shouldn't ever end up here, but it's possible that the
378
- # process ended right after the check above.
379
- return
380
-
381
- async def check_for_cancelled_flow_runs(self) -> list["FlowRun"]:
382
- if not self.is_setup:
383
- raise RuntimeError(
384
- "Worker is not set up. Please make sure you are running this worker "
385
- "as an async context manager."
386
- )
387
-
388
- self._logger.debug("Checking for cancelled flow runs...")
389
-
390
- work_queue_filter = (
391
- WorkQueueFilter(name=WorkQueueFilterName(any_=list(self._work_queues)))
392
- if self._work_queues
393
- else None
394
- )
395
-
396
- named_cancelling_flow_runs = await self._client.read_flow_runs(
397
- flow_run_filter=FlowRunFilter(
398
- state=FlowRunFilterState(
399
- type=FlowRunFilterStateType(any_=[StateType.CANCELLED]),
400
- name=FlowRunFilterStateName(any_=["Cancelling"]),
401
- ),
402
- # Avoid duplicate cancellation calls
403
- id=FlowRunFilterId(not_any_=list(self._cancelling_flow_run_ids)),
404
- ),
405
- work_pool_filter=WorkPoolFilter(
406
- name=WorkPoolFilterName(any_=[self._work_pool_name])
407
- ),
408
- work_queue_filter=work_queue_filter,
222
+ async def __aenter__(self) -> ProcessWorker:
223
+ await super().__aenter__()
224
+ self._runner = await self._exit_stack.enter_async_context(
225
+ Runner(pause_on_shutdown=False, limit=None)
409
226
  )
227
+ return self
410
228
 
411
- typed_cancelling_flow_runs = await self._client.read_flow_runs(
412
- flow_run_filter=FlowRunFilter(
413
- state=FlowRunFilterState(
414
- type=FlowRunFilterStateType(any_=[StateType.CANCELLING]),
415
- ),
416
- # Avoid duplicate cancellation calls
417
- id=FlowRunFilterId(not_any_=list(self._cancelling_flow_run_ids)),
418
- ),
419
- work_pool_filter=WorkPoolFilter(
420
- name=WorkPoolFilterName(any_=[self._work_pool_name])
421
- ),
422
- work_queue_filter=work_queue_filter,
423
- )
424
-
425
- cancelling_flow_runs = named_cancelling_flow_runs + typed_cancelling_flow_runs
426
-
427
- if cancelling_flow_runs:
428
- self._logger.info(
429
- f"Found {len(cancelling_flow_runs)} flow runs awaiting cancellation."
430
- )
431
-
432
- for flow_run in cancelling_flow_runs:
433
- self._cancelling_flow_run_ids.add(flow_run.id)
434
- self._runs_task_group.start_soon(self.cancel_run, flow_run)
435
-
436
- return cancelling_flow_runs
437
-
438
- async def cancel_run(self, flow_run: "FlowRun") -> None:
439
- run_logger = self.get_flow_run_logger(flow_run)
440
-
441
- try:
442
- configuration = await self._get_configuration(flow_run)
443
- except ObjectNotFound:
444
- self._logger.warning(
445
- f"Flow run {flow_run.id!r} cannot be cancelled by this worker:"
446
- f" associated deployment {flow_run.deployment_id!r} does not exist."
447
- )
448
- await self._mark_flow_run_as_cancelled(
449
- flow_run,
450
- state_updates={
451
- "message": (
452
- "This flow run is missing infrastructure configuration information"
453
- " and cancellation cannot be guaranteed."
454
- )
455
- },
456
- )
457
- return
458
- else:
459
- if configuration.is_using_a_runner:
460
- self._logger.info(
461
- f"Skipping cancellation because flow run {str(flow_run.id)!r} is"
462
- " using enhanced cancellation. A dedicated runner will handle"
463
- " cancellation."
464
- )
465
- return
466
-
467
- if not flow_run.infrastructure_pid:
468
- run_logger.error(
469
- f"Flow run '{flow_run.id}' does not have an infrastructure pid"
470
- " attached. Cancellation cannot be guaranteed."
471
- )
472
- await self._mark_flow_run_as_cancelled(
473
- flow_run,
474
- state_updates={
475
- "message": (
476
- "This flow run is missing infrastructure tracking information"
477
- " and cancellation cannot be guaranteed."
478
- )
479
- },
480
- )
481
- return
482
-
483
- try:
484
- await self.kill_process(
485
- infrastructure_pid=flow_run.infrastructure_pid,
486
- )
487
- except NotImplementedError:
488
- self._logger.error(
489
- f"Worker type {self.type!r} does not support killing created "
490
- "infrastructure. Cancellation cannot be guaranteed."
491
- )
492
- except InfrastructureNotFound as exc:
493
- self._logger.warning(f"{exc} Marking flow run as cancelled.")
494
- await self._mark_flow_run_as_cancelled(flow_run)
495
- except InfrastructureNotAvailable as exc:
496
- self._logger.warning(f"{exc} Flow run cannot be cancelled by this worker.")
497
- except Exception:
498
- run_logger.exception(
499
- "Encountered exception while killing infrastructure for flow run "
500
- f"'{flow_run.id}'. Flow run may not be cancelled."
501
- )
502
- # We will try again on generic exceptions
503
- self._cancelling_flow_run_ids.remove(flow_run.id)
504
- return
505
- else:
506
- self._emit_flow_run_cancelled_event(
507
- flow_run=flow_run, configuration=configuration
508
- )
509
- await self._mark_flow_run_as_cancelled(flow_run)
510
- run_logger.info(f"Cancelled flow run '{flow_run.id}'!")
511
-
512
- def _emit_flow_run_cancelled_event(
513
- self, flow_run: "FlowRun", configuration: BaseJobConfiguration
514
- ):
515
- related = self._event_related_resources(configuration=configuration)
516
-
517
- for resource in related:
518
- if resource.role == "flow-run":
519
- resource["prefect.infrastructure.identifier"] = str(
520
- flow_run.infrastructure_pid
521
- )
522
-
523
- emit_event(
524
- event="prefect.worker.cancelled-flow-run",
525
- resource=self._event_resource(),
526
- related=related,
527
- )
229
+ async def __aexit__(self, *exc_info: Any) -> None:
230
+ await super().__aexit__(*exc_info)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefect-client
3
- Version: 3.2.7
3
+ Version: 3.2.9
4
4
  Summary: Workflow orchestration and management.
5
5
  Project-URL: Changelog, https://github.com/PrefectHQ/prefect/releases
6
6
  Project-URL: Documentation, https://docs.prefect.io
@@ -57,7 +57,7 @@ Requires-Dist: toml>=0.10.0
57
57
  Requires-Dist: typing-extensions<5.0.0,>=4.5.0
58
58
  Requires-Dist: ujson<6.0.0,>=5.8.0
59
59
  Requires-Dist: uvicorn!=0.29.0,>=0.14.0
60
- Requires-Dist: websockets<14.0,>=10.4
60
+ Requires-Dist: websockets<16.0,>=13.0
61
61
  Provides-Extra: notifications
62
62
  Requires-Dist: apprise<2.0.0,>=1.1.0; extra == 'notifications'
63
63
  Description-Content-Type: text/markdown