Repository: PrefectHQ/prefect

Issue:
PIN 16: Extend `Result` interface
## Use Case
See PIN 16 (PR #2134) for background.
This issue includes only extensions to the `Result` interface from the PIN that are independently deliverable from other issues.

## Solution
Update the `Result` base class with the following init kwargs:
- `validators: Iterable[Callable]`
- `should_validate_result: bool`
- `cache_for: datetime.timedelta`
- `cache_validator: Callable`
- `filename_template: str`

and the following new methods:
- `exists -> bool`, not implemented in the base class
- ~`validate -> bool`, don't implement it yet~ (this will be added in #2144 to await ValidationFailed state in case we need it at that time)
- `read`, not implemented in the base class
- `write`, not implemented in the base class

## Alternatives



Discussion:

Resolving patch:
diff --git a/src/prefect/engine/result.py b/src/prefect/engine/result.py
--- a/src/prefect/engine/result.py
+++ b/src/prefect/engine/result.py
@@ -17,8 +17,10 @@
 whose value is `None`.
 """
 
-from typing import Any
+import datetime
+from typing import Any, Callable, Iterable, Optional
 
+from prefect.engine.cache_validators import duration_only
 from prefect.engine.result_handlers import ResultHandler
 
 
@@ -71,12 +73,39 @@ class Result(ResultInterface):
         - value (Any): the value of the result
         - result_handler (ResultHandler, optional): the result handler to use
             when storing / serializing this result's value; required if you intend on persisting this result in some way
+        - validators (Iterable[Callable], optional): Iterable of validation functions to apply to
+            the result to ensure it is `valid`.
+        - run_validators (bool): Whether the result value should be validated.
+        - cache_for (timedelta, optional): The amount of time to maintain a cache
+            of this result.  Useful for situations where the containing Flow
+            will be rerun multiple times, but this task doesn't need to be.
+        - cache_validator (Callable, optional): Validator that will determine
+            whether the cache for this result is still valid (only required if `cache_for`
+            is provided; defaults to `prefect.engine.cache_validators.duration_only`)
+        - filename_template (str, optional): Template file name to be used for saving the
+            result to the destination.
     """
 
-    def __init__(self, value: Any, result_handler: ResultHandler = None):
+    def __init__(
+        self,
+        value: Any,
+        result_handler: ResultHandler = None,
+        validators: Iterable[Callable] = None,
+        run_validators: bool = True,
+        cache_for: Optional[datetime.timedelta] = None,
+        cache_validator: Optional[Callable] = None,
+        filename_template: Optional[str] = None,
+    ):
         self.value = value
         self.safe_value = NoResult  # type: SafeResult
         self.result_handler = result_handler  # type: ignore
+        self.validators = validators
+        self.run_validators = run_validators
+        if cache_for is not None and cache_validator is None:
+            cache_validator = duration_only
+        self.cache_for = cache_for
+        self.cache_validator = cache_validator
+        self.filename_template = filename_template
 
     def store_safe_value(self) -> None:
         """
@@ -94,6 +123,38 @@ def store_safe_value(self) -> None:
                 value=value, result_handler=self.result_handler
             )
 
+    def exists(self) -> bool:
+        """
+        Checks whether the target result exists.
+
+        Does not validate whether the result is `valid`, only that it is present.
+
+        Returns:
+            - bool: whether or not the target result exists.
+        """
+        raise NotImplementedError()
+
+    def read(self, loc: Optional[str] = None) -> Any:
+        """
+        Reads from the target result.
+
+        Args:
+            - loc (str): Location of the result in the specific result target.
+
+        Returns:
+            - Any: The value saved to the result.
+        """
+        raise NotImplementedError()
+
+    def write(self) -> Any:
+        """
+        Serialize and write the result to the target location.
+
+        Returns:
+            - Any: Result specific metadata about the written data.
+        """
+        raise NotImplementedError()
+
 
 class SafeResult(ResultInterface):
     """


Test patch:
diff --git a/tests/engine/test_result.py b/tests/engine/test_result.py
--- a/tests/engine/test_result.py
+++ b/tests/engine/test_result.py
@@ -1,3 +1,5 @@
+import datetime
+
 import cloudpickle
 import pytest
 
@@ -25,11 +27,21 @@ def test_result_inits_with_value(self):
         assert r.value == 3
         assert r.safe_value is NoResult
         assert r.result_handler is None
+        assert r.validators is None
+        assert r.cache_for is None
+        assert r.cache_validator is None
+        assert r.filename_template is None
+        assert r.run_validators is True
 
         s = Result(value=5)
         assert s.value == 5
         assert s.safe_value is NoResult
         assert s.result_handler is None
+        assert s.validators is None
+        assert s.cache_for is None
+        assert s.cache_validator is None
+        assert s.filename_template is None
+        assert r.run_validators is True
 
     def test_result_inits_with_handled_and_result_handler(self):
         handler = JSONResultHandler()
@@ -38,6 +50,27 @@ def test_result_inits_with_handled_and_result_handler(self):
         assert r.safe_value is NoResult
         assert r.result_handler == handler
 
+    def test_cache_validator_provided_if_needed(self):
+        """
+        If `cache_for` is provided, and `cache_validator` is not,
+        a `cache_validator` should be provided.
+        """
+        r = Result(value=3, cache_for=datetime.timedelta(days=2))
+        assert r.cache_validator is not None
+        assert callable(r.cache_validator)
+
+    def test_uses_provided_cache_validator(self):
+        def custom_cache_validator(*args, **kwargs):
+            # Creating a custom function for identity comparison
+            return True
+
+        r = Result(
+            value=3,
+            cache_for=datetime.timedelta(days=2),
+            cache_validator=custom_cache_validator,
+        )
+        assert r.cache_validator is custom_cache_validator
+
     def test_result_ignores_none_values(self):
         handler = JSONResultHandler()
         r = Result(value=None, result_handler=handler)
@@ -64,6 +97,19 @@ def test_safe_result_inits_with_both_args(self):
         assert res.safe_value is res
 
 
+@pytest.mark.parametrize("abstract_interface", ["exists", "read", "write"])
+def test_has_abstract_interfaces(abstract_interface: str):
+    """
+    Tests to make sure that calling the abstract interfaces directly
+    on the base `Result` class results in `NotImplementedError`s.
+    """
+    r = Result(value=3)
+
+    func = getattr(r, abstract_interface)
+    with pytest.raises(NotImplementedError):
+        func()
+
+
 def test_noresult_is_safe():
     assert isinstance(NoResult, SafeResult)