prefect-client 3.3.8.dev4__py3-none-any.whl → 3.4.1__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 (35) hide show
  1. prefect/_build_info.py +3 -3
  2. prefect/_experimental/bundles/__init__.py +1 -1
  3. prefect/_internal/schemas/bases.py +11 -1
  4. prefect/_internal/schemas/validators.py +0 -98
  5. prefect/_internal/uuid7.py +11 -0
  6. prefect/_versioning.py +2 -0
  7. prefect/blocks/core.py +20 -1
  8. prefect/client/orchestration/__init__.py +16 -8
  9. prefect/client/schemas/actions.py +13 -35
  10. prefect/client/schemas/objects.py +30 -22
  11. prefect/client/subscriptions.py +18 -9
  12. prefect/deployments/runner.py +54 -4
  13. prefect/events/clients.py +6 -6
  14. prefect/events/filters.py +25 -11
  15. prefect/events/schemas/automations.py +3 -1
  16. prefect/events/schemas/events.py +3 -2
  17. prefect/flows.py +94 -28
  18. prefect/infrastructure/provisioners/cloud_run.py +1 -0
  19. prefect/runner/_observers.py +60 -0
  20. prefect/runner/runner.py +72 -214
  21. prefect/server/api/server.py +18 -1
  22. prefect/server/api/workers.py +42 -6
  23. prefect/settings/base.py +7 -7
  24. prefect/settings/models/experiments.py +2 -0
  25. prefect/task_runners.py +2 -1
  26. prefect/tasks.py +3 -2
  27. prefect/types/__init__.py +24 -36
  28. prefect/types/names.py +139 -0
  29. prefect/utilities/dockerutils.py +18 -8
  30. prefect/utilities/importtools.py +12 -4
  31. prefect/workers/base.py +66 -21
  32. {prefect_client-3.3.8.dev4.dist-info → prefect_client-3.4.1.dist-info}/METADATA +4 -3
  33. {prefect_client-3.3.8.dev4.dist-info → prefect_client-3.4.1.dist-info}/RECORD +35 -32
  34. {prefect_client-3.3.8.dev4.dist-info → prefect_client-3.4.1.dist-info}/WHEEL +0 -0
  35. {prefect_client-3.3.8.dev4.dist-info → prefect_client-3.4.1.dist-info}/licenses/LICENSE +0 -0
prefect/types/__init__.py CHANGED
@@ -1,13 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from functools import partial
4
- from typing import Annotated, Any, Dict, List, Optional, Set, TypeVar, Union
4
+ from typing import Annotated, Any, Optional, TypeVar, Union, cast
5
+ from uuid import UUID
5
6
  from typing_extensions import Literal
6
7
  import orjson
7
8
  import pydantic
8
9
 
9
-
10
10
  from ._datetime import DateTime, Date
11
+ from .names import (
12
+ Name,
13
+ NameOrEmpty,
14
+ NonEmptyishName,
15
+ BANNED_CHARACTERS,
16
+ WITHOUT_BANNED_CHARACTERS,
17
+ MAX_VARIABLE_NAME_LENGTH,
18
+ )
11
19
  from pydantic import (
12
20
  BeforeValidator,
13
21
  Field,
@@ -21,7 +29,6 @@ from zoneinfo import available_timezones
21
29
 
22
30
  T = TypeVar("T")
23
31
 
24
- MAX_VARIABLE_NAME_LENGTH = 255
25
32
  MAX_VARIABLE_VALUE_LENGTH = 5000
26
33
 
27
34
  NonNegativeInteger = Annotated[int, Field(ge=0)]
@@ -39,37 +46,14 @@ TimeZone = Annotated[
39
46
  ]
40
47
 
41
48
 
42
- BANNED_CHARACTERS = ["/", "%", "&", ">", "<"]
43
-
44
- WITHOUT_BANNED_CHARACTERS = r"^[^" + "".join(BANNED_CHARACTERS) + "]+$"
45
- Name = Annotated[str, Field(pattern=WITHOUT_BANNED_CHARACTERS)]
46
-
47
- WITHOUT_BANNED_CHARACTERS_EMPTY_OK = r"^[^" + "".join(BANNED_CHARACTERS) + "]*$"
48
- NameOrEmpty = Annotated[str, Field(pattern=WITHOUT_BANNED_CHARACTERS_EMPTY_OK)]
49
-
50
-
51
- def non_emptyish(value: str) -> str:
52
- if not value.strip("' \""):
53
- raise ValueError("name cannot be an empty string")
54
-
55
- return value
56
-
57
-
58
- NonEmptyishName = Annotated[
59
- str,
60
- Field(pattern=WITHOUT_BANNED_CHARACTERS),
61
- BeforeValidator(non_emptyish),
62
- ]
63
-
64
-
65
49
  VariableValue = Union[
66
50
  StrictStr,
67
51
  StrictInt,
68
52
  StrictBool,
69
53
  StrictFloat,
70
54
  None,
71
- Dict[str, Any],
72
- List[Any],
55
+ dict[str, Any],
56
+ list[Any],
73
57
  ]
74
58
 
75
59
 
@@ -100,24 +84,24 @@ def cast_none_to_empty_dict(value: Any) -> dict[str, Any]:
100
84
 
101
85
 
102
86
  KeyValueLabels = Annotated[
103
- Dict[str, Union[StrictBool, StrictInt, StrictFloat, str]],
87
+ dict[str, Union[StrictBool, StrictInt, StrictFloat, str]],
104
88
  BeforeValidator(cast_none_to_empty_dict),
105
89
  ]
106
90
 
107
91
 
108
92
  ListOfNonEmptyStrings = Annotated[
109
- List[str],
93
+ list[str],
110
94
  BeforeValidator(lambda x: [str(s) for s in x if str(s).strip()]),
111
95
  ]
112
96
 
113
97
 
114
- class SecretDict(pydantic.Secret[Dict[str, Any]]):
98
+ class SecretDict(pydantic.Secret[dict[str, Any]]):
115
99
  pass
116
100
 
117
101
 
118
102
  def validate_set_T_from_delim_string(
119
- value: Union[str, T, Set[T], None], type_: type[T], delim: str | None = None
120
- ) -> Set[T]:
103
+ value: Union[str, T, set[T], None], type_: Any, delim: str | None = None
104
+ ) -> set[T]:
121
105
  """
122
106
  "no-info" before validator useful in scooping env vars
123
107
 
@@ -131,20 +115,20 @@ def validate_set_T_from_delim_string(
131
115
  delim = delim or ","
132
116
  if isinstance(value, str):
133
117
  return {T_adapter.validate_strings(s.strip()) for s in value.split(delim)}
134
- errors = []
118
+ errors: list[pydantic.ValidationError] = []
135
119
  try:
136
120
  return {T_adapter.validate_python(value)}
137
121
  except pydantic.ValidationError as e:
138
122
  errors.append(e)
139
123
  try:
140
- return TypeAdapter(Set[type_]).validate_python(value)
124
+ return TypeAdapter(set[type_]).validate_python(value)
141
125
  except pydantic.ValidationError as e:
142
126
  errors.append(e)
143
127
  raise ValueError(f"Invalid set[{type_}]: {errors}")
144
128
 
145
129
 
146
130
  ClientRetryExtraCodes = Annotated[
147
- Union[str, StatusCode, Set[StatusCode], None],
131
+ Union[str, StatusCode, set[StatusCode], None],
148
132
  BeforeValidator(partial(validate_set_T_from_delim_string, type_=StatusCode)),
149
133
  ]
150
134
 
@@ -170,11 +154,15 @@ KeyValueLabelsField = Annotated[
170
154
 
171
155
 
172
156
  __all__ = [
157
+ "BANNED_CHARACTERS",
158
+ "WITHOUT_BANNED_CHARACTERS",
173
159
  "ClientRetryExtraCodes",
174
160
  "Date",
175
161
  "DateTime",
176
162
  "LogLevel",
177
163
  "KeyValueLabelsField",
164
+ "MAX_VARIABLE_NAME_LENGTH",
165
+ "MAX_VARIABLE_VALUE_LENGTH",
178
166
  "NonNegativeInteger",
179
167
  "PositiveInteger",
180
168
  "ListOfNonEmptyStrings",
prefect/types/names.py ADDED
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import partial
5
+ from typing import Annotated, overload
6
+
7
+ from pydantic import AfterValidator, BeforeValidator, Field
8
+
9
+ LOWERCASE_LETTERS_NUMBERS_AND_DASHES_ONLY_REGEX = "^[a-z0-9-]*$"
10
+ LOWERCASE_LETTERS_NUMBERS_AND_UNDERSCORES_REGEX = "^[a-z0-9_]*$"
11
+ LOWERCASE_LETTERS_NUMBERS_AND_DASHES_OR_UNDERSCORES_REGEX = "^[a-z0-9-_]*$"
12
+
13
+
14
+ @overload
15
+ def raise_on_name_alphanumeric_dashes_only(
16
+ value: str, field_name: str = ...
17
+ ) -> str: ...
18
+
19
+
20
+ @overload
21
+ def raise_on_name_alphanumeric_dashes_only(
22
+ value: None, field_name: str = ...
23
+ ) -> None: ...
24
+
25
+
26
+ def raise_on_name_alphanumeric_dashes_only(
27
+ value: str | None, field_name: str = "value"
28
+ ) -> str | None:
29
+ if value is not None and not bool(
30
+ re.match(LOWERCASE_LETTERS_NUMBERS_AND_DASHES_ONLY_REGEX, value)
31
+ ):
32
+ raise ValueError(
33
+ f"{field_name} must only contain lowercase letters, numbers, and dashes."
34
+ )
35
+ return value
36
+
37
+
38
+ @overload
39
+ def raise_on_name_alphanumeric_underscores_only(
40
+ value: str, field_name: str = ...
41
+ ) -> str: ...
42
+
43
+
44
+ @overload
45
+ def raise_on_name_alphanumeric_underscores_only(
46
+ value: None, field_name: str = ...
47
+ ) -> None: ...
48
+
49
+
50
+ def raise_on_name_alphanumeric_underscores_only(
51
+ value: str | None, field_name: str = "value"
52
+ ) -> str | None:
53
+ if value is not None and not re.match(
54
+ LOWERCASE_LETTERS_NUMBERS_AND_UNDERSCORES_REGEX, value
55
+ ):
56
+ raise ValueError(
57
+ f"{field_name} must only contain lowercase letters, numbers, and"
58
+ " underscores."
59
+ )
60
+ return value
61
+
62
+
63
+ def raise_on_name_alphanumeric_dashes_underscores_only(
64
+ value: str, field_name: str = "value"
65
+ ) -> str:
66
+ if not re.match(LOWERCASE_LETTERS_NUMBERS_AND_DASHES_OR_UNDERSCORES_REGEX, value):
67
+ raise ValueError(
68
+ f"{field_name} must only contain lowercase letters, numbers, and"
69
+ " dashes or underscores."
70
+ )
71
+ return value
72
+
73
+
74
+ BANNED_CHARACTERS = ["/", "%", "&", ">", "<"]
75
+
76
+ WITHOUT_BANNED_CHARACTERS = r"^[^" + "".join(BANNED_CHARACTERS) + "]+$"
77
+ Name = Annotated[str, Field(pattern=WITHOUT_BANNED_CHARACTERS)]
78
+
79
+ WITHOUT_BANNED_CHARACTERS_EMPTY_OK = r"^[^" + "".join(BANNED_CHARACTERS) + "]*$"
80
+ NameOrEmpty = Annotated[str, Field(pattern=WITHOUT_BANNED_CHARACTERS_EMPTY_OK)]
81
+
82
+
83
+ def non_emptyish(value: str) -> str:
84
+ if not value.strip("' \""):
85
+ raise ValueError("name cannot be an empty string")
86
+
87
+ return value
88
+
89
+
90
+ NonEmptyishName = Annotated[
91
+ str,
92
+ Field(pattern=WITHOUT_BANNED_CHARACTERS),
93
+ BeforeValidator(non_emptyish),
94
+ ]
95
+
96
+
97
+ ### specific names
98
+
99
+ BlockDocumentName = Annotated[
100
+ Name,
101
+ AfterValidator(
102
+ partial(
103
+ raise_on_name_alphanumeric_dashes_only, field_name="Block document name"
104
+ )
105
+ ),
106
+ ]
107
+
108
+
109
+ BlockTypeSlug = Annotated[
110
+ str,
111
+ AfterValidator(
112
+ partial(raise_on_name_alphanumeric_dashes_only, field_name="Block type slug")
113
+ ),
114
+ ]
115
+
116
+ ArtifactKey = Annotated[
117
+ str,
118
+ AfterValidator(
119
+ partial(raise_on_name_alphanumeric_dashes_only, field_name="Artifact key")
120
+ ),
121
+ ]
122
+
123
+ MAX_VARIABLE_NAME_LENGTH = 255
124
+
125
+
126
+ VariableName = Annotated[
127
+ str,
128
+ AfterValidator(
129
+ partial(
130
+ raise_on_name_alphanumeric_dashes_underscores_only,
131
+ field_name="Variable name",
132
+ )
133
+ ),
134
+ Field(
135
+ max_length=MAX_VARIABLE_NAME_LENGTH,
136
+ description="The name of the variable",
137
+ examples=["my_variable"],
138
+ ),
139
+ ]
@@ -495,10 +495,11 @@ def parse_image_tag(name: str) -> tuple[str, Optional[str]]:
495
495
  """
496
496
  Parse Docker Image String
497
497
 
498
- - If a tag exists, this function parses and returns the image registry and tag,
498
+ - If a tag or digest exists, this function parses and returns the image registry and tag/digest,
499
499
  separately as a tuple.
500
500
  - Example 1: 'prefecthq/prefect:latest' -> ('prefecthq/prefect', 'latest')
501
501
  - Example 2: 'hostname.io:5050/folder/subfolder:latest' -> ('hostname.io:5050/folder/subfolder', 'latest')
502
+ - Example 3: 'prefecthq/prefect@sha256:abc123' -> ('prefecthq/prefect', 'sha256:abc123')
502
503
  - Supports parsing Docker Image strings that follow Docker Image Specification v1.1.0
503
504
  - Image building tools typically enforce this standard
504
505
 
@@ -506,26 +507,35 @@ def parse_image_tag(name: str) -> tuple[str, Optional[str]]:
506
507
  name (str): Name of Docker Image
507
508
 
508
509
  Return:
509
- tuple: image registry, image tag
510
+ tuple: image registry, image tag/digest
510
511
  """
511
512
  tag = None
512
513
  name_parts = name.split("/")
513
- # First handles the simplest image names (DockerHub-based, index-free, potentionally with a tag)
514
- # - Example: simplename:latest
514
+
515
+ # First handles the simplest image names (DockerHub-based, index-free, potentially with a tag or digest)
516
+ # - Example: simplename:latest or simplename@sha256:abc123
515
517
  if len(name_parts) == 1:
516
- if ":" in name_parts[0]:
518
+ if "@" in name_parts[0]:
519
+ image_name, tag = name_parts[0].split("@")
520
+ elif ":" in name_parts[0]:
517
521
  image_name, tag = name_parts[0].split(":")
522
+
518
523
  else:
519
524
  image_name = name_parts[0]
520
525
  else:
521
526
  # 1. Separates index (hostname.io or prefecthq) from path:tag (folder/subfolder:latest or prefect:latest)
522
- # 2. Separates path and tag (if tag exists)
523
- # 3. Reunites index and path (without tag) as image name
527
+ # 2. Separates path and tag/digest (if exists)
528
+ # 3. Reunites index and path (without tag/digest) as image name
524
529
  index_name = name_parts[0]
525
530
  image_path = "/".join(name_parts[1:])
526
- if ":" in image_path:
531
+
532
+ if "@" in image_path:
533
+ image_path, tag = image_path.split("@")
534
+ elif ":" in image_path:
527
535
  image_path, tag = image_path.split(":")
536
+
528
537
  image_name = f"{index_name}/{image_path}"
538
+
529
539
  return image_name, tag
530
540
 
531
541
 
@@ -145,17 +145,19 @@ def import_object(import_path: str) -> Any:
145
145
  - module.object
146
146
  - module:object
147
147
  - /path/to/script.py:object
148
+ - module:object.method
149
+ - /path/to/script.py:object.method
148
150
 
149
151
  This function is not thread safe as it modifies the 'sys' module during execution.
150
152
  """
151
153
  if ".py:" in import_path:
152
- script_path, object_name = import_path.rsplit(":", 1)
154
+ script_path, object_path = import_path.rsplit(":", 1)
153
155
  module = load_script_as_module(script_path)
154
156
  else:
155
157
  if ":" in import_path:
156
- module_name, object_name = import_path.rsplit(":", 1)
158
+ module_name, object_path = import_path.rsplit(":", 1)
157
159
  elif "." in import_path:
158
- module_name, object_name = import_path.rsplit(".", 1)
160
+ module_name, object_path = import_path.rsplit(".", 1)
159
161
  else:
160
162
  raise ValueError(
161
163
  f"Invalid format for object import. Received {import_path!r}."
@@ -163,7 +165,13 @@ def import_object(import_path: str) -> Any:
163
165
 
164
166
  module = load_module(module_name)
165
167
 
166
- return getattr(module, object_name)
168
+ # Handle nested object/method access
169
+ parts = object_path.split(".")
170
+ obj = module
171
+ for part in parts:
172
+ obj = getattr(obj, part)
173
+
174
+ return obj
167
175
 
168
176
 
169
177
  class DelayedImportErrorModule(ModuleType):
prefect/workers/base.py CHANGED
@@ -55,6 +55,7 @@ from prefect.exceptions import (
55
55
  Abort,
56
56
  ObjectNotFound,
57
57
  )
58
+ from prefect.filesystems import LocalFileSystem
58
59
  from prefect.futures import PrefectFlowRunFuture
59
60
  from prefect.logging.loggers import (
60
61
  PrefectLogAdapter,
@@ -696,6 +697,25 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
696
697
  "Workers must implement a method for running submitted flow runs"
697
698
  )
698
699
 
700
+ async def _initiate_run(
701
+ self,
702
+ flow_run: "FlowRun",
703
+ configuration: C,
704
+ ) -> None:
705
+ """
706
+ This method is called by the worker to initiate a flow run and should return as
707
+ soon as possible.
708
+
709
+ This method is used in `.submit` to allow non-blocking submission of flows. For
710
+ workers that wait for completion in their `run` method, this method should be
711
+ implemented to return immediately.
712
+
713
+ If this method is not implemented, `.submit` will fall back to the `.run` method.
714
+ """
715
+ raise NotImplementedError(
716
+ "This worker has not implemented `_initiate_run`. Please use `run` instead."
717
+ )
718
+
699
719
  async def submit(
700
720
  self,
701
721
  flow: "Flow[..., FR]",
@@ -722,15 +742,6 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
722
742
  if self._runs_task_group is None:
723
743
  raise RuntimeError("Worker not properly initialized")
724
744
 
725
- from prefect.results import get_result_store
726
-
727
- current_result_store = get_result_store()
728
- if current_result_store.result_storage is None and flow.result_storage is None:
729
- self._logger.warning(
730
- f"Flow {flow.name!r} has no result storage configured. Please configure "
731
- "result storage for the flow if you want to retrieve the result for the flow run."
732
- )
733
-
734
745
  flow_run = await self._runs_task_group.start(
735
746
  partial(
736
747
  self._submit_adhoc_run,
@@ -766,6 +777,32 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
766
777
  "work-pool storage configure`."
767
778
  )
768
779
 
780
+ from prefect.results import aresolve_result_storage, get_result_store
781
+
782
+ current_result_store = get_result_store()
783
+ # Check result storage and use the work pool default if needed
784
+ if (
785
+ current_result_store.result_storage is None
786
+ or isinstance(current_result_store.result_storage, LocalFileSystem)
787
+ and flow.result_storage is None
788
+ ):
789
+ if (
790
+ self.work_pool.storage_configuration.default_result_storage_block_id
791
+ is None
792
+ ):
793
+ self._logger.warning(
794
+ f"Flow {flow.name!r} has no result storage configured. Please configure "
795
+ "result storage for the flow if you want to retrieve the result for the flow run."
796
+ )
797
+ else:
798
+ # Use the work pool's default result storage block for the flow run to ensure the caller can retrieve the result
799
+ flow = flow.with_options(
800
+ result_storage=await aresolve_result_storage(
801
+ self.work_pool.storage_configuration.default_result_storage_block_id
802
+ ),
803
+ persist_result=True,
804
+ )
805
+
769
806
  bundle_key = str(uuid.uuid4())
770
807
  upload_command = convert_step_to_command(
771
808
  self.work_pool.storage_configuration.bundle_upload_step,
@@ -778,8 +815,9 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
778
815
 
779
816
  job_variables = (job_variables or {}) | {"command": " ".join(execute_command)}
780
817
  parameters = parameters or {}
781
- parent_task_run = None
782
818
 
819
+ # Create a parent task run if this is a child flow run to ensure it shows up as a child flow in the UI
820
+ parent_task_run = None
783
821
  if flow_run_ctx := FlowRunContext.get():
784
822
  parent_task = Task[Any, Any](
785
823
  name=flow.name,
@@ -821,6 +859,8 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
821
859
 
822
860
  bundle = create_bundle_for_flow_run(flow=flow, flow_run=flow_run)
823
861
 
862
+ # Write the bundle to a temporary directory so it can be uploaded to the bundle storage
863
+ # via the upload command
824
864
  with tempfile.TemporaryDirectory() as temp_dir:
825
865
  await (
826
866
  anyio.Path(temp_dir)
@@ -843,16 +883,21 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
843
883
  logger.debug("Successfully uploaded execution bundle")
844
884
 
845
885
  try:
846
- result = await self.run(flow_run, configuration)
847
-
848
- if result.status_code != 0:
849
- await self._propose_crashed_state(
850
- flow_run,
851
- (
852
- "Flow run infrastructure exited with non-zero status code"
853
- f" {result.status_code}."
854
- ),
855
- )
886
+ # Call the implementation-specific run method with the constructed configuration. This is where the
887
+ # rubber meets the road.
888
+ try:
889
+ await self._initiate_run(flow_run, configuration)
890
+ except NotImplementedError:
891
+ result = await self.run(flow_run, configuration)
892
+
893
+ if result.status_code != 0:
894
+ await self._propose_crashed_state(
895
+ flow_run,
896
+ (
897
+ "Flow run infrastructure exited with non-zero status code"
898
+ f" {result.status_code}."
899
+ ),
900
+ )
856
901
  except Exception as exc:
857
902
  # This flow run was being submitted and did not start successfully
858
903
  logger.exception(
@@ -1128,7 +1173,7 @@ class BaseWorker(abc.ABC, Generic[C, V, R]):
1128
1173
  if self._limiter:
1129
1174
  self._limiter.acquire_on_behalf_of_nowait(flow_run.id)
1130
1175
  except anyio.WouldBlock:
1131
- self._logger.info(
1176
+ self._logger.debug(
1132
1177
  f"Flow run limit reached; {self.limiter.borrowed_tokens} flow runs"
1133
1178
  " in progress."
1134
1179
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefect-client
3
- Version: 3.3.8.dev4
3
+ Version: 3.4.1
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
@@ -40,7 +40,7 @@ Requires-Dist: jsonpatch<2.0,>=1.32
40
40
  Requires-Dist: jsonschema<5.0.0,>=4.0.0
41
41
  Requires-Dist: opentelemetry-api<2.0.0,>=1.27.0
42
42
  Requires-Dist: orjson<4.0,>=3.7
43
- Requires-Dist: packaging<24.3,>=21.3
43
+ Requires-Dist: packaging<25.1,>=21.3
44
44
  Requires-Dist: pathspec>=0.8.0
45
45
  Requires-Dist: pendulum<4,>=3.0.0; python_version < '3.13'
46
46
  Requires-Dist: prometheus-client>=0.20.0
@@ -60,9 +60,10 @@ Requires-Dist: sniffio<2.0.0,>=1.3.0
60
60
  Requires-Dist: toml>=0.10.0
61
61
  Requires-Dist: typing-extensions<5.0.0,>=4.10.0
62
62
  Requires-Dist: ujson<6.0.0,>=5.8.0
63
+ Requires-Dist: uuid7>=0.1.0
63
64
  Requires-Dist: uvicorn!=0.29.0,>=0.14.0
64
65
  Requires-Dist: websockets<16.0,>=13.0
65
- Requires-Dist: whenever<0.8.0,>=0.7.3; python_version >= '3.13'
66
+ Requires-Dist: whenever<0.9.0,>=0.7.3; python_version >= '3.13'
66
67
  Provides-Extra: notifications
67
68
  Requires-Dist: apprise<2.0.0,>=1.1.0; extra == 'notifications'
68
69
  Description-Content-Type: text/markdown