Repository: matplotlib/matplotlib
Pull request: Convert axis limit units in Qt plot options widget
## PR Summary

Qt backend's plot options widget converts axis limits to floats regardless of the actual data type. If the axes use e.g. datetime values, this makes editing the axis limits next to impossible.

This tries to solve the problem at least for datetime values by converting them to a proper type that can be shown in `QDateTime` widget. The solution is probably not ideal as discussed in the issue but at least it helps users that regularly use datetime X axis a lot.

Resolves #19075

## PR Checklist

<!-- Please mark any checkboxes that do not apply to this PR as [N/A]. -->

- [ ] Has pytest style unit tests (and `pytest` passes).
- [ ] Is [Flake 8](https://flake8.pycqa.org/en/latest/) compliant (run `flake8` on changed files to check).
- [ ] New features are documented, with examples if plot related.
- [ ] Documentation is sphinx and numpydoc compliant (the docs should [build](https://matplotlib.org/devel/documenting_mpl.html#building-the-docs) without error).
- [ ] Conforms to Matplotlib style conventions (install `flake8-docstrings` and run `flake8 --docstring-convention=all`).
- [ ] New features have an entry in `doc/users/next_whats_new/` (follow instructions in README.rst there).
- [ ] API changes documented in `doc/api/next_api_changes/` (follow instructions in README.rst there).

<!--
Thank you so much for your PR!  To help us review your contribution, please
consider the following points:

- A development guide is available at https://matplotlib.org/devdocs/devel/index.html.

- Help with git and github is available at
  https://matplotlib.org/devel/gitwash/development_workflow.html.

- Do not create the PR out of master, but out of a separate branch.

- The PR title should summarize the changes, for example "Raise ValueError on
  non-numeric input to set_xlim".  Avoid non-descriptive titles such as
  "Addresses issue #8576".

- The summary should provide at least 1-2 sentences describing the pull request
  in detail (Why is this change required?  What problem does it solve?) and
  link to any relevant issues.

- If you are contributing fixes to docstrings, please pay attention to
  http://matplotlib.org/devel/documenting_mpl.html#formatting.  In particular,
  note the difference between using single backquotes, double backquotes, and
  asterisks in the markup.

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:
Qt backend's Figure options to support axis units
<!--
Welcome! Thanks for thinking of a way to improve Matplotlib.


Before creating a new feature request please search the issues for relevant feature requests.
-->

### Problem

We often use datetimes on X axis. However, Qt backend's figure options dialog converts these to floating point numbers making it all but possible to edit the axis xmin and xmax.
<!--
Provide a clear and concise description of the problem this feature will solve. 

For example:
* I'm always frustrated when [...] because [...]
* I would like it if [...] happened when I [...] because [...]
* Here is a sample image of what I am asking for [...]
-->

### Proposed Solution

Make the options dialog deal with axis units more properly, e.g. datetimes could be shown using `QDateTimeEdit` widget.

I managed to get this working for datetimes and X axis on a monkey-patched options dialog by replacing line 37 in `matploglib/backends/qt_editor` by

```python
    if isinstance(axes.xaxis.converter, DateConverter):
        xmin, xmax = tuple(num2date(lim) for lim in axes.get_xlim())
    else:
        xmin, xmax = map(float, axes.get_xlim())
```

Would this be an acceptable way of dealing with the units in general? I would be willing to work on a Pull request on this.
<!-- Provide a clear and concise description of a way to accomplish what you want. For example:

* Add an option so that when [...]  [...] will happen
 -->

Hints from issue discussion:
That seems reasonable (in the absence of having a way to get unitized limits back, but I guess that's not going to happen until we have a general unconverter (https://github.com/matplotlib/matplotlib/pull/12270, https://github.com/matplotlib/matplotlib/pull/16922)).
Note that I'd guess the patch at https://github.com/matplotlib/matplotlib/issues/18958#issuecomment-727779571 would still be needed to support PySide2, though.
Right, thats a reasonable hack for one converter, but not for units in general.  I'd be mildly against having a bunch of special casing of dates, but maybe its worth the pain?
I cannot say I am fully happy with the `isinstance()` solution myself so I am all ears if there is a better way to do this. Maybe this could work as a stopgap solution until something like a general unconverted gets implemented, though?

Commit message: Fix unit tests on systems without Qt backend

Re #19075

Patch to review:
diff --git a/lib/matplotlib/backends/qt_editor/_formlayout.py b/lib/matplotlib/backends/qt_editor/_formlayout.py
index e2b371f1652d..2f690a8e109c 100644
--- a/lib/matplotlib/backends/qt_editor/_formlayout.py
+++ b/lib/matplotlib/backends/qt_editor/_formlayout.py
@@ -342,9 +342,17 @@ def get(self):
             elif isinstance(value, Real):
                 value = float(str(field.text()))
             elif isinstance(value, datetime.datetime):
-                value = field.dateTime().toPyDateTime()
+                datetime_ = field.dateTime()
+                if hasattr(datetime_, "toPyDateTime"):
+                    value = datetime_.toPyDateTime()
+                else:
+                    value = datetime_.toPython()
             elif isinstance(value, datetime.date):
-                value = field.date().toPyDate()
+                date_ = field.date()
+                if hasattr(date_, "toPyDate"):
+                    value = date_.toPyDate()
+                else:
+                    value = date_.toPython()
             else:
                 value = eval(str(field.text()))
             valuelist.append(value)
diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py
index 98cf3c610773..202e615486cc 100644
--- a/lib/matplotlib/backends/qt_editor/figureoptions.py
+++ b/lib/matplotlib/backends/qt_editor/figureoptions.py
@@ -10,7 +10,7 @@
 from matplotlib import cbook, cm, colors as mcolors, markers, image as mimage
 from matplotlib.backends.qt_compat import QtGui
 from matplotlib.backends.qt_editor import _formlayout
-
+from matplotlib.dates import DateConverter, num2date
 
 LINESTYLES = {'-': 'Solid',
               '--': 'Dashed',
@@ -33,9 +33,17 @@ def figure_edit(axes, parent=None):
     sep = (None, None)  # separator
 
     # Get / General
-    # Cast to builtin floats as they have nicer reprs.
-    xmin, xmax = map(float, axes.get_xlim())
-    ymin, ymax = map(float, axes.get_ylim())
+    def convert_limits(lim, converter):
+        """Convert axis limits for correct input editors."""
+        if isinstance(converter, DateConverter):
+            return map(num2date, lim)
+        # Cast to builtin floats as they have nicer reprs.
+        return map(float, lim)
+
+    xconverter = axes.xaxis.converter
+    xmin, xmax = convert_limits(axes.get_xlim(), xconverter)
+    yconverter = axes.yaxis.converter
+    ymin, ymax = convert_limits(axes.get_ylim(), yconverter)
     general = [('Title', axes.get_title()),
                sep,
                (None, "<b>X-Axis</b>"),
@@ -52,8 +60,6 @@ def figure_edit(axes, parent=None):
                ]
 
     # Save the unit data
-    xconverter = axes.xaxis.converter
-    yconverter = axes.yaxis.converter
     xunits = axes.xaxis.get_units()
     yunits = axes.yaxis.get_units()
 
diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py
index d0d997e1ce2e..af8d44c637fd 100644
--- a/lib/matplotlib/tests/test_backend_qt.py
+++ b/lib/matplotlib/tests/test_backend_qt.py
@@ -1,4 +1,5 @@
 import copy
+from datetime import date, datetime
 import signal
 from unittest import mock
 
@@ -10,7 +11,8 @@
 
 
 try:
-    from matplotlib.backends.qt_compat import QtGui
+    from matplotlib.backends.qt_compat import QtGui, QtWidgets
+    from matplotlib.backends.qt_editor import _formlayout
 except ImportError:
     pytestmark = pytest.mark.skip('No usable Qt5 bindings')
 
@@ -258,6 +260,20 @@ def test_figureoptions():
         fig.canvas.manager.toolbar.edit_parameters()
 
 
+@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
+def test_figureoptions_with_datetime_axes():
+    fig, ax = plt.subplots()
+    xydata = [
+        datetime(year=2021, month=1, day=1),
+        datetime(year=2021, month=2, day=1)
+    ]
+    ax.plot(xydata, xydata)
+    with mock.patch(
+            "matplotlib.backends.qt_editor._formlayout.FormDialog.exec_",
+            lambda self: None):
+        fig.canvas.manager.toolbar.edit_parameters()
+
+
 @pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
 def test_double_resize():
     # Check that resizing a figure twice keeps the same window size
@@ -295,3 +311,25 @@ def crashing_callback(fig, stale):
     canvas = FigureCanvasQTAgg(fig)
     fig.stale = True
     assert called
+
+
+@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
+def test_form_widget_get_with_datetime_field():
+    if not QtWidgets.QApplication.instance():
+        QtWidgets.QApplication()
+    form = [("Field name", datetime(year=2021, month=3, day=11))]
+    widget = _formlayout.FormWidget(form)
+    widget.setup()
+    values = widget.get()
+    assert(values == [datetime(year=2021, month=3, day=11)])
+
+
+@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
+def test_form_widget_get_with_date_field():
+    if not QtWidgets.QApplication.instance():
+        QtWidgets.QApplication()
+    form = [("Field name", date(year=2021, month=3, day=11))]
+    widget = _formlayout.FormWidget(form)
+    widget.setup()
+    values = widget.get()
+    assert(values == [date(year=2021, month=3, day=11)])


Code review comments:
diff_hunk: @@ -295,3 +311,25 @@ def crashing_callback(fig, stale):
     canvas = FigureCanvasQTAgg(fig)
     fig.stale = True
     assert called
+
+
+@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
+def test_form_widget_get_with_datetime_field():
+    if not QtWidgets.QApplication.instance():
+        QtWidgets.QApplication()
+    form = [("Field name", datetime(year=2021, month=3, day=11))]
+    widget = _formlayout.FormWidget(form)
+    widget.setup()
+    values = widget.get()
+    assert(values == [datetime(year=2021, month=3, day=11)])
+
+
+@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
+def test_form_widget_get_with_date_field():
path: lib/matplotlib/tests/test_backend_qt.py
text: @user1:
I would merge this test with the one above to avoid paying twice for the setup of the widget.

@author:
Done.

diff_hunk: @@ -295,3 +311,25 @@ def crashing_callback(fig, stale):
     canvas = FigureCanvasQTAgg(fig)
     fig.stale = True
     assert called
+
+
+@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
+def test_form_widget_get_with_datetime_field():
+    if not QtWidgets.QApplication.instance():
+        QtWidgets.QApplication()
+    form = [("Field name", datetime(year=2021, month=3, day=11))]
+    widget = _formlayout.FormWidget(form)
+    widget.setup()
+    values = widget.get()
+    assert(values == [datetime(year=2021, month=3, day=11)])
path: lib/matplotlib/tests/test_backend_qt.py
text: @user1:
`assert` needs no parentheses (it's not a function).

@author:
True. Thanks for the heads up!