Repository: matplotlib/matplotlib
Pull request: ENH: Change logging to warning when creating a legend with no labels
Fixes #27145

Also add test_legend_nolabels_warning()

## PR summary
After discussion in the linked issue, it was determined that `warnings.warn()` was better than `log.warning()` for alerting users that we can't draw a legend if there are no labels.  This PR implements the change from `log.warning()` to `warnings.warn` (actually, `_api.warn_external()`)
 
## PR checklist
<!-- Please mark any checkboxes that do not apply to this PR as [N/A].-->

- [X] "closes #0000" is in the body of the PR description to [link the related issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [X] new and changed code is [tested](https://matplotlib.org/devdocs/devel/testing.html)
- [X] *Plotting related* features are demonstrated in an [example](https://matplotlib.org/devdocs/devel/document.html#write-examples-and-tutorials)
- [ ] *New Features* and *API Changes* are noted with a [directive and release note](https://matplotlib.org/devdocs/devel/coding_guide.html#new-features-and-api-changes)
- [x] Documentation complies with [general](https://matplotlib.org/devdocs/devel/document.html#write-rest-pages) and [docstring](https://matplotlib.org/devdocs/devel/document.html#write-docstrings) guidelines

<!--We understand that PRs can sometimes be overwhelming, especially as the
reviews start coming in.  Please let us know if the reviews are unclear or
the recommended next step seems overly demanding, if you would like help in
addressing a reviewer's comments, or if you have been waiting too long to hear
back on your PR.-->


Problem statement:
[ENH]: Make "No artists with labels found to put in legend" a warning
### Problem

When I receive this issue, it's hard to debug and find the specific call to `ax.legend()`  if I can't raise Warnings to Exceptions.  I sometimes nest plotting functions in order to draw the same plot different ways or arrange plots in a composite figure, depending upon the presentation.

MWE and Full message:

```python
from matplotlib import pyplot as plt
import warnings

warnings.simplefilter("error", category=Warning)
plt.plot([1,2,3])
plt.legend()
```
(no error, plot appears)
> No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.

### Proposed solution

Make this a true `UserWarning`.  Currently, it's a `logging.LogRecord` of level=`logging.Warning`.  
```python
log = logging.getLogger(__name__)
...
log.warning(<this message>)
```
**Additional pro:** In the future, it would be easier to find and work on this warning if it is, in fact, a true `Warning`.

**Con:** It won't be handled by the logging infrastructure.  But legend.py uses the default module logger, and the only `LogRecord` that it emits is this one.  The only situation I could think of where this might matter is if a user has some override of global logging setup (by calling `logging.setLoggerClass()`) but does not apply a similar philosophy to capturing warnings in STDERR.

Hints from issue discussion:
This is related to #23422 though this one is a little more ambiguous if it should actually be a warning or not as the user _can_ suppress the warning by either adding a label or removing the legend call.  
Thanks for the link :)  My issue is definitely an example of @mwaskom's point that "you can convert a warning to an error and then drop into a debugger to figure out what's happening"


--------------------------------------
Also, I'm not sure I understand how its ambiguous?  Your statement:

> the user can suppress the warning by either adding a label or removing the legend call.

sounds like the standard, "the issue is avoidable and the client application should be modified to eliminate the warning."  I can't think of a situation where a user would legitimately intend to call `.legend()` without labels.  Matplotlib won't draw a legend when it triggers the warning.
I agree, but I don't remember the original rationale for a logging call versus warning, and the rule itself is a bit ambiguous. 
TL;DR:  logging was added to this module at a time when you were making the package-wide effort of replacing the old `Verbose` class with python's standard `logging`.  I _think_ adding this particular logging case (and perhaps others) was consumed in effort, and the `warnings` vs `logging` choice wasn't given serious discussion in the original planning & review.

Doing a bit of `git blame` investigation, it looks like this case was added in #9324, which sought to fix #9320, a regression in 2.1 where it seems labels/handles behaved differently as kwargs vs positional args.  The PR included code to warn about missing handles, incl the case of a leading underscore in a label.  Early in the PR you were using `warnings.warn`, but mentioned 

> I’d suggest we just strip the illegal underscore and warn (or logging.warning 😉) or set it to an empty string.

That was the extent of discussion I could find about logging vs warning.  Another commit later amended the message (#20956) but didn't touch on logging vs warning.

A pickaxe search of the git log for "logging" around the same time finds another commit (844877c141) & PR (#9313) that address a discussion (#9302) about replacing matplotlib's old `Verbose` class with python's standard logging.  A grep search of the documentation diff explains it's better to use logging than `print()` and links to the logging tutorial's distinction between `warnings` and `logging`, but stops there.  The issue discussion never addresses the difference between `warning` and `logging` modules, but does note that the old `Verbose` class did not have equivalents to `logging.WARNING`, `logging.ERROR`, or `logging.CRITICAL`. ([comment](https://github.com/matplotlib/matplotlib/issues/9302#issuecomment-334904619))
Thanks for the archeology 🚀! I conclude that the current state has evolved organically and there has probably not been a conscious decision between logging and warnings for this specific case.

Either way,

> I can't think of a situation where a user would legitimately intend to call .legend() without labels.

plus

> the user can suppress the warning by either adding a label or removing the legend call.

is a clear indication for me that this should be a `UserWarning`.
@Jacob-Stevens-Haas Did you have bandwidth to put in a PR to change this?  
I think so!  Got the dev environment/install up and working, and all tests (at least all relevant tests) pass, so I can start a PR.
Am I supposed to add a file to [matplotlib/doc/users/next_whats_new](https://github.com/matplotlib/matplotlib/tree/main/doc/users/next_whats_new) for this kind of change?

Also, should I keep the default module logger definition
`log = logging.getLogger(__name__)`?  It no longer is accessed.

Commit message: TST: Add test for drawing empty legend()

Patch to review:
diff --git a/galleries/examples/text_labels_and_annotations/custom_legends.py b/galleries/examples/text_labels_and_annotations/custom_legends.py
index f1714a21a481..18ace0513228 100644
--- a/galleries/examples/text_labels_and_annotations/custom_legends.py
+++ b/galleries/examples/text_labels_and_annotations/custom_legends.py
@@ -28,6 +28,7 @@
 # Fixing random state for reproducibility
 np.random.seed(19680801)
 
+# %%
 N = 10
 data = (np.geomspace(1, 10, 100) + np.random.randn(N, 100)).T
 cmap = plt.cm.coolwarm
@@ -35,10 +36,10 @@
 
 fig, ax = plt.subplots()
 lines = ax.plot(data)
-ax.legend()
 
 # %%
-# Note that no legend entries were created.
+# Since the data does not have any labels, creating a legend requires
+# us to define the icons and labels.
 # In this case, we can compose a legend using Matplotlib objects that aren't
 # explicitly tied to the data that was plotted. For example:
 
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index 0fcabac8c7c0..b6935ddbac0a 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -239,8 +239,8 @@ def legend(self, *args, **kwargs):
             selection by using a label starting with an underscore, "_".
             A string starting with an underscore is the default label for all
             artists, so calling `.Axes.legend` without any arguments and
-            without setting the labels manually will result in no legend being
-            drawn.
+            without setting the labels manually will result in a ``UserWarning``
+            and no legend being drawn.
 
 
         **2. Explicitly listing the artists and labels in the legend**
diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py
index 58e5dfcfe3b8..fa43a31d7c17 100644
--- a/lib/matplotlib/legend.py
+++ b/lib/matplotlib/legend.py
@@ -1358,7 +1358,7 @@ def _parse_legend_args(axs, *args, handles=None, labels=None, **kwargs):
     elif len(args) == 0:  # 0 args: automatically detect labels and handles.
         handles, labels = _get_legend_handles_labels(axs, handlers)
         if not handles:
-            log.warning(
+            _api.warn_external(
                 "No artists with labels found to put in legend.  Note that "
                 "artists whose label start with an underscore are ignored "
                 "when legend() is called with no argument.")
diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index 3a35b4051c71..422c85b37ba3 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -636,6 +636,7 @@ def test_handler_numpoints():
 def test_text_nohandler_warning():
     """Test that Text artists with labels raise a warning"""
     fig, ax = plt.subplots()
+    ax.plot([0], label="mock data")
     ax.text(x=0, y=0, s="text", label="label")
     with pytest.warns(UserWarning) as record:
         ax.legend()
@@ -703,7 +704,7 @@ def test_legend_title_empty():
     # it comes back as an empty string, and that it is not
     # visible:
     fig, ax = plt.subplots()
-    ax.plot(range(10))
+    ax.plot(range(10), label="mock data")
     leg = ax.legend()
     assert leg.get_title().get_text() == ""
     assert not leg.get_title().get_visible()
@@ -736,7 +737,7 @@ def test_window_extent_cached_renderer():
 
 def test_legend_title_fontprop_fontsize():
     # test the title_fontsize kwarg
-    plt.plot(range(10))
+    plt.plot(range(10), label="mock data")
     with pytest.raises(ValueError):
         plt.legend(title='Aardvark', title_fontsize=22,
                    title_fontproperties={'family': 'serif', 'size': 22})
@@ -747,27 +748,27 @@ def test_legend_title_fontprop_fontsize():
 
     fig, axes = plt.subplots(2, 3, figsize=(10, 6))
     axes = axes.flat
-    axes[0].plot(range(10))
+    axes[0].plot(range(10), label="mock data")
     leg0 = axes[0].legend(title='Aardvark', title_fontsize=22)
     assert leg0.get_title().get_fontsize() == 22
-    axes[1].plot(range(10))
+    axes[1].plot(range(10), label="mock data")
     leg1 = axes[1].legend(title='Aardvark',
                           title_fontproperties={'family': 'serif', 'size': 22})
     assert leg1.get_title().get_fontsize() == 22
-    axes[2].plot(range(10))
+    axes[2].plot(range(10), label="mock data")
     mpl.rcParams['legend.title_fontsize'] = None
     leg2 = axes[2].legend(title='Aardvark',
                           title_fontproperties={'family': 'serif'})
     assert leg2.get_title().get_fontsize() == mpl.rcParams['font.size']
-    axes[3].plot(range(10))
+    axes[3].plot(range(10), label="mock data")
     leg3 = axes[3].legend(title='Aardvark')
     assert leg3.get_title().get_fontsize() == mpl.rcParams['font.size']
-    axes[4].plot(range(10))
+    axes[4].plot(range(10), label="mock data")
     mpl.rcParams['legend.title_fontsize'] = 20
     leg4 = axes[4].legend(title='Aardvark',
                           title_fontproperties={'family': 'serif'})
     assert leg4.get_title().get_fontsize() == 20
-    axes[5].plot(range(10))
+    axes[5].plot(range(10), label="mock data")
     leg5 = axes[5].legend(title='Aardvark')
     assert leg5.get_title().get_fontsize() == 20
 
@@ -1072,7 +1073,7 @@ def test_legend_labelcolor_rcparam_markerfacecolor_short():
 
 
 def test_get_set_draggable():
-    legend = plt.legend()
+    legend = plt.legend(labels=["mock data"])
     assert not legend.get_draggable()
     legend.set_draggable(True)
     assert legend.get_draggable()
@@ -1289,74 +1290,75 @@ def test_loc_invalid_tuple_exception():
     fig, ax = plt.subplots()
     with pytest.raises(ValueError, match=('loc must be string, coordinate '
                        'tuple, or an integer 0-10, not \\(1.1,\\)')):
-        ax.legend(loc=(1.1, ))
+        ax.legend(loc=(1.1, ), labels=["mock data"])
 
     with pytest.raises(ValueError, match=('loc must be string, coordinate '
                        'tuple, or an integer 0-10, not \\(0.481, 0.4227, 0.4523\\)')):
-        ax.legend(loc=(0.481, 0.4227, 0.4523))
+        ax.legend(loc=(0.481, 0.4227, 0.4523), labels=["mock data"])
 
     with pytest.raises(ValueError, match=('loc must be string, coordinate '
                        'tuple, or an integer 0-10, not \\(0.481, \'go blue\'\\)')):
-        ax.legend(loc=(0.481, "go blue"))
+        ax.legend(loc=(0.481, "go blue"), labels=["mock data"])
 
 
 def test_loc_valid_tuple():
     fig, ax = plt.subplots()
-    ax.legend(loc=(0.481, 0.442))
-    ax.legend(loc=(1, 2))
+    ax.legend(loc=(0.481, 0.442), labels=["mock data"])
+    ax.legend(loc=(1, 2), labels=["mock data"])
 
 
 def test_loc_valid_list():
     fig, ax = plt.subplots()
-    ax.legend(loc=[0.481, 0.442])
-    ax.legend(loc=[1, 2])
+    ax.legend(loc=[0.481, 0.442], labels=["mock data"])
+    ax.legend(loc=[1, 2], labels=["mock data"])
 
 
 def test_loc_invalid_list_exception():
     fig, ax = plt.subplots()
     with pytest.raises(ValueError, match=('loc must be string, coordinate '
                        'tuple, or an integer 0-10, not \\[1.1, 2.2, 3.3\\]')):
-        ax.legend(loc=[1.1, 2.2, 3.3])
+        ax.legend(loc=[1.1, 2.2, 3.3], labels=["mock data"])
 
 
 def test_loc_invalid_type():
     fig, ax = plt.subplots()
     with pytest.raises(ValueError, match=("loc must be string, coordinate "
                        "tuple, or an integer 0-10, not {'not': True}")):
-        ax.legend(loc={'not': True})
+        ax.legend(loc={'not': True}, labels=["mock data"])
 
 
 def test_loc_validation_numeric_value():
     fig, ax = plt.subplots()
-    ax.legend(loc=0)
-    ax.legend(loc=1)
-    ax.legend(loc=5)
-    ax.legend(loc=10)
+    ax.legend(loc=0, labels=["mock data"])
+    ax.legend(loc=1, labels=["mock data"])
+    ax.legend(loc=5, labels=["mock data"])
+    ax.legend(loc=10, labels=["mock data"])
     with pytest.raises(ValueError, match=('loc must be string, coordinate '
                        'tuple, or an integer 0-10, not 11')):
-        ax.legend(loc=11)
+        ax.legend(loc=11, labels=["mock data"])
 
     with pytest.raises(ValueError, match=('loc must be string, coordinate '
                        'tuple, or an integer 0-10, not -1')):
-        ax.legend(loc=-1)
+        ax.legend(loc=-1, labels=["mock data"])
 
 
 def test_loc_validation_string_value():
     fig, ax = plt.subplots()
-    ax.legend(loc='best')
-    ax.legend(loc='upper right')
-    ax.legend(loc='best')
-    ax.legend(loc='upper right')
-    ax.legend(loc='upper left')
-    ax.legend(loc='lower left')
-    ax.legend(loc='lower right')
-    ax.legend(loc='right')
-    ax.legend(loc='center left')
-    ax.legend(loc='center right')
-    ax.legend(loc='lower center')
-    ax.legend(loc='upper center')
+    labels = ["mock data"]
+    ax.legend(loc='best', labels=labels)
+    ax.legend(loc='upper right', labels=labels)
+    ax.legend(loc='best', labels=labels)
+    ax.legend(loc='upper right', labels=labels)
+    ax.legend(loc='upper left', labels=labels)
+    ax.legend(loc='lower left', labels=labels)
+    ax.legend(loc='lower right', labels=labels)
+    ax.legend(loc='right', labels=labels)
+    ax.legend(loc='center left', labels=labels)
+    ax.legend(loc='center right', labels=labels)
+    ax.legend(loc='lower center', labels=labels)
+    ax.legend(loc='upper center', labels=labels)
     with pytest.raises(ValueError, match="'wrong' is not a valid value for"):
-        ax.legend(loc='wrong')
+        ax.legend(loc='wrong', labels=labels)
 
 
 def test_legend_handle_label_mismatch():
@@ -1375,3 +1377,16 @@ def test_legend_handle_label_mismatch_no_len():
                         labels=iter(["pl1", "pl2", "pl3"]))
     assert len(legend.legend_handles) == 2
     assert len(legend.get_texts()) == 2
+
+
+def test_legend_nolabels_warning():
+    plt.plot([1, 2, 3])
+    with pytest.raises(UserWarning):
+        plt.legend()
+
+
+def test_legend_nolabels_draw():
+    plt.plot([1, 2, 3])
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        plt.legend()
diff --git a/lib/matplotlib/tests/test_pickle.py b/lib/matplotlib/tests/test_pickle.py
index e222a495e437..cab412bff561 100644
--- a/lib/matplotlib/tests/test_pickle.py
+++ b/lib/matplotlib/tests/test_pickle.py
@@ -90,7 +90,7 @@ def _generate_complete_test_figure(fig_ref):
     plt.legend(loc='upper left')
 
     plt.subplot(3, 3, 9)
-    plt.errorbar(x, x * -0.5, xerr=0.2, yerr=0.4)
+    plt.errorbar(x, x * -0.5, xerr=0.2, yerr=0.4, label='$-.5 x$')
     plt.legend(draggable=True)
 
     fig_ref.align_ylabels()  # Test handling of _align_label_groups Groupers.


Code review comments:
diff_hunk: @@ -1375,3 +1377,16 @@ def test_legend_handle_label_mismatch_no_len():
                         labels=iter(["pl1", "pl2", "pl3"]))
     assert len(legend.legend_handles) == 2
     assert len(legend.get_texts()) == 2
+
+
+def test_legend_nolabels_warning():
+    plt.plot([1, 2, 3])
+    with pytest.raises(UserWarning):
path: lib/matplotlib/tests/test_legend.py
text: @user1:
I like to be sure to catch the correct warning:
```suggestion
    with pytest.raises(UserWarning, match="No artists with labels found"):
```

diff_hunk: @@ -239,8 +239,8 @@ def legend(self, *args, **kwargs):
             selection by using a label starting with an underscore, "_".
             A string starting with an underscore is the default label for all
             artists, so calling `.Axes.legend` without any arguments and
-            without setting the labels manually will result in no legend being
-            drawn.
+            without setting the labels manually will result in a ``UserWarning``
+            and no legend being drawn.
path: lib/matplotlib/axes/_axes.py
text: @user1:
I think this isn't (and was not) correct. We draw an empty legend. Please fix this along the way.

@author:
Happy to, but a bit confused.  When I run the following, no legend appears on the image:

```python
plt.plot([1, 2, 3])
ax.lengend()
```

Is this a backend-bependent behavior?  Or by "draw" do you mean the legend object is created and some drawing code runs on it, even if there's no visible legend?

@user2:
There is actually a tiny gray box in the upper right hand corner when you do that
<img width="201" alt="NoLegend" src="https://github.com/matplotlib/matplotlib/assets/1562854/5791fdbf-57c4-4d4d-91e4-e0f104c32f77">


@user1:
Correct, the legend is empty and thus shrinks to the small box. You will also notice that `ax.legend()` will return a `Legend` object.

@author:
Ahh, I totally missed that, haha.  Thanks!