Repository: yt-project/yt

Issue:
Confusing error message in ds.add_field()
### Bug report

**Bug summary**

The current implementation for `ds.add_field` is probably somewhat messy (I intentionally didn't look it up before reporting), so it throws confusing and unhelpful errors on bad input.

**Code for reproduction**

```python
import yt

def _test(field, data):
    return data["x"]
ds = yt.testing.fake_amr_ds(fields=["density"])

# the error is that I'm passing "func" instead of "function"
ds.add_field(name="test", func=_test, sampling_type="cell", units="m")
```

**Actual outcome**

<!--The output produced by the above code, which may be a screenshot, console
output, etc.-->

```python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-29-01d8197cb9a9> in <module>
      6     return data["x"].base * unyt.msun
      7 ds = yt.testing.fake_amr_ds(fields=["density"])
----> 8 ds.add_field(name="test", func=_test, sampling_type="cell", units="kg")

~/dev/python/yt-project/yt/yt/data_objects/static_output.py in add_field(self, name, function, sampling_type, **kwargs)
   1271         self.field_info.add_field(name, sampling_type, function=function, **kwargs)
   1272         self.field_info._show_field_errors.append(name)
-> 1273         deps, _ = self.field_info.check_derived_fields([name])
   1274         self.field_dependencies.update(deps)
   1275 

~/dev/python/yt-project/yt/yt/fields/field_info_container.py in check_derived_fields(self, fields_to_check)
    395         for field in fields_to_check:
    396             if field not in self:
--> 397                 raise YTFieldNotFound(str(field))
    398             fi = self[field]
    399             try:

TypeError: __init__() missing 1 required positional argument: 'ds'
```
Not only does this not help me, as a user, to discover what I'm doing wrong, it also looks like a plain bug, which I _thought_ it was since this was my first time using yt since we merged the 4.0 branch.

**Expected outcome**

A more helpful error message such as
```python
TypeError: add_field() missing 1 required positional argument: 'function'

# or
TypeError: add_field() missing 1 required keyword argument: 'function'
```

depending on what is more idiomatic to the current implementation.

**Version Information**
  * yt version: master


Discussion:

Resolving patch:
diff --git a/yt/data_objects/static_output.py b/yt/data_objects/static_output.py
--- a/yt/data_objects/static_output.py
+++ b/yt/data_objects/static_output.py
@@ -4,7 +4,6 @@
 import os
 import pickle
 import time
-import warnings
 import weakref
 from collections import defaultdict
 from stat import ST_CTIME
@@ -38,7 +37,7 @@
     SpectralCubeCoordinateHandler,
     SphericalCoordinateHandler,
 )
-from yt.units import UnitContainer, _wrap_display_ytarray
+from yt.units import UnitContainer, _wrap_display_ytarray, dimensions
 from yt.units.dimensions import current_mks
 from yt.units.unit_object import Unit, define_unit
 from yt.units.unit_registry import UnitRegistry
@@ -1089,8 +1088,6 @@ def _assign_unit_system(self, unit_system):
         self.unit_registry.unit_system = self.unit_system
 
     def _create_unit_registry(self, unit_system):
-        from yt.units import dimensions as dimensions
-
         # yt assumes a CGS unit system by default (for back compat reasons).
         # Since unyt is MKS by default we specify the MKS values of the base
         # units in the CGS system. So, for length, 1 cm = .01 m. And so on.
@@ -1120,7 +1117,6 @@ def set_units(self):
         Creates the unit registry for this dataset.
 
         """
-        from yt.units.dimensions import length
 
         if getattr(self, "cosmological_simulation", False):
             # this dataset is cosmological, so add cosmological units.
@@ -1132,7 +1128,7 @@ def set_units(self):
                 self.unit_registry.add(
                     new_unit,
                     my_u.base_value / (1 + self.current_redshift),
-                    length,
+                    dimensions.length,
                     "\\rm{%s}/(1+z)" % my_unit,
                     prefixable=True,
                 )
@@ -1344,7 +1340,7 @@ def quan(self):
         self._quan = functools.partial(YTQuantity, registry=self.unit_registry)
         return self._quan
 
-    def add_field(self, name, function=None, sampling_type=None, **kwargs):
+    def add_field(self, name, function, sampling_type, **kwargs):
         """
         Dataset-specific call to add_field
 
@@ -1361,6 +1357,8 @@ def add_field(self, name, function=None, sampling_type=None, **kwargs):
         function : callable
            A function handle that defines the field.  Should accept
            arguments (field, data)
+        sampling_type: str
+           "cell" or "particle" or "local"
         units : str
            A plain text string encoding the unit.  Powers must be in
            python syntax (** instead of ^).
@@ -1368,8 +1366,6 @@ def add_field(self, name, function=None, sampling_type=None, **kwargs):
            Describes whether the field should be logged
         validators : list
            A list of :class:`FieldValidator` objects
-        particle_type : bool
-           Is this a particle (1D) field?
         vector_field : bool
            Describes the dimensionality of the field.  Currently unused.
         display_name : str
@@ -1388,27 +1384,11 @@ def add_field(self, name, function=None, sampling_type=None, **kwargs):
             )
         # Handle the case where the field has already been added.
         if not override and name in self.field_info:
-            mylog.error(
-                "Field %s already exists. To override use " + "force_override=True.",
-                name,
+            mylog.warning(
+                "Field %s already exists. To override use `force_override=True`.", name,
             )
-        if kwargs.setdefault("particle_type", False):
-            if sampling_type is not None and sampling_type != "particle":
-                raise RuntimeError(
-                    "Clashing definition of 'sampling_type' and "
-                    "'particle_type'. Note that 'particle_type' is "
-                    "deprecated. Please just use 'sampling_type'."
-                )
-            else:
-                sampling_type = "particle"
-        if sampling_type is None:
-            warnings.warn(
-                "Because 'sampling_type' not specified, yt will "
-                "assume a cell 'sampling_type'",
-                stacklevel=2,
-            )
-            sampling_type = "cell"
-        self.field_info.add_field(name, sampling_type, function=function, **kwargs)
+
+        self.field_info.add_field(name, function, sampling_type, **kwargs)
         self.field_info._show_field_errors.append(name)
         deps, _ = self.field_info.check_derived_fields([name])
         self.field_dependencies.update(deps)
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
@@ -1,9 +1,8 @@
-import warnings
 from numbers import Number as numeric_type
 
 import numpy as np
 
-from yt.funcs import mylog, only_on_root
+from yt.funcs import issue_deprecation_warning, mylog, only_on_root
 from yt.geometry.geometry_handler import is_curvilinear
 from yt.units.dimensions import dimensionless
 from yt.units.unit_object import Unit
@@ -247,7 +246,55 @@ def setup_fluid_aliases(self, ftype="gas"):
                             alias = "%s_%s" % (alias[:-2], axis_names[2])
                 self.alias((ftype, alias), field)
 
-    def add_field(self, name, sampling_type, function=None, **kwargs):
+    @staticmethod
+    def _sanitize_sampling_type(sampling_type, particle_type=None):
+        """Detect conflicts between deprecated and new parameters to specify the
+        sampling type in a new field.
+
+        This is a helper function to add_field methods.
+
+        Parameters
+        ----------
+        sampling_type: str
+            One of "cell", "particle" or "local" (case insensitive)
+        particle_type: str
+            This is a deprecated argument of the add_field method,
+            which was replaced by sampling_type.
+
+        Raises
+        ------
+        ValueError
+            For unsupported values in sampling_type
+        RuntimeError
+            If conflicting parameters are passed.
+        """
+        try:
+            sampling_type = sampling_type.lower()
+        except AttributeError as e:
+            raise TypeError("sampling_type should be a string.") from e
+
+        acceptable_samplings = ("cell", "particle", "local")
+        if sampling_type not in acceptable_samplings:
+            raise ValueError(
+                "Invalid sampling type %s. Valid sampling types are %s",
+                sampling_type,
+                ", ".join(acceptable_samplings),
+            )
+
+        if particle_type:
+            issue_deprecation_warning(
+                "'particle_type' keyword argument is deprecated in favour "
+                "of the positional argument 'sampling_type'."
+            )
+            if sampling_type != "particle":
+                raise RuntimeError(
+                    "Conflicting values for parameters "
+                    "'sampling_type' and 'particle_type'."
+                )
+
+        return sampling_type
+
+    def add_field(self, name, function, sampling_type, **kwargs):
         """
         Add a new field, along with supplemental metadata, to the list of
         available fields.  This respects a number of arguments, all of which
@@ -262,6 +309,8 @@ def add_field(self, name, sampling_type, function=None, **kwargs):
         function : callable
            A function handle that defines the field.  Should accept
            arguments (field, data)
+        sampling_type: str
+           "cell" or "particle" or "local"
         units : str
            A plain text string encoding the unit.  Powers must be in
            python syntax (** instead of ^). If set to "auto" the units
@@ -270,8 +319,6 @@ def add_field(self, name, sampling_type, function=None, **kwargs):
            Describes whether the field should be logged
         validators : list
            A list of :class:`FieldValidator` objects
-        particle_type : bool
-           Is this a particle (1D) field?
         vector_field : bool
            Describes the dimensionality of the field.  Currently unused.
         display_name : str
@@ -290,8 +337,8 @@ def create_function(f):
                 return create_function
             return
         # add_field can be used in two different ways: it can be called
-        # directly, or used as a decorator. If called directly, the
-        # function will be passed in as an argument, and we simply create
+        # directly, or used as a decorator (as yt.derived_field). If called directly,
+        # the function will be passed in as an argument, and we simply create
         # the derived field and exit. If used as a decorator, function will
         # be None. In that case, we return a function that will be applied
         # to the function that the decorator is applied to.
@@ -308,19 +355,11 @@ def create_function(f):
             self[name] = DerivedField(name, sampling_type, function, **kwargs)
             return
 
-        particle_field = False
-        if sampling_type == "particle":
-            particle_field = True
-
-        if kwargs.get("particle_type", False):
-            warnings.warn(
-                "The particle_type keyword argument of add_field has been "
-                'deprecated. Please set sampling_type="particle" instead.',
-                stacklevel=2,
-            )
-            particle_field = True
+        sampling_type = self._sanitize_sampling_type(
+            sampling_type, particle_type=kwargs.get("particle_type")
+        )
 
-        if particle_field:
+        if sampling_type == "particle":
             ftype = "all"
         else:
             ftype = self.ds.default_fluid_type
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,5 +1,4 @@
-import warnings
-
+from yt.funcs import iterable
 from yt.utilities.logger import ytLogger as mylog
 
 from .field_info_container import FieldInfoContainer
@@ -7,44 +6,42 @@
 
 
 class LocalFieldInfoContainer(FieldInfoContainer):
-    def add_field(self, name, function=None, sampling_type=None, **kwargs):
-        if not isinstance(name, tuple):
-            if kwargs.setdefault("particle_type", False):
-                name = ("all", name)
+    def add_field(self, name, function, sampling_type, **kwargs):
+
+        sampling_type = self._sanitize_sampling_type(
+            sampling_type, kwargs.get("particle_type")
+        )
+
+        if isinstance(name, str) or not iterable(name):
+            if sampling_type == "particle":
+                ftype = "all"
             else:
-                name = ("gas", name)
+                ftype = "gas"
+            name = (ftype, name)
+
         override = kwargs.get("force_override", False)
         # Handle the case where the field has already been added.
         if not override and name in self:
-            mylog.error(
-                "Field %s already exists. To override use " + "force_override=True.",
-                name,
+            mylog.warning(
+                "Field %s already exists. To override use `force_override=True`.", name,
             )
-        if kwargs.setdefault("particle_type", False):
-            if sampling_type is not None and sampling_type != "particle":
-                raise RuntimeError(
-                    "Clashing definition of 'sampling_type' and "
-                    "'particle_type'. Note that 'particle_type' is "
-                    "deprecated. Please just use 'sampling_type'."
-                )
-            else:
-                sampling_type = "particle"
-        if sampling_type is None:
-            warnings.warn(
-                "Because 'sampling_type' is not specified, yt will "
-                "assume a 'cell' sampling_type for the %s field" % (name,),
-                stacklevel=3,
-            )
-            sampling_type = "cell"
+
         return super(LocalFieldInfoContainer, self).add_field(
-            name, sampling_type, function, **kwargs
+            name, function, sampling_type, **kwargs
         )
 
 
 # Empty FieldInfoContainer
 local_fields = LocalFieldInfoContainer(None, [], None)
 
-add_field = derived_field = local_fields.add_field
+# we define two handles, 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)
 
 
 @register_field_plugin


Test patch: