Repository: matplotlib/matplotlib

Issue:
[Bug]: String `contour(colors)` gives confusing error when `extend` used
### Bug summary

`contour` (and friends) specify that a single string can be passed to `colors`:

> As a shortcut, single color strings may be used in place of one-element lists

But this only works when `levels` is the same length as the color string.

### Code for reproduction

```Python
import numpy as np
import matplotlib.pyplot as plt

x = np.arange(1, 10)
y = x.reshape(-1, 1)
h = x * y

cs = plt.contour(h, levels=[10, 30, 50],
    colors='green', extend='both')
```


### Actual outcome

`colors='green'` gives error (while `colors='red'` works, or making `levels` to be length of 5 also works):
<details>

```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[64], line 8
      5 y = x.reshape(-1, 1)
      6 h = x * y
----> 8 cs = plt.contour(h, levels=[10, 30, 50],
      9     colors='green', extend='both')

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/pyplot.py:3143, in contour(data, *args, **kwargs)
   3141 @_copy_docstring_and_deprecators(Axes.contour)
   3142 def contour(*args, data=None, **kwargs) -> QuadContourSet:
-> 3143     __ret = gca().contour(
   3144         *args, **({"data": data} if data is not None else {}), **kwargs
   3145     )
   3146     if __ret._A is not None:  # type: ignore[attr-defined]
   3147         sci(__ret)

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/__init__.py:1473, in _preprocess_data.<locals>.inner(ax, data, *args, **kwargs)
   1470 @functools.wraps(func)
   1471 def inner(ax, *args, data=None, **kwargs):
   1472     if data is None:
-> 1473         return func(
   1474             ax,
   1475             *map(sanitize_sequence, args),
   1476             **{k: sanitize_sequence(v) for k, v in kwargs.items()})
   1478     bound = new_sig.bind(ax, *args, **kwargs)
   1479     auto_label = (bound.arguments.get(label_namer)
   1480                   or bound.kwargs.get(label_namer))

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/axes/_axes.py:6659, in Axes.contour(self, *args, **kwargs)
   6650 """
   6651 Plot contour lines.
   6652 
   (...)
   6656 %(contour_doc)s
   6657 """
   6658 kwargs['filled'] = False
-> 6659 contours = mcontour.QuadContourSet(self, *args, **kwargs)
   6660 self._request_autoscale_view()
   6661 return contours

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/contour.py:847, in ContourSet.__init__(self, ax, levels, filled, linewidths, linestyles, hatches, alpha, origin, extent, cmap, colors, norm, vmin, vmax, extend, antialiased, nchunk, locator, transform, negative_linestyles, clip_path, *args, **kwargs)
    845             cmap.set_under(self.colors[0])
    846         if self._extend_max:
--> 847             cmap.set_over(self.colors[-1])
    849 # label lists must be initialized here
    850 self.labelTexts = []

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/colors.py:834, in Colormap.set_over(self, color, alpha)
    832 def set_over(self, color='k', alpha=None):
    833     """Set the color for high out-of-range values."""
--> 834     self._rgba_over = to_rgba(color, alpha)
    835     if self._isinit:
    836         self._set_extremes()

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/colors.py:314, in to_rgba(c, alpha)
    312     rgba = None
    313 if rgba is None:  # Suppress exception chaining of cache lookup failure.
--> 314     rgba = _to_rgba_no_colorcycle(c, alpha)
    315     try:
    316         _colors_full_map.cache[c, alpha] = rgba

File ~/miniconda3/envs/sklearn-env/lib/python3.12/site-packages/matplotlib/colors.py:391, in _to_rgba_no_colorcycle(c, alpha)
    387             raise ValueError(
    388                 f"Invalid string grayscale value {orig_c!r}. "
    389                 f"Value must be within 0-1 range")
    390         return c, c, c, alpha if alpha is not None else 1.
--> 391     raise ValueError(f"Invalid RGBA argument: {orig_c!r}")
    392 # turn 2-D array into 1-D array
    393 if isinstance(c, np.ndarray):

ValueError: Invalid RGBA argument: 'n'
```
</details>

### Expected outcome

Does not depend on color string length.

### Additional information

I think it is because we don't convert to list if `colors` is given as string, and then we do this:

https://github.com/matplotlib/matplotlib/blob/ef32d223eac1b603ad5e9e328efcc774b81f8745/lib/matplotlib/contour.py#L835-L836

### Operating system

Ubuntu

### Matplotlib Version

3.9.2

### Matplotlib Backend

_No response_

### Python version

_No response_

### Jupyter version

_No response_

### Installation

None


Discussion:
Thanks for the report @lucyleeow!  I have reproduced this with our `main` development branch and back to v3.6.
Actually, now I look again, this seems to only be an issue if using `contour` with the _extend_ keyword.  If I omit _extend_, all is well.  If I use `contourf` instead, the plot works (although a completely green axes is not very useful).

_extend_ is not meaningful with `contour`, so I think we should either raise a more informative error here, or have `contour` throw away this parameter.
Ah yes you're right. I think I was confused because it seems that `contour` and `contourf` seem to share the same parameters in the [documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.contour.html#matplotlib-pyplot-contour), and I just copied the example from the `contour` page which happened to be for `extend` and use `contourf`.

Maybe `contour` could ignore `extend` but you give a warning to tell users that `contour` does not take `extend` (adn was thus ignored)?
Some improvement to the docs would also be nice, maybe you could make it clearer that `extend` is only for `contourf` (like what has been done for the `linewidths` parameter, which specifies in italics on the first line that it only applies to `contour`?). I think ideally `contour` and `contourf` would only include parameters relevant to each function in the docs but I can understand that this may be too difficult to achieve.  
It's pretty hard to see what these two things have to do with each other.  We definitely should clean this up 
I have now learned that we can't just ignore _extend_ for `contour` because that has implications for colorbars.  Specifically, I broke two image tests.

https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/tests/baseline_images/test_colorbar/contour_colorbar.png
https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/tests/baseline_images/test_contour/contour_manual_colors_and_levels.png

So I guess we do need to fix single-colour support?
That is a nutty use of a colorbar, but I think we do need to keep supporting it.  

Isn't it the case that a single string will always be a colorspec?  We aren't going to allow a spec like 'rgbyk' to represent ~three~ five colors are we?  Or is that what it did before?  If so, that is a terrible API.


I agree: 'rgbyk' should not be a valid color spec.



Patch:
diff --git a/doc/users/next_whats_new/contour_color.rst b/doc/users/next_whats_new/contour_color.rst
new file mode 100644
index 000000000000..1f7a326ec2b5
--- /dev/null
+++ b/doc/users/next_whats_new/contour_color.rst
@@ -0,0 +1,21 @@
+Specifying a single color in ``contour`` and ``contourf``
+---------------------------------------------------------
+
+`~.Axes.contour` and `~.Axes.contourf` previously accepted a single color
+provided it was expressed as a string.  This restriction has now been removed
+and a single color in any format described in the :ref:`colors_def` tutorial
+may be passed.
+
+.. plot::
+    :include-source: true
+    :alt: Two-panel example contour plots.  The left panel has all transparent red contours.  The right panel has all dark blue contours.
+
+    import matplotlib.pyplot as plt
+
+    fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(6, 3))
+    z = [[0, 1], [1, 2]]
+
+    ax1.contour(z, colors=('r', 0.4))
+    ax2.contour(z, colors=(0.1, 0.2, 0.5))
+
+    plt.show()
diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py
index 503ec2747f10..e2b857a57a7f 100644
--- a/lib/matplotlib/contour.py
+++ b/lib/matplotlib/contour.py
@@ -702,6 +702,11 @@ def __init__(self, ax, *args,
         self._extend_min = self.extend in ['min', 'both']
         self._extend_max = self.extend in ['max', 'both']
         if self.colors is not None:
+            if mcolors.is_color_like(self.colors):
+                color_sequence = [self.colors]
+            else:
+                color_sequence = self.colors
+
             ncolors = len(self.levels)
             if self.filled:
                 ncolors -= 1
@@ -718,19 +723,19 @@ def __init__(self, ax, *args,
             total_levels = (ncolors +
                             int(self._extend_min) +
                             int(self._extend_max))
-            if (len(self.colors) == total_levels and
+            if (len(color_sequence) == total_levels and
                     (self._extend_min or self._extend_max)):
                 use_set_under_over = True
                 if self._extend_min:
                     i0 = 1
 
-            cmap = mcolors.ListedColormap(self.colors[i0:None], N=ncolors)
+            cmap = mcolors.ListedColormap(color_sequence[i0:None], N=ncolors)
 
             if use_set_under_over:
                 if self._extend_min:
-                    cmap.set_under(self.colors[0])
+                    cmap.set_under(color_sequence[0])
                 if self._extend_max:
-                    cmap.set_over(self.colors[-1])
+                    cmap.set_over(color_sequence[-1])
 
         # label lists must be initialized here
         self.labelTexts = []
@@ -1498,10 +1503,12 @@ def _initialize_x_y(self, z):
     The sequence is cycled for the levels in ascending order. If the
     sequence is shorter than the number of levels, it's repeated.
 
-    As a shortcut, single color strings may be used in place of
-    one-element lists, i.e. ``'red'`` instead of ``['red']`` to color
-    all levels with the same color. This shortcut does only work for
-    color strings, not for other ways of specifying colors.
+    As a shortcut, a single color may be used in place of one-element lists, i.e.
+    ``'red'`` instead of ``['red']`` to color all levels with the same color.
+
+    .. versionchanged:: 3.10
+        Previously a single color had to be expressed as a string, but now any
+        valid color format may be passed.
 
     By default (value *None*), the colormap specified by *cmap*
     will be used.
@@ -1569,10 +1576,10 @@ def _initialize_x_y(self, z):
 
         An existing `.QuadContourSet` does not get notified if
         properties of its colormap are changed. Therefore, an explicit
-        call `.QuadContourSet.changed()` is needed after modifying the
+        call ``QuadContourSet.changed()`` is needed after modifying the
         colormap. The explicit call can be left out, if a colorbar is
         assigned to the `.QuadContourSet` because it internally calls
-        `.QuadContourSet.changed()`.
+        ``QuadContourSet.changed()``.
 
     Example::
 


Test patch:
diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py
index 6211b2d8418b..8ccff360a51a 100644
--- a/lib/matplotlib/tests/test_contour.py
+++ b/lib/matplotlib/tests/test_contour.py
@@ -171,6 +171,20 @@ def test_given_colors_levels_and_extends():
         plt.colorbar(c, ax=ax)
 
 
+@pytest.mark.parametrize('color, extend', [('darkred', 'neither'),
+                                           ('darkred', 'both'),
+                                           (('r', 0.5), 'neither'),
+                                           ((0.1, 0.2, 0.5, 0.3), 'neither')])
+def test_single_color_and_extend(color, extend):
+    z = [[0, 1], [1, 2]]
+
+    _, ax = plt.subplots()
+    levels = [0.5, 0.75, 1, 1.25, 1.5]
+    cs = ax.contour(z, levels=levels, colors=color, extend=extend)
+    for c in cs.get_edgecolors():
+        assert same_color(c, color)
+
+
 @image_comparison(['contour_log_locator.svg'], style='mpl20', remove_text=False)
 def test_log_locator_levels():