Repository: matplotlib/matplotlib

Issue:
[Bug]: Figure Number Gives Type Error
### Bug summary

It looks like the `Figure` stub does not include the number variable, resulting in a type error.

### Code for reproduction

```Python
import matplotlib.pyplot as plt
screen = plt.figure()
print(screen.number)
```


### Actual outcome

![Screenshot](https://github.com/user-attachments/assets/6cbdb071-b14e-4be3-a569-a60c889371e6)

### Expected outcome

![Screenshot](https://github.com/user-attachments/assets/b9911591-38bf-4c75-b9da-1bf8e3e85e2e)

### Additional information

I think that this bug is happening because the stub files do not include the number variable for Figure.

### Operating system

Windows 11

### Matplotlib Version

3.9.2

### Matplotlib Backend

tkagg

### Python version

3.12.5

### Jupyter version

_No response_

### Installation

pip


Discussion:
The issue here is that `number` is not a native Figure attribute, but it is dynamically added by pyplot at

https://github.com/matplotlib/matplotlib/blob/9bfc330430b13753ba983549929e8c39ae5ee495/lib/matplotlib/_pylab_helpers.py#L111

This naturally does not work with static checkers.

IMHO we should get away from this dynamic way of working and instead make `number` a property, returning `self.canvas.manager.num` if that exists and `None` otherwise. AFAIK, the number is not meant to be changed and must stay in sync with the manager anyway.



Patch:
diff --git a/doc/api/next_api_changes/deprecations/28007-TH.rst b/doc/api/next_api_changes/deprecations/28007-TH.rst
new file mode 100644
index 000000000000..cfaa329dd9e0
--- /dev/null
+++ b/doc/api/next_api_changes/deprecations/28007-TH.rst
@@ -0,0 +1,6 @@
+Changing ``Figure.number``
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Changing ``Figure.number`` is deprecated. This value is used by `.pyplot`
+to identify figures. It must stay in sync with the pyplot internal state
+and is not intended to be modified by the user.
diff --git a/lib/matplotlib/_pylab_helpers.py b/lib/matplotlib/_pylab_helpers.py
index cb6ca41d02c9..a3861aef5920 100644
--- a/lib/matplotlib/_pylab_helpers.py
+++ b/lib/matplotlib/_pylab_helpers.py
@@ -108,7 +108,7 @@ def _set_new_active_manager(cls, manager):
             manager._cidgcf = manager.canvas.mpl_connect(
                 "button_press_event", lambda event: cls.set_active(manager))
         fig = manager.canvas.figure
-        fig.number = manager.num
+        fig._number = manager.num
         label = fig.get_label()
         if label:
             manager.set_window_title(label)
diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py
index 4271bb78e8de..d3623d1e82b8 100644
--- a/lib/matplotlib/figure.py
+++ b/lib/matplotlib/figure.py
@@ -2808,6 +2808,36 @@ def axes(self):
 
     get_axes = axes.fget
 
+    @property
+    def number(self):
+        """The figure id, used to identify figures in `.pyplot`."""
+        # Historically, pyplot dynamically added a number attribute to figure.
+        # However, this number must stay in sync with the figure manager.
+        # AFAICS overwriting the number attribute does not have the desired
+        # effect for pyplot. But there are some repos in GitHub that do change
+        # number. So let's take it slow and properly migrate away from writing.
+        #
+        # Making the dynamic attribute private and wrapping it in a property
+        # allows to maintain current behavior and deprecate write-access.
+        #
+        # When the deprecation expires, there's no need for duplicate state
+        # anymore and the private _number attribute can be replaced by
+        # `self.canvas.manager.num` if that exists and None otherwise.
+        if hasattr(self, '_number'):
+            return self._number
+        else:
+            raise AttributeError(
+                "'Figure' object has no attribute 'number'. In the future this"
+                "will change to returning 'None' instead.")
+
+    @number.setter
+    def number(self, num):
+        _api.warn_deprecated(
+            "3.10",
+            message="Changing 'Figure.number' is deprecated since %(since)s and "
+                    "will raise an error starting %(removal)s")
+        self._number = num
+
     def _get_renderer(self):
         if hasattr(self.canvas, 'get_renderer'):
             return self.canvas.get_renderer()
diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi
index ade4cfd6f16d..7fb09fb71a6e 100644
--- a/lib/matplotlib/figure.pyi
+++ b/lib/matplotlib/figure.pyi
@@ -343,6 +343,10 @@ class Figure(FigureBase):
     def get_layout_engine(self) -> LayoutEngine | None: ...
     def _repr_html_(self) -> str | None: ...
     def show(self, warn: bool = ...) -> None: ...
+    @property
+    def number(self) -> int | str: ...
+    @number.setter
+    def number(self, num: int | str) -> None: ...
     @property  # type: ignore[misc]
     def axes(self) -> list[Axes]: ...  # type: ignore[override]
     def get_axes(self) -> list[Axes]: ...


Test patch:
diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py
index 528df182a2d0..61fd71875e2a 100644
--- a/lib/matplotlib/tests/test_figure.py
+++ b/lib/matplotlib/tests/test_figure.py
@@ -151,6 +151,29 @@ def test_figure_label():
         plt.figure(Figure())
 
 
+def test_figure_label_replaced():
+    plt.close('all')
+    fig = plt.figure(1)
+    with pytest.warns(mpl.MatplotlibDeprecationWarning,
+                      match="Changing 'Figure.number' is deprecated"):
+        fig.number = 2
+    assert fig.number == 2
+
+
+def test_figure_no_label():
+    # standalone figures do not have a figure attribute
+    fig = Figure()
+    with pytest.raises(AttributeError):
+        fig.number
+    # but one can set one
+    with pytest.warns(mpl.MatplotlibDeprecationWarning,
+                      match="Changing 'Figure.number' is deprecated"):
+        fig.number = 5
+    assert fig.number == 5
+    # even though it's not known by pyplot
+    assert not plt.fignum_exists(fig.number)
+
+
 def test_fignum_exists():
     # pyplot figure creation, selection and closing with fignum_exists
     plt.figure('one')