prefect-client 3.0.0rc19__py3-none-any.whl → 3.0.0rc20__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.
prefect/results.py CHANGED
@@ -17,7 +17,15 @@ from typing import (
17
17
  )
18
18
  from uuid import UUID
19
19
 
20
- from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError
20
+ from pydantic import (
21
+ BaseModel,
22
+ ConfigDict,
23
+ Field,
24
+ PrivateAttr,
25
+ ValidationError,
26
+ model_serializer,
27
+ model_validator,
28
+ )
21
29
  from pydantic_core import PydanticUndefinedType
22
30
  from pydantic_extra_types.pendulum_dt import DateTime
23
31
  from typing_extensions import ParamSpec, Self
@@ -25,13 +33,13 @@ from typing_extensions import ParamSpec, Self
25
33
  import prefect
26
34
  from prefect.blocks.core import Block
27
35
  from prefect.client.utilities import inject_client
28
- from prefect.exceptions import MissingResult
36
+ from prefect.exceptions import SerializationError
29
37
  from prefect.filesystems import (
30
38
  LocalFileSystem,
31
39
  WritableFileSystem,
32
40
  )
33
41
  from prefect.logging import get_logger
34
- from prefect.serializers import Serializer
42
+ from prefect.serializers import PickleSerializer, Serializer
35
43
  from prefect.settings import (
36
44
  PREFECT_DEFAULT_RESULT_STORAGE_BLOCK,
37
45
  PREFECT_LOCAL_STORAGE_PATH,
@@ -332,22 +340,15 @@ class ResultFactory(BaseModel):
332
340
  obj: R,
333
341
  key: Optional[str] = None,
334
342
  expiration: Optional[DateTime] = None,
335
- defer_persistence: bool = False,
336
343
  ) -> Union[R, "BaseResult[R]"]:
337
344
  """
338
345
  Create a result type for the given object.
339
346
 
340
- If persistence is disabled, the object is wrapped in an `UnpersistedResult` and
341
- returned.
342
-
343
347
  If persistence is enabled the object is serialized, persisted to storage, and a reference is returned.
344
348
  """
345
349
  # Null objects are "cached" in memory at no cost
346
350
  should_cache_object = self.cache_result_in_memory or obj is None
347
351
 
348
- if not self.persist_result:
349
- return await UnpersistedResult.create(obj, cache_object=should_cache_object)
350
-
351
352
  if key:
352
353
 
353
354
  def key_fn():
@@ -365,23 +366,198 @@ class ResultFactory(BaseModel):
365
366
  serializer=self.serializer,
366
367
  cache_object=should_cache_object,
367
368
  expiration=expiration,
368
- defer_persistence=defer_persistence,
369
+ serialize_to_none=not self.persist_result,
369
370
  )
370
371
 
372
+ # TODO: These two methods need to find a new home
373
+
371
374
  @sync_compatible
372
375
  async def store_parameters(self, identifier: UUID, parameters: Dict[str, Any]):
373
- data = self.serializer.dumps(parameters)
374
- blob = PersistedResultBlob(serializer=self.serializer, data=data)
376
+ record = ResultRecord(
377
+ result=parameters,
378
+ metadata=ResultRecordMetadata(
379
+ serializer=self.serializer, storage_key=str(identifier)
380
+ ),
381
+ )
375
382
  await self.storage_block.write_path(
376
- f"parameters/{identifier}", content=blob.to_bytes()
383
+ f"parameters/{identifier}", content=record.serialize()
377
384
  )
378
385
 
379
386
  @sync_compatible
380
387
  async def read_parameters(self, identifier: UUID) -> Dict[str, Any]:
381
- blob = PersistedResultBlob.model_validate_json(
388
+ record = ResultRecord.deserialize(
382
389
  await self.storage_block.read_path(f"parameters/{identifier}")
383
390
  )
384
- return self.serializer.loads(blob.data)
391
+ return record.result
392
+
393
+
394
+ class ResultRecordMetadata(BaseModel):
395
+ """
396
+ Metadata for a result record.
397
+ """
398
+
399
+ storage_key: Optional[str] = Field(
400
+ default=None
401
+ ) # optional for backwards compatibility
402
+ expiration: Optional[DateTime] = Field(default=None)
403
+ serializer: Serializer = Field(default_factory=PickleSerializer)
404
+ prefect_version: str = Field(default=prefect.__version__)
405
+
406
+ def dump_bytes(self) -> bytes:
407
+ """
408
+ Serialize the metadata to bytes.
409
+
410
+ Returns:
411
+ bytes: the serialized metadata
412
+ """
413
+ return self.model_dump_json(serialize_as_any=True).encode()
414
+
415
+ @classmethod
416
+ def load_bytes(cls, data: bytes) -> "ResultRecordMetadata":
417
+ """
418
+ Deserialize metadata from bytes.
419
+
420
+ Args:
421
+ data: the serialized metadata
422
+
423
+ Returns:
424
+ ResultRecordMetadata: the deserialized metadata
425
+ """
426
+ return cls.model_validate_json(data)
427
+
428
+
429
+ class ResultRecord(BaseModel, Generic[R]):
430
+ """
431
+ A record of a result.
432
+ """
433
+
434
+ metadata: ResultRecordMetadata
435
+ result: R
436
+
437
+ @property
438
+ def expiration(self) -> Optional[DateTime]:
439
+ return self.metadata.expiration
440
+
441
+ @property
442
+ def serializer(self) -> Serializer:
443
+ return self.metadata.serializer
444
+
445
+ def serialize_result(self) -> bytes:
446
+ try:
447
+ data = self.serializer.dumps(self.result)
448
+ except Exception as exc:
449
+ extra_info = (
450
+ 'You can try a different serializer (e.g. result_serializer="json") '
451
+ "or disabling persistence (persist_result=False) for this flow or task."
452
+ )
453
+ # check if this is a known issue with cloudpickle and pydantic
454
+ # and add extra information to help the user recover
455
+
456
+ if (
457
+ isinstance(exc, TypeError)
458
+ and isinstance(self.result, BaseModel)
459
+ and str(exc).startswith("cannot pickle")
460
+ ):
461
+ try:
462
+ from IPython import get_ipython
463
+
464
+ if get_ipython() is not None:
465
+ extra_info = inspect.cleandoc(
466
+ """
467
+ This is a known issue in Pydantic that prevents
468
+ locally-defined (non-imported) models from being
469
+ serialized by cloudpickle in IPython/Jupyter
470
+ environments. Please see
471
+ https://github.com/pydantic/pydantic/issues/8232 for
472
+ more information. To fix the issue, either: (1) move
473
+ your Pydantic class definition to an importable
474
+ location, (2) use the JSON serializer for your flow
475
+ or task (`result_serializer="json"`), or (3)
476
+ disable result persistence for your flow or task
477
+ (`persist_result=False`).
478
+ """
479
+ ).replace("\n", " ")
480
+ except ImportError:
481
+ pass
482
+ raise SerializationError(
483
+ f"Failed to serialize object of type {type(self.result).__name__!r} with "
484
+ f"serializer {self.serializer.type!r}. {extra_info}"
485
+ ) from exc
486
+
487
+ return data
488
+
489
+ @model_validator(mode="before")
490
+ @classmethod
491
+ def coerce_old_format(cls, value: Any):
492
+ if isinstance(value, dict):
493
+ if "data" in value:
494
+ value["result"] = value.pop("data")
495
+ if "metadata" not in value:
496
+ value["metadata"] = {}
497
+ if "expiration" in value:
498
+ value["metadata"]["expiration"] = value.pop("expiration")
499
+ if "serializer" in value:
500
+ value["metadata"]["serializer"] = value.pop("serializer")
501
+ if "prefect_version" in value:
502
+ value["metadata"]["prefect_version"] = value.pop("prefect_version")
503
+ return value
504
+
505
+ def serialize_metadata(self) -> bytes:
506
+ return self.metadata.dump_bytes()
507
+
508
+ def serialize(
509
+ self,
510
+ ) -> bytes:
511
+ """
512
+ Serialize the record to bytes.
513
+
514
+ Returns:
515
+ bytes: the serialized record
516
+
517
+ """
518
+ return (
519
+ self.model_copy(update={"result": self.serialize_result()})
520
+ .model_dump_json(serialize_as_any=True)
521
+ .encode()
522
+ )
523
+
524
+ @classmethod
525
+ def deserialize(cls, data: bytes) -> "ResultRecord[R]":
526
+ """
527
+ Deserialize a record from bytes.
528
+
529
+ Args:
530
+ data: the serialized record
531
+
532
+ Returns:
533
+ ResultRecord: the deserialized record
534
+ """
535
+ instance = cls.model_validate_json(data)
536
+ if isinstance(instance.result, bytes):
537
+ instance.result = instance.serializer.loads(instance.result)
538
+ elif isinstance(instance.result, str):
539
+ instance.result = instance.serializer.loads(instance.result.encode())
540
+ return instance
541
+
542
+ @classmethod
543
+ def deserialize_from_result_and_metadata(
544
+ cls, result: bytes, metadata: bytes
545
+ ) -> "ResultRecord[R]":
546
+ """
547
+ Deserialize a record from separate result and metadata bytes.
548
+
549
+ Args:
550
+ result: the result
551
+ metadata: the serialized metadata
552
+
553
+ Returns:
554
+ ResultRecord: the deserialized record
555
+ """
556
+ result_record_metadata = ResultRecordMetadata.load_bytes(metadata)
557
+ return cls(
558
+ metadata=result_record_metadata,
559
+ result=result_record_metadata.serializer.loads(result),
560
+ )
385
561
 
386
562
 
387
563
  @register_base_type
@@ -432,40 +608,12 @@ class BaseResult(BaseModel, abc.ABC, Generic[R]):
432
608
  return cls.__name__ if isinstance(default, PydanticUndefinedType) else default
433
609
 
434
610
 
435
- class UnpersistedResult(BaseResult):
436
- """
437
- Result type for results that are not persisted outside of local memory.
438
- """
439
-
440
- type: str = "unpersisted"
441
-
442
- @sync_compatible
443
- async def get(self) -> R:
444
- if self.has_cached_object():
445
- return self._cache
446
-
447
- raise MissingResult("The result was not persisted and is no longer available.")
448
-
449
- @classmethod
450
- @sync_compatible
451
- async def create(
452
- cls: "Type[UnpersistedResult]",
453
- obj: R,
454
- cache_object: bool = True,
455
- ) -> "UnpersistedResult[R]":
456
- result = cls()
457
- # Only store the object in local memory, it will not be sent to the API
458
- if cache_object:
459
- result._cache_object(obj)
460
- return result
461
-
462
-
463
611
  class PersistedResult(BaseResult):
464
612
  """
465
613
  Result type which stores a reference to a persisted result.
466
614
 
467
615
  When created, the user's object is serialized and stored. The format for the content
468
- is defined by `PersistedResultBlob`. This reference contains metadata necessary for retrieval
616
+ is defined by `ResultRecord`. This reference contains metadata necessary for retrieval
469
617
  of the object, such as a reference to the storage block and the key where the
470
618
  content was written.
471
619
  """
@@ -476,12 +624,19 @@ class PersistedResult(BaseResult):
476
624
  storage_key: str
477
625
  storage_block_id: Optional[uuid.UUID] = None
478
626
  expiration: Optional[DateTime] = None
627
+ serialize_to_none: bool = False
479
628
 
480
- _should_cache_object: bool = PrivateAttr(default=True)
481
629
  _persisted: bool = PrivateAttr(default=False)
630
+ _should_cache_object: bool = PrivateAttr(default=True)
482
631
  _storage_block: WritableFileSystem = PrivateAttr(default=None)
483
632
  _serializer: Serializer = PrivateAttr(default=None)
484
633
 
634
+ @model_serializer(mode="wrap")
635
+ def serialize_model(self, handler, info):
636
+ if self.serialize_to_none:
637
+ return None
638
+ return handler(self, info)
639
+
485
640
  def _cache_object(
486
641
  self,
487
642
  obj: Any,
@@ -512,21 +667,20 @@ class PersistedResult(BaseResult):
512
667
  if self.has_cached_object():
513
668
  return self._cache
514
669
 
515
- blob = await self._read_blob(client=client)
516
- obj = blob.load()
517
- self.expiration = blob.expiration
670
+ record = await self._read_result_record(client=client)
671
+ self.expiration = record.expiration
518
672
 
519
673
  if self._should_cache_object:
520
- self._cache_object(obj)
674
+ self._cache_object(record.result)
521
675
 
522
- return obj
676
+ return record.result
523
677
 
524
678
  @inject_client
525
- async def _read_blob(self, client: "PrefectClient") -> "PersistedResultBlob":
679
+ async def _read_result_record(self, client: "PrefectClient") -> "ResultRecord":
526
680
  block = await self._get_storage_block(client=client)
527
681
  content = await block.read_path(self.storage_key)
528
- blob = PersistedResultBlob.model_validate_json(content)
529
- return blob
682
+ record = ResultRecord.deserialize(content)
683
+ return record
530
684
 
531
685
  @staticmethod
532
686
  def _infer_path(storage_block, key) -> str:
@@ -547,7 +701,7 @@ class PersistedResult(BaseResult):
547
701
  Write the result to the storage block.
548
702
  """
549
703
 
550
- if self._persisted:
704
+ if self._persisted or self.serialize_to_none:
551
705
  # don't double write or overwrite
552
706
  return
553
707
 
@@ -567,50 +721,15 @@ class PersistedResult(BaseResult):
567
721
  # this could error if the serializer requires kwargs
568
722
  serializer = Serializer(type=self.serializer_type)
569
723
 
570
- try:
571
- data = serializer.dumps(obj)
572
- except Exception as exc:
573
- extra_info = (
574
- 'You can try a different serializer (e.g. result_serializer="json") '
575
- "or disabling persistence (persist_result=False) for this flow or task."
576
- )
577
- # check if this is a known issue with cloudpickle and pydantic
578
- # and add extra information to help the user recover
579
-
580
- if (
581
- isinstance(exc, TypeError)
582
- and isinstance(obj, BaseModel)
583
- and str(exc).startswith("cannot pickle")
584
- ):
585
- try:
586
- from IPython import get_ipython
587
-
588
- if get_ipython() is not None:
589
- extra_info = inspect.cleandoc(
590
- """
591
- This is a known issue in Pydantic that prevents
592
- locally-defined (non-imported) models from being
593
- serialized by cloudpickle in IPython/Jupyter
594
- environments. Please see
595
- https://github.com/pydantic/pydantic/issues/8232 for
596
- more information. To fix the issue, either: (1) move
597
- your Pydantic class definition to an importable
598
- location, (2) use the JSON serializer for your flow
599
- or task (`result_serializer="json"`), or (3)
600
- disable result persistence for your flow or task
601
- (`persist_result=False`).
602
- """
603
- ).replace("\n", " ")
604
- except ImportError:
605
- pass
606
- raise ValueError(
607
- f"Failed to serialize object of type {type(obj).__name__!r} with "
608
- f"serializer {serializer.type!r}. {extra_info}"
609
- ) from exc
610
- blob = PersistedResultBlob(
611
- serializer=serializer, data=data, expiration=self.expiration
724
+ record = ResultRecord(
725
+ result=obj,
726
+ metadata=ResultRecordMetadata(
727
+ storage_key=self.storage_key,
728
+ expiration=self.expiration,
729
+ serializer=serializer,
730
+ ),
612
731
  )
613
- await storage_block.write_path(self.storage_key, content=blob.to_bytes())
732
+ await storage_block.write_path(self.storage_key, content=record.serialize())
614
733
  self._persisted = True
615
734
 
616
735
  if not self._should_cache_object:
@@ -627,7 +746,7 @@ class PersistedResult(BaseResult):
627
746
  storage_block_id: Optional[uuid.UUID] = None,
628
747
  cache_object: bool = True,
629
748
  expiration: Optional[DateTime] = None,
630
- defer_persistence: bool = False,
749
+ serialize_to_none: bool = False,
631
750
  ) -> "PersistedResult[R]":
632
751
  """
633
752
  Create a new result reference from a user's object.
@@ -651,24 +770,13 @@ class PersistedResult(BaseResult):
651
770
  storage_block_id=storage_block_id,
652
771
  storage_key=key,
653
772
  expiration=expiration,
773
+ serialize_to_none=serialize_to_none,
654
774
  )
655
775
 
656
- if cache_object and not defer_persistence:
657
- # Attach the object to the result so it's available without deserialization
658
- result._cache_object(
659
- obj, storage_block=storage_block, serializer=serializer
660
- )
661
-
662
776
  object.__setattr__(result, "_should_cache_object", cache_object)
663
-
664
- if not defer_persistence:
665
- await result.write(obj=obj)
666
- else:
667
- # we must cache temporarily to allow for writing later
668
- # the cache will be removed on write
669
- result._cache_object(
670
- obj, storage_block=storage_block, serializer=serializer
671
- )
777
+ # we must cache temporarily to allow for writing later
778
+ # the cache will be removed on write
779
+ result._cache_object(obj, storage_block=storage_block, serializer=serializer)
672
780
 
673
781
  return result
674
782
 
@@ -682,59 +790,3 @@ class PersistedResult(BaseResult):
682
790
  and self.storage_block_id == other.storage_block_id
683
791
  and self.expiration == other.expiration
684
792
  )
685
-
686
-
687
- class PersistedResultBlob(BaseModel):
688
- """
689
- The format of the content stored by a persisted result.
690
-
691
- Typically, this is written to a file as bytes.
692
- """
693
-
694
- serializer: Serializer
695
- data: bytes
696
- prefect_version: str = Field(default=prefect.__version__)
697
- expiration: Optional[DateTime] = None
698
-
699
- def load(self) -> Any:
700
- return self.serializer.loads(self.data)
701
-
702
- def to_bytes(self) -> bytes:
703
- return self.model_dump_json(serialize_as_any=True).encode()
704
-
705
-
706
- class UnknownResult(BaseResult):
707
- """
708
- Result type for unknown results. Typically used to represent the result
709
- of tasks that were forced from a failure state into a completed state.
710
-
711
- The value for this result is always None and is not persisted to external
712
- result storage, but orchestration treats the result the same as persisted
713
- results when determining orchestration rules, such as whether to rerun a
714
- completed task.
715
- """
716
-
717
- type: str = "unknown"
718
- value: None
719
-
720
- def has_cached_object(self) -> bool:
721
- # This result type always has the object cached in memory
722
- return True
723
-
724
- @sync_compatible
725
- async def get(self) -> R:
726
- return self.value
727
-
728
- @classmethod
729
- @sync_compatible
730
- async def create(
731
- cls: "Type[UnknownResult]",
732
- obj: R = None,
733
- ) -> "UnknownResult[R]":
734
- if obj is not None:
735
- raise TypeError(
736
- f"Unsupported type {type(obj).__name__!r} for unknown result. "
737
- "Only None is supported."
738
- )
739
-
740
- return cls(value=obj)
prefect/settings.py CHANGED
@@ -425,13 +425,20 @@ def default_database_connection_url(settings: "Settings", value: Optional[str]):
425
425
  f"Missing required database connection settings: {', '.join(missing)}"
426
426
  )
427
427
 
428
- host = PREFECT_API_DATABASE_HOST.value_from(settings)
429
- port = PREFECT_API_DATABASE_PORT.value_from(settings) or 5432
430
- user = PREFECT_API_DATABASE_USER.value_from(settings)
431
- name = PREFECT_API_DATABASE_NAME.value_from(settings)
432
- password = PREFECT_API_DATABASE_PASSWORD.value_from(settings)
433
-
434
- return f"{driver}://{user}:{password}@{host}:{port}/{name}"
428
+ # We only need SQLAlchemy here if we're parsing a remote database connection
429
+ # string. Import it here so that we don't require the prefect-client package
430
+ # to have SQLAlchemy installed.
431
+ from sqlalchemy import URL
432
+
433
+ return URL(
434
+ drivername=driver,
435
+ host=PREFECT_API_DATABASE_HOST.value_from(settings),
436
+ port=PREFECT_API_DATABASE_PORT.value_from(settings) or 5432,
437
+ username=PREFECT_API_DATABASE_USER.value_from(settings),
438
+ password=PREFECT_API_DATABASE_PASSWORD.value_from(settings),
439
+ database=PREFECT_API_DATABASE_NAME.value_from(settings),
440
+ query=[],
441
+ ).render_as_string(hide_password=False)
435
442
 
436
443
  elif driver == "sqlite+aiosqlite":
437
444
  path = PREFECT_API_DATABASE_NAME.value_from(settings)