Skip to content

Commit d17990c

Browse files
tlambert03claude
andauthored
feat!: drop python 3.9 and 3.10, require >=3.11 (#745)
BREAKING CHANGE: python 3.9 and 3.10 are no longer supported, and the PySide2 backend is dropped along with them (PySide2 5.15.2.1 declares `requires_python <3.11` and ships no wheel past cp310, so it cannot be installed or tested on the new floor). - requires-python = ">=3.11"; drop the 3.9/3.10 classifiers - drop the pyside2 extra, dependency-group, docs and CI jobs, along with the test-qt-legacy group and [tool.uv] conflicts block that existed only to keep PySide2 on pytest-qt 4.4 - CI: base matrix 3.10 -> 3.11, min-deps 3.9 -> 3.11, pyqt5 job 3.9 -> 3.11, test-dependents default 3.10 -> 3.11 - SLOTS is now unconditional, and the <3.11 weakref_slot skip is dead - ruff target-version py39 -> py311, and pin the dev ruff/mypy to the versions pre-commit already runs (they had drifted to ruff 0.15.12 against pre-commit's 0.16.4) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 128e4ba commit d17990c

33 files changed

Lines changed: 105 additions & 95 deletions

.github/workflows/test_and_deploy.yml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,20 @@ jobs:
2323
strategy:
2424
fail-fast: false
2525
matrix:
26-
python-version: ["3.10", "3.13"]
26+
python-version: ["3.11", "3.13"]
2727
os: [ubuntu-latest, macos-latest, windows-latest]
2828
add-group: [pyqt6, pyside6]
2929
include:
3030
- os: windows-latest
3131
python-version: "3.11"
3232
resolution: "lowest-direct"
3333
add-group: pyside6
34-
- python-version: "3.9"
34+
- python-version: "3.11"
3535
os: ubuntu-latest
3636
add-group: pyqt5
37-
- python-version: "3.9"
38-
os: ubuntu-latest
39-
add-group: pyside2
4037
- python-version: "3.12"
4138
os: windows-latest
4239
add-group: pyqt5
43-
- python-version: "3.10"
44-
os: windows-latest
45-
add-group: pyside2
4640
- python-version: "3.12"
4741
os: ubuntu-latest
4842
add-group: pyqt6
@@ -109,7 +103,7 @@ jobs:
109103
strategy:
110104
fail-fast: false
111105
matrix:
112-
python-version: ["3.9", "3.13"]
106+
python-version: ["3.11", "3.13"]
113107
os: [macos-latest]
114108

115109
steps:
@@ -148,7 +142,7 @@ jobs:
148142
dependency-extras: ${{ matrix.package-extras || '' }}
149143
dependency-group: ${{ matrix.package-group || '' }}
150144
qt: pyqt5
151-
python-version: ${{ matrix.python-version || '3.10' }}
145+
python-version: ${{ matrix.python-version || '3.11' }}
152146
post-install-cmd: "python -m pip install pytest-pretty lxml_html_clean" # just for napari
153147
pytest-args: ${{ matrix.pytest-args }}
154148
strategy:

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,21 @@
3939

4040
## Installation
4141

42-
`magicgui` uses `qtpy` to support both `pyside2` and `pyqt5` backends. However, you
42+
`magicgui` uses `qtpy` to support both `pyqt` and `pyside` backends. However, you
4343
must have one of those installed for magicgui to work.
4444

4545
install with pip
4646

4747
```bash
48-
pip install magicgui[pyqt5]
48+
pip install magicgui[pyqt6]
4949
# or
50-
pip install magicgui[pyside2]
50+
pip install magicgui[pyside6]
5151
```
5252

5353
or with conda:
5454

5555
```bash
56-
conda install -c conda-forge magicgui pyqt # or pyside2 instead of pyqt
56+
conda install -c conda-forge magicgui pyqt # or pyside6 instead of pyqt
5757
```
5858

5959
> :information_source: If you'd like to help us extend support to a different backend,

docs/index.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,7 @@ environments (such as a desktop app, or a Jupyter notebook).
160160
Currently, **magicgui** supports the following backends:
161161

162162
- [Qt](https://www.qt.io/) (via
163-
[PySide2](https://pypi.org/project/PySide2/)/[PySide6](https://pypi.org/project/PySide6/)
164-
or
163+
[PySide6](https://pypi.org/project/PySide6/) or
165164
[PyQt5](https://pypi.org/project/PyQt5/)/[PyQt6](https://pypi.org/project/PyQt6/))
166165
- [Jupyter Widgets](https://ipywidgets.readthedocs.io/en/latest/) (a.k.a.
167166
"IPyWidgets")

docs/installation.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ available:
2020

2121
- `PyQt5`: `pip install magicgui[pyqt5]`
2222
- `PyQt6`: `pip install magicgui[pyqt6]`
23-
- `PySide2`: `pip install magicgui[pyside2]`
2423
- `PySide6`: `pip install magicgui[pyside6]`
2524
- `Jupyter Widgets`: `pip install magicgui[jupyter]`
2625

pyproject.toml

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ name = "magicgui"
2020
description = " build GUIs from python types"
2121
keywords = ["gui", "widgets", "type annotations"]
2222
readme = "README.md"
23-
requires-python = ">=3.9"
23+
requires-python = ">=3.11"
2424
license = { text = "MIT" }
2525
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
2626
classifiers = [
@@ -32,8 +32,6 @@ classifiers = [
3232
"Operating System :: OS Independent",
3333
"Programming Language :: Python",
3434
"Programming Language :: Python :: 3 :: Only",
35-
"Programming Language :: Python :: 3.9",
36-
"Programming Language :: Python :: 3.10",
3735
"Programming Language :: Python :: 3.11",
3836
"Programming Language :: Python :: 3.12",
3937
"Programming Language :: Python :: 3.13",
@@ -63,7 +61,6 @@ pyqt5 = [
6361
"pyqt5-qt5>5.15.2; sys_platform != 'win32'",
6462
]
6563
pyqt6 = ["pyqt6>=6.4.0"]
66-
pyside2 = ["pyside2>=5.15"]
6764
pyside6 = ["pyside6>=6.4.0"]
6865
tqdm = ["tqdm>=4.30.0"]
6966
jupyter = ["ipywidgets>=8.0.0"]
@@ -78,31 +75,27 @@ third-party-support = [
7875
"matplotlib>=3.9.4",
7976
"numpy>=2.1.0; python_version >= '3.13'",
8077
"numpy>=1.26.4",
81-
"pandas>=2.2.3; python_version >= '3.11'",
82-
"pandas>=2.1",
78+
"pandas>=2.2.3",
8379
"pydantic>=1.10.18",
8480
"toolz>=1.0.0",
8581
]
8682
test-min = ["pytest>=8.4.0", "pytest-cov >=6.1", "pytest-mypy-plugins>=3.1"]
8783
# pytest-qt >=4.5 fixes SystemError/TimeoutError in qtbot.waitSignal with
88-
# recent PySide6 (pytest-dev/pytest-qt#552), but drops PySide2 support --
89-
# so the pyside2 group uses the legacy pin (see [tool.uv] conflicts below)
84+
# recent PySide6 (pytest-dev/pytest-qt#552)
9085
test-qt = [{ include-group = "test-min" }, "pytest-qt>=4.5.0"]
91-
test-qt-legacy = [{ include-group = "test-min" }, "pytest-qt==4.4.0"]
9286
test = [
9387
"magicgui[tqdm,jupyter,image,quantity]",
9488
{ include-group = "test-min" },
9589
{ include-group = "third-party-support" },
9690
]
9791
pyqt5 = ["magicgui[pyqt5]", { include-group = "test-qt" }]
9892
pyqt6 = ["magicgui[pyqt6]", { include-group = "test-qt" }]
99-
pyside2 = ["magicgui[pyside2]", { include-group = "test-qt-legacy" }, "numpy<2; python_version < '3.13'"]
10093
pyside6 = ["magicgui[pyside6]", { include-group = "test-qt" }]
10194
dev = [
10295
{ include-group = "test" },
103-
"ruff>=0.8.3",
96+
"ruff>=0.16.4",
10497
"ipython>=8.18.0",
105-
"mypy>=1.13.0",
98+
"mypy>=2.3.1",
10699
"pdbpp>=0.11.6; sys_platform != 'win32'",
107100
"pre-commit-uv>=4",
108101
"pyqt6>=6.8.0",
@@ -129,10 +122,6 @@ docs = [
129122
"ipykernel>=6.29.5",
130123
]
131124

132-
[tool.uv]
133-
# the two pytest-qt pins are mutually exclusive; no environment installs both
134-
conflicts = [[{ group = "test-qt" }, { group = "test-qt-legacy" }]]
135-
136125
[tool.uv.sources]
137126
magicgui = { workspace = true }
138127

@@ -148,7 +137,7 @@ documentation = "https://pyapp-kit.github.io/magicgui/"
148137
# https://docs.astral.sh/ruff
149138
[tool.ruff]
150139
line-length = 88
151-
target-version = "py39"
140+
target-version = "py311"
152141
src = ["src", "tests"]
153142
fix = true
154143
unsafe-fixes = true
@@ -172,8 +161,20 @@ select = [
172161
]
173162
ignore = [
174163
"D401", # First line should be in imperative mood
164+
# magicgui resolves annotations at *runtime*, and on python < 3.14
165+
# `get_origin(int | None)` is `types.UnionType`, not `typing.Union` -- so
166+
# rewriting Optional/Union to PEP 604 changes behaviour rather than just
167+
# spelling. Tests also deliberately exercise both spellings.
168+
"UP007", # Use `X | Y` for type annotations
169+
"UP045", # Use `X | None` for type annotations
175170
]
176171

172+
[tool.ruff.lint.flake8-type-checking]
173+
# ruff exempts `typing`/`typing_extensions` by default; `collections.abc` now
174+
# supplies Callable etc. after the pyupgrade rewrite, and magicgui resolves
175+
# annotations at runtime -- so these names must stay importable at runtime.
176+
exempt-modules = ["typing", "typing_extensions", "collections.abc"]
177+
177178
[tool.ruff.lint.per-file-ignores]
178179
"tests/*.py" = ["D", "S", "E501"]
179180
"tests/test_util.py" = ["D", "S", "E501", "UP006"]

src/magicgui/_type_resolution.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import types
22
import typing
3+
from collections.abc import Callable
34
from copy import copy
45
from functools import lru_cache, partial
56
from importlib import import_module
6-
from typing import Any, Callable, Optional, Union, get_type_hints
7+
from typing import Any, Optional, Union, get_type_hints
78

89
try:
910
from toolz import curry

src/magicgui/_util.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
import os
55
import sys
66
import time
7+
from collections.abc import Callable
78
from functools import wraps
89
from pathlib import Path
910
from typing import (
1011
TYPE_CHECKING,
11-
Callable,
1212
get_args,
1313
get_origin,
1414
overload,
@@ -177,7 +177,8 @@ def _safe_isinstance_tuple(obj: object, superclass: object) -> bool:
177177
return all(safe_issubclass(o, superclass_args[0]) for o in obj_args)
178178
# fallback to simple compare
179179
return len(obj_args) == len(superclass_args) and all(
180-
safe_issubclass(o, s) for o, s in zip(obj_args, superclass_args)
180+
safe_issubclass(o, s)
181+
for o, s in zip(obj_args, superclass_args, strict=False)
181182
)
182183

183184
if len(obj_args) == 2 and obj_args[1] is Ellipsis:
@@ -215,7 +216,10 @@ def safe_issubclass(obj: object, superclass: object) -> bool:
215216
return True
216217
if len(obj_args) != len(superclass_args):
217218
return False
218-
return all(safe_issubclass(o, s) for o, s in zip(obj_args, superclass_args))
219+
return all(
220+
safe_issubclass(o, s)
221+
for o, s in zip(obj_args, superclass_args, strict=False)
222+
)
219223

220224
except Exception:
221225
return False

src/magicgui/application.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from __future__ import annotations
44

55
import signal
6+
from collections.abc import Callable
67
from contextlib import contextmanager
78
from importlib import import_module
8-
from typing import TYPE_CHECKING, Any, Callable, Union
9+
from typing import TYPE_CHECKING, Any, Union
910

1011
from magicgui.backends import BACKENDS
1112

src/magicgui/backends/_ipynb/widgets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any, Callable, get_type_hints
3+
from collections.abc import Callable
4+
from typing import TYPE_CHECKING, Any, get_type_hints
45

56
try:
67
import ipywidgets

src/magicgui/backends/_qtpy/widgets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
import math
66
import re
77
import warnings
8+
from collections.abc import Callable
89
from contextlib import contextmanager, suppress
910
from functools import partial
1011
from itertools import chain
11-
from typing import TYPE_CHECKING, Any, Callable
12+
from typing import TYPE_CHECKING, Any
1213

1314
import qtpy
1415
import superqt

0 commit comments

Comments
 (0)