prefect-client 2.17.0__py3-none-any.whl → 2.18.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 (45) hide show
  1. prefect/_internal/compatibility/deprecated.py +2 -0
  2. prefect/_internal/pydantic/_compat.py +1 -0
  3. prefect/_internal/pydantic/utilities/field_validator.py +25 -10
  4. prefect/_internal/pydantic/utilities/model_dump.py +1 -1
  5. prefect/_internal/pydantic/utilities/model_validate.py +1 -1
  6. prefect/_internal/pydantic/utilities/model_validator.py +11 -3
  7. prefect/_internal/schemas/validators.py +0 -6
  8. prefect/_version.py +97 -38
  9. prefect/blocks/abstract.py +34 -1
  10. prefect/blocks/notifications.py +14 -5
  11. prefect/client/base.py +10 -5
  12. prefect/client/orchestration.py +125 -66
  13. prefect/client/schemas/actions.py +4 -3
  14. prefect/client/schemas/objects.py +6 -5
  15. prefect/client/schemas/schedules.py +2 -6
  16. prefect/deployments/__init__.py +0 -2
  17. prefect/deployments/base.py +2 -144
  18. prefect/deployments/deployments.py +2 -2
  19. prefect/deployments/runner.py +2 -2
  20. prefect/deployments/steps/core.py +3 -3
  21. prefect/deprecated/packaging/serializers.py +5 -4
  22. prefect/events/__init__.py +45 -0
  23. prefect/events/actions.py +250 -19
  24. prefect/events/cli/__init__.py +0 -0
  25. prefect/events/cli/automations.py +163 -0
  26. prefect/events/clients.py +133 -7
  27. prefect/events/schemas/automations.py +76 -3
  28. prefect/events/schemas/deployment_triggers.py +17 -59
  29. prefect/events/utilities.py +2 -0
  30. prefect/events/worker.py +12 -2
  31. prefect/exceptions.py +1 -1
  32. prefect/logging/__init__.py +2 -2
  33. prefect/logging/loggers.py +64 -1
  34. prefect/results.py +29 -10
  35. prefect/serializers.py +62 -31
  36. prefect/settings.py +6 -10
  37. prefect/types/__init__.py +90 -0
  38. prefect/utilities/pydantic.py +34 -15
  39. prefect/utilities/schema_tools/hydration.py +88 -19
  40. prefect/variables.py +4 -4
  41. {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/METADATA +1 -1
  42. {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/RECORD +45 -42
  43. {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/LICENSE +0 -0
  44. {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/WHEEL +0 -0
  45. {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/top_level.txt +0 -0
@@ -344,6 +344,8 @@ class DeprecatedInfraOverridesField(BaseModel):
344
344
  exclude.add("job_variables")
345
345
  elif exclude_type is dict:
346
346
  exclude["job_variables"] = True
347
+ else:
348
+ exclude = {"job_variables"}
347
349
  kwargs["exclude"] = exclude
348
350
 
349
351
  return super().dict(**kwargs)
@@ -2,6 +2,7 @@
2
2
  Functions within this module check for Pydantic V2 compatibility and provide mechanisms for copying,
3
3
  dumping, and validating models in a way that is agnostic to the underlying Pydantic version.
4
4
  """
5
+
5
6
  import typing
6
7
 
7
8
  from ._base_model import BaseModel as PydanticBaseModel
@@ -4,7 +4,7 @@ Conditional decorator for fields depending on Pydantic version.
4
4
 
5
5
  import functools
6
6
  from inspect import signature
7
- from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, TypeVar, Union
7
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, TypeVar, Union
8
8
 
9
9
  from typing_extensions import TypeAlias
10
10
 
@@ -21,11 +21,8 @@ def field_validator(
21
21
  field: str,
22
22
  /,
23
23
  *fields: str,
24
- mode: FieldValidatorModes = "after", # v2 only
24
+ mode: FieldValidatorModes = "after",
25
25
  check_fields: Union[bool, None] = None,
26
- pre: bool = False, # v1 only
27
- allow_reuse: Optional[bool] = None,
28
- always: bool = False, # v1 only
29
26
  ) -> Callable[[Any], Any]:
30
27
  """Usage docs: https://docs.pydantic.dev/2.7/concepts/validators/#field-validators
31
28
  Returns a decorator that conditionally applies Pydantic's `field_validator` or `validator`,
@@ -37,6 +34,26 @@ def field_validator(
37
34
 
38
35
  Decorate methods on the class indicating that they should be used to validate fields.
39
36
 
37
+ !!! note Replacing Pydantic V1 `pre=True` kwarg:
38
+ To replace a @validator that uses Pydantic V1's `pre` parameter, e.g. `@validator('a', pre=True)`,
39
+ you can use `mode='before'`, e.g. @field_validator('a', mode='before').
40
+
41
+ If a user has Pydantic V1 installed, `mode` will map to the `pre` parameter of `validator` if the value is `before`.
42
+
43
+ !!! note Replacing Pydantic V1 `always=True` kwarg:
44
+ To replace a @validator that uses Pydantic V1's `always` parameter, e.g. `@validator('a', always=True)`,
45
+ you can use the @model_validator (not the @field_validator) with the `mode='before'` parameter, (and also add a check that the field is not None, if necessary).
46
+
47
+ Read more discussion on that here: https://github.com/pydantic/pydantic/discussions/6337
48
+
49
+ !!! note Replacing Pydantic V1 `allow_reuse=True` kwarg:
50
+ To replace a @validator that uses Pydantic V1's `allow_reuse=True` parameter, e.g. `@validator('a', allow_reuse=True)`,
51
+ you can simply remove the `allow_reuse` parameter when replacing the decorator, e.g. `@field_validator('a')`. This is because
52
+ Pydantic V2 by default allows reuse of the decorated function, rendering the kwarg necessary), while Pydantic V1 required explicit
53
+ declaration of `allow_reuse=True`.
54
+
55
+ https://docs.pydantic.dev/2.0/migration/#the-allow_reuse-keyword-argument-is-no-longer-necessary
56
+
40
57
  Example usage:
41
58
  ```py
42
59
  from typing import Any
@@ -120,14 +137,12 @@ def field_validator(
120
137
 
121
138
  return validate_func(cls, v, **filtered_kwargs)
122
139
 
123
- # In Pydantic V1, `allow_reuse` is by default False, while in Pydantic V2, it is by default True.
124
- # We default to False in Pydantic V1 to maintain backward compatibility
125
- # e.g. One uses @validator("a", pre=True, allow_reuse=True) in Pydantic V1
140
+ # Map Pydantic V2's `mode` to Pydantic V1's `pre` parameter for use in `@validator`
141
+ pre: bool = mode == "before"
142
+
126
143
  validator_kwargs: Dict[str, Any] = {
127
144
  "pre": pre,
128
- "always": always,
129
145
  "check_fields": check_fields if check_fields is not None else True,
130
- "allow_reuse": allow_reuse if allow_reuse is not None else False,
131
146
  }
132
147
 
133
148
  return validator(field, *fields, **validator_kwargs)(wrapper) # type: ignore
@@ -47,7 +47,7 @@ def model_dump( # type: ignore[no-redef]
47
47
  Returns:
48
48
  A dictionary representation of the model.
49
49
  """
50
- if USE_V2_MODELS:
50
+ if USE_V2_MODELS and hasattr(model_instance, "model_dump"):
51
51
  return model_instance.model_dump(
52
52
  mode=mode,
53
53
  include=include,
@@ -39,7 +39,7 @@ def model_validate(
39
39
  context=context,
40
40
  )
41
41
  else:
42
- return getattr(model_instance, "validate")(obj)
42
+ return getattr(model_instance, "parse_obj")(obj)
43
43
 
44
44
 
45
45
  class ModelValidateMixin(BaseModel):
@@ -13,8 +13,6 @@ def model_validator(
13
13
  _func: Optional[Callable] = None,
14
14
  *,
15
15
  mode: Literal["wrap", "before", "after"] = "before", # v2 only
16
- pre: bool = False, # v1 only
17
- skip_on_failure: bool = False, # v1 only
18
16
  ) -> Any:
19
17
  """Usage docs: https://docs.pydantic.dev/2.6/concepts/validators/#model-validators
20
18
 
@@ -33,6 +31,15 @@ def model_validator(
33
31
  validation logic before, after, or wrapping the original method call, depending on the
34
32
  `mode` parameter.
35
33
 
34
+ !!! note Replacing Pydantic V1 `pre=True` kwarg:
35
+ To replace a @root_validator that uses Pydantic V1's `pre=True` parameter, e.g. `@root_validator('a', pre=True)`,
36
+ you can use the @model_validator with the `mode='before'` parameter, (and also add a check that the field is not None, if necessary).
37
+ This will map to the `pre` parameter of `root_validator` in Pydantic V1, if the value is `True`.
38
+
39
+ !!! note Replacing Pydantic V1 `skip_on_failure=True` kwarg:
40
+ To replace a @root_validator that uses Pydantic V1's `skip_on_failure=True` parameter, e.g. `@root_validator('a', skip_on_failure=True)`,
41
+ we'll simply remove it. Pydantic V2 does not have an equivalent parameter, and we use it in only 3 places in Prefect, none of which are critical.
42
+
36
43
  Args:
37
44
  _func: The function to be decorated. If None, the decorator is applied with parameters.
38
45
  mode: Specifies when the validation should occur. 'before' or 'after' are for v1 compatibility,
@@ -69,9 +76,10 @@ def model_validator(
69
76
  ) -> Any:
70
77
  return validate_func(cls, v)
71
78
 
79
+ pre: bool = mode == "before"
80
+
72
81
  return root_validator(
73
82
  pre=pre,
74
- skip_on_failure=skip_on_failure,
75
83
  )(wrapper) # type: ignore
76
84
 
77
85
  if _func is None:
@@ -372,12 +372,6 @@ def reconcile_paused_deployment(values):
372
372
  return values
373
373
 
374
374
 
375
- def interval_schedule_must_be_positive(v: datetime.timedelta) -> datetime.timedelta:
376
- if v.total_seconds() <= 0:
377
- raise ValueError("The interval must be positive")
378
- return v
379
-
380
-
381
375
  def default_anchor_date(v: DateTimeTZ) -> DateTimeTZ:
382
376
  if v is None:
383
377
  return pendulum.now("UTC")
prefect/_version.py CHANGED
@@ -4,19 +4,22 @@
4
4
  # directories (produced by setup.py build) will contain a much shorter file
5
5
  # that just contains the computed version number.
6
6
 
7
- # This file is released into the public domain. Generated by
8
- # versioneer-0.20 (https://github.com/python-versioneer/python-versioneer)
7
+ # This file is released into the public domain.
8
+ # Generated by versioneer-0.29
9
+ # https://github.com/python-versioneer/python-versioneer
9
10
 
10
11
  """Git implementation of _version.py."""
11
12
 
12
13
  import errno
14
+ import functools
13
15
  import os
14
16
  import re
15
17
  import subprocess
16
18
  import sys
19
+ from typing import Any, Callable, Dict, List, Optional, Tuple
17
20
 
18
21
 
19
- def get_keywords():
22
+ def get_keywords() -> Dict[str, str]:
20
23
  """Get the keywords needed to look up the version information."""
21
24
  # these strings will be replaced by git during git-archive.
22
25
  # setup.py/versioneer.py will grep for the variable names, so they must
@@ -29,11 +32,18 @@ def get_keywords():
29
32
  return keywords
30
33
 
31
34
 
32
- class VersioneerConfig: # pylint: disable=too-few-public-methods
35
+ class VersioneerConfig:
33
36
  """Container for Versioneer configuration parameters."""
34
37
 
38
+ VCS: str
39
+ style: str
40
+ tag_prefix: str
41
+ parentdir_prefix: str
42
+ versionfile_source: str
43
+ verbose: bool
35
44
 
36
- def get_config():
45
+
46
+ def get_config() -> VersioneerConfig:
37
47
  """Create, populate and return the VersioneerConfig() object."""
38
48
  # these strings are filled in when 'setup.py versioneer' creates
39
49
  # _version.py
@@ -51,14 +61,14 @@ class NotThisMethod(Exception):
51
61
  """Exception raised if a method is not valid for the current scenario."""
52
62
 
53
63
 
54
- LONG_VERSION_PY = {}
55
- HANDLERS = {}
64
+ LONG_VERSION_PY: Dict[str, str] = {}
65
+ HANDLERS: Dict[str, Dict[str, Callable]] = {}
56
66
 
57
67
 
58
- def register_vcs_handler(vcs, method): # decorator
68
+ def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator
59
69
  """Create decorator to mark a method as the handler of a VCS."""
60
70
 
61
- def decorate(f):
71
+ def decorate(f: Callable) -> Callable:
62
72
  """Store f in HANDLERS[vcs][method]."""
63
73
  if vcs not in HANDLERS:
64
74
  HANDLERS[vcs] = {}
@@ -68,11 +78,25 @@ def register_vcs_handler(vcs, method): # decorator
68
78
  return decorate
69
79
 
70
80
 
71
- # pylint:disable=too-many-arguments,consider-using-with # noqa
72
- def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None):
81
+ def run_command(
82
+ commands: List[str],
83
+ args: List[str],
84
+ cwd: Optional[str] = None,
85
+ verbose: bool = False,
86
+ hide_stderr: bool = False,
87
+ env: Optional[Dict[str, str]] = None,
88
+ ) -> Tuple[Optional[str], Optional[int]]:
73
89
  """Call the given command(s)."""
74
90
  assert isinstance(commands, list)
75
91
  process = None
92
+
93
+ popen_kwargs: Dict[str, Any] = {}
94
+ if sys.platform == "win32":
95
+ # This hides the console window if pythonw.exe is used
96
+ startupinfo = subprocess.STARTUPINFO()
97
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
98
+ popen_kwargs["startupinfo"] = startupinfo
99
+
76
100
  for command in commands:
77
101
  try:
78
102
  dispcmd = str([command] + args)
@@ -83,10 +107,10 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=
83
107
  env=env,
84
108
  stdout=subprocess.PIPE,
85
109
  stderr=(subprocess.PIPE if hide_stderr else None),
110
+ **popen_kwargs,
86
111
  )
87
112
  break
88
- except EnvironmentError:
89
- e = sys.exc_info()[1]
113
+ except OSError as e:
90
114
  if e.errno == errno.ENOENT:
91
115
  continue
92
116
  if verbose:
@@ -106,7 +130,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=
106
130
  return stdout, process.returncode
107
131
 
108
132
 
109
- def versions_from_parentdir(parentdir_prefix, root, verbose):
133
+ def versions_from_parentdir(
134
+ parentdir_prefix: str,
135
+ root: str,
136
+ verbose: bool,
137
+ ) -> Dict[str, Any]:
110
138
  """Try to determine the version from the parent directory name.
111
139
 
112
140
  Source tarballs conventionally unpack into a directory that includes both
@@ -137,13 +165,13 @@ def versions_from_parentdir(parentdir_prefix, root, verbose):
137
165
 
138
166
 
139
167
  @register_vcs_handler("git", "get_keywords")
140
- def git_get_keywords(versionfile_abs):
168
+ def git_get_keywords(versionfile_abs: str) -> Dict[str, str]:
141
169
  """Extract version information from the given file."""
142
170
  # the code embedded in _version.py can just fetch the value of these
143
171
  # keywords. When used from setup.py, we don't want to import _version.py,
144
172
  # so we do it with a regexp instead. This function is not used from
145
173
  # _version.py.
146
- keywords = {}
174
+ keywords: Dict[str, str] = {}
147
175
  try:
148
176
  with open(versionfile_abs, "r") as fobj:
149
177
  for line in fobj:
@@ -159,13 +187,17 @@ def git_get_keywords(versionfile_abs):
159
187
  mo = re.search(r'=\s*"(.*)"', line)
160
188
  if mo:
161
189
  keywords["date"] = mo.group(1)
162
- except EnvironmentError:
190
+ except OSError:
163
191
  pass
164
192
  return keywords
165
193
 
166
194
 
167
195
  @register_vcs_handler("git", "keywords")
168
- def git_versions_from_keywords(keywords, tag_prefix, verbose):
196
+ def git_versions_from_keywords(
197
+ keywords: Dict[str, str],
198
+ tag_prefix: str,
199
+ verbose: bool,
200
+ ) -> Dict[str, Any]:
169
201
  """Get version information from git keywords."""
170
202
  if "refnames" not in keywords:
171
203
  raise NotThisMethod("Short version file found")
@@ -236,7 +268,9 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
236
268
 
237
269
 
238
270
  @register_vcs_handler("git", "pieces_from_vcs")
239
- def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
271
+ def git_pieces_from_vcs(
272
+ tag_prefix: str, root: str, verbose: bool, runner: Callable = run_command
273
+ ) -> Dict[str, Any]:
240
274
  """Get version from 'git describe' in the root of the source tree.
241
275
 
242
276
  This only gets called if the git-archive 'subst' keywords were *not*
@@ -247,7 +281,14 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
247
281
  if sys.platform == "win32":
248
282
  GITS = ["git.cmd", "git.exe"]
249
283
 
250
- _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True)
284
+ # GIT_DIR can interfere with correct operation of Versioneer.
285
+ # It may be intended to be passed to the Versioneer-versioned project,
286
+ # but that should not change where we get our version from.
287
+ env = os.environ.copy()
288
+ env.pop("GIT_DIR", None)
289
+ runner = functools.partial(runner, env=env)
290
+
291
+ _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=not verbose)
251
292
  if rc != 0:
252
293
  if verbose:
253
294
  print("Directory %s not under git control" % root)
@@ -264,7 +305,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
264
305
  "--always",
265
306
  "--long",
266
307
  "--match",
267
- "%s*" % tag_prefix,
308
+ f"{tag_prefix}[[:digit:]]*",
268
309
  ],
269
310
  cwd=root,
270
311
  )
@@ -277,7 +318,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
277
318
  raise NotThisMethod("'git rev-parse' failed")
278
319
  full_out = full_out.strip()
279
320
 
280
- pieces = {}
321
+ pieces: Dict[str, Any] = {}
281
322
  pieces["long"] = full_out
282
323
  pieces["short"] = full_out[:7] # maybe improved later
283
324
  pieces["error"] = None
@@ -356,8 +397,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
356
397
  else:
357
398
  # HEX: no tags
358
399
  pieces["closest-tag"] = None
359
- count_out, rc = runner(GITS, ["rev-list", "HEAD", "--count"], cwd=root)
360
- pieces["distance"] = int(count_out) # total number of commits
400
+ out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root)
401
+ pieces["distance"] = len(out.split()) # total number of commits
361
402
 
362
403
  # commit date: see ISO-8601 comment in git_versions_from_keywords()
363
404
  date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
@@ -369,14 +410,14 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
369
410
  return pieces
370
411
 
371
412
 
372
- def plus_or_dot(pieces):
413
+ def plus_or_dot(pieces: Dict[str, Any]) -> str:
373
414
  """Return a + if we don't already have one, else return a ."""
374
415
  if "+" in pieces.get("closest-tag", ""):
375
416
  return "."
376
417
  return "+"
377
418
 
378
419
 
379
- def render_pep440(pieces):
420
+ def render_pep440(pieces: Dict[str, Any]) -> str:
380
421
  """Build up version string, with post-release "local version identifier".
381
422
 
382
423
  Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
@@ -400,7 +441,7 @@ def render_pep440(pieces):
400
441
  return rendered
401
442
 
402
443
 
403
- def render_pep440_branch(pieces):
444
+ def render_pep440_branch(pieces: Dict[str, Any]) -> str:
404
445
  """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
405
446
 
406
447
  The ".dev0" means not master branch. Note that .dev0 sorts backwards
@@ -429,23 +470,41 @@ def render_pep440_branch(pieces):
429
470
  return rendered
430
471
 
431
472
 
432
- def render_pep440_pre(pieces):
433
- """TAG[.post0.devDISTANCE] -- No -dirty.
473
+ def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]:
474
+ """Split pep440 version string at the post-release segment.
475
+
476
+ Returns the release segments before the post-release and the
477
+ post-release version number (or -1 if no post-release segment is present).
478
+ """
479
+ vc = str.split(ver, ".post")
480
+ return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
481
+
482
+
483
+ def render_pep440_pre(pieces: Dict[str, Any]) -> str:
484
+ """TAG[.postN.devDISTANCE] -- No -dirty.
434
485
 
435
486
  Exceptions:
436
487
  1: no tags. 0.post0.devDISTANCE
437
488
  """
438
489
  if pieces["closest-tag"]:
439
- rendered = pieces["closest-tag"]
440
490
  if pieces["distance"]:
441
- rendered += ".post0.dev%d" % pieces["distance"]
491
+ # update the post release segment
492
+ tag_version, post_version = pep440_split_post(pieces["closest-tag"])
493
+ rendered = tag_version
494
+ if post_version is not None:
495
+ rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"])
496
+ else:
497
+ rendered += ".post0.dev%d" % (pieces["distance"])
498
+ else:
499
+ # no commits, use the tag as the version
500
+ rendered = pieces["closest-tag"]
442
501
  else:
443
502
  # exception #1
444
503
  rendered = "0.post0.dev%d" % pieces["distance"]
445
504
  return rendered
446
505
 
447
506
 
448
- def render_pep440_post(pieces):
507
+ def render_pep440_post(pieces: Dict[str, Any]) -> str:
449
508
  """TAG[.postDISTANCE[.dev0]+gHEX] .
450
509
 
451
510
  The ".dev0" means dirty. Note that .dev0 sorts backwards
@@ -472,7 +531,7 @@ def render_pep440_post(pieces):
472
531
  return rendered
473
532
 
474
533
 
475
- def render_pep440_post_branch(pieces):
534
+ def render_pep440_post_branch(pieces: Dict[str, Any]) -> str:
476
535
  """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
477
536
 
478
537
  The ".dev0" means not master branch.
@@ -501,7 +560,7 @@ def render_pep440_post_branch(pieces):
501
560
  return rendered
502
561
 
503
562
 
504
- def render_pep440_old(pieces):
563
+ def render_pep440_old(pieces: Dict[str, Any]) -> str:
505
564
  """TAG[.postDISTANCE[.dev0]] .
506
565
 
507
566
  The ".dev0" means dirty.
@@ -523,7 +582,7 @@ def render_pep440_old(pieces):
523
582
  return rendered
524
583
 
525
584
 
526
- def render_git_describe(pieces):
585
+ def render_git_describe(pieces: Dict[str, Any]) -> str:
527
586
  """TAG[-DISTANCE-gHEX][-dirty].
528
587
 
529
588
  Like 'git describe --tags --dirty --always'.
@@ -543,7 +602,7 @@ def render_git_describe(pieces):
543
602
  return rendered
544
603
 
545
604
 
546
- def render_git_describe_long(pieces):
605
+ def render_git_describe_long(pieces: Dict[str, Any]) -> str:
547
606
  """TAG-DISTANCE-gHEX[-dirty].
548
607
 
549
608
  Like 'git describe --tags --dirty --always -long'.
@@ -563,7 +622,7 @@ def render_git_describe_long(pieces):
563
622
  return rendered
564
623
 
565
624
 
566
- def render(pieces, style):
625
+ def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]:
567
626
  """Render the given version pieces into the requested style."""
568
627
  if pieces["error"]:
569
628
  return {
@@ -605,7 +664,7 @@ def render(pieces, style):
605
664
  }
606
665
 
607
666
 
608
- def get_versions():
667
+ def get_versions() -> Dict[str, Any]:
609
668
  """Get version information or return default if unable to do so."""
610
669
  # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
611
670
  # __file__, we can work backwards from there to the root. Some
@@ -1,7 +1,19 @@
1
1
  from abc import ABC, abstractmethod
2
+ from contextlib import contextmanager
2
3
  from logging import Logger
3
4
  from pathlib import Path
4
- from typing import Any, BinaryIO, Dict, Generic, List, Optional, Tuple, TypeVar, Union
5
+ from typing import (
6
+ Any,
7
+ BinaryIO,
8
+ Dict,
9
+ Generator,
10
+ Generic,
11
+ List,
12
+ Optional,
13
+ Tuple,
14
+ TypeVar,
15
+ Union,
16
+ )
5
17
 
6
18
  from typing_extensions import Self
7
19
 
@@ -48,6 +60,13 @@ class CredentialsBlock(Block, ABC):
48
60
  """
49
61
 
50
62
 
63
+ class NotificationError(Exception):
64
+ """Raised if a notification block fails to send a notification."""
65
+
66
+ def __init__(self, log: str) -> None:
67
+ self.log = log
68
+
69
+
51
70
  class NotificationBlock(Block, ABC):
52
71
  """
53
72
  Block that represents a resource in an external system that is able to send notifications.
@@ -82,6 +101,20 @@ class NotificationBlock(Block, ABC):
82
101
  subject: The subject of the notification.
83
102
  """
84
103
 
104
+ _raise_on_failure: bool = False
105
+
106
+ @contextmanager
107
+ def raise_on_failure(self) -> Generator[None, None, None]:
108
+ """
109
+ Context manager that, while active, causes the block to raise errors if it
110
+ encounters a failure sending notifications.
111
+ """
112
+ self._raise_on_failure = True
113
+ try:
114
+ yield
115
+ finally:
116
+ self._raise_on_failure = False
117
+
85
118
 
86
119
  class JobRun(ABC, Generic[T]): # not a block
87
120
  """
@@ -1,7 +1,9 @@
1
+ import logging
1
2
  from abc import ABC
2
3
  from typing import Dict, List, Optional
3
4
 
4
5
  from prefect._internal.pydantic import HAS_PYDANTIC_V2
6
+ from prefect.logging import LogEavesdropper
5
7
 
6
8
  if HAS_PYDANTIC_V2:
7
9
  from pydantic.v1 import AnyHttpUrl, Field, SecretStr
@@ -10,7 +12,7 @@ else:
10
12
 
11
13
  from typing_extensions import Literal
12
14
 
13
- from prefect.blocks.abstract import NotificationBlock
15
+ from prefect.blocks.abstract import NotificationBlock, NotificationError
14
16
  from prefect.blocks.fields import SecretDict
15
17
  from prefect.events.instrument import instrument_instance_method_call
16
18
  from prefect.utilities.asyncutils import sync_compatible
@@ -61,10 +63,17 @@ class AbstractAppriseNotificationBlock(NotificationBlock, ABC):
61
63
 
62
64
  @sync_compatible
63
65
  @instrument_instance_method_call()
64
- async def notify(self, body: str, subject: Optional[str] = None):
65
- await self._apprise_client.async_notify(
66
- body=body, title=subject, notify_type=self.notify_type
67
- )
66
+ async def notify(
67
+ self,
68
+ body: str,
69
+ subject: Optional[str] = None,
70
+ ):
71
+ with LogEavesdropper("apprise", level=logging.DEBUG) as eavesdropper:
72
+ result = await self._apprise_client.async_notify(
73
+ body=body, title=subject, notify_type=self.notify_type
74
+ )
75
+ if not result and self._raise_on_failure:
76
+ raise NotificationError(log=eavesdropper.text())
68
77
 
69
78
 
70
79
  class AppriseNotificationBlock(AbstractAppriseNotificationBlock, ABC):
prefect/client/base.py CHANGED
@@ -193,11 +193,18 @@ class PrefectHttpxClient(httpx.AsyncClient):
193
193
  [Configuring Cloudflare Rate Limiting](https://support.cloudflare.com/hc/en-us/articles/115001635128-Configuring-Rate-Limiting-from-UI)
194
194
  """
195
195
 
196
- def __init__(self, *args, enable_csrf_support: bool = False, **kwargs):
196
+ def __init__(
197
+ self,
198
+ *args,
199
+ enable_csrf_support: bool = False,
200
+ raise_on_all_errors: bool = True,
201
+ **kwargs,
202
+ ):
197
203
  self.enable_csrf_support: bool = enable_csrf_support
198
204
  self.csrf_token: Optional[str] = None
199
205
  self.csrf_token_expiration: Optional[datetime] = None
200
206
  self.csrf_client_id: uuid.UUID = uuid.uuid4()
207
+ self.raise_on_all_errors: bool = raise_on_all_errors
201
208
 
202
209
  super().__init__(*args, **kwargs)
203
210
 
@@ -345,10 +352,8 @@ class PrefectHttpxClient(httpx.AsyncClient):
345
352
  # Convert to a Prefect response to add nicer errors messages
346
353
  response = PrefectResponse.from_httpx_response(response)
347
354
 
348
- # Always raise bad responses
349
- # NOTE: We may want to remove this and handle responses per route in the
350
- # `PrefectClient`
351
- response.raise_for_status()
355
+ if self.raise_on_all_errors:
356
+ response.raise_for_status()
352
357
 
353
358
  return response
354
359