Repository: yt-project/yt

Issue:
BUG: @yt.derived_field decorator is broken
### Bug report

**Bug summary**
broken since #3948

**Code for reproduction**

Taking this example straight from the docs
```python
from yt import derived_field

@derived_field(name="pressure", sampling_type="cell", units="dyne/cm**2")
def _pressure(field, data):
    return (
        (data.ds.gamma - 1.0)
        * data["gas", "density"]
        * data["gas", "specific_thermal_energy"]
    )
```

**Actual outcome**

```python-traceback
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ <ipython-input-1-cebd4a43cd86>:4 in <cell line: 4>                                               │
│ /Users/robcleme/dev/yt-project/yt/yt/fields/local_fields.py:44 in derived_field                  │
│                                                                                                  │
│   41                                                                                             │
│   42                                                                                             │
│   43 def derived_field(name, sampling_type, **kwargs):                                           │
│ ❱ 44 │   return add_field(name=name, function=None, sampling_type=sampling_type, **kwargs)       │
│   45                                                                                             │
│   46                                                                                             │
│   47 @register_field_plugin                                                                      │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/fields/local_fields.py:29 in add_field                      │
│                                                                                                  │
│   26 │   │   │   │   name,                                                                       │
│   27 │   │   │   )                                                                               │
│   28 │   │                                                                                       │
│ ❱ 29 │   │   return super().add_field(                                                           │
│   30 │   │   │   name, function, sampling_type, force_override=force_override, **kwargs          │
│   31 │   │   )                                                                                   │
│   32                                                                                             │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/fields/field_info_container.py:374 in add_field             │
│                                                                                                  │
│   371 │   │   if not isinstance(function, Callable):  # type: ignore [arg-type]                  │
│   372 │   │   │   # type-checking is disabled because of https://github.com/python/mypy/issues   │
│   373 │   │   │   # this is compatible with lambdas and functools.partial objects                │
│ ❱ 374 │   │   │   raise TypeError(                                                               │
│   375 │   │   │   │   f"Expected a callable object, got {function} with type {type(function)}"   │
│   376 │   │   │   )                                                                              │
│   377                                                                                            │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
TypeError: Expected a callable object, got None with type <class 'NoneType'>
```

**Expected outcome**
field being added without a complaint




Discussion:

Resolving patch:
diff --git a/yt/data_objects/construction_data_containers.py b/yt/data_objects/construction_data_containers.py
--- a/yt/data_objects/construction_data_containers.py
+++ b/yt/data_objects/construction_data_containers.py
@@ -1889,7 +1889,7 @@ def export_obj(
         ...         * np.sqrt(data[("gas", "temperature")])
         ...     )
         >>> ds.add_field(
-        ...     "emissivity",
+        ...     ("gas", "emissivity"),
         ...     function=_Emissivity,
         ...     sampling_type="cell",
         ...     units=r"g**2*sqrt(K)/cm**6",
@@ -2224,7 +2224,7 @@ def export_blender(
         ...         * data[("gas", "density")]
         ...         * np.sqrt(data[("gas", "temperature")])
         ...     )
-        >>> ds.add_field("emissivity", function=_Emissivity, units="g / cm**6")
+        >>> ds.add_field(("gas", "emissivity"), function=_Emissivity, units="g / cm**6")
         >>> for i, r in enumerate(rhos):
         ...     surf = ds.surface(sp, "density", r)
         ...     surf.export_obj(
diff --git a/yt/fields/field_info_container.py b/yt/fields/field_info_container.py
--- a/yt/fields/field_info_container.py
+++ b/yt/fields/field_info_container.py
@@ -315,12 +315,12 @@ def _sanitize_sampling_type(sampling_type: str) -> str:
 
     def add_field(
         self,
-        name,
+        name: Tuple[str, str],
         function: Callable,
-        sampling_type,
+        sampling_type: str,
         *,
         alias: Optional[DerivedField] = None,
-        force_override=False,
+        force_override: bool = False,
         **kwargs,
     ):
         """
@@ -332,8 +332,8 @@ def add_field(
         Parameters
         ----------
 
-        name : str
-           is the name of the field.
+        name : tuple[str, str]
+           field (or particle) type, field name
         function : callable
            A function handle that defines the field.  Should accept
            arguments (field, data)
@@ -403,22 +403,8 @@ def add_field(
             self[name] = DerivedField(
                 name, sampling_type, function, alias=alias, **kwargs
             )
-            return
-
-        if sampling_type == "particle":
-            ftype = "all"
-        else:
-            ftype = self.ds.default_fluid_type
-
-        if (ftype, name) not in self:
-            tuple_name = (ftype, name)
-            self[tuple_name] = DerivedField(
-                tuple_name, sampling_type, function, alias=alias, **kwargs
-            )
         else:
-            self[name] = DerivedField(
-                name, sampling_type, function, alias=alias, **kwargs
-            )
+            raise ValueError(f"Expected name to be a tuple[str, str], got {name}")
 
     def load_all_plugins(self, ftype: Optional[str] = "gas"):
         if ftype is None:
diff --git a/yt/fields/local_fields.py b/yt/fields/local_fields.py
--- a/yt/fields/local_fields.py
+++ b/yt/fields/local_fields.py
@@ -1,18 +1,26 @@
+from functools import partial
+from typing import Any, Callable, TypeVar
+
 from yt.funcs import is_sequence
 from yt.utilities.logger import ytLogger as mylog
 
 from .field_info_container import FieldInfoContainer
 from .field_plugin_registry import register_field_plugin
 
+# workaround mypy not being confortable around decorator preserving signatures
+# adapted from
+# https://github.com/python/mypy/issues/1551#issuecomment-253978622
+TFun = TypeVar("TFun", bound=Callable[..., Any])
+
 
 class LocalFieldInfoContainer(FieldInfoContainer):
     def add_field(
         self, name, function, sampling_type, *, force_override=False, **kwargs
     ):
-
-        sampling_type = self._sanitize_sampling_type(sampling_type)
-
         if isinstance(name, str) or not is_sequence(name):
+            # the base method only accepts proper tuple field keys
+            # and is only used internally, while this method is exposed to users
+            # and is documented as usable with single strings as name
             if sampling_type == "particle":
                 ftype = "all"
             else:
@@ -34,14 +42,21 @@ def add_field(
 # Empty FieldInfoContainer
 local_fields = LocalFieldInfoContainer(None, [], None)
 
-# we define two handles, pointing to the same function but documented differently
+# we define two handles, essentially pointing to the same function but documented differently
 # yt.add_field() is meant to be used directly, while yt.derived_field is documented
 # as a decorator.
 add_field = local_fields.add_field
 
 
-def derived_field(name, sampling_type, **kwargs):
-    return add_field(name=name, function=None, sampling_type=sampling_type, **kwargs)
+class derived_field:
+    # implement a decorator accepting keyword arguments to be passed down to add_field
+
+    def __init__(self, **kwargs) -> None:
+        self._kwargs = kwargs
+
+    def __call__(self, f: Callable) -> Callable:
+        partial(local_fields.add_field, function=f)(**self._kwargs)
+        return f
 
 
 @register_field_plugin


Test patch:
diff --git a/yt/data_objects/tests/test_add_field.py b/yt/data_objects/tests/test_add_field.py
--- a/yt/data_objects/tests/test_add_field.py
+++ b/yt/data_objects/tests/test_add_field.py
@@ -2,6 +2,8 @@
 
 import pytest
 
+from yt import derived_field
+from yt.fields import local_fields
 from yt.testing import fake_random_ds
 
 
@@ -93,3 +95,17 @@ def _spam(field, *, data):
             _spam,
             sampling_type="cell",
         )
+
+
+def test_derived_field(monkeypatch):
+
+    tmp_field_info = local_fields.LocalFieldInfoContainer(None, [], None)
+    monkeypatch.setattr(local_fields, "local_fields", tmp_field_info)
+
+    @derived_field(name="pressure", sampling_type="cell", units="dyne/cm**2")
+    def _pressure(field, data):
+        return (
+            (data.ds.gamma - 1.0)
+            * data["gas", "density"]
+            * data["gas", "specific_thermal_energy"]
+        )