Repository: yt-project/yt

Issue:
BUG: slice plot can not be saved in "pdf" format
<!--To help us understand and resolve your issue, please fill out the form to
the best of your ability.-->
<!--You can feel free to delete the sections that do not apply.-->

### Bug report

**Bug summary**

Slice plot can not be saved in "pdf" format or any other format other than the default "png".

<!--A short 1-2 sentences that succinctly describes the bug-->

**Code for reproduction**

<!--A minimum code snippet required to reproduce the bug, also minimizing the
number of dependencies required.-->

<!-- If you need to use a data file to trigger the issue you're having, consider
using one of the datasets from the yt data hub (http://yt-project.org/data). If
your issue cannot be triggered using a public dataset, you can use the yt
curldrop (https://docs.hub.yt/services.html#curldrop) to share data
files. Please include a link to the dataset in the issue if you use the
curldrop.-->

```python
import numpy as np
import yt

dataRandom = np.random.random([32,32,32])
data       = dict(density=(dataRandom,"Msun/Mpc**3"))

bbox       = [[0,1], [0,1], [0,1]]

ds         = yt.load_uniform_grid(data=data, domain_dimensions=dataRandom.shape, length_unit="Mpc", \
                                  bbox=np.array(bbox))

slc        = yt.SlicePlot(ds, 2, [('gas', 'density')])

slc.save("random.pdf")
```

**Actual outcome**

<!--The output produced by the above code, which may be a screenshot, console
output, etc.-->

```
yt : [WARNING  ] 2021-11-18 22:23:25,995 Received two valid image formats 'pdf' (from `filename`) and 'png' (from `suffix`). The former is ignored.
yt : [INFO     ] 2021-11-18 22:23:25,996 Saving plot random_Slice_z_density.png
```

**Expected outcome**

<!--A description of the expected outcome from the code snippet-->
<!--If this used to work in an earlier version of yt, please note the
version it used to work on-->

I was able to generate "pdf" plots with yt 3.x. But when I update yt to 4.x recently, the issue appears. I did some tests, it seems to be caused by the following function in the file "yt/visualization/_commons.py"

```python
def validate_image_name(filename, suffix: Optional[str] = None) -> str:
    """
    Build a valid image filename with a specified extension (default to png).
    The suffix parameter is ignored if the input filename has a valid extension already.
    Otherwise, suffix is appended to the filename, replacing any existing extension.
    """
    name, psuffix = os.path.splitext(filename)
    psuffix = normalize_extension_string(psuffix)

    if suffix is not None:
        suffix = normalize_extension_string(suffix)

    if psuffix in SUPPORTED_FORMATS:
        if suffix in SUPPORTED_FORMATS and suffix != psuffix:
            mylog.warning(
                "Received two valid image formats '%s' (from `filename`) "
                "and '%s' (from `suffix`). The former is ignored.",
                psuffix,
                suffix,
            )
            return f"{name}.{suffix}"
        return str(filename)

    if suffix is None:
        suffix = "png"

    if suffix not in SUPPORTED_FORMATS:
        raise ValueError(f"Unsupported file format '{suffix}'.")

    return f"{filename}.{suffix}"
```
As can be seen, even if "psuffix" is a supported format, it is always replaced by "suffix". This is inconsistent with the comment at the beginning of the function that the replacement is only done when the specified format is not supported. After changing the condition sentences in the middle of the function to

```python
    if psuffix in SUPPORTED_FORMATS:
        return str(filename)
    else:
        if suffix in SUPPORTED_FORMATS and suffix != psuffix:
            mylog.warning(
                "Received two valid image formats '%s' (from `filename`) "
                "and '%s' (from `suffix`). The former is ignored.",
                psuffix,
                suffix,
            )
            return f"{name}.{suffix}"
```
everything  goes back to normal and I can save the plot in the format I specified.

**Version Information**
<!--Please specify your platform and versions of the relevant libraries you are
using:-->
  * Operating System: Linux 2.6.32-504.30.3.el6.x86_64 x86_64
  * Python Version: 4.1.dev0
  * yt version: 3.8.8
  * Other Libraries (if applicable):

<!--Please tell us how you installed yt and python e.g., from source,
pip, conda. If you installed from conda, please specify which channel you used
if not the default-->



Discussion:
Hi, and welcome to yt!  Thanks for opening your first issue.  We have an issue template that helps us to gather relevant information to help diagnosing and fixing the issue.

Thank you so much for reporting this ! So your patch indeed fixes the bug but it will also bypass the warning in when conflicting arguments are detected. I think the real cause of the bug isn't in this function, but lies in the fact that many `save()` methods have an explicit default value for the `suffix` argument (namely `suffix=".png"`), that we cannot differentiate internally from a user passing this argument explicitly.
So I think the correct patch would be to use a sentinel value for a default and handle it everywhere needed. Beware that it is considerably more work (although not a substantial amount). Since you've started to work on this on your own (thank you _so_ much for that), do you wish to pursue and submit a PR ? It doesn't have to be perfect on first try and I can guide you through the process, otherwise I'd be happy to fix this myself. Please let me know what you'd like more.
Hi @neutrinoceros , thanks for your reply. You are right that the changes I made will also lead to other issues. I would be happy to help submit a PR. Unfortunately, I will not be able to find the time to do this in the near future. So it would be great if you could fix this.
Ok will do, thanks for getting back to me !

Resolving patch:
diff --git a/yt/visualization/_commons.py b/yt/visualization/_commons.py
--- a/yt/visualization/_commons.py
+++ b/yt/visualization/_commons.py
@@ -1,12 +1,11 @@
 import os
 import sys
+import warnings
 from typing import Optional, Type
 
 import matplotlib
 from packaging.version import Version
 
-from yt.utilities.logger import ytLogger as mylog
-
 from ._mpl_imports import (
     FigureCanvasAgg,
     FigureCanvasBase,
@@ -68,11 +67,9 @@ def validate_image_name(filename, suffix: Optional[str] = None) -> str:
 
     if psuffix in SUPPORTED_FORMATS:
         if suffix in SUPPORTED_FORMATS and suffix != psuffix:
-            mylog.warning(
-                "Received two valid image formats '%s' (from `filename`) "
-                "and '%s' (from `suffix`). The former is ignored.",
-                psuffix,
-                suffix,
+            warnings.warn(
+                f"Received two valid image formats {psuffix!r} (from filename) "
+                f"and {suffix!r} (from suffix). The former is ignored."
             )
             return f"{name}.{suffix}"
         return str(filename)
@@ -81,7 +78,7 @@ def validate_image_name(filename, suffix: Optional[str] = None) -> str:
         suffix = "png"
 
     if suffix not in SUPPORTED_FORMATS:
-        raise ValueError(f"Unsupported file format '{suffix}'.")
+        raise ValueError(f"Unsupported file format {suffix!r}")
 
     return f"{filename}.{suffix}"
 
diff --git a/yt/visualization/plot_container.py b/yt/visualization/plot_container.py
--- a/yt/visualization/plot_container.py
+++ b/yt/visualization/plot_container.py
@@ -523,22 +523,22 @@ def set_figure_size(self, size):
     def save(
         self,
         name: Optional[Union[str, List[str], Tuple[str, ...]]] = None,
-        suffix: str = ".png",
+        suffix: Optional[str] = None,
         mpl_kwargs: Optional[Dict[str, Any]] = None,
     ):
         """saves the plot to disk.
 
         Parameters
         ----------
-        name : string or tuple
+        name : string or tuple, optional
            The base of the filename. If name is a directory or if name is not
            set, the filename of the dataset is used. For a tuple, the
            resulting path will be given by joining the elements of the
            tuple
-        suffix : string
+        suffix : string, optional
            Specify the image type by its suffix. If not specified, the output
-           type will be inferred from the filename. Defaults to PNG.
-        mpl_kwargs : dict
+           type will be inferred from the filename. Defaults to '.png'.
+        mpl_kwargs : dict, optional
            A dict of keyword arguments to be passed to matplotlib.
 
         >>> slc.save(mpl_kwargs={"bbox_inches": "tight"})
diff --git a/yt/visualization/profile_plotter.py b/yt/visualization/profile_plotter.py
--- a/yt/visualization/profile_plotter.py
+++ b/yt/visualization/profile_plotter.py
@@ -3,6 +3,7 @@
 import os
 from collections import OrderedDict
 from functools import wraps
+from typing import Any, Dict, Optional
 
 import matplotlib
 import numpy as np
@@ -270,45 +271,53 @@ def __init__(
         ProfilePlot._initialize_instance(self, profiles, label, plot_spec, y_log)
 
     @validate_plot
-    def save(self, name=None, suffix=".png", mpl_kwargs=None):
+    def save(
+        self,
+        name: Optional[str] = None,
+        suffix: Optional[str] = None,
+        mpl_kwargs: Optional[Dict[str, Any]] = None,
+    ):
         r"""
         Saves a 1d profile plot.
 
         Parameters
         ----------
-        name : str
+        name : str, optional
             The output file keyword.
-        suffix : string
+        suffix : string, optional
             Specify the image type by its suffix. If not specified, the output
-            type will be inferred from the filename. Defaults to PNG.
-        mpl_kwargs : dict
+            type will be inferred from the filename. Defaults to '.png'.
+        mpl_kwargs : dict, optional
             A dict of keyword arguments to be passed to matplotlib.
         """
         if not self._plot_valid:
             self._setup_plots()
-        unique = set(self.plots.values())
-        if len(unique) < len(self.plots):
+
+        # Mypy is hardly convinced that we have a `plots` and a `profile` attr
+        # at this stage, so we're lasily going to deactivate it locally
+        unique = set(self.plots.values())  # type: ignore
+        if len(unique) < len(self.plots):  # type: ignore
             iters = zip(range(len(unique)), sorted(unique))
         else:
-            iters = self.plots.items()
+            iters = self.plots.items()  # type: ignore
 
         if name is None:
-            if len(self.profiles) == 1:
-                name = str(self.profiles[0].ds)
+            if len(self.profiles) == 1:  # type: ignore
+                name = str(self.profiles[0].ds)  # type: ignore
             else:
                 name = "Multi-data"
 
         name = validate_image_name(name, suffix)
         prefix, suffix = os.path.splitext(name)
 
-        xfn = self.profiles[0].x_field
+        xfn = self.profiles[0].x_field  # type: ignore
         if isinstance(xfn, tuple):
             xfn = xfn[1]
 
         names = []
         for uid, plot in iters:
-            if isinstance(uid, tuple):
-                uid = uid[1]
+            if isinstance(uid, tuple):  # type: ignore
+                uid = uid[1]  # type: ignore
             uid_name = f"{prefix}_1d-Profile_{xfn}_{uid}{suffix}"
             names.append(uid_name)
             mylog.info("Saving %s", uid_name)
@@ -1282,18 +1291,20 @@ def annotate_text(self, xpos=0.0, ypos=0.0, text=None, **text_kwargs):
         return self
 
     @validate_plot
-    def save(self, name=None, suffix=".png", mpl_kwargs=None):
+    def save(
+        self, name: Optional[str] = None, suffix: Optional[str] = None, mpl_kwargs=None
+    ):
         r"""
         Saves a 2d profile plot.
 
         Parameters
         ----------
-        name : str
+        name : str, optional
             The output file keyword.
-        suffix : string
+        suffix : string, optional
            Specify the image type by its suffix. If not specified, the output
-           type will be inferred from the filename. Defaults to PNG.
-        mpl_kwargs : dict
+           type will be inferred from the filename. Defaults to '.png'.
+        mpl_kwargs : dict, optional
            A dict of keyword arguments to be passed to matplotlib.
 
         >>> plot.save(mpl_kwargs={"bbox_inches": "tight"})


Test patch:
diff --git a/yt/visualization/tests/test_commons.py b/yt/visualization/tests/test_commons.py
--- a/yt/visualization/tests/test_commons.py
+++ b/yt/visualization/tests/test_commons.py
@@ -21,18 +21,27 @@ def test_default(name, expected):
     "name, suffix, expected",
     [
         ("noext", ".png", "noext.png"),
+        ("noext", None, "noext.png"),
         ("nothing.png", ".png", "nothing.png"),
+        ("nothing.png", None, "nothing.png"),
         ("nothing.png", ".pdf", "nothing.pdf"),
         ("nothing.pdf", ".pdf", "nothing.pdf"),
+        ("nothing.pdf", None, "nothing.pdf"),
         ("nothing.pdf", ".png", "nothing.png"),
         ("version.1.2.3", ".png", "version.1.2.3.png"),
+        ("version.1.2.3", None, "version.1.2.3.png"),
         ("version.1.2.3", ".pdf", "version.1.2.3.pdf"),
     ],
 )
+@pytest.mark.filterwarnings(
+    r"ignore:Received two valid image formats '\w+' \(from filename\) "
+    r"and '\w+' \(from suffix\). The former is ignored.:UserWarning"
+)
 def test_custom_valid_ext(name, suffix, expected):
-    alt_suffix = suffix.replace(".", "")
     result1 = validate_image_name(name, suffix=suffix)
-    result2 = validate_image_name(name, suffix=alt_suffix)
-
     assert result1 == expected
-    assert result2 == expected
+
+    if suffix is not None:
+        alt_suffix = suffix.replace(".", "")
+        result2 = validate_image_name(name, suffix=alt_suffix)
+        assert result2 == expected
diff --git a/yt/visualization/tests/test_save.py b/yt/visualization/tests/test_save.py
--- a/yt/visualization/tests/test_save.py
+++ b/yt/visualization/tests/test_save.py
@@ -1,19 +1,30 @@
 import os
+import re
+
+import pytest
 
 import yt
 
+# avoid testing every supported format as some backends may be buggy on testing platforms
+FORMATS_TO_TEST = [".eps", ".pdf", ".svg"]
+
 
-def test_save_to_path(tmp_path):
+@pytest.fixture(scope="session")
+def simple_sliceplot():
     ds = yt.testing.fake_amr_ds()
     p = yt.SlicePlot(ds, "z", "Density")
+    yield p
+
+
+def test_save_to_path(simple_sliceplot, tmp_path):
+    p = simple_sliceplot
     p.save(f"{tmp_path}/")
     assert len(list((tmp_path).glob("*.png"))) == 1
 
 
-def test_save_to_missing_path(tmp_path):
+def test_save_to_missing_path(simple_sliceplot, tmp_path):
     # the missing layer should be created
-    ds = yt.testing.fake_amr_ds()
-    p = yt.SlicePlot(ds, "z", "Density")
+    p = simple_sliceplot
 
     # using forward slashes should work even on windows !
     save_path = os.path.join(tmp_path / "out") + "/"
@@ -22,13 +33,65 @@ def test_save_to_missing_path(tmp_path):
     assert len(list((tmp_path / "out").glob("*.png"))) == 1
 
 
-def test_save_to_missing_path_with_file_prefix(tmp_path):
+def test_save_to_missing_path_with_file_prefix(simple_sliceplot, tmp_path):
     # see issue
     # https://github.com/yt-project/yt/issues/3210
-    ds = yt.testing.fake_amr_ds()
-    p = yt.SlicePlot(ds, "z", "Density")
+    p = simple_sliceplot
     p.save(tmp_path.joinpath("out", "saymyname"))
     assert (tmp_path / "out").exists()
     output_files = list((tmp_path / "out").glob("*.png"))
     assert len(output_files) == 1
     assert output_files[0].stem.startswith("saymyname")  # you're goddamn right
+
+
+@pytest.mark.parametrize("ext", FORMATS_TO_TEST)
+def test_suffix_from_filename(ext, simple_sliceplot, tmp_path):
+    p = simple_sliceplot
+
+    target = (tmp_path / "myfile").with_suffix(ext)
+    # this shouldn't raise a warning, see issue
+    # https://github.com/yt-project/yt/issues/3667
+    p.save(target)
+    assert target.is_file()
+
+
+@pytest.mark.parametrize("ext", FORMATS_TO_TEST)
+def test_suffix_clashing(ext, simple_sliceplot, tmp_path):
+    if ext == ".png":
+        pytest.skip()
+
+    p = simple_sliceplot
+
+    target = (tmp_path / "myfile").with_suffix(ext)
+    expected_warning = re.compile(
+        r"Received two valid image formats '%s' \(from filename\) "
+        r"and 'png' \(from suffix\)\. The former is ignored\." % ext[1:]
+    )
+
+    with pytest.warns(UserWarning, match=expected_warning):
+        p.save(target, suffix="png")
+    output_files = list(tmp_path.glob("*.png"))
+    assert len(output_files) == 1
+    assert output_files[0].stem.startswith("myfile")
+    assert not list((tmp_path / "out").glob(f"*.{ext}"))
+
+
+def test_invalid_format_from_filename(simple_sliceplot, tmp_path):
+    p = simple_sliceplot
+    target = (tmp_path / "myfile").with_suffix(".nope")
+
+    p.save(target)
+    output_files = list(tmp_path.glob("*"))
+    assert len(output_files) == 1
+    # the output filename may contain a generated part
+    # it's not exactly clear if it's desirable or intented in this case
+    # so we just check conditions that should hold in any case
+    assert output_files[0].name.startswith("myfile.nope")
+    assert output_files[0].name.endswith(".png")
+
+
+def test_invalid_format_from_suffix(simple_sliceplot, tmp_path):
+    p = simple_sliceplot
+    target = tmp_path / "myfile"
+    with pytest.raises(ValueError, match=r"Unsupported file format 'nope'"):
+        p.save(target, suffix="nope")