diff --git a/examples/float.py b/examples/float.py new file mode 100644 index 0000000..7e83355 --- /dev/null +++ b/examples/float.py @@ -0,0 +1,28 @@ +from qtrangeslider import QRangeSlider +from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider +from qtrangeslider.qtcompat.QtCore import Qt +from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget + +app = QApplication([]) + +w = QWidget() + +sld1 = QDoubleSlider(Qt.Horizontal) +sld2 = QDoubleRangeSlider(Qt.Horizontal) +rs = QRangeSlider(Qt.Horizontal) + +sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e)) + +sld2.setMaximum(1) +sld2.setValue((0.2, 0.8)) +sld2.valueChanged.connect(lambda e: print("valueChanged", e)) +sld2.sliderMoved.connect(lambda e: print("sliderMoved", e)) +sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f))) + +w.setLayout(QVBoxLayout()) +w.layout().addWidget(sld1) +w.layout().addWidget(sld2) +w.layout().addWidget(rs) +w.show() +w.resize(500, 150) +app.exec_() diff --git a/examples/labeled.py b/examples/labeled.py index e53df93..14129bd 100644 --- a/examples/labeled.py +++ b/examples/labeled.py @@ -1,17 +1,49 @@ -from qtrangeslider._labeled import QLabeledRangeSlider, QLabeledSlider +from qtrangeslider._labeled import ( + QLabeledDoubleRangeSlider, + QLabeledDoubleSlider, + QLabeledRangeSlider, + QLabeledSlider, +) from qtrangeslider.qtcompat.QtCore import Qt -from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtrangeslider.qtcompat.QtWidgets import ( + QApplication, + QHBoxLayout, + QVBoxLayout, + QWidget, +) app = QApplication([]) -w = QWidget() -sld = QLabeledRangeSlider() +ORIENTATION = Qt.Horizontal -sld.setRange(0, 500) -sld.setValue((100, 400)) -w.setLayout(QVBoxLayout()) -w.layout().addWidget(sld) -w.layout().addWidget(QLabeledSlider(Qt.Horizontal)) +w = QWidget() +qls = QLabeledSlider(ORIENTATION) +qls.valueChanged.connect(lambda e: print("qls valueChanged", e)) +qls.setRange(0, 500) +qls.setValue(300) + + +qlds = QLabeledDoubleSlider(ORIENTATION) +qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e)) +qlds.setRange(0, 1) +qlds.setValue(0.5) +qlds.setSingleStep(0.1) + +qlrs = QLabeledRangeSlider(ORIENTATION) +qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e)) +qlrs.setValue((20, 60)) + +qldrs = QLabeledDoubleRangeSlider(ORIENTATION) +qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e)) +qldrs.setRange(0, 1) +qldrs.setValue((0.2, 0.7)) + + +w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout()) +w.layout().addWidget(qls) +w.layout().addWidget(qlds) +w.layout().addWidget(qlrs) +w.layout().addWidget(qldrs) w.show() w.resize(500, 150) app.exec_() diff --git a/qtrangeslider/__init__.py b/qtrangeslider/__init__.py index 3bd8d0b..83f4c74 100644 --- a/qtrangeslider/__init__.py +++ b/qtrangeslider/__init__.py @@ -3,7 +3,21 @@ try: except ImportError: __version__ = "unknown" -from ._labeled import QLabeledRangeSlider, QLabeledSlider +from ._float_slider import QDoubleRangeSlider, QDoubleSlider +from ._labeled import ( + QLabeledDoubleRangeSlider, + QLabeledDoubleSlider, + QLabeledRangeSlider, + QLabeledSlider, +) from ._qrangeslider import QRangeSlider -__all__ = ["QRangeSlider", "QLabeledRangeSlider", "QLabeledSlider"] +__all__ = [ + "QDoubleRangeSlider", + "QDoubleSlider", + "QLabeledDoubleRangeSlider", + "QLabeledDoubleSlider", + "QLabeledRangeSlider", + "QLabeledSlider", + "QRangeSlider", +] diff --git a/qtrangeslider/_float_slider.py b/qtrangeslider/_float_slider.py new file mode 100644 index 0000000..e407dbd --- /dev/null +++ b/qtrangeslider/_float_slider.py @@ -0,0 +1,96 @@ +import math +from typing import Tuple + +from ._hooked import _HookedSlider +from ._qrangeslider import QRangeSlider +from .qtcompat.QtCore import Signal + + +class QDoubleSlider(_HookedSlider): + + valueChanged = Signal(float) + rangeChanged = Signal(float, float) + sliderMoved = Signal(float) + _multiplier = 1 + + def __init__(self, *args): + super().__init__(*args) + self._multiplier = 10 ** 2 + self.setMinimum(0) + self.setMaximum(99) + self.setSingleStep(1) + self.setPageStep(10) + super().sliderMoved.connect( + lambda e: self.sliderMoved.emit(self._post_get_hook(e)) + ) + + def decimals(self) -> int: + """This property holds the precision of the slider, in decimals.""" + return int(math.log10(self._multiplier)) + + def setDecimals(self, prec: int): + """This property holds the precision of the slider, in decimals + + Sets how many decimals the slider uses for displaying and interpreting doubles. + """ + previous = self._multiplier + self._multiplier = 10 ** int(prec) + ratio = self._multiplier / previous + + if ratio != 1: + self.blockSignals(True) + try: + newmin = self.minimum() * ratio + newmax = self.maximum() * ratio + newval = self._scale_value(ratio) + newstep = self.singleStep() * ratio + newpage = self.pageStep() * ratio + self.setRange(newmin, newmax) + self.setValue(newval) + self.setSingleStep(newstep) + self.setPageStep(newpage) + except OverflowError as err: + self._multiplier = previous + raise OverflowError( + f"Cannot use {prec} decimals with a range of {newmin}-" + f"{newmax}. If you need this feature, please open a feature" + " request at github." + ) from err + self.blockSignals(False) + + def _scale_value(self, p): + # for subclasses + return self.value() * p + + def _post_get_hook(self, value: int) -> float: + return value / self._multiplier + + def _pre_set_hook(self, value: float) -> int: + return int(value * self._multiplier) + + def sliderChange(self, change) -> None: + if change == self.SliderValueChange: + self.valueChanged.emit(self.value()) + if change == self.SliderRangeChange: + self.rangeChanged.emit(self.minimum(), self.maximum()) + return super().sliderChange(self.SliderChange(change)) + + +class QDoubleRangeSlider(QRangeSlider, QDoubleSlider): + rangeChanged = Signal(float, float) + + def value(self) -> Tuple[float, ...]: + """Get current value of the widget as a tuple of integers.""" + return tuple(float(i) for i in self._value) + + def _min_max_bound(self, val: int) -> float: + return round(super()._min_max_bound(val), self.decimals()) + + def _scale_value(self, p): + # This function is called during setDecimals... + # but because QRangeSlider has a private nonQt `_value` + # we don't actually need to scale + return self._value + + def setDecimals(self, prec: int): + return super().setDecimals(prec) diff --git a/qtrangeslider/_hooked.py b/qtrangeslider/_hooked.py new file mode 100644 index 0000000..4fa8f3c --- /dev/null +++ b/qtrangeslider/_hooked.py @@ -0,0 +1,49 @@ +from .qtcompat.QtWidgets import QSlider + + +class _HookedSlider(QSlider): + def _post_get_hook(self, value): + return value + + def _pre_set_hook(self, value): + return value + + def value(self) -> float: # type: ignore[override] + return float(self._post_get_hook(super().value())) + + def setValue(self, value: float) -> None: + super().setValue(self._pre_set_hook(value)) + + def minimum(self) -> float: # type: ignore[override] + return self._post_get_hook(super().minimum()) + + def setMinimum(self, minimum: float): + super().setMinimum(self._pre_set_hook(minimum)) + + def maximum(self) -> float: # type: ignore[override] + return self._post_get_hook(super().maximum()) + + def setMaximum(self, maximum: float): + super().setMaximum(self._pre_set_hook(maximum)) + + def singleStep(self) -> float: # type: ignore[override] + return self._post_get_hook(super().singleStep()) + + def setSingleStep(self, step: float): + super().setSingleStep(self._pre_set_hook(step)) + + def pageStep(self) -> float: # type: ignore[override] + return self._post_get_hook(super().pageStep()) + + def setPageStep(self, step: float) -> None: + super().setPageStep(self._pre_set_hook(step)) + + def setRange(self, min: float, max: float) -> None: + super().setRange(self._pre_set_hook(min), self._pre_set_hook(max)) + + # def sliderChange(self, change) -> None: + # if change == QSlider.SliderValueChange: + # self.valueChanged.emit(self.value()) + # if change == QSlider.SliderRangeChange: + # self.rangeChanged.emit(self.minimum(), self.maximum()) + # return super().sliderChange(change) diff --git a/qtrangeslider/_labeled.py b/qtrangeslider/_labeled.py index ceae7a1..9b4daac 100644 --- a/qtrangeslider/_labeled.py +++ b/qtrangeslider/_labeled.py @@ -1,12 +1,14 @@ from enum import IntEnum from functools import partial +from ._float_slider import QDoubleRangeSlider, QDoubleSlider from ._qrangeslider import QRangeSlider from .qtcompat.QtCore import QPoint, QSize, Qt, Signal -from .qtcompat.QtGui import QFontMetrics +from .qtcompat.QtGui import QFontMetrics, QValidator from .qtcompat.QtWidgets import ( QAbstractSlider, QApplication, + QDoubleSpinBox, QHBoxLayout, QSlider, QSpinBox, @@ -31,7 +33,47 @@ class EdgeLabelMode(IntEnum): LabelIsValue = 2 -class QLabeledSlider(QAbstractSlider): +class SliderProxy: + _slider: QAbstractSlider + + def value(self): + return self._slider.value() + + def setValue(self, value) -> None: + self._slider.setValue(value) + + def minimum(self): + return self._slider.minimum() + + def setMinimum(self, minimum): + self._slider.setMinimum(minimum) + + def maximum(self): + return self._slider.maximum() + + def setMaximum(self, maximum): + self._slider.setMaximum(maximum) + + def singleStep(self): + return self._slider.singleStep() + + def setSingleStep(self, step): + self._slider.setSingleStep(step) + + def pageStep(self): + return self._slider.pageStep() + + def setPageStep(self, step) -> None: + self._slider.setPageStep(step) + + def setRange(self, min, max) -> None: + self._slider.setRange(min, max) + + +class QLabeledSlider(SliderProxy, QAbstractSlider): + _slider_class = QSlider + _slider: QSlider + def __init__(self, *args) -> None: parent = None orientation = Qt.Horizontal @@ -45,8 +87,9 @@ class QLabeledSlider(QAbstractSlider): super().__init__(parent) - self._slider = QSlider() + self._slider = self._slider_class() self._slider.valueChanged.connect(self.valueChanged.emit) + self._slider.rangeChanged.connect(self.rangeChanged.emit) self._label = SliderLabel(self._slider, connect=self.setValue) self.valueChanged.connect(self._label.setValue) @@ -80,10 +123,30 @@ class QLabeledSlider(QAbstractSlider): self.setLayout(layout) -class QLabeledRangeSlider(QAbstractSlider): +class QLabeledDoubleSlider(QLabeledSlider): + _slider_class = QDoubleSlider + _slider: QDoubleSlider + valueChanged = Signal(float) + rangeChanged = Signal(float, float) + + def __init__(self, *args) -> None: + super().__init__(*args) + self.setDecimals(2) + + def decimals(self) -> int: + return self._slider.decimals() + + def setDecimals(self, prec: int): + self._slider.setDecimals(prec) + self._label.setDecimals(prec) + + +class QLabeledRangeSlider(SliderProxy, QAbstractSlider): valueChanged = Signal(tuple) LabelPosition = LabelPosition EdgeLabelMode = EdgeLabelMode + _slider_class = QRangeSlider + _slider: QRangeSlider def __init__(self, *args) -> None: parent = None @@ -105,8 +168,9 @@ class QLabeledRangeSlider(QAbstractSlider): self.label_shift_x = 0 self.label_shift_y = 0 - self._slider = QRangeSlider() + self._slider = self._slider_class() self._slider.valueChanged.connect(self.valueChanged.emit) + self._slider.rangeChanged.connect(self.rangeChanged.emit) self._min_label = SliderLabel( self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited @@ -117,7 +181,7 @@ class QLabeledRangeSlider(QAbstractSlider): self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange) self._slider.valueChanged.connect(self._on_value_changed) - self.rangeChanged.connect(self._on_range_changed) + self._slider.rangeChanged.connect(self._on_range_changed) self._on_value_changed(self._slider.value()) self._on_range_changed(self._slider.minimum(), self._slider.maximum()) @@ -180,7 +244,7 @@ class QLabeledRangeSlider(QAbstractSlider): else: dx *= 3 pos = self._slider.mapToParent(rect.center()) - pos += QPoint(dx + self.label_shift_x, dy + self.label_shift_y) + pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y)) label.move(pos) label.clearFocus() @@ -231,12 +295,12 @@ class QLabeledRangeSlider(QAbstractSlider): self._max_label.setValue(max) self._reposition_labels() - def value(self): - return self._slider.value() + # def setValue(self, value) -> None: + # super().setValue(value) + # self.sliderChange(QSlider.SliderValueChange) - def setValue(self, v: int) -> None: - self._slider.setValue(v) - self.sliderChange(QSlider.SliderValueChange) + def setRange(self, min, max) -> None: + self._on_range_changed(min, max) def setOrientation(self, orientation): """Set orientation, value will be 'horizontal' or 'vertical'.""" @@ -285,7 +349,27 @@ class QLabeledRangeSlider(QAbstractSlider): self._reposition_labels() -class SliderLabel(QSpinBox): +class QLabeledDoubleRangeSlider(QLabeledRangeSlider): + _slider_class = QDoubleRangeSlider + _slider: QDoubleRangeSlider + rangeChanged = Signal(float, float) + + def __init__(self, *args) -> None: + super().__init__(*args) + self.setDecimals(2) + + def decimals(self) -> int: + return self._slider.decimals() + + def setDecimals(self, prec: int): + self._slider.setDecimals(prec) + self._min_label.setDecimals(prec) + self._max_label.setDecimals(prec) + for lbl in self._handle_labels: + lbl.setDecimals(prec) + + +class SliderLabel(QDoubleSpinBox): def __init__( self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None ) -> None: @@ -293,6 +377,7 @@ class SliderLabel(QSpinBox): self._slider = slider self.setFocusPolicy(Qt.ClickFocus) self.setMode(EdgeLabelMode.LabelIsValue) + self.setDecimals(0) self.setRange(slider.minimum(), slider.maximum()) slider.rangeChanged.connect(self._update_size) @@ -304,6 +389,10 @@ class SliderLabel(QSpinBox): self.editingFinished.connect(self.clearFocus) self._update_size() + def setDecimals(self, prec: int) -> None: + super().setDecimals(prec) + self._update_size() + def _update_size(self): # fontmetrics to measure the width of text fm = QFontMetrics(self.font()) @@ -360,3 +449,9 @@ class SliderLabel(QSpinBox): self.setMaximum(self._slider.maximum()) self._slider.rangeChanged.connect(self.setRange) self._update_size() + + def validate(self, input: str, pos: int): + # fake like an integer spinbox + if "." in input and self.decimals() < 1: + return QValidator.Invalid, input, len(input) + return super().validate(input, pos) diff --git a/qtrangeslider/_qrangeslider.py b/qtrangeslider/_qrangeslider.py index 5f40900..3fb2ece 100644 --- a/qtrangeslider/_qrangeslider.py +++ b/qtrangeslider/_qrangeslider.py @@ -2,6 +2,7 @@ import textwrap from collections import abc from typing import List, Sequence, Tuple +from ._hooked import _HookedSlider from ._style import RangeSliderStyle, update_styles_from_stylesheet from .qtcompat import QtGui from .qtcompat.QtCore import ( @@ -25,7 +26,7 @@ from .qtcompat.QtWidgets import ( ControlType = Tuple[str, int] -class QRangeSlider(QSlider): +class QRangeSlider(_HookedSlider, QSlider): """MultiHandle Range Slider widget. Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and @@ -93,12 +94,11 @@ class QRangeSlider(QSlider): The number of handles will be equal to the length of the sequence """ - if not isinstance(val, abc.Sequence) and len(val) >= 2: + if not (isinstance(val, abc.Sequence) and len(val) >= 2): raise ValueError("value must be iterable of len >= 2") val = [self._min_max_bound(v) for v in val] if self._value == val and self._position == val: return - self._value[:] = val[:] if self._position != val: self._position = val @@ -106,7 +106,7 @@ class QRangeSlider(QSlider): self.sliderMoved.emit(tuple(self._position)) self.sliderChange(QSlider.SliderValueChange) - self.valueChanged.emit(tuple(self._value)) + self.valueChanged.emit(self.value()) def sliderPosition(self) -> Tuple[int, ...]: """Get current value of the widget as a tuple of integers. @@ -173,6 +173,7 @@ class QRangeSlider(QSlider): pos = self._neighbor_bound(pos, index, self._position) if pos == self._position[index]: return + self._position[index] = pos if _update: self._updateSliderMove() @@ -256,7 +257,8 @@ class QRangeSlider(QSlider): elif self._hoverControl[0] == "handle": hidx = self._hoverControl[1] for idx, pos in enumerate(self._position): - opt.sliderPosition = pos + opt.sliderPosition = self._pre_set_hook(pos) + if idx == pidx: # make pressed handles appear sunken opt.state |= QStyle.State_Sunken else: @@ -361,14 +363,14 @@ class QRangeSlider(QSlider): style = self.style().proxy() if handle_index is not None: # get specific handle rect - opt.sliderPosition = self._position[handle_index] + opt.sliderPosition = self._pre_set_hook(self._position[handle_index]) return style.subControlRect( QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self ) else: rects = [] for p in self._position: - opt.sliderPosition = p + opt.sliderPosition = self._pre_set_hook(p) r = style.subControlRect( QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self ) @@ -454,7 +456,6 @@ class QRangeSlider(QSlider): def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int: if not opt: opt = self._getStyleOption() - groove_rect = self._grooveRect(opt) handle_rect = self._handleRects(opt, 0) if self.orientation() == Qt.Horizontal: @@ -465,13 +466,14 @@ class QRangeSlider(QSlider): sliderLength = handle_rect.height() sliderMin = groove_rect.y() sliderMax = groove_rect.bottom() - sliderLength + 1 - return QStyle.sliderValueFromPosition( - self.minimum(), - self.maximum(), + v = QStyle.sliderValueFromPosition( + opt.minimum, + opt.maximum, pos - sliderMin, sliderMax - sliderMin, opt.upsideDown, ) + return self._post_get_hook(v) def _pick(self, pt: QPoint) -> int: return pt.x() if self.orientation() == Qt.Horizontal else pt.y() @@ -550,10 +552,9 @@ class QRangeSlider(QSlider): _prev_value = self.value() if modifiers & Qt.AltModifier: - self._spreadAllPositions(shrink=steps_to_scroll < 0) else: - self._offsetAllPositions(steps_to_scroll) + self._offsetAllPositions(self._post_get_hook(steps_to_scroll)) self.triggerAction(QSlider.SliderMove) if _prev_value == self.value(): diff --git a/qtrangeslider/_tests/test_float.py b/qtrangeslider/_tests/test_float.py new file mode 100644 index 0000000..69062f2 --- /dev/null +++ b/qtrangeslider/_tests/test_float.py @@ -0,0 +1,134 @@ +import os + +import pytest + +from qtrangeslider import ( + QDoubleRangeSlider, + QDoubleSlider, + QLabeledDoubleRangeSlider, + QLabeledDoubleSlider, +) +from qtrangeslider.qtcompat import API_NAME + +range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider} + + +@pytest.fixture( + params=[ + QDoubleSlider, + QLabeledDoubleSlider, + QDoubleRangeSlider, + QLabeledDoubleRangeSlider, + ] +) +def ds(qtbot, request): + # convenience fixture that converts value() and setValue() + # to let us use setValue((a, b)) for both range and non-range sliders + cls = request.param + wdg = cls() + qtbot.addWidget(wdg) + + def assert_val_type(): + type_ = float + if cls in range_types: + assert all([isinstance(i, type_) for i in wdg.value()]) # sourcery skip + else: + assert isinstance(wdg.value(), type_) + + def assert_val_eq(val): + assert wdg.value() == val if cls is QDoubleRangeSlider else val[0] + + wdg.assert_val_type = assert_val_type + wdg.assert_val_eq = assert_val_eq + + if cls not in range_types: + superset = wdg.setValue + + def _safe_set(val): + superset(val[0] if isinstance(val, tuple) else val) + + wdg.setValue = _safe_set + + return wdg + + +def test_double_sliders(ds): + ds.setMinimum(10) + ds.setMaximum(99) + ds.setValue((20, 40)) + ds.setSingleStep(1) + assert ds.minimum() == 10 + assert ds.maximum() == 99 + ds.assert_val_eq((20, 40)) + assert ds.singleStep() == 1 + + ds.setDecimals(2) + ds.assert_val_eq((20, 40)) + ds.assert_val_type() + + ds.setValue((20.23435, 40.2342)) + ds.assert_val_eq((20.23, 40.23)) # because of decimals + ds.assert_val_type() + + ds.setDecimals(4) + assert ds.minimum() == 10 + assert ds.maximum() == 99 + assert ds.singleStep() == 1 + ds.assert_val_eq((20.23, 40.23)) + ds.setValue((20.2343, 40.2342)) + ds.assert_val_eq((20.2343, 40.2342)) + + ds.setDecimals(6) + ds.assert_val_eq((20.2343, 40.2342)) + assert ds.minimum() == 10 + assert ds.maximum() == 99 + assert ds.singleStep() == 1 + + with pytest.raises(OverflowError) as err: + ds.setDecimals(8) + assert "open a feature request" in str(err) + + ds.assert_val_eq((20.2343, 40.2342)) + assert ds.minimum() == 10 + assert ds.maximum() == 99 + assert ds.singleStep() == 1 + + +def test_double_sliders_small(ds): + ds.setMaximum(1) + ds.setDecimals(8) + ds.setValue((0.5, 0.9)) + assert ds.minimum() == 0 + assert ds.maximum() == 1 + ds.assert_val_eq((0.5, 0.9)) + + ds.setValue((0.122233, 0.72644353)) + ds.assert_val_eq((0.122233, 0.72644353)) + + +def test_double_sliders_big(ds): + ds.setValue((20, 80)) + ds.setDecimals(-6) + assert ds.decimals() == -6 + ds.setMaximum(5e14) + assert ds.minimum() == 0 + assert ds.maximum() == 5e14 + ds.setValue((1.74e9, 1.432e10)) + ds.assert_val_eq((1.74e9, 1.432e10)) + + +@pytest.mark.skipif( + os.name == "nt" and API_NAME == "PyQt6", reason="Not ready for pyqt6" +) +def test_signals(ds, qtbot): + with qtbot.waitSignal(ds.valueChanged): + ds.setValue((10, 20)) + + with qtbot.waitSignal(ds.rangeChanged): + ds.setMinimum(0.5) + + with qtbot.waitSignal(ds.rangeChanged): + ds.setMaximum(3.7) + + with qtbot.waitSignal(ds.rangeChanged): + ds.setRange(1.2, 3.3) diff --git a/setup.cfg b/setup.cfg index 08691b4..71f3c1e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,3 +63,6 @@ ignore = E203,W503,E501,C901,F403,F405 [isort] profile=black + +[tool:pytest] +addopts = -W error