Repository: dagster-io/dagster (Python)

Issue:
It is no longer possible to use callable objects for "evaluation_fn" argument in "SensorDefinition" due to "get_type_hints" call
### Dagster version

1.5.7

### What's the issue?

Previously is was possible to use callable objects as arguments for `evaluation_fn` in `SensorDefinition`. For example:

```
class MyFancySensorFn:
...
    def __call__(self, context: SensorEvaluationContext) -> Sequence[RunRequest]:
        yield RunRequest(...)


sensor_def = SensorDefinition(
...
    evaluation_fn=MyFancySensorFn(...),
...
)
```

Now I get the following exception from `get_type_hints`:

```
  File "/Users/vmarkov/project/dagster-test/dagster_declarative/defs_builder.py", line 248, in add_autorun_sensor
    sensor_def = SensorDefinition(
                 ^^^^^^^^^^^^^^^^^
  File "/Users/vmarkov/miniconda3/envs/dagster/lib/python3.11/site-packages/dagster/_core/definitions/sensor_definition.py", line 590, in __init__
    ] = wrap_sensor_evaluation(self._name, evaluation_fn)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/vmarkov/miniconda3/envs/dagster/lib/python3.11/site-packages/dagster/_core/definitions/sensor_definition.py", line 1011, in wrap_sensor_evaluation
    resource_arg_names: Set[str] = {arg.name for arg in get_resource_args(fn)}
                                                        ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/vmarkov/miniconda3/envs/dagster/lib/python3.11/site-packages/dagster/_core/definitions/resource_annotation.py", line 11, in get_resource_args
    type_annotations = get_type_hints(fn)
                       ^^^^^^^^^^^^^^^^^^
  File "/Users/vmarkov/miniconda3/envs/dagster/lib/python3.11/site-packages/dagster/_core/decorator_utils.py", line 82, in get_type_hints
    return typing_get_type_hints(target, include_extras=True)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/vmarkov/miniconda3/envs/dagster/lib/python3.11/typing.py", line 2369, in get_type_hints
    raise TypeError('{!r} is not a module, class, method, '
```

I believe it was broken in this commit: https://github.com/dagster-io/dagster/commit/cbadd60152b3bfae828395b003e7d54bf5a0efed

I see there is a special condition for `functools.partial`, but there is no condition for presence of `__call__` method.

I believe `fn.__call__` should be used as target if it is present. Just a one-line fix.

Thank you.

### What did you expect to happen?

Callable objects should be accepted as `evaluation_fn`.

### How to reproduce?

_No response_

### Deployment type

Local

### Deployment details

_No response_

### Additional information

_No response_

### Message from the maintainers

Impacted by this issue? Give it a 👍! We factor engagement into prioritization.


Discussion:

Patch:
diff --git a/python_modules/dagster/dagster/_core/decorator_utils.py b/python_modules/dagster/dagster/_core/decorator_utils.py
index 87a7d3b309cb1..5391e9ada6572 100644
--- a/python_modules/dagster/dagster/_core/decorator_utils.py
+++ b/python_modules/dagster/dagster/_core/decorator_utils.py
@@ -26,6 +26,7 @@
     get_type_hints as typing_get_type_hints,
 )
 
+import dagster._check as check
 from dagster._core.errors import DagsterInvalidDefinitionError
 
 if TYPE_CHECKING:
@@ -73,7 +74,14 @@ def get_function_params(fn: Callable[..., Any]) -> Sequence[Parameter]:
 
 
 def get_type_hints(fn: Callable) -> Mapping[str, Any]:
-    target = fn.func if isinstance(fn, functools.partial) else fn
+    if isinstance(fn, functools.partial):
+        target = fn.func
+    elif inspect.isfunction(fn):
+        target = fn
+    elif hasattr(fn, "__call__"):
+        target = fn.__call__
+    else:
+        check.failed(f"Unhandled Callable object {fn}")
 
     try:
         return typing_get_type_hints(target, include_extras=True)


Test patch:
diff --git a/python_modules/dagster/dagster_tests/definitions_tests/test_sensor_invocation.py b/python_modules/dagster/dagster_tests/definitions_tests/test_sensor_invocation.py
index 2d02a7e04ef20..4a39dda0e032c 100644
--- a/python_modules/dagster/dagster_tests/definitions_tests/test_sensor_invocation.py
+++ b/python_modules/dagster/dagster_tests/definitions_tests/test_sensor_invocation.py
@@ -47,6 +47,7 @@
 from dagster._core.definitions.metadata import MetadataValue
 from dagster._core.definitions.partition import DynamicPartitionsDefinition
 from dagster._core.definitions.resource_annotation import ResourceParam
+from dagster._core.definitions.sensor_definition import SensorDefinition
 from dagster._core.errors import DagsterInvalidDefinitionError, DagsterInvalidInvocationError
 from dagster._core.execution.build_resources import build_resources
 from dagster._core.storage.tags import PARTITION_NAME_TAG
@@ -137,6 +138,32 @@ def basic_sensor_resource_req(my_resource: MyResource):
     ).run_config == {"foo": "foo"}
 
 
+def test_sensor_invocation_resources_callable() -> None:
+    class MyResource(ConfigurableResource):
+        a_str: str
+
+    class Foo:
+        def __call__(self, my_resource: MyResource):
+            return RunRequest(run_key=None, run_config={"foo": my_resource.a_str}, tags={})
+
+    weird_sensor = SensorDefinition(
+        name="weird",
+        evaluation_fn=Foo(),
+    )
+
+    with pytest.raises(
+        DagsterInvalidDefinitionError,
+        match=("Resource with key 'my_resource' required by sensor 'weird' was not" " provided."),
+    ):
+        weird_sensor()
+
+    # Just need to pass context, which splats out into resource parameters
+    assert cast(
+        RunRequest,
+        weird_sensor(build_sensor_context(resources={"my_resource": MyResource(a_str="foo")})),
+    ).run_config == {"foo": "foo"}
+
+
 def test_sensor_invocation_resources_direct() -> None:
     class MyResource(ConfigurableResource):
         a_str: str