prefect-client 3.0.0rc2__py3-none-any.whl → 3.0.0rc4__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 (69) hide show
  1. prefect/__init__.py +0 -1
  2. prefect/_internal/compatibility/migration.py +124 -0
  3. prefect/_internal/concurrency/__init__.py +2 -2
  4. prefect/_internal/concurrency/primitives.py +1 -0
  5. prefect/_internal/pydantic/annotations/pendulum.py +2 -2
  6. prefect/_internal/pytz.py +1 -1
  7. prefect/blocks/core.py +1 -1
  8. prefect/client/orchestration.py +96 -22
  9. prefect/client/schemas/actions.py +1 -1
  10. prefect/client/schemas/filters.py +6 -0
  11. prefect/client/schemas/objects.py +10 -3
  12. prefect/client/subscriptions.py +6 -5
  13. prefect/context.py +1 -27
  14. prefect/deployments/__init__.py +3 -0
  15. prefect/deployments/base.py +4 -2
  16. prefect/deployments/deployments.py +3 -0
  17. prefect/deployments/steps/pull.py +1 -0
  18. prefect/deployments/steps/utility.py +2 -1
  19. prefect/engine.py +3 -0
  20. prefect/events/cli/automations.py +1 -1
  21. prefect/events/clients.py +7 -1
  22. prefect/exceptions.py +9 -0
  23. prefect/filesystems.py +22 -11
  24. prefect/flow_engine.py +195 -153
  25. prefect/flows.py +95 -36
  26. prefect/futures.py +9 -1
  27. prefect/infrastructure/provisioners/container_instance.py +1 -0
  28. prefect/infrastructure/provisioners/ecs.py +2 -2
  29. prefect/input/__init__.py +4 -0
  30. prefect/logging/formatters.py +2 -2
  31. prefect/logging/handlers.py +2 -2
  32. prefect/logging/loggers.py +1 -1
  33. prefect/plugins.py +1 -0
  34. prefect/records/cache_policies.py +3 -3
  35. prefect/records/result_store.py +10 -3
  36. prefect/results.py +47 -73
  37. prefect/runner/runner.py +1 -1
  38. prefect/runner/server.py +1 -1
  39. prefect/runtime/__init__.py +1 -0
  40. prefect/runtime/deployment.py +1 -0
  41. prefect/runtime/flow_run.py +1 -0
  42. prefect/runtime/task_run.py +1 -0
  43. prefect/settings.py +16 -3
  44. prefect/states.py +15 -4
  45. prefect/task_engine.py +195 -39
  46. prefect/task_runners.py +9 -3
  47. prefect/task_runs.py +26 -12
  48. prefect/task_worker.py +149 -20
  49. prefect/tasks.py +153 -71
  50. prefect/transactions.py +85 -15
  51. prefect/types/__init__.py +10 -3
  52. prefect/utilities/asyncutils.py +3 -3
  53. prefect/utilities/callables.py +16 -4
  54. prefect/utilities/collections.py +120 -57
  55. prefect/utilities/dockerutils.py +5 -3
  56. prefect/utilities/engine.py +11 -0
  57. prefect/utilities/filesystem.py +4 -5
  58. prefect/utilities/importtools.py +29 -0
  59. prefect/utilities/services.py +2 -2
  60. prefect/utilities/urls.py +195 -0
  61. prefect/utilities/visualization.py +1 -0
  62. prefect/variables.py +4 -0
  63. prefect/workers/base.py +35 -0
  64. {prefect_client-3.0.0rc2.dist-info → prefect_client-3.0.0rc4.dist-info}/METADATA +2 -2
  65. {prefect_client-3.0.0rc2.dist-info → prefect_client-3.0.0rc4.dist-info}/RECORD +68 -66
  66. prefect/blocks/kubernetes.py +0 -115
  67. {prefect_client-3.0.0rc2.dist-info → prefect_client-3.0.0rc4.dist-info}/LICENSE +0 -0
  68. {prefect_client-3.0.0rc2.dist-info → prefect_client-3.0.0rc4.dist-info}/WHEEL +0 -0
  69. {prefect_client-3.0.0rc2.dist-info → prefect_client-3.0.0rc4.dist-info}/top_level.txt +0 -0
prefect/transactions.py CHANGED
@@ -7,17 +7,22 @@ from typing import (
7
7
  List,
8
8
  Optional,
9
9
  Type,
10
- TypeVar,
11
10
  )
12
11
 
13
12
  from pydantic import Field
13
+ from typing_extensions import Self
14
14
 
15
- from prefect.context import ContextModel
15
+ from prefect.context import ContextModel, FlowRunContext, TaskRunContext
16
16
  from prefect.records import RecordStore
17
+ from prefect.records.result_store import ResultFactoryStore
18
+ from prefect.results import (
19
+ BaseResult,
20
+ ResultFactory,
21
+ get_or_create_default_result_storage,
22
+ )
23
+ from prefect.utilities.asyncutils import run_coro_as_sync
17
24
  from prefect.utilities.collections import AutoEnum
18
25
 
19
- T = TypeVar("T")
20
-
21
26
 
22
27
  class IsolationLevel(AutoEnum):
23
28
  READ_COMMITTED = AutoEnum.auto()
@@ -54,7 +59,7 @@ class Transaction(ContextModel):
54
59
  )
55
60
  overwrite: bool = False
56
61
  _staged_value: Any = None
57
- __var__ = ContextVar("transaction")
62
+ __var__: ContextVar = ContextVar("transaction")
58
63
 
59
64
  def is_committed(self) -> bool:
60
65
  return self.state == TransactionState.COMMITTED
@@ -92,7 +97,8 @@ class Transaction(ContextModel):
92
97
  self._token = self.__var__.set(self)
93
98
  return self
94
99
 
95
- def __exit__(self, exc_type, exc_val, exc_tb):
100
+ def __exit__(self, *exc_info):
101
+ exc_type, exc_val, _ = exc_info
96
102
  if not self._token:
97
103
  raise RuntimeError(
98
104
  "Asymmetric use of context. Context exit called without an enter."
@@ -123,11 +129,19 @@ class Transaction(ContextModel):
123
129
  def begin(self):
124
130
  # currently we only support READ_COMMITTED isolation
125
131
  # i.e., no locking behavior
126
- if not self.overwrite and self.store and self.store.exists(key=self.key):
132
+ if (
133
+ not self.overwrite
134
+ and self.store
135
+ and self.key
136
+ and self.store.exists(key=self.key)
137
+ ):
127
138
  self.state = TransactionState.COMMITTED
128
139
 
129
- def read(self) -> dict:
130
- return self.store.read(key=self.key)
140
+ def read(self) -> BaseResult:
141
+ if self.store and self.key:
142
+ return self.store.read(key=self.key)
143
+ else:
144
+ return {} # TODO: Determine what this should be
131
145
 
132
146
  def reset(self) -> None:
133
147
  parent = self.get_parent()
@@ -136,8 +150,9 @@ class Transaction(ContextModel):
136
150
  # parent takes responsibility
137
151
  parent.add_child(self)
138
152
 
139
- self.__var__.reset(self._token)
140
- self._token = None
153
+ if self._token:
154
+ self.__var__.reset(self._token)
155
+ self._token = None
141
156
 
142
157
  # do this below reset so that get_transaction() returns the relevant txn
143
158
  if parent and self.state == TransactionState.ROLLED_BACK:
@@ -165,7 +180,7 @@ class Transaction(ContextModel):
165
180
  for hook in self.on_commit_hooks:
166
181
  hook(self)
167
182
 
168
- if self.store:
183
+ if self.store and self.key:
169
184
  self.store.write(key=self.key, value=self._staged_value)
170
185
  self.state = TransactionState.COMMITTED
171
186
  return True
@@ -174,11 +189,17 @@ class Transaction(ContextModel):
174
189
  return False
175
190
 
176
191
  def stage(
177
- self, value: dict, on_rollback_hooks: list, on_commit_hooks: list
192
+ self,
193
+ value: BaseResult,
194
+ on_rollback_hooks: Optional[List] = None,
195
+ on_commit_hooks: Optional[List] = None,
178
196
  ) -> None:
179
197
  """
180
198
  Stage a value to be committed later.
181
199
  """
200
+ on_commit_hooks = on_commit_hooks or []
201
+ on_rollback_hooks = on_rollback_hooks or []
202
+
182
203
  if self.state != TransactionState.COMMITTED:
183
204
  self._staged_value = value
184
205
  self.on_rollback_hooks += on_rollback_hooks
@@ -203,11 +224,11 @@ class Transaction(ContextModel):
203
224
  return False
204
225
 
205
226
  @classmethod
206
- def get_active(cls: Type[T]) -> Optional[T]:
227
+ def get_active(cls: Type[Self]) -> Optional[Self]:
207
228
  return cls.__var__.get(None)
208
229
 
209
230
 
210
- def get_transaction() -> Transaction:
231
+ def get_transaction() -> Optional[Transaction]:
211
232
  return Transaction.get_active()
212
233
 
213
234
 
@@ -218,6 +239,55 @@ def transaction(
218
239
  commit_mode: CommitMode = CommitMode.LAZY,
219
240
  overwrite: bool = False,
220
241
  ) -> Generator[Transaction, None, None]:
242
+ """
243
+ A context manager for opening and managing a transaction.
244
+
245
+ Args:
246
+ - key: An identifier to use for the transaction
247
+ - store: The store to use for persisting the transaction result. If not provided,
248
+ a default store will be used based on the current run context.
249
+ - commit_mode: The commit mode controlling when the transaction and
250
+ child transactions are committed
251
+ - overwrite: Whether to overwrite an existing transaction record in the store
252
+
253
+ Yields:
254
+ - Transaction: An object representing the transaction state
255
+ """
256
+ # if there is no key, we won't persist a record
257
+ if key and not store:
258
+ flow_run_context = FlowRunContext.get()
259
+ task_run_context = TaskRunContext.get()
260
+ existing_factory = getattr(task_run_context, "result_factory", None) or getattr(
261
+ flow_run_context, "result_factory", None
262
+ )
263
+
264
+ if existing_factory and existing_factory.storage_block_id:
265
+ new_factory = existing_factory.model_copy(
266
+ update={
267
+ "persist_result": True,
268
+ }
269
+ )
270
+ else:
271
+ default_storage = get_or_create_default_result_storage(_sync=True)
272
+ if existing_factory:
273
+ new_factory = existing_factory.model_copy(
274
+ update={
275
+ "persist_result": True,
276
+ "storage_block": default_storage,
277
+ "storage_block_id": default_storage._block_document_id,
278
+ }
279
+ )
280
+ else:
281
+ new_factory = run_coro_as_sync(
282
+ ResultFactory.default_factory(
283
+ persist_result=True,
284
+ result_storage=default_storage,
285
+ )
286
+ )
287
+ store = ResultFactoryStore(
288
+ result_factory=new_factory,
289
+ )
290
+
221
291
  with Transaction(
222
292
  key=key, store=store, commit_mode=commit_mode, overwrite=overwrite
223
293
  ) as txn:
prefect/types/__init__.py CHANGED
@@ -15,12 +15,19 @@ from zoneinfo import available_timezones
15
15
  MAX_VARIABLE_NAME_LENGTH = 255
16
16
  MAX_VARIABLE_VALUE_LENGTH = 5000
17
17
 
18
- timezone_set = available_timezones()
19
-
20
18
  NonNegativeInteger = Annotated[int, Field(ge=0)]
21
19
  PositiveInteger = Annotated[int, Field(gt=0)]
22
20
  NonNegativeFloat = Annotated[float, Field(ge=0.0)]
23
- TimeZone = Annotated[str, Field(default="UTC", pattern="|".join(timezone_set))]
21
+
22
+ TimeZone = Annotated[
23
+ str,
24
+ Field(
25
+ default="UTC",
26
+ pattern="|".join(
27
+ [z for z in sorted(available_timezones()) if "localtime" not in z]
28
+ ),
29
+ ),
30
+ ]
24
31
 
25
32
 
26
33
  BANNED_CHARACTERS = ["/", "%", "&", ">", "<"]
@@ -314,7 +314,7 @@ def sync_compatible(async_fn: T, force_sync: bool = False) -> T:
314
314
  """
315
315
 
316
316
  @wraps(async_fn)
317
- def coroutine_wrapper(*args, _sync: bool = None, **kwargs):
317
+ def coroutine_wrapper(*args, _sync: Optional[bool] = None, **kwargs):
318
318
  from prefect.context import MissingContextError, get_run_context
319
319
  from prefect.settings import (
320
320
  PREFECT_EXPERIMENTAL_DISABLE_SYNC_COMPAT,
@@ -376,8 +376,8 @@ def sync_compatible(async_fn: T, force_sync: bool = False) -> T:
376
376
 
377
377
 
378
378
  @asynccontextmanager
379
- async def asyncnullcontext():
380
- yield
379
+ async def asyncnullcontext(value=None):
380
+ yield value
381
381
 
382
382
 
383
383
  def sync(__async_fn: Callable[P, Awaitable[T]], *args: P.args, **kwargs: P.kwargs) -> T:
@@ -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:
@@ -4,6 +4,7 @@ Utilities for extensions of and operations on Python collections.
4
4
 
5
5
  import io
6
6
  import itertools
7
+ import types
7
8
  import warnings
8
9
  from collections import OrderedDict, defaultdict
9
10
  from collections.abc import Iterator as IteratorABC
@@ -220,25 +221,31 @@ class StopVisiting(BaseException):
220
221
 
221
222
 
222
223
  def visit_collection(
223
- expr,
224
- visit_fn: Union[Callable[[Any, dict], Any], Callable[[Any], Any]],
224
+ expr: Any,
225
+ visit_fn: Union[Callable[[Any, Optional[dict]], Any], Callable[[Any], Any]],
225
226
  return_data: bool = False,
226
227
  max_depth: int = -1,
227
228
  context: Optional[dict] = None,
228
229
  remove_annotations: bool = False,
229
- ):
230
+ _seen: Optional[Set[int]] = None,
231
+ ) -> Any:
230
232
  """
231
- This function visits every element of an arbitrary Python collection. If an element
232
- is a Python collection, it will be visited recursively. If an element is not a
233
- collection, `visit_fn` will be called with the element. The return value of
234
- `visit_fn` can be used to alter the element if `return_data` is set.
233
+ Visits and potentially transforms every element of an arbitrary Python collection.
235
234
 
236
- Note that when using `return_data` a copy of each collection is created to avoid
237
- mutating the original object. This may have significant performance penalties and
238
- should only be used if you intend to transform the collection.
235
+ If an element is a Python collection, it will be visited recursively. If an element
236
+ is not a collection, `visit_fn` will be called with the element. The return value of
237
+ `visit_fn` can be used to alter the element if `return_data` is set to `True`.
238
+
239
+ Note:
240
+ - When `return_data` is `True`, a copy of each collection is created only if
241
+ `visit_fn` modifies an element within that collection. This approach minimizes
242
+ performance penalties by avoiding unnecessary copying.
243
+ - When `return_data` is `False`, no copies are created, and only side effects from
244
+ `visit_fn` are applied. This mode is faster and should be used when no transformation
245
+ of the collection is required, because it never has to copy any data.
239
246
 
240
247
  Supported types:
241
- - List
248
+ - List (including iterators)
242
249
  - Tuple
243
250
  - Set
244
251
  - Dict (note: keys are also visited recursively)
@@ -246,32 +253,41 @@ def visit_collection(
246
253
  - Pydantic model
247
254
  - Prefect annotations
248
255
 
256
+ Note that visit_collection will not consume generators or async generators, as it would prevent
257
+ the caller from iterating over them.
258
+
249
259
  Args:
250
- expr (Any): a Python object or expression
251
- visit_fn (Callable[[Any, Optional[dict]], Awaitable[Any]]): a function that
252
- will be applied to every non-collection element of expr. The function can
253
- accept one or two arguments. If two arguments are accepted, the second
254
- argument will be the context dictionary.
255
- return_data (bool): if `True`, a copy of `expr` containing data modified
256
- by `visit_fn` will be returned. This is slower than `return_data=False`
257
- (the default).
258
- max_depth: Controls the depth of recursive visitation. If set to zero, no
259
- recursion will occur. If set to a positive integer N, visitation will only
260
- descend to N layers deep. If set to any negative integer, no limit will be
260
+ expr (Any): A Python object or expression.
261
+ visit_fn (Callable[[Any, Optional[dict]], Any] or Callable[[Any], Any]): A function
262
+ that will be applied to every non-collection element of `expr`. The function can
263
+ accept one or two arguments. If two arguments are accepted, the second argument
264
+ will be the context dictionary.
265
+ return_data (bool): If `True`, a copy of `expr` containing data modified by `visit_fn`
266
+ will be returned. This is slower than `return_data=False` (the default).
267
+ max_depth (int): Controls the depth of recursive visitation. If set to zero, no
268
+ recursion will occur. If set to a positive integer `N`, visitation will only
269
+ descend to `N` layers deep. If set to any negative integer, no limit will be
261
270
  enforced and recursion will continue until terminal items are reached. By
262
271
  default, recursion is unlimited.
263
- context: An optional dictionary. If passed, the context will be sent to each
264
- call to the `visit_fn`. The context can be mutated by each visitor and will
265
- be available for later visits to expressions at the given depth. Values
272
+ context (Optional[dict]): An optional dictionary. If passed, the context will be sent
273
+ to each call to the `visit_fn`. The context can be mutated by each visitor and
274
+ will be available for later visits to expressions at the given depth. Values
266
275
  will not be available "up" a level from a given expression.
267
-
268
276
  The context will be automatically populated with an 'annotation' key when
269
- visiting collections within a `BaseAnnotation` type. This requires the
270
- caller to pass `context={}` and will not be activated by default.
271
- remove_annotations: If set, annotations will be replaced by their contents. By
277
+ visiting collections within a `BaseAnnotation` type. This requires the caller to
278
+ pass `context={}` and will not be activated by default.
279
+ remove_annotations (bool): If set, annotations will be replaced by their contents. By
272
280
  default, annotations are preserved but their contents are visited.
281
+ _seen (Optional[Set[int]]): A set of object ids that have already been visited. This
282
+ prevents infinite recursion when visiting recursive data structures.
283
+
284
+ Returns:
285
+ Any: The modified collection if `return_data` is `True`, otherwise `None`.
273
286
  """
274
287
 
288
+ if _seen is None:
289
+ _seen = set()
290
+
275
291
  def visit_nested(expr):
276
292
  # Utility for a recursive call, preserving options and updating the depth.
277
293
  return visit_collection(
@@ -282,6 +298,7 @@ def visit_collection(
282
298
  max_depth=max_depth - 1,
283
299
  # Copy the context on nested calls so it does not "propagate up"
284
300
  context=context.copy() if context is not None else None,
301
+ _seen=_seen,
285
302
  )
286
303
 
287
304
  def visit_expression(expr):
@@ -290,7 +307,7 @@ def visit_collection(
290
307
  else:
291
308
  return visit_fn(expr)
292
309
 
293
- # Visit every expression
310
+ # --- 1. Visit every expression
294
311
  try:
295
312
  result = visit_expression(expr)
296
313
  except StopVisiting:
@@ -298,47 +315,92 @@ def visit_collection(
298
315
  result = expr
299
316
 
300
317
  if return_data:
301
- # Only mutate the expression while returning data, otherwise it could be null
318
+ # Only mutate the root expression if the user indicated we're returning data,
319
+ # otherwise the function could return null and we have no collection to check
302
320
  expr = result
303
321
 
304
- # Then, visit every child of the expression recursively
322
+ # --- 2. Visit every child of the expression recursively
305
323
 
306
- # If we have reached the maximum depth, do not perform any recursion
307
- if max_depth == 0:
324
+ # If we have reached the maximum depth or we have already visited this object,
325
+ # return the result if we are returning data, otherwise return None
326
+ if max_depth == 0 or id(expr) in _seen:
308
327
  return result if return_data else None
328
+ else:
329
+ _seen.add(id(expr))
309
330
 
310
331
  # Get the expression type; treat iterators like lists
311
332
  typ = list if isinstance(expr, IteratorABC) and isiterable(expr) else type(expr)
312
333
  typ = cast(type, typ) # mypy treats this as 'object' otherwise and complains
313
334
 
314
335
  # Then visit every item in the expression if it is a collection
315
- if isinstance(expr, Mock):
336
+
337
+ # presume that the result is the original expression.
338
+ # in each of the following cases, we will update the result if we need to.
339
+ result = expr
340
+
341
+ # --- Generators
342
+
343
+ if isinstance(expr, (types.GeneratorType, types.AsyncGeneratorType)):
344
+ # Do not attempt to iterate over generators, as it will exhaust them
345
+ pass
346
+
347
+ # --- Mocks
348
+
349
+ elif isinstance(expr, Mock):
316
350
  # Do not attempt to recurse into mock objects
317
- result = expr
351
+ pass
352
+
353
+ # --- Annotations (unmapped, quote, etc.)
318
354
 
319
355
  elif isinstance(expr, BaseAnnotation):
320
356
  if context is not None:
321
357
  context["annotation"] = expr
322
- value = visit_nested(expr.unwrap())
358
+ unwrapped = expr.unwrap()
359
+ value = visit_nested(unwrapped)
323
360
 
324
- if remove_annotations:
325
- result = value if return_data else None
326
- else:
327
- result = expr.rewrap(value) if return_data else None
361
+ if return_data:
362
+ # if we are removing annotations, return the value
363
+ if remove_annotations:
364
+ result = value
365
+ # if the value was modified, rewrap it
366
+ elif value is not unwrapped:
367
+ result = expr.rewrap(value)
368
+ # otherwise return the expr
369
+
370
+ # --- Sequences
328
371
 
329
372
  elif typ in (list, tuple, set):
330
373
  items = [visit_nested(o) for o in expr]
331
- result = typ(items) if return_data else None
374
+ if return_data:
375
+ modified = any(item is not orig for item, orig in zip(items, expr))
376
+ if modified:
377
+ result = typ(items)
378
+
379
+ # --- Dictionaries
332
380
 
333
381
  elif typ in (dict, OrderedDict):
334
382
  assert isinstance(expr, (dict, OrderedDict)) # typecheck assertion
335
383
  items = [(visit_nested(k), visit_nested(v)) for k, v in expr.items()]
336
- result = typ(items) if return_data else None
384
+ if return_data:
385
+ modified = any(
386
+ k1 is not k2 or v1 is not v2
387
+ for (k1, v1), (k2, v2) in zip(items, expr.items())
388
+ )
389
+ if modified:
390
+ result = typ(items)
391
+
392
+ # --- Dataclasses
337
393
 
338
394
  elif is_dataclass(expr) and not isinstance(expr, type):
339
395
  values = [visit_nested(getattr(expr, f.name)) for f in fields(expr)]
340
- items = {field.name: value for field, value in zip(fields(expr), values)}
341
- result = typ(**items) if return_data else None
396
+ if return_data:
397
+ modified = any(
398
+ getattr(expr, f.name) is not v for f, v in zip(fields(expr), values)
399
+ )
400
+ if modified:
401
+ result = typ(**{f.name: v for f, v in zip(fields(expr), values)})
402
+
403
+ # --- Pydantic models
342
404
 
343
405
  elif isinstance(expr, pydantic.BaseModel):
344
406
  typ = cast(Type[pydantic.BaseModel], typ)
@@ -355,20 +417,21 @@ def visit_collection(
355
417
  }
356
418
 
357
419
  if return_data:
358
- # Use construct to avoid validation and handle immutability
359
- model_instance = typ.model_construct(
360
- _fields_set=expr.model_fields_set, **updated_data
420
+ modified = any(
421
+ getattr(expr, field) is not updated_data[field]
422
+ for field in model_fields
361
423
  )
362
- for private_attr in expr.__private_attributes__:
363
- setattr(model_instance, private_attr, getattr(expr, private_attr))
364
- result = model_instance
365
- else:
366
- result = None
424
+ if modified:
425
+ # Use construct to avoid validation and handle immutability
426
+ model_instance = typ.model_construct(
427
+ _fields_set=expr.model_fields_set, **updated_data
428
+ )
429
+ for private_attr in expr.__private_attributes__:
430
+ setattr(model_instance, private_attr, getattr(expr, private_attr))
431
+ result = model_instance
367
432
 
368
- else:
369
- result = result if return_data else None
370
-
371
- return result
433
+ if return_data:
434
+ return result
372
435
 
373
436
 
374
437
  def remove_nested_keys(keys_to_remove: List[Hashable], obj):
@@ -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)}
@@ -380,6 +380,15 @@ def safe_load_namespace(source_code: str):
380
380
 
381
381
  namespace = {"__name__": "prefect_safe_namespace_loader"}
382
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
391
+
383
392
  # Walk through the AST and find all import statements
384
393
  for node in ast.walk(parsed_code):
385
394
  if isinstance(node, ast.Import):
@@ -426,3 +435,23 @@ def safe_load_namespace(source_code: str):
426
435
  except Exception as e:
427
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