Repository: yt-project/yt

Issue:
API: regression in SlicePlot and ProjectionPlot
### Bug report

**Bug summary**

Some cases where overlooked in #3489

**Code for reproduction**
running this with pytest
```python
import yt

ds = yt.load_sample("IsolatedGalaxy")

def test_sliceplot_kwarg():
    yt.SlicePlot(ds, normal="z", fields="density")

def test_sliceplot_posarg():
    yt.SlicePlot(ds, "z", fields="density")

def test_projplot_kwarg():
    yt.ProjectionPlot(ds, normal="z", fields="density")

def test_projplot_posarg():
    yt.ProjectionPlot(ds, "z", fields="density")
```

- with yt 4.0.1, `test_projplot_kwarg` is the only one failing (it shouldn't, but that's not my main problem here)
- with yt 4.1.dev0 (current main branch) `test_sliceplot_kwarg` fails too


Discussion:

Resolving patch:
diff --git a/yt/visualization/plot_window.py b/yt/visualization/plot_window.py
--- a/yt/visualization/plot_window.py
+++ b/yt/visualization/plot_window.py
@@ -12,6 +12,7 @@
 from packaging.version import Version
 from unyt.exceptions import UnitConversionError
 
+from yt._maintenance.deprecation import issue_deprecation_warning
 from yt.config import ytcfg
 from yt.data_objects.image_array import ImageArray
 from yt.frontends.ytdata.data_structures import YTSpatialPlotDataset
@@ -1534,6 +1535,33 @@ def sanitize_normal_vector(ds, normal) -> Union[str, np.ndarray]:
 
         return retv
 
+    @staticmethod
+    def _validate_init_args(*, normal, axis, fields) -> None:
+        # TODO: remove this method in yt 4.2
+
+        if axis is not None:
+            issue_deprecation_warning(
+                "Argument 'axis' is a deprecated alias for 'normal'.",
+                since="4.1.0",
+                removal="4.2.0",
+            )
+            if normal is not None:
+                raise TypeError("Received incompatible arguments 'axis' and 'normal'")
+            normal = axis
+
+        if (normal, fields) == (None, None):
+            raise TypeError(
+                "missing 2 required positional arguments: 'normal' and 'fields'"
+            )
+
+        if fields is None:
+            raise TypeError("missing required positional argument: 'fields'")
+
+        if normal is None:
+            raise TypeError("missing required positional argument: 'normal'")
+
+        return normal
+
 
 class SlicePlot(NormalPlot):
     r"""
@@ -1679,8 +1707,11 @@ class SlicePlot(NormalPlot):
     # on the pathlib.Path class from the standard library
     # https://github.com/python/mypy/issues/1020
     def __new__(  # type: ignore
-        cls, ds, normal, fields, *args, **kwargs
+        cls, ds, normal=None, fields=None, *args, axis=None, **kwargs
     ) -> Union["AxisAlignedSlicePlot", "OffAxisSlicePlot"]:
+        # TODO: in yt 4.2, remove default values for normal and fields, drop axis kwarg
+        normal = cls._validate_init_args(normal=normal, axis=axis, fields=fields)
+
         if cls is SlicePlot:
             normal = cls.sanitize_normal_vector(ds, normal)
             if isinstance(normal, str):
@@ -1740,8 +1771,11 @@ class ProjectionPlot(NormalPlot):
     # on the pathlib.Path class from the standard library
     # https://github.com/python/mypy/issues/1020
     def __new__(  # type: ignore
-        cls, ds, normal, fields, *args, **kwargs
+        cls, ds, normal=None, fields=None, *args, axis=None, **kwargs
     ) -> Union["AxisAlignedProjectionPlot", "OffAxisProjectionPlot"]:
+        # TODO: in yt 4.2, remove default values for normal and fields, drop axis kwarg
+        normal = cls._validate_init_args(normal=normal, axis=axis, fields=fields)
+
         if cls is ProjectionPlot:
             normal = cls.sanitize_normal_vector(ds, normal)
             if isinstance(normal, str):
@@ -1767,7 +1801,7 @@ class AxisAlignedSlicePlot(SlicePlot, PWViewerMPL):
     ds : `Dataset`
          This is the dataset object corresponding to the
          simulation output to be plotted.
-    axis : int or one of 'x', 'y', 'z'
+    normal : int or one of 'x', 'y', 'z'
          An int corresponding to the axis to slice along (0=x, 1=y, 2=z)
          or the axis name itself
     fields : string
@@ -1874,8 +1908,8 @@ class AxisAlignedSlicePlot(SlicePlot, PWViewerMPL):
     def __init__(
         self,
         ds,
-        axis,
-        fields,
+        normal=None,
+        fields=None,
         center="c",
         width=None,
         axes_unit=None,
@@ -1889,7 +1923,9 @@ def __init__(
         buff_size=(800, 800),
         *,
         north_vector=None,
+        axis=None,
     ):
+        # TODO: in yt 4.2, remove default values for normal and fields, drop axis kwarg
         if north_vector is not None:
             # this kwarg exists only for symmetry reasons with OffAxisSlicePlot
             mylog.warning(
@@ -1898,8 +1934,14 @@ def __init__(
             )
             del north_vector
 
+        normal = self._validate_init_args(
+            normal=normal,
+            axis=axis,
+            fields=fields,
+        )
+
         # this will handle time series data and controllers
-        axis = fix_axis(axis, ds)
+        axis = fix_axis(normal, ds)
         (bounds, center, display_center) = get_window_parameters(
             axis, center, width, ds
         )
@@ -1962,7 +2004,7 @@ class AxisAlignedProjectionPlot(ProjectionPlot, PWViewerMPL):
     ds : `Dataset`
         This is the dataset object corresponding to the
         simulation output to be plotted.
-    axis : int or one of 'x', 'y', 'z'
+    normal : int or one of 'x', 'y', 'z'
          An int corresponding to the axis to slice along (0=x, 1=y, 2=z)
          or the axis name itself
     fields : string
@@ -2097,8 +2139,8 @@ class AxisAlignedProjectionPlot(ProjectionPlot, PWViewerMPL):
     def __init__(
         self,
         ds,
-        axis,
-        fields,
+        normal=None,
+        fields=None,
         center="c",
         width=None,
         axes_unit=None,
@@ -2113,8 +2155,13 @@ def __init__(
         window_size=8.0,
         buff_size=(800, 800),
         aspect=None,
+        *,
+        axis=None,
     ):
-        axis = fix_axis(axis, ds)
+        # TODO: in yt 4.2, remove default values for normal and fields, drop axis kwarg
+        normal = self._validate_init_args(normal=normal, fields=fields, axis=axis)
+
+        axis = fix_axis(normal, ds)
         if ds.geometry in (
             "spherical",
             "cylindrical",


Test patch:
diff --git a/tests/tests.yaml b/tests/tests.yaml
--- a/tests/tests.yaml
+++ b/tests/tests.yaml
@@ -194,6 +194,7 @@ other_tests:
      - "--ignore-files=test_registration.py"
      - "--ignore-files=test_invalid_origin.py"
      - "--ignore-files=test_outputs_pytest\\.py"
+     - "--ignore-files=test_normal_plot_api\\.py"
      - "--exclude-test=yt.frontends.gdf.tests.test_outputs.TestGDF"
      - "--exclude-test=yt.frontends.adaptahop.tests.test_outputs"
   cookbook:
diff --git a/yt/visualization/tests/test_normal_plot_api.py b/yt/visualization/tests/test_normal_plot_api.py
new file mode 100644
--- /dev/null
+++ b/yt/visualization/tests/test_normal_plot_api.py
@@ -0,0 +1,78 @@
+import pytest
+
+from yt._maintenance.deprecation import VisibleDeprecationWarning
+from yt.testing import fake_amr_ds
+from yt.visualization.plot_window import ProjectionPlot, SlicePlot
+
+
+@pytest.fixture(scope="module")
+def ds():
+    return fake_amr_ds(geometry="cartesian")
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_normalplot_all_positional_args(ds, plot_cls):
+    plot_cls(ds, "z", ("stream", "Density"))
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_normalplot_normal_kwarg(ds, plot_cls):
+
+    plot_cls(ds, normal="z", fields=("stream", "Density"))
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_normalplot_axis_kwarg(ds, plot_cls):
+
+    with pytest.warns(
+        VisibleDeprecationWarning,
+        match=(
+            "Argument 'axis' is a deprecated alias for 'normal'.\n"
+            "Deprecated since v4.1.0. This feature will be removed in v4.2.0"
+        ),
+    ):
+        plot_cls(ds, axis="z", fields=("stream", "Density"))
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_error_with_missing_fields_and_normal(ds, plot_cls):
+
+    with pytest.raises(
+        TypeError,
+        match="missing 2 required positional arguments: 'normal' and 'fields'",
+    ):
+        plot_cls(ds)
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_error_with_missing_fields_with_axis_kwarg(ds, plot_cls):
+
+    with pytest.warns(
+        VisibleDeprecationWarning,
+        match=(
+            "Argument 'axis' is a deprecated alias for 'normal'.\n"
+            "Deprecated since v4.1.0. This feature will be removed in v4.2.0"
+        ),
+    ):
+        with pytest.raises(
+            TypeError, match="missing required positional argument: 'fields'"
+        ):
+            plot_cls(ds, axis="z")
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_error_with_missing_fields_with_normal_kwarg(ds, plot_cls):
+
+    with pytest.raises(
+        TypeError, match="missing required positional argument: 'fields'"
+    ):
+        plot_cls(ds, normal="z")
+
+
+@pytest.mark.parametrize("plot_cls", (SlicePlot, ProjectionPlot))
+def test_error_with_missing_fields_with_positional(ds, plot_cls):
+
+    with pytest.raises(
+        TypeError, match="missing required positional argument: 'fields'"
+    ):
+        plot_cls(ds, "z")