Repository: matplotlib/viscm

Issue:
Drop support for Python 3.8
For rationale: https://scientific-python.org/specs/spec-0000/

Discussion:

Patch:
diff --git a/Makefile b/Makefile
index 344a63e..c1cfef0 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 .PHONY: test
 test:
 	python -m pytest --version
-	python -m pytest -v test/
+	python -m pytest -sv test/
 
 
 .PHONY: lint 
diff --git a/README.rst b/README.rst
index 020b6a7..90e5d3e 100644
--- a/README.rst
+++ b/README.rst
@@ -3,16 +3,6 @@ viscm
 
 This is a little tool for analyzing colormaps and creating new colormaps.
 
-Try::
-
-  $ pip install viscm
-  $ python -m viscm view jet
-  $ python -m viscm edit
-
-There is some information available about how to interpret the
-resulting visualizations and use the editor tool `on this website
-<https://bids.github.io/colormap/>`_.
-
 Downloads:
   * https://pypi.python.org/pypi/viscm/
   * https://anaconda.org/conda-forge/viscm/
@@ -24,23 +14,50 @@ Contact:
   Nathaniel J. Smith <njs@pobox.com> and Stéfan van der Walt <stefanv@berkeley.edu>
 
 Dependencies:
-  * Python 3.8+
-  * `colorspacious <https://pypi.python.org/pypi/colorspacious>`_
-  * Matplotlib
-  * NumPy
+  * Python 3.9+
+  * `colorspacious <https://pypi.python.org/pypi/colorspacious>`_ 1.1+
+  * Matplotlib 3.5+
+  * NumPy 1.22+
+  * SciPy 1.8+
+  * one of PyQt6, PySide6
 
 License:
   MIT, see `LICENSE <LICENSE>`__ for details.
 
+
+Installation
+------------
+
+This is a GUI application, and requires Qt Python bindings.
+They can be provided by PyQt (GPL) or PySide (LGPL)::
+
+  $ pip install viscm[PySide]
+
+...or::
+
+  $ pip install viscm[PyQt]
+
+
+Usage
+-----
+
+::
+
+  $ viscm view jet
+  $ viscm edit
+
+There is some information available about how to interpret the
+resulting visualizations and use the editor tool `on this website
+<https://bids.github.io/colormap/>`_.
+
+
 Reproducing viridis
--------------------
+^^^^^^^^^^^^^^^^^^^
 
-Load [viridis AKA option_d.py](https://github.com/BIDS/colormap/) using:
+Load `viridis AKA option_d.py <https://github.com/BIDS/colormap/>`__ using::
 
-```
-python -m viscm --uniform-space buggy-CAM02-UCS -m Bezier edit /tmp/option_d.py
-```
+  viscm --uniform-space buggy-CAM02-UCS -m Bezier edit /tmp/option_d.py
 
 Note that there was a small bug in the assumed sRGB viewing conditions
 while designing viridis. It does not affect the outcome by much. Also
-see `python -m viscm --help`.
+see :code:`viscm --help`.
diff --git a/doc/contributing.md b/doc/contributing.md
index 1729689..6955eab 100644
--- a/doc/contributing.md
+++ b/doc/contributing.md
@@ -22,6 +22,8 @@ python -m build
 pip install dist/*.whl  # or `dist/*.tar.gz`
 ```
 
+To automatically install a Qt dependency, try `pip install dist/<wheel_filename>[PyQt]`.
+
 
 ## Tests
 
diff --git a/environment.yml b/environment.yml
index 599ba24..bf96902 100644
--- a/environment.yml
+++ b/environment.yml
@@ -9,6 +9,7 @@ dependencies:
   - "matplotlib ~=3.7"
   - "colorspacious ~=1.1"
   - "scipy ~=1.10"
+  - "PySide6"
 
   # Development
   - "mypy ~=1.3.0"
diff --git a/pyproject.toml b/pyproject.toml
index e572469..f5cb284 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,14 +15,19 @@ classifiers = [
   "Programming Language :: Python :: 3",
 ]
 
-requires-python = "~=3.8"
+requires-python = "~=3.9"
 dependencies = [
-  "numpy",
-  "matplotlib",
-  "colorspacious",
-  "scipy",
+  "numpy ~=1.22",
+  "matplotlib ~=3.5",
+  "colorspacious ~=1.1",
+  "scipy ~=1.8",
 ]
 
+[project.optional-dependencies]
+# Qt6 was released 2020.08.12
+PySide = ["PySide6"]
+PyQt = ["PyQt6"]
+
 [project.urls]
 repository = "https://github.com/matplotlib/viscm"
 # documentation = "https://viscm.readthedocs.io"
@@ -46,7 +51,7 @@ package-data = {viscm = ["examples/*"]}
 
 
 [tool.mypy]
-python_version = "3.8"
+python_version = "3.9"
 
 # These libraries don't have type stubs. Mypy will see them as `Any` and not
 # throw an [import] error.
@@ -62,10 +67,10 @@ ignore_missing_imports = true
 
 
 [tool.black]
-target-version = ["py38", "py39", "py310", "py311"]
+target-version = ["py39", "py310", "py311"]
 
 [tool.ruff]
-target-version = "py38"
+target-version = "py39"
 select = [
   "F",
   "E",
diff --git a/viscm/bezierbuilder/__init__.py b/viscm/bezierbuilder/__init__.py
index 0c7c199..cce4e21 100644
--- a/viscm/bezierbuilder/__init__.py
+++ b/viscm/bezierbuilder/__init__.py
@@ -32,6 +32,8 @@ from matplotlib.lines import Line2D
 from viscm.bezierbuilder.curve import curve_method
 from viscm.minimvc import Trigger
 
+Qt = QtCore.Qt
+
 
 class ControlPointModel:
     def __init__(self, xp, yp, fixed=None):
@@ -104,12 +106,14 @@ class ControlPointBuilder:
         if event.inaxes != self.ax:
             return
         res, ind = self.control_polygon.contains(event)
-        if res and modkey == QtCore.Qt.NoModifier:
+        if res and modkey == Qt.KeyboardModifier.NoModifier:
             self._index = ind["ind"][0]
-        if res and (modkey == QtCore.Qt.ControlModifier or self.mode == "remove"):
+        if res and (
+            modkey == Qt.KeyboardModifier.ControlModifier or self.mode == "remove"
+        ):
             # Control-click deletes
             self.control_point_model.remove_point(ind["ind"][0])
-        if modkey == QtCore.Qt.ShiftModifier or self.mode == "add":
+        if modkey == Qt.KeyboardModifier.ShiftModifier or self.mode == "add":
             # Adding a new point. Find the two closest points and insert it in
             # between them.
             total_squared_dists = []
diff --git a/viscm/cli.py b/viscm/cli.py
index c32f77d..af1cd47 100644
--- a/viscm/cli.py
+++ b/viscm/cli.py
@@ -111,7 +111,7 @@ def cli():
 
     signal.signal(signal.SIGINT, signal.SIG_DFL)
 
-    app.exec_()
+    app.exec()
 
 
 def _make_window(
@@ -145,7 +145,7 @@ def _make_window(
             v.figure.savefig(str(save))
     elif action == "edit":
         if not cm.can_edit:
-            sys.exit("Sorry, I don't know how to edit the specified colormap")
+            raise RuntimeError("Sorry, I don't know how to edit the specified colormap")
 
         v = gui.viscm_editor(
             figure=fig,
@@ -164,7 +164,8 @@ def _make_window(
         sys.exit()
 
     figure_canvas.setSizePolicy(
-        gui.QtWidgets.QSizePolicy.Expanding, gui.QtWidgets.QSizePolicy.Expanding
+        gui.QtWidgets.QSizePolicy.Policy.Expanding,
+        gui.QtWidgets.QSizePolicy.Policy.Expanding,
     )
     figure_canvas.updateGeometry()
 
diff --git a/viscm/gui.py b/viscm/gui.py
index 0fba1fa..e527ae3 100644
--- a/viscm/gui.py
+++ b/viscm/gui.py
@@ -10,35 +10,30 @@ import json
 import os.path
 import sys
 
-import numpy as np
-
-# matplotlib.rcParams['backend'] = "QT4AGG"
-# Do this first before any other matplotlib imports, to force matplotlib to
-# use a Qt backend
-from matplotlib.backends.qt_compat import QtCore, QtGui, QtWidgets, _getSaveFileName
-
-try:
-    from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
-except ImportError:
-    try:
-        from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
-    except ImportError:
-        from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
-
 import matplotlib
 import matplotlib.colors
 import matplotlib.pyplot as plt
 import mpl_toolkits.mplot3d
+import numpy as np
 from colorspacious import (
     CIECAM02Space,
     CIECAM02Surround,
     cspace_convert,
     cspace_converter,
 )
+from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
+
+# matplotlib.rcParams['backend'] = "QtAgg"
+# Do this first before any other matplotlib imports, to force matplotlib to
+# use a Qt backend
+from matplotlib.backends.qt_compat import QtCore, QtGui, QtWidgets
 from matplotlib.colors import ListedColormap
 from matplotlib.gridspec import GridSpec
 
-from .minimvc import Trigger
+from viscm.minimvc import Trigger
+
+Qt = QtCore.Qt
+
 
 # The correct L_A value for the standard sRGB viewing conditions is:
 #   (64 / np.pi) / 5
@@ -114,7 +109,11 @@ def _setup_Jpapbp_axis(ax):
 # Adapt a matplotlib colormap to a linearly transformed version -- useful for
 # visualizing how colormaps look given color deficiency.
 # Kinda a hack, b/c we inherit from Colormap (this is required), but then
-# ignore its implementation entirely.
+# ignore its implementation entirely. This results in errors at runtime:
+#       File "/<env>/site-packages/matplotlib/artist.py", line 1343, in format_cursor_data  # noqa: E501
+#         n = self.cmap.N
+#             ^^^^^^^^^^^
+#     AttributeError: 'TransformedCMap' object has no attribute 'N'
 class TransformedCMap(matplotlib.colors.Colormap):
     def __init__(self, transform, base_cmap):
         self.transform = transform
@@ -1075,18 +1074,16 @@ def about():
 class ViewerWindow(QtWidgets.QMainWindow):
     def __init__(self, figurecanvas, viscm, cmapname, parent=None):
         QtWidgets.QMainWindow.__init__(self, parent)
-        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
         self.main_widget = QtWidgets.QWidget(self)
         self.cmapname = cmapname
 
         file_menu = QtWidgets.QMenu("&File", self)
-        file_menu.addAction("&Save", self.save, QtCore.Qt.CTRL + QtCore.Qt.Key_S)
-        file_menu.addAction("&Quit", self.fileQuit, QtCore.Qt.CTRL + QtCore.Qt.Key_Q)
+        file_menu.addAction("&Save", self.save).setShortcut("Ctrl+S")
+        file_menu.addAction("&Quit", self.fileQuit).setShortcut("Ctrl+Q")
 
         options_menu = QtWidgets.QMenu("&Options", self)
-        options_menu.addAction(
-            "&Toggle Gamut", self.toggle_gamut, QtCore.Qt.CTRL + QtCore.Qt.Key_G
-        )
+        options_menu.addAction("&Toggle Gamut", self.toggle_gamut).setShortcut("Ctrl+G")
 
         help_menu = QtWidgets.QMenu("&Help", self)
         help_menu.addAction("&About", about)
@@ -1116,7 +1113,8 @@ class ViewerWindow(QtWidgets.QMainWindow):
         self.fileQuit()
 
     def save(self):
-        fileName, _ = _getSaveFileName(
+        fileName, _ = QtWidgets.QFileDialog.getSaveFileName(
+            self,
             caption="Save file",
             directory=self.cmapname + ".png",
             filter="Image Files (*.png *.jpg *.bmp)",
@@ -1128,18 +1126,16 @@ class ViewerWindow(QtWidgets.QMainWindow):
 class EditorWindow(QtWidgets.QMainWindow):
     def __init__(self, figurecanvas, viscm_editor, parent=None):
         QtWidgets.QMainWindow.__init__(self, parent)
-        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
         self.viscm_editor = viscm_editor
 
         file_menu = QtWidgets.QMenu("&File", self)
-        file_menu.addAction("&Save", self.save, QtCore.Qt.CTRL + QtCore.Qt.Key_S)
+        file_menu.addAction("&Save", self.save).setShortcut("Ctrl+S")
         file_menu.addAction("&Export .py", self.export)
-        file_menu.addAction("&Quit", self.fileQuit, QtCore.Qt.CTRL + QtCore.Qt.Key_Q)
+        file_menu.addAction("&Quit", self.fileQuit).setShortcut("Ctrl+Q")
 
         options_menu = QtWidgets.QMenu("&Options", self)
-        options_menu.addAction(
-            "&Load in Viewer", self.loadviewer, QtCore.Qt.CTRL + QtCore.Qt.Key_V
-        )
+        options_menu.addAction("&Load in Viewer", self.loadviewer).setShortcut("Ctrl+V")
 
         help_menu = QtWidgets.QMenu("&Help", self)
         help_menu.addAction("&About", about)
@@ -1151,21 +1147,21 @@ class EditorWindow(QtWidgets.QMainWindow):
 
         self.main_widget = QtWidgets.QWidget(self)
 
-        self.max_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
+        self.max_slider = QtWidgets.QSlider(Qt.Orientation.Horizontal)
         self.max_slider.setMinimum(0)
         self.max_slider.setMaximum(100)
         self.max_slider.setValue(int(viscm_editor.max_Jp))
-        self.max_slider.setTickPosition(QtWidgets.QSlider.TicksBelow)
+        self.max_slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow)
         self.max_slider.setTickInterval(10)
         self.max_slider.valueChanged.connect(self.updatejp)
         self.max_slider_num = QtWidgets.QLabel(str(viscm_editor.max_Jp))
         self.max_slider_num.setFixedWidth(30)
 
-        self.min_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
+        self.min_slider = QtWidgets.QSlider(Qt.Orientation.Horizontal)
         self.min_slider.setMinimum(0)
         self.min_slider.setMaximum(100)
         self.min_slider.setValue(int(viscm_editor.min_Jp))
-        self.min_slider.setTickPosition(QtWidgets.QSlider.TicksBelow)
+        self.min_slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow)
         self.min_slider.setTickInterval(10)
         self.min_slider.valueChanged.connect(self.updatejp)
         self.min_slider_num = QtWidgets.QLabel(str(viscm_editor.min_Jp))
@@ -1189,7 +1185,7 @@ class EditorWindow(QtWidgets.QMainWindow):
         mainlayout.addLayout(min_slider_layout)
 
         if viscm_editor.cmtype == "diverging":
-            smoothness_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
+            smoothness_slider = QtWidgets.QSlider(Qt.Horizontal)
             # We want the slider to vary filter_k exponentially between 5 and
             # 1000. So it should go from [log10(5), log10(1000)]
             # which is about [0.699, 3.0]
@@ -1206,7 +1202,7 @@ class EditorWindow(QtWidgets.QMainWindow):
             metrics = QtGui.QFontMetrics(smoothness_slider_num.font())
             max_width = metrics.width("1000.00")
             smoothness_slider_num.setFixedWidth(max_width)
-            smoothness_slider_num.setAlignment(QtCore.Qt.AlignRight)
+            smoothness_slider_num.setAlignment(Qt.AlignRight)
             self.smoothness_slider_num = smoothness_slider_num
 
             smoothness_slider_layout = QtWidgets.QHBoxLayout()
@@ -1221,41 +1217,32 @@ class EditorWindow(QtWidgets.QMainWindow):
                 self.update_smoothness_slider
             )
 
-        self.moveAction = QtWidgets.QAction("Drag points", self)
+        self.toolbar = self.addToolBar("Tools")
+        self.moveAction = self.toolbar.addAction("Drag points")
         self.moveAction.triggered.connect(self.set_move_mode)
         self.moveAction.setCheckable(True)
-
-        self.addAction = QtWidgets.QAction("Add points", self)
+        self.addAction = self.toolbar.addAction("Add points")
         self.addAction.triggered.connect(self.set_add_mode)
         self.addAction.setCheckable(True)
-
-        self.removeAction = QtWidgets.QAction("Remove points", self)
+        self.removeAction = self.toolbar.addAction("Remove points")
         self.removeAction.triggered.connect(self.set_remove_mode)
         self.removeAction.setCheckable(True)
 
-        self.swapAction = QtWidgets.QAction("Flip brightness", self)
+        self.toolbar.addSeparator()
+        self.swapAction = self.toolbar.addAction("Flip brightness")
         self.swapAction.triggered.connect(self.swapjp)
-        renameAction = QtWidgets.QAction("Rename colormap", self)
-        renameAction.triggered.connect(self.rename)
 
-        saveAction = QtWidgets.QAction("Save", self)
-        saveAction.triggered.connect(self.save)
-
-        self.toolbar = self.addToolBar("Tools")
-        self.toolbar.addAction(self.moveAction)
-        self.toolbar.addAction(self.addAction)
-        self.toolbar.addAction(self.removeAction)
         self.toolbar.addSeparator()
-        self.toolbar.addAction(self.swapAction)
-        self.toolbar.addSeparator()
-        self.toolbar.addAction(renameAction)
-        self.toolbar.addAction(saveAction)
+        renameAction = self.toolbar.addAction("Rename colormap")
+        renameAction.triggered.connect(self.rename)
+        saveAction = self.toolbar.addAction("Save")
+        saveAction.triggered.connect(self.save)
 
         self.moveAction.setChecked(True)
 
         self.main_widget.setFocus()
         figurecanvas.setFocus()
-        figurecanvas.setFocusPolicy(QtCore.Qt.StrongFocus)
+        figurecanvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
         self.setCentralWidget(self.main_widget)
 
     def rename(self):
@@ -1304,7 +1291,8 @@ class EditorWindow(QtWidgets.QMainWindow):
         self.viscm_editor.bezier_builder.mode = "remove"
 
     def export(self):
-        fileName, _ = _getSaveFileName(
+        fileName, _ = QtWidgets.QFileDialog.getSaveFileName(
+            self,
             caption="Export file",
             directory=self.viscm_editor.name + ".py",
             filter=".py (*.py)",
@@ -1319,7 +1307,8 @@ class EditorWindow(QtWidgets.QMainWindow):
         self.fileQuit()
 
     def save(self):
-        fileName, _ = _getSaveFileName(
+        fileName, _ = QtWidgets.QFileDialog.getSaveFileName(
+            self,
             caption="Save file",
             directory=self.viscm_editor.name + ".jscm",
             filter="JSCM Files (*.jscm)",
@@ -1334,7 +1323,8 @@ class EditorWindow(QtWidgets.QMainWindow):
         v = viscm(cm, name=self.viscm_editor.name, figure=newfig)
 
         newcanvas.setSizePolicy(
-            QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
+            QtWidgets.QSizePolicy.Policy.Expanding,
+            QtWidgets.QSizePolicy.Policy.Expanding,
         )
         newcanvas.updateGeometry()
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c8ab78d..2174dc7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,10 +13,9 @@ jobs:
           - "3.11"
           - "3.10"
           - "3.9"
-          - "3.8"
         pyqt-dependency:
-          - "PyQt5"
-            # - "PyQt6"
+          - "PyQt6"
+          - "PySide6"
 
     steps:
       - uses: "actions/checkout@v3"
@@ -37,8 +36,9 @@ jobs:
           # pytest-qt CI dependencies: https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions
           sudo apt update
           sudo apt install -y \
-            xvfb \
-            libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils
+            xvfb libegl1 \
+            libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils \
+            libxcb-cursor0
 
       - name: "Run tests"
         run: "make test"