Repository navigation
fix: porting bugs in rarely used APIs, and release 0.17.0 - #116
Merged
Merged
Conversation
QwtGraphic.toPixmap(size), QwtGraphic.toImage() and QwtGraphic.toImage(size) raised a TypeError instead of rendering the graphic, and could abort the process because the painter was still active when the pixmap or image was destroyed. The Qwt C++ code passes a QRect to render(), which is implicitly converted to a QRectF. The Python port passed the QRect as is, which render() rejects, and QRect(0, 0, width, height) also fails when the size is a QSizeF. toImage(size) did not end its painter either. - Build a QRectF, like toPixmap() already does, and end the painter in toImage(size). - Regression tests: default size, given size and kept aspect ratio, for both toPixmap and toImage. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
QwtPlotMarker.legendIcon raised an AssertionError (QSize icon size) or a TypeError (QSizeF icon size) for markers with a symbol, so that such a marker could not be shown in a legend. The Qwt C++ code passes a QRect to QwtSymbol::drawSymbol, which is implicitly converted to a QRectF. The Python port passed the QRect as is, which QwtSymbol.drawSymbol rejects, and QRect(0, 0, width, height) also fails when the size is a QSizeF. - Build a QRectF, like QwtPlotCurve.legendIcon already does. - Regression tests: legend icon with QSize and QSizeF sizes, marker with a symbol shown in a QwtLegend. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
QwtPlotGrid.make raised a TypeError when only some of the minor pen arguments (mincolor, minwidth, minstyle) were given, e.g. QwtPlotGrid.make(mincolor="red"): the default minor width and style were chosen by testing the major width and style arguments, which are never None at that point, instead of the minor ones, so None was passed to QPen. - Test minwidth and minstyle against None, as documented, and convert the minor width to float like the major one. - Regression tests: every combination of minor pen arguments, and major pen unchanged; calls which already worked keep the same pens. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
QwtPowerTransform could not be used to draw curves: curve data are mapped by QwtScaleMap.transform as NumPy arrays, and the scalar test `if value < 0.0` raised "ValueError: The truth value of an array with more than one element is ambiguous". It also relied on numpy.pow, which was added in NumPy 2.0, whereas PythonQwt requires NumPy >= 1.21. - Apply the Qwt C++ formula (value < 0.0 ? -pow(-value, e) : pow(value, e)) element-wise with numpy.less, numpy.where and numpy.power. For scalars, results are bit-for-bit identical to the previous implementation (value, type, sign of zero, infinities, NaN and warnings), as checked on 364 scalar cases. - Regression tests: scalars against the C++ formula, arrays and round trip, numpy.pow unavailable (simulating NumPy < 2.0), scale map on arrays, curve rendered on a power-scaled axis. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
QwtPlotDirectPainter.setAttribute decided whether to set or to clear the attribute by comparing its current state with `on`, instead of using `on`: enabling AtomicPainter twice disabled it, and FullRepaint or CopyBackingStore could not be disabled anymore once enabled (their non-boolean flag values never compare equal to `on`). - Restore the Qwt C++ logic: change the attribute only when its state differs from `on`, set it when `on` is true and clear it otherwise, and reset the painter only when AtomicPainter gets enabled. - Regression tests: set, set again, clear, clear again and default `on` for each attribute, other attributes unchanged; painter reset count. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
Painting into a QwtGraphic with clipping enabled raised "TypeError: unsupported operand type(s) for &=: 'QRectF' and 'QRect'" on each paint operation. As the exception is raised in a Qt virtual method (paint engine drawPath, drawPixmap and drawImage), PyQt aborts the process with its default exception hook. QwtGraphic.updateBoundingRect intersects the bounding rectangle with the bounding rectangle of the clip region, which is a QRect. The Qwt C++ code converts it implicitly to a QRectF; the Python port did not. - Convert the clip region bounding rectangle to a QRectF before mapping and intersecting it. - Regression test: clipped path, pixmap and image recorded with a translated painter; control point rectangle not clipped, bounding rectangle clipped. Exceptions raised in Qt virtual methods are collected so that the test fails instead of aborting the process. - Changelog entry. Found while writing the tests for the painter command copy. Refs #115 Assisted-by: Claude Opus 5.5
The QwtPainterCommand copy constructor (QwtPainterCommand(other)) only worked for path commands: - state commands raised an AttributeError, because the copied data was compared (`==`) instead of being assigned (`=`); - pixmap and image commands raised "TypeError: cannot pickle 'QPixmap'" (or 'QImage'), because copy.deepcopy cannot copy Qt objects. The Qwt C++ code copies the command data member-wise, Qt value types being copied by value. - Copy the command data attribute by attribute, Qt value types (pen, brush, font, transform, region, path, pixmap, image, point, rectangle) with their copy constructor, other values (enums, flags, numbers) being immutable. - Regression tests: copy of path, pixmap, image and state commands (same type and equal data), copies independent of the originals, graphic replaying the copied commands rendered like the original. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
QwtDynGridLayout.setGeometry and QwtDynGridLayout.stretchGrid computed the number of rows with a true division (`/`) where the Qwt C++ code uses an integer division: numRows() returned a float (e.g. 2.5 for 3 items on 2 columns), and laying out the items with vertical expansion (setExpandingDirections(Qt.Vertical)) raised "TypeError: 'float' object cannot be interpreted as an integer". - Use an integer division (`//`) in both methods. The other row count computations already convert the quotient to an integer and are left unchanged. - Regression tests: integer number of rows for several item and column counts, rows stretched to the layout height with vertical expansion. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
A canvas style sheet with rounded borders, e.g.
plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;"),
raised a TypeError on each style change, resize and paint event. As these
exceptions are raised in Qt virtual methods, PyQt aborts the process with
its default exception hook (0xC0000409 on Windows).
Three porting bugs were on this code path, which the Qwt C++ code avoids
with implicit conversions:
- QwtStyleSheetRecorder.drawPath built QRectF(QPointF, QSize): the size is
now converted to a QSizeF;
- qwtCombinePathList used `rect.bottom` instead of `rect.bottom()` and
None placeholders instead of empty paths (so that any border without
all its corners failed), and reverted the input sub-paths in place: it
now works on a QRectF, empty paths and copies of the sub-paths, as in
Qwt C++;
- qwtFillBackground passed the QRectF corner rectangles of the style sheet
to QRegion.intersects, QPixmap and QWidget.mapTo, which expect integer
types: they are now converted with QRectF.toAlignedRect(), as in Qwt
C++. QRect rectangles (plain canvas with a border radius) are left
unchanged by this conversion.
Canvases without rounded style sheet borders render exactly as before:
same images, pixel for pixel, with and without backing store, for
canvases without style sheet, with non-rounded style sheets and with a
border radius set by setBorderRadius.
- Regression tests: incomplete and complete rounded corners combined
without modifying the input paths; canvases with three rounded style
sheets, with and without backing store, rendered without exception
(exceptions raised in Qt virtual methods are collected), with a border
path and a rounded corner filled with the plot background.
- Changelog entry.
Refs #115
Assisted-by: Claude Opus 5.5
The image filter of the file dialog shown by QwtPlotRenderer.exportTo read "Images (*.b'bmp' *.b'png' ...)": the image format names returned by QImageWriter.supportedImageFormats() are QByteArray objects, which were converted with str() instead of being decoded. - Decode the format names (bytes(fmt).decode(), supported by PyQt5, PyQt6 and PySide6). - Regression test: file dialog replaced by a stub capturing the filters, image filter equal to the decoded format names. - Changelog entry. The export itself (QwtPlotRenderer.renderDocument) is not affected: PNG, SVG and PDF exports already work with the three Qt bindings. Refs #115 Assisted-by: Claude Opus 5.5
QwtInterval.intersect (and the `&` operator) returned an invalid interval for partially overlapping intervals, e.g. QwtInterval(0, 2) & QwtInterval(1, 3), and for intervals sharing an included bound: after ordering the intervals by their minimum, it tested `i1.maxValue() < i2.maxValue()` instead of `i1.maxValue() < i2.minValue()` (Qwt C++). QwtInterval.intersects gave the opposite result for intervals sharing a bound: `[0, 1]` and `[1, 2]` did not intersect, while `[0, 1)` and `(1, 2]` did. Qwt C++ returns `!(i1 excludes its maximum || i2 excludes its minimum)`. - Restore the Qwt C++ conditions; intersects now always returns a bool. - Results which were already valid are unchanged: checked on all the 4096 pairs of intervals with bounds in 0..3 and any border flags (including invalid intervals), both methods now match a direct transcription of the Qwt C++ code. - Regression tests: table of overlapping, nested, equal, disjoint and touching intervals with various border flags, in both orders, with `intersect` and `&`; `intersects` cases; invalid intervals. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
The source distribution did not include qtdesigner/qwtplugin.py, the Qt Designer plugin added in 0.16.3 (whose installation, documented in the "Qt Designer" section of the reference, relies on this file), nor CHANGELOG.md, referenced by the README. - MANIFEST.in: include CHANGELOG.md and qtdesigner/*.py (an explicit pattern rather than graft, so that no __pycache__ is ever shipped). - Checked with `python -m build`: the sdist gains exactly these two files, the wheel content is unchanged. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
The documentation build reported 3 errors and 1 warning: the docstrings of the qwt.qtdesigner module follow the Google style (Args/Returns), which Sphinx does not understand without the napoleon extension. Besides, QwtDateTimeScaleDraw and QwtDateTimeScaleEngine were missing from the reference documentation. - doc/conf.py: enable sphinx.ext.napoleon. - Add QwtDateTimeScaleDraw and QwtDateTimeScaleEngine to the module documentation of qwt.scale_draw and qwt.scale_engine. - QwtDateTimeScaleDraw.get_format/label: drop the return types from the Returns sections (already in the signatures), as "QwtText" was an ambiguous cross-reference. - QwtPlotLayout.expandLineBreaks: move its "Returns:" list into the `:return:` field, which napoleon would otherwise misinterpret. - Changelog entry. `sphinx -b html doc ... -W` now succeeds without any warning. Compared with a build without napoleon, only the qtdesigner, scale and plot_layout reference pages change. Refs #115 Assisted-by: Claude Opus 5.5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes porting bugs (differences with the Qwt C++ code) which made several rarely used APIs raise an exception, sometimes aborting the process, or ignore their arguments. It also completes the source distribution and the documentation, and bumps the version to 0.17.0.
Safety rule followed: only code paths which fail today were changed, so that no rendering or behavior that works today can change. Code paths which work today but differ from Qwt C++ are listed in #115 ("Out of scope") and left for dedicated changes.
Changes (one commit per fix, each with its regression tests and changelog entry)
fix(graphic)QwtGraphic.toPixmap(size),toImage(),toImage(size)TypeError(QRectpassed torender(), which expects aQRectF), possibly aborting the processQRectF;toImage(size)ends its painterfix(plot_marker)QwtPlotMarker.legendIcon(marker with a symbol)AssertionError/TypeErrorQRectF, likeQwtPlotCurve.legendIconfix(plot_grid)QwtPlotGrid.make(mincolor=..., minwidth=..., minstyle=...)TypeErrorunless bothminwidthandminstyleare givenfix(transform)QwtPowerTransformValueErrorwhen drawing curves (arrays),AttributeErrorwith NumPy < 2 (np.pow)np.where/np.power; scalar results bit-for-bit identical (364 cases: values, types, signed zeros, inf, NaN, warnings)fix(plot_directpainter)QwtPlotDirectPainter.setAttributeAtomicPainter;FullRepaint/CopyBackingStorecannot be clearedfix(graphic)(found during this work)QwtGraphicwith clippingTypeErroron each paint operation (QRectF &= QRect) in a Qt virtual method → process abortQRectF, as in C++fix(painter_command)QwtPainterCommand(other)(copy)AttributeError(state,==instead of=),TypeError(pixmap/image:deepcopyof Qt objects)fix(dyngrid_layout)QwtDynGridLayoutnumRows()is a float (2.5);TypeErrorwith vertical expansionfix(plot_canvas)TypeErroron each style change/resize/paint in Qt virtual methods → process abort (0xC0000409)QwtStyleSheetRecorder.drawPath(QSize→QSizeF),qwtCombinePathList(rect.bottom(), empty paths, copies),qwtFillBackground(toAlignedRect()), as in C++fix(plot_renderer)QwtPlotRenderer.exportTofile dialog*.b'bmp' *.b'png' ...fix(interval)QwtInterval.intersect/&,QwtInterval.intersectsbuildqtdesigner/qwtplugin.pyandCHANGELOG.md__pycache__)docssphinx.ext.napoleon,QwtDateTimeScaleDraw/QwtDateTimeScaleEnginedocumented; build passes with-WchoreTests
Regression tests in
qwt/tests/test_issue115_porting_bugs.py(73 tests). Each test was run before its fix (failure or abort reproduced) and after it (pass). Exceptions raised in Qt virtual methods are collected, so that a regression makes the test fail instead of aborting the process.Local results (Windows, offscreen):
masternp.pow)develop)develop)Non-regression checks:
setBorderRadius(10); with and without backing store) are pixel-identical tomaster(PyQt5); on Qt 6, where some of these renders are not deterministic even onmaster, each one matches amasterrender.QRectF(r).toAlignedRect() == rfor anyQRect(200,000 random cases), soqwtFillBackgroundis unchanged for its existingQRectinputs.qtdesigner,scaleandplot_layoutreference pages change.CHANGELOG.mdandqtdesigner/qwtplugin.py(plus the new test module); the wheel only gains the new test module.Out of scope
Findings which would change the rendering of plots that work today, or diverge from Qwt C++, are listed in #115 and left for separate changes (e.g.
setTickLighterFactor,ColorStopsalpha flag,QwtPlot.updateLayout,QwtPlotLayout.minimumSizeHint,QwtPlotRenderer.render,QwtPlotCanvas.paintEvent, float border radius inqwtFillBackground).Fixes #115