prefect-client 3.0.0rc1__py3-none-any.whl → 3.0.0rc3__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 (72) hide show
  1. prefect/_internal/compatibility/migration.py +124 -0
  2. prefect/_internal/concurrency/__init__.py +2 -2
  3. prefect/_internal/concurrency/primitives.py +1 -0
  4. prefect/_internal/pydantic/annotations/pendulum.py +2 -2
  5. prefect/_internal/pytz.py +1 -1
  6. prefect/blocks/core.py +1 -1
  7. prefect/blocks/redis.py +168 -0
  8. prefect/client/orchestration.py +113 -23
  9. prefect/client/schemas/actions.py +1 -1
  10. prefect/client/schemas/filters.py +6 -0
  11. prefect/client/schemas/objects.py +22 -11
  12. prefect/client/subscriptions.py +3 -2
  13. prefect/concurrency/asyncio.py +1 -1
  14. prefect/concurrency/services.py +1 -1
  15. prefect/context.py +1 -27
  16. prefect/deployments/__init__.py +3 -0
  17. prefect/deployments/base.py +11 -3
  18. prefect/deployments/deployments.py +3 -0
  19. prefect/deployments/steps/pull.py +1 -0
  20. prefect/deployments/steps/utility.py +2 -1
  21. prefect/engine.py +3 -0
  22. prefect/events/cli/automations.py +1 -1
  23. prefect/events/clients.py +7 -1
  24. prefect/events/schemas/events.py +2 -0
  25. prefect/exceptions.py +9 -0
  26. prefect/filesystems.py +22 -11
  27. prefect/flow_engine.py +118 -156
  28. prefect/flow_runs.py +2 -2
  29. prefect/flows.py +91 -35
  30. prefect/futures.py +44 -43
  31. prefect/infrastructure/provisioners/container_instance.py +1 -0
  32. prefect/infrastructure/provisioners/ecs.py +2 -2
  33. prefect/input/__init__.py +4 -0
  34. prefect/input/run_input.py +4 -2
  35. prefect/logging/formatters.py +2 -2
  36. prefect/logging/handlers.py +2 -2
  37. prefect/logging/loggers.py +1 -1
  38. prefect/plugins.py +1 -0
  39. prefect/records/cache_policies.py +179 -0
  40. prefect/records/result_store.py +10 -3
  41. prefect/results.py +27 -55
  42. prefect/runner/runner.py +1 -1
  43. prefect/runner/server.py +1 -1
  44. prefect/runtime/__init__.py +1 -0
  45. prefect/runtime/deployment.py +1 -0
  46. prefect/runtime/flow_run.py +1 -0
  47. prefect/runtime/task_run.py +1 -0
  48. prefect/settings.py +21 -5
  49. prefect/states.py +17 -4
  50. prefect/task_engine.py +337 -209
  51. prefect/task_runners.py +15 -5
  52. prefect/task_runs.py +203 -0
  53. prefect/{task_server.py → task_worker.py} +66 -36
  54. prefect/tasks.py +180 -77
  55. prefect/transactions.py +92 -16
  56. prefect/types/__init__.py +1 -1
  57. prefect/utilities/asyncutils.py +3 -3
  58. prefect/utilities/callables.py +90 -7
  59. prefect/utilities/dockerutils.py +5 -3
  60. prefect/utilities/engine.py +11 -0
  61. prefect/utilities/filesystem.py +4 -5
  62. prefect/utilities/importtools.py +34 -5
  63. prefect/utilities/services.py +2 -2
  64. prefect/utilities/urls.py +195 -0
  65. prefect/utilities/visualization.py +1 -0
  66. prefect/variables.py +19 -10
  67. prefect/workers/base.py +46 -1
  68. {prefect_client-3.0.0rc1.dist-info → prefect_client-3.0.0rc3.dist-info}/METADATA +3 -2
  69. {prefect_client-3.0.0rc1.dist-info → prefect_client-3.0.0rc3.dist-info}/RECORD +72 -66
  70. {prefect_client-3.0.0rc1.dist-info → prefect_client-3.0.0rc3.dist-info}/LICENSE +0 -0
  71. {prefect_client-3.0.0rc1.dist-info → prefect_client-3.0.0rc3.dist-info}/WHEEL +0 -0
  72. {prefect_client-3.0.0rc1.dist-info → prefect_client-3.0.0rc3.dist-info}/top_level.txt +0 -0
@@ -44,11 +44,23 @@ def get_call_parameters(
44
44
  apply_defaults: bool = True,
45
45
  ) -> Dict[str, Any]:
46
46
  """
47
- Bind a call to a function to get parameter/value mapping. Default values on the
48
- signature will be included if not overridden.
49
-
50
- Raises a ParameterBindError if the arguments/kwargs are not valid for the function
47
+ Bind a call to a function to get parameter/value mapping. Default values on
48
+ the signature will be included if not overridden.
49
+
50
+ If the function has a `__prefect_self__` attribute, it will be included as
51
+ the first parameter. This attribute is set when Prefect decorates a bound
52
+ method, so this approach allows Prefect to work with bound methods in a way
53
+ that is consistent with how Python handles them (i.e. users don't have to
54
+ pass the instance argument to the method) while still making the implicit self
55
+ argument visible to all of Prefect's parameter machinery (such as cache key
56
+ functions).
57
+
58
+ Raises a ParameterBindError if the arguments/kwargs are not valid for the
59
+ function
51
60
  """
61
+ if hasattr(fn, "__prefect_self__"):
62
+ call_args = (fn.__prefect_self__,) + call_args
63
+
52
64
  try:
53
65
  bound_signature = inspect.signature(fn).bind(*call_args, **call_kwargs)
54
66
  except TypeError as exc:
@@ -456,7 +468,14 @@ def _generate_signature_from_source(
456
468
  (
457
469
  node
458
470
  for node in ast.walk(parsed_code)
459
- if isinstance(node, ast.FunctionDef) and node.name == func_name
471
+ if isinstance(
472
+ node,
473
+ (
474
+ ast.FunctionDef,
475
+ ast.AsyncFunctionDef,
476
+ ),
477
+ )
478
+ and node.name == func_name
460
479
  ),
461
480
  None,
462
481
  )
@@ -464,6 +483,26 @@ def _generate_signature_from_source(
464
483
  raise ValueError(f"Function {func_name} not found in source code")
465
484
  parameters = []
466
485
 
486
+ # Handle annotations for positional only args e.g. def func(a, /, b, c)
487
+ for arg in func_def.args.posonlyargs:
488
+ name = arg.arg
489
+ annotation = arg.annotation
490
+ if annotation is not None:
491
+ try:
492
+ ann_code = compile(ast.Expression(annotation), "<string>", "eval")
493
+ annotation = eval(ann_code, namespace)
494
+ except Exception as e:
495
+ logger.debug("Failed to evaluate annotation for %s: %s", name, e)
496
+ annotation = inspect.Parameter.empty
497
+ else:
498
+ annotation = inspect.Parameter.empty
499
+
500
+ param = inspect.Parameter(
501
+ name, inspect.Parameter.POSITIONAL_ONLY, annotation=annotation
502
+ )
503
+ parameters.append(param)
504
+
505
+ # Determine the annotations for args e.g. def func(a: int, b: str, c: float)
467
506
  for arg in func_def.args.args:
468
507
  name = arg.arg
469
508
  annotation = arg.annotation
@@ -486,6 +525,7 @@ def _generate_signature_from_source(
486
525
  )
487
526
  parameters.append(param)
488
527
 
528
+ # Handle default values for args e.g. def func(a=1, b="hello", c=3.14)
489
529
  defaults = [None] * (
490
530
  len(func_def.args.args) - len(func_def.args.defaults)
491
531
  ) + func_def.args.defaults
@@ -501,6 +541,42 @@ def _generate_signature_from_source(
501
541
  default = None # Set to None if evaluation fails
502
542
  parameters[parameters.index(param)] = param.replace(default=default)
503
543
 
544
+ # Handle annotations for keyword only args e.g. def func(*, a: int, b: str)
545
+ for kwarg in func_def.args.kwonlyargs:
546
+ name = kwarg.arg
547
+ annotation = kwarg.annotation
548
+ if annotation is not None:
549
+ try:
550
+ ann_code = compile(ast.Expression(annotation), "<string>", "eval")
551
+ annotation = eval(ann_code, namespace)
552
+ except Exception as e:
553
+ logger.debug("Failed to evaluate annotation for %s: %s", name, e)
554
+ annotation = inspect.Parameter.empty
555
+ else:
556
+ annotation = inspect.Parameter.empty
557
+
558
+ param = inspect.Parameter(
559
+ name, inspect.Parameter.KEYWORD_ONLY, annotation=annotation
560
+ )
561
+ parameters.append(param)
562
+
563
+ # Handle default values for keyword only args e.g. def func(*, a=1, b="hello")
564
+ defaults = [None] * (
565
+ len(func_def.args.kwonlyargs) - len(func_def.args.kw_defaults)
566
+ ) + func_def.args.kw_defaults
567
+ for param, default in zip(parameters[-len(func_def.args.kwonlyargs) :], defaults):
568
+ if default is not None:
569
+ try:
570
+ def_code = compile(ast.Expression(default), "<string>", "eval")
571
+ default = eval(def_code, namespace)
572
+ except Exception as e:
573
+ logger.debug(
574
+ "Failed to evaluate default value for %s: %s", param.name, e
575
+ )
576
+ default = None
577
+ parameters[parameters.index(param)] = param.replace(default=default)
578
+
579
+ # Handle annotations for varargs and kwargs e.g. def func(*args: int, **kwargs: str)
504
580
  if func_def.args.vararg:
505
581
  parameters.append(
506
582
  inspect.Parameter(
@@ -512,7 +588,7 @@ def _generate_signature_from_source(
512
588
  inspect.Parameter(func_def.args.kwarg.arg, inspect.Parameter.VAR_KEYWORD)
513
589
  )
514
590
 
515
- # Handle return annotation
591
+ # Handle return annotation e.g. def func() -> int
516
592
  return_annotation = func_def.returns
517
593
  if return_annotation is not None:
518
594
  try:
@@ -544,7 +620,14 @@ def _get_docstring_from_source(source_code: str, func_name: str) -> Optional[str
544
620
  (
545
621
  node
546
622
  for node in ast.walk(parsed_code)
547
- if isinstance(node, ast.FunctionDef) and node.name == func_name
623
+ if isinstance(
624
+ node,
625
+ (
626
+ ast.FunctionDef,
627
+ ast.AsyncFunctionDef,
628
+ ),
629
+ )
630
+ and node.name == func_name
548
631
  ),
549
632
  None,
550
633
  )
@@ -41,7 +41,9 @@ def python_version_micro() -> str:
41
41
 
42
42
 
43
43
  def get_prefect_image_name(
44
- prefect_version: str = None, python_version: str = None, flavor: str = None
44
+ prefect_version: Optional[str] = None,
45
+ python_version: Optional[str] = None,
46
+ flavor: Optional[str] = None,
45
47
  ) -> str:
46
48
  """
47
49
  Get the Prefect image name matching the current Prefect and Python versions.
@@ -138,7 +140,7 @@ def build_image(
138
140
  dockerfile: str = "Dockerfile",
139
141
  tag: Optional[str] = None,
140
142
  pull: bool = False,
141
- platform: str = None,
143
+ platform: Optional[str] = None,
142
144
  stream_progress_to: Optional[TextIO] = None,
143
145
  **kwargs,
144
146
  ) -> str:
@@ -209,7 +211,7 @@ class ImageBuilder:
209
211
  self,
210
212
  base_image: str,
211
213
  base_directory: Path = None,
212
- platform: str = None,
214
+ platform: Optional[str] = None,
213
215
  context: Path = None,
214
216
  ):
215
217
  """Create an ImageBuilder
@@ -786,6 +786,17 @@ def resolve_to_final_result(expr, context):
786
786
  raise StopVisiting()
787
787
 
788
788
  if isinstance(expr, NewPrefectFuture):
789
+ upstream_task_run = context.get("current_task_run")
790
+ upstream_task = context.get("current_task")
791
+ if (
792
+ upstream_task
793
+ and upstream_task_run
794
+ and expr.task_run_id == upstream_task_run.id
795
+ ):
796
+ raise ValueError(
797
+ f"Discovered a task depending on itself. Raising to avoid a deadlock. Please inspect the inputs and dependencies of {upstream_task.name}."
798
+ )
799
+
789
800
  expr.wait()
790
801
  state = expr.state
791
802
  elif isinstance(expr, State):
@@ -1,12 +1,13 @@
1
1
  """
2
2
  Utilities for working with file systems
3
3
  """
4
+
4
5
  import os
5
6
  import pathlib
6
7
  import threading
7
8
  from contextlib import contextmanager
8
9
  from pathlib import Path, PureWindowsPath
9
- from typing import Union
10
+ from typing import Optional, Union
10
11
 
11
12
  import fsspec
12
13
  import pathspec
@@ -32,7 +33,7 @@ def create_default_ignore_file(path: str) -> bool:
32
33
 
33
34
 
34
35
  def filter_files(
35
- root: str = ".", ignore_patterns: list = None, include_dirs: bool = True
36
+ root: str = ".", ignore_patterns: Optional[list] = None, include_dirs: bool = True
36
37
  ) -> set:
37
38
  """
38
39
  This function accepts a root directory path and a list of file patterns to ignore, and returns
@@ -40,9 +41,7 @@ def filter_files(
40
41
 
41
42
  The specification matches that of [.gitignore files](https://git-scm.com/docs/gitignore).
42
43
  """
43
- if ignore_patterns is None:
44
- ignore_patterns = []
45
- spec = pathspec.PathSpec.from_lines("gitwildmatch", ignore_patterns)
44
+ spec = pathspec.PathSpec.from_lines("gitwildmatch", ignore_patterns or [])
46
45
  ignored_files = {p.path for p in spec.match_tree_entries(root)}
47
46
  if include_dirs:
48
47
  all_files = {p.path for p in pathspec.util.iter_tree_entries(root)}
@@ -378,7 +378,16 @@ def safe_load_namespace(source_code: str):
378
378
  """
379
379
  parsed_code = ast.parse(source_code)
380
380
 
381
- namespace = {}
381
+ namespace = {"__name__": "prefect_safe_namespace_loader"}
382
+
383
+ # Remove the body of the if __name__ == "__main__": block from the AST to prevent
384
+ # execution of guarded code
385
+ new_body = []
386
+ for node in parsed_code.body:
387
+ if _is_main_block(node):
388
+ continue
389
+ new_body.append(node)
390
+ parsed_code.body = new_body
382
391
 
383
392
  # Walk through the AST and find all import statements
384
393
  for node in ast.walk(parsed_code):
@@ -412,11 +421,11 @@ def safe_load_namespace(source_code: str):
412
421
  except ImportError as e:
413
422
  logger.debug("Failed to import from %s: %s", node.module, e)
414
423
 
415
- # Handle local class definitions
424
+ # Handle local definitions
416
425
  for node in ast.walk(parsed_code):
417
- if isinstance(node, (ast.ClassDef, ast.FunctionDef)):
426
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.Assign)):
418
427
  try:
419
- # Compile and execute each class and function definition locally
428
+ # Compile and execute each class and function definition and assignment
420
429
  code = compile(
421
430
  ast.Module(body=[node], type_ignores=[]),
422
431
  filename="<ast>",
@@ -424,5 +433,25 @@ def safe_load_namespace(source_code: str):
424
433
  )
425
434
  exec(code, namespace)
426
435
  except Exception as e:
427
- logger.debug("Failed to compile class definition: %s", e)
436
+ logger.debug("Failed to compile: %s", e)
428
437
  return namespace
438
+
439
+
440
+ def _is_main_block(node: ast.AST):
441
+ """
442
+ Check if the node is an `if __name__ == "__main__":` block.
443
+ """
444
+ if isinstance(node, ast.If):
445
+ try:
446
+ # Check if the condition is `if __name__ == "__main__":`
447
+ if (
448
+ isinstance(node.test, ast.Compare)
449
+ and isinstance(node.test.left, ast.Name)
450
+ and node.test.left.id == "__name__"
451
+ and isinstance(node.test.comparators[0], ast.Constant)
452
+ and node.test.comparators[0].value == "__main__"
453
+ ):
454
+ return True
455
+ except AttributeError:
456
+ pass
457
+ return False
@@ -2,7 +2,7 @@ import sys
2
2
  from collections import deque
3
3
  from traceback import format_exception
4
4
  from types import TracebackType
5
- from typing import Callable, Coroutine, Deque, Tuple
5
+ from typing import Callable, Coroutine, Deque, Optional, Tuple
6
6
 
7
7
  import anyio
8
8
  import httpx
@@ -22,7 +22,7 @@ async def critical_service_loop(
22
22
  backoff: int = 1,
23
23
  printer: Callable[..., None] = print,
24
24
  run_once: bool = False,
25
- jitter_range: float = None,
25
+ jitter_range: Optional[float] = None,
26
26
  ):
27
27
  """
28
28
  Runs the given `workload` function on the specified `interval`, while being
@@ -0,0 +1,195 @@
1
+ import inspect
2
+ import urllib.parse
3
+ from typing import Any, Literal, Optional, Union
4
+ from uuid import UUID
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from prefect import settings
9
+ from prefect.blocks.core import Block
10
+ from prefect.events.schemas.automations import Automation
11
+ from prefect.events.schemas.events import ReceivedEvent, Resource
12
+ from prefect.futures import PrefectFuture
13
+ from prefect.logging.loggers import get_logger
14
+ from prefect.variables import Variable
15
+
16
+ logger = get_logger("utilities.urls")
17
+
18
+ # The following objects are excluded from UI URL generation because we lack a
19
+ # directly-addressable URL:
20
+ # worker
21
+ # artifact
22
+ # variable
23
+ # saved-search
24
+ UI_URL_FORMATS = {
25
+ "flow": "flows/flow/{obj_id}",
26
+ "flow-run": "runs/flow-run/{obj_id}",
27
+ "task-run": "runs/task-run/{obj_id}",
28
+ "block": "blocks/block/{obj_id}",
29
+ "block-document": "blocks/block/{obj_id}",
30
+ "work-pool": "work-pools/work-pool/{obj_id}",
31
+ "work-queue": "work-queues/work-queue/{obj_id}",
32
+ "concurrency-limit": "concurrency-limits/concurrency-limit/{obj_id}",
33
+ "deployment": "deployments/deployment/{obj_id}",
34
+ "automation": "automations/automation/{obj_id}",
35
+ "received-event": "events/event/{occurred}/{obj_id}",
36
+ }
37
+
38
+ # The following objects are excluded from API URL generation because we lack a
39
+ # directly-addressable URL:
40
+ # worker
41
+ # artifact
42
+ # saved-search
43
+ # received-event
44
+ API_URL_FORMATS = {
45
+ "flow": "flows/{obj_id}",
46
+ "flow-run": "flow_runs/{obj_id}",
47
+ "task-run": "task_runs/{obj_id}",
48
+ "variable": "variables/name/{obj_id}",
49
+ "block": "blocks/{obj_id}",
50
+ "work-pool": "work_pools/{obj_id}",
51
+ "work-queue": "work_queues/{obj_id}",
52
+ "concurrency-limit": "concurrency_limits/{obj_id}",
53
+ "deployment": "deployments/{obj_id}",
54
+ "automation": "automations/{obj_id}",
55
+ }
56
+
57
+ URLType = Literal["ui", "api"]
58
+ RUN_TYPES = {"flow-run", "task-run"}
59
+
60
+
61
+ def convert_class_to_name(obj: Any) -> str:
62
+ """
63
+ Convert CamelCase class name to dash-separated lowercase name
64
+ """
65
+ cls = obj if inspect.isclass(obj) else obj.__class__
66
+ name = cls.__name__
67
+ return "".join(["-" + i.lower() if i.isupper() else i for i in name]).lstrip("-")
68
+
69
+
70
+ def url_for(
71
+ obj: Union[
72
+ PrefectFuture,
73
+ Block,
74
+ Variable,
75
+ Automation,
76
+ Resource,
77
+ ReceivedEvent,
78
+ BaseModel,
79
+ str,
80
+ ],
81
+ obj_id: Optional[Union[str, UUID]] = None,
82
+ url_type: URLType = "ui",
83
+ default_base_url: Optional[str] = None,
84
+ ) -> Optional[str]:
85
+ """
86
+ Returns the URL for a Prefect object.
87
+
88
+ Pass in a supported object directly or provide an object name and ID.
89
+
90
+ Args:
91
+ obj (Union[PrefectFuture, Block, Variable, Automation, Resource, ReceivedEvent, BaseModel, str]):
92
+ A Prefect object to get the URL for, or its URL name and ID.
93
+ obj_id (Union[str, UUID], optional):
94
+ The UUID of the object.
95
+ url_type (Literal["ui", "api"], optional):
96
+ Whether to return the URL for the UI (default) or API.
97
+ default_base_url (str, optional):
98
+ The default base URL to use if no URL is configured.
99
+
100
+ Returns:
101
+ Optional[str]: The URL for the given object or None if the object is not supported.
102
+
103
+ Examples:
104
+ url_for(my_flow_run)
105
+ url_for(obj=my_flow_run)
106
+ url_for("flow-run", obj_id="123e4567-e89b-12d3-a456-426614174000")
107
+ """
108
+ if isinstance(obj, PrefectFuture):
109
+ name = "task-run"
110
+ elif isinstance(obj, Block):
111
+ name = "block"
112
+ elif isinstance(obj, Automation):
113
+ name = "automation"
114
+ elif isinstance(obj, ReceivedEvent):
115
+ name = "received-event"
116
+ elif isinstance(obj, Resource):
117
+ if obj.id.startswith("prefect."):
118
+ name = obj.id.split(".")[1]
119
+ else:
120
+ logger.warning(f"No URL known for resource with ID: {obj.id}")
121
+ return None
122
+ elif isinstance(obj, str):
123
+ name = obj
124
+ else:
125
+ name = convert_class_to_name(obj)
126
+
127
+ # Can't do an isinstance check here because the client build
128
+ # doesn't have access to that server schema.
129
+ if name == "work-queue-with-status":
130
+ name = "work-queue"
131
+
132
+ if url_type != "ui" and url_type != "api":
133
+ raise ValueError(f"Invalid URL type: {url_type}. Use 'ui' or 'api'.")
134
+
135
+ if url_type == "ui" and name not in UI_URL_FORMATS:
136
+ logger.error("No UI URL known for this object: %s", name)
137
+ return None
138
+ elif url_type == "api" and name not in API_URL_FORMATS:
139
+ logger.error("No API URL known for this object: %s", name)
140
+ return None
141
+
142
+ if isinstance(obj, str) and not obj_id:
143
+ raise ValueError(
144
+ "If passing an object name, you must also provide an object ID."
145
+ )
146
+
147
+ base_url = (
148
+ settings.PREFECT_UI_URL.value()
149
+ if url_type == "ui"
150
+ else settings.PREFECT_API_URL.value()
151
+ )
152
+ base_url = base_url or default_base_url
153
+
154
+ if not base_url:
155
+ logger.warning(
156
+ f"No URL found for the Prefect {'UI' if url_type == 'ui' else 'API'}, "
157
+ f"and no default base path provided."
158
+ )
159
+ return None
160
+
161
+ if not obj_id:
162
+ # We treat PrefectFuture as if it was the underlying task run,
163
+ # so we need to check the object type here instead of name.
164
+ if isinstance(obj, PrefectFuture):
165
+ obj_id = getattr(obj, "task_run_id", None)
166
+ elif name == "block":
167
+ # Blocks are client-side objects whose API representation is a
168
+ # BlockDocument.
169
+ obj_id = obj._block_document_id
170
+ elif name in ("variable", "work-pool"):
171
+ obj_id = obj.name
172
+ elif isinstance(obj, Resource):
173
+ obj_id = obj.id.rpartition(".")[2]
174
+ else:
175
+ obj_id = getattr(obj, "id", None)
176
+ if not obj_id:
177
+ logger.error(
178
+ "An ID is required to build a URL, but object did not have one: %s", obj
179
+ )
180
+ return ""
181
+
182
+ url_format = (
183
+ UI_URL_FORMATS.get(name) if url_type == "ui" else API_URL_FORMATS.get(name)
184
+ )
185
+
186
+ if isinstance(obj, ReceivedEvent):
187
+ url = url_format.format(
188
+ occurred=obj.occurred.strftime("%Y-%m-%d"), obj_id=obj_id
189
+ )
190
+ else:
191
+ url = url_format.format(obj_id=obj_id)
192
+
193
+ if not base_url.endswith("/"):
194
+ base_url += "/"
195
+ return urllib.parse.urljoin(base_url, url)
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Utilities for working with Flow.visualize()
3
3
  """
4
+
4
5
  from functools import partial
5
6
  from typing import Any, List, Optional
6
7
 
prefect/variables.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import List, Optional, Union
2
2
 
3
+ from prefect._internal.compatibility.migration import getattr_migration
3
4
  from prefect.client.schemas.actions import VariableCreate as VariableRequest
4
5
  from prefect.client.schemas.actions import VariableUpdate as VariableUpdateRequest
5
6
  from prefect.client.schemas.objects import Variable as VariableResponse
@@ -28,17 +29,19 @@ class Variable(VariableRequest):
28
29
  value: StrictVariableValue,
29
30
  tags: Optional[List[str]] = None,
30
31
  overwrite: bool = False,
32
+ as_object: bool = False,
31
33
  ):
32
34
  """
33
- Sets a new variable. If one exists with the same name, user must pass `overwrite=True`
35
+ Sets a new variable. If one exists with the same name, must pass `overwrite=True`
34
36
 
35
- Returns `True` if the variable was created or updated
37
+ Returns the newly set value. If `as_object=True`, return the full Variable object
36
38
 
37
39
  Args:
38
40
  - name: The name of the variable to set.
39
41
  - value: The value of the variable to set.
40
42
  - tags: An optional list of strings to associate with the variable.
41
43
  - overwrite: Whether to overwrite the variable if it already exists.
44
+ - as_object: Whether to return the full Variable object.
42
45
 
43
46
  Example:
44
47
  Set a new variable and overwrite it if it already exists.
@@ -51,17 +54,22 @@ class Variable(VariableRequest):
51
54
  ```
52
55
  """
53
56
  client, _ = get_or_create_client()
54
- variable = await client.read_variable_by_name(name)
57
+ variable_exists = await client.read_variable_by_name(name)
55
58
  var_dict = {"name": name, "value": value, "tags": tags or []}
56
- if variable:
59
+
60
+ if variable_exists:
57
61
  if not overwrite:
58
62
  raise ValueError(
59
- "You are attempting to set a variable with a name that is already in use. "
60
- "If you would like to overwrite it, pass `overwrite=True`."
63
+ f"Variable {name!r} already exists. Use `overwrite=True` to update it."
61
64
  )
62
65
  await client.update_variable(variable=VariableUpdateRequest(**var_dict))
66
+ variable = await client.read_variable_by_name(name)
63
67
  else:
64
- await client.create_variable(variable=VariableRequest(**var_dict))
68
+ variable = await client.create_variable(
69
+ variable=VariableRequest(**var_dict)
70
+ )
71
+
72
+ return variable if as_object else variable.value
65
73
 
66
74
  @classmethod
67
75
  @sync_compatible
@@ -96,10 +104,8 @@ class Variable(VariableRequest):
96
104
  """
97
105
  client, _ = get_or_create_client()
98
106
  variable = await client.read_variable_by_name(name)
99
- if as_object:
100
- return variable
101
107
 
102
- return variable.value if variable else default
108
+ return variable if as_object else (variable.value if variable else default)
103
109
 
104
110
  @classmethod
105
111
  @sync_compatible
@@ -129,3 +135,6 @@ class Variable(VariableRequest):
129
135
  return True
130
136
  except ObjectNotFound:
131
137
  return False
138
+
139
+
140
+ __getattr__ = getattr_migration(__name__)
prefect/workers/base.py CHANGED
@@ -8,6 +8,8 @@ import anyio
8
8
  import anyio.abc
9
9
  import pendulum
10
10
  from pydantic import BaseModel, Field, PrivateAttr, field_validator
11
+ from pydantic.json_schema import GenerateJsonSchema
12
+ from typing_extensions import Literal
11
13
 
12
14
  import prefect
13
15
  from prefect._internal.compatibility.experimental import (
@@ -42,8 +44,10 @@ from prefect.exceptions import (
42
44
  from prefect.logging.loggers import PrefectLogAdapter, flow_run_logger, get_logger
43
45
  from prefect.plugins import load_prefect_collections
44
46
  from prefect.settings import (
47
+ PREFECT_API_URL,
45
48
  PREFECT_EXPERIMENTAL_WARN,
46
49
  PREFECT_EXPERIMENTAL_WARN_ENHANCED_CANCELLATION,
50
+ PREFECT_TEST_MODE,
47
51
  PREFECT_WORKER_HEARTBEAT_SECONDS,
48
52
  PREFECT_WORKER_PREFETCH_SECONDS,
49
53
  get_current_settings,
@@ -106,12 +110,22 @@ class BaseJobConfiguration(BaseModel):
106
110
  def _coerce_command(cls, v):
107
111
  return return_v_or_none(v)
108
112
 
113
+ @field_validator("env", mode="before")
114
+ @classmethod
115
+ def _coerce_env(cls, v):
116
+ return {k: str(v) if v is not None else None for k, v in v.items()}
117
+
109
118
  @staticmethod
110
119
  def _get_base_config_defaults(variables: dict) -> dict:
111
120
  """Get default values from base config for all variables that have them."""
112
121
  defaults = dict()
113
122
  for variable_name, attrs in variables.items():
114
- if "default" in attrs:
123
+ # We remote `None` values because we don't want to use them in templating.
124
+ # The currently logic depends on keys not existing to populate the correct value
125
+ # in some cases.
126
+ # Pydantic will provide default values if the keys are missing when creating
127
+ # a configuration class.
128
+ if "default" in attrs and attrs.get("default") is not None:
115
129
  defaults[variable_name] = attrs["default"]
116
130
 
117
131
  return defaults
@@ -323,6 +337,33 @@ class BaseVariables(BaseModel):
323
337
  ),
324
338
  )
325
339
 
340
+ @classmethod
341
+ def model_json_schema(
342
+ cls,
343
+ by_alias: bool = True,
344
+ ref_template: str = "#/definitions/{model}",
345
+ schema_generator: Type[GenerateJsonSchema] = GenerateJsonSchema,
346
+ mode: Literal["validation", "serialization"] = "validation",
347
+ ) -> Dict[str, Any]:
348
+ """TODO: stop overriding this method - use GenerateSchema in ConfigDict instead?"""
349
+ schema = super().model_json_schema(
350
+ by_alias, ref_template, schema_generator, mode
351
+ )
352
+
353
+ # ensure backwards compatibility by copying $defs into definitions
354
+ if "$defs" in schema:
355
+ schema["definitions"] = schema.pop("$defs")
356
+
357
+ # we aren't expecting these additional fields in the schema
358
+ if "additionalProperties" in schema:
359
+ schema.pop("additionalProperties")
360
+
361
+ for _, definition in schema.get("definitions", {}).items():
362
+ if "additionalProperties" in definition:
363
+ definition.pop("additionalProperties")
364
+
365
+ return schema
366
+
326
367
 
327
368
  class BaseWorkerResult(BaseModel, abc.ABC):
328
369
  identifier: str
@@ -511,6 +552,10 @@ class BaseWorker(abc.ABC):
511
552
  self._limiter = (
512
553
  anyio.CapacityLimiter(self._limit) if self._limit is not None else None
513
554
  )
555
+
556
+ if not PREFECT_TEST_MODE and not PREFECT_API_URL.value():
557
+ raise ValueError("`PREFECT_API_URL` must be set to start a Worker.")
558
+
514
559
  self._client = get_client()
515
560
  await self._client.__aenter__()
516
561
  await self._runs_task_group.__aenter__()