Skip to content

fix: porting bugs in rarely used APIs, and release 0.17.0 - #116

Merged
PierreRaybaut merged 14 commits into
masterfrom
fix/115-porting-bugs
Oct 30, 2026
Merged

PierreRaybaut merged 14 commits into
masterfrom
fix/115-porting-bugs

Conversation

@PierreRaybaut

Copy link
Copy Markdown
Collaborator

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)

Commit API Before After (aligned with Qwt C++)
fix(graphic) QwtGraphic.toPixmap(size), toImage(), toImage(size) TypeError (QRect passed to render(), which expects a QRectF), possibly aborting the process Render into a QRectF; toImage(size) ends its painter
fix(plot_marker) QwtPlotMarker.legendIcon (marker with a symbol) AssertionError/TypeError Symbol drawn into a QRectF, like QwtPlotCurve.legendIcon
fix(plot_grid) QwtPlotGrid.make(mincolor=..., minwidth=..., minstyle=...) TypeError unless both minwidth and minstyle are given Each argument honored, documented defaults for the others
fix(transform) QwtPowerTransform ValueError when drawing curves (arrays), AttributeError with NumPy < 2 (np.pow) Element-wise C++ formula with np.where/np.power; scalar results bit-for-bit identical (364 cases: values, types, signed zeros, inf, NaN, warnings)
fix(plot_directpainter) QwtPlotDirectPainter.setAttribute Setting twice clears AtomicPainter; FullRepaint/CopyBackingStore cannot be cleared C++ logic
fix(graphic) (found during this work) Painting into a QwtGraphic with clipping TypeError on each paint operation (QRectF &= QRect) in a Qt virtual method → process abort Clip rectangle converted to QRectF, as in C++
fix(painter_command) QwtPainterCommand(other) (copy) AttributeError (state, == instead of =), TypeError (pixmap/image: deepcopy of Qt objects) Member-wise copy with Qt copy constructors
fix(dyngrid_layout) QwtDynGridLayout numRows() is a float (2.5); TypeError with vertical expansion Integer division
fix(plot_canvas) Canvas style sheets with rounded borders TypeError on 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.exportTo file dialog Filter *.b'bmp' *.b'png' ... Format names decoded
fix(interval) QwtInterval.intersect / &, QwtInterval.intersects Invalid result for overlapping intervals; inverted result for touching intervals C++ conditions; previously valid results unchanged (4096 interval pairs checked against a transcription of the C++ code)
build sdist Missing qtdesigner/qwtplugin.py and CHANGELOG.md Included (explicit pattern, no __pycache__)
docs Sphinx 3 errors, 1 warning; datetime scale classes undocumented sphinx.ext.napoleon, QwtDateTimeScaleDraw/QwtDateTimeScaleEngine documented; build passes with -W
chore Version 0.16.3 0.17.0 (Python 3.10 now required, fixes for #113 and #115)

Tests

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):

Configuration master This branch
Python 3.11, PyQt5 5.15 36 passed, 1 skipped 109 passed, 1 skipped
Python 3.14, PyQt5 36 passed, 1 skipped 109 passed, 1 skipped
Python 3.11, PyQt6 6.12 36 passed, 1 skipped 109 passed, 1 skipped
Python 3.11, PySide6 6.11 35 passed, 2 skipped 108 passed, 2 skipped
Python 3.11, PyQt5, NumPy 1.26 (no np.pow) – 109 passed, 1 skipped
PlotPy test suite (develop) 428 passed, 16 skipped 428 passed, 16 skipped
DataLab test suite (develop) – 1273 passed, 3 skipped (no failure)

Non-regression checks:

  • Canvas rendering unchanged: images of canvases which already worked (no style sheet, non-rounded style sheets, dark style sheet, setBorderRadius(10); with and without backing store) are pixel-identical to master (PyQt5); on Qt 6, where some of these renders are not deterministic even on master, each one matches a master render. QRectF(r).toAlignedRect() == r for any QRect (200,000 random cases), so qwtFillBackground is unchanged for its existing QRect inputs.
  • Rounded style sheets: 9 style sheets × with/without backing store render without any exception on PyQt5, PyQt6 and PySide6.
  • Documentation: compared with a build without napoleon, only the qtdesigner, scale and plot_layout reference pages change.
  • Packaging: the sdist gains exactly CHANGELOG.md and qtdesigner/qwtplugin.py (plus the new test module); the wheel only gains the new test module.
  • Ruff format and check: clean.

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, ColorStops alpha flag, QwtPlot.updateLayout, QwtPlotLayout.minimumSizeHint, QwtPlotRenderer.render, QwtPlotCanvas.paintEvent, float border radius in qwtFillBackground).

Fixes #115

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
Python 3.10 is now required (minimum supported version raised), hence a
new minor version, which also gathers the fixes for #113 and #115.

- qwt/__init__.py: __version__ = "0.17.0".
- CHANGELOG.md: "Unreleased" section renamed to "Version 0.17.0".

Refs #115

Assisted-by: Claude Opus 5.5
@PierreRaybaut
PierreRaybaut merged commit e3e3ba0 into master Oct 30, 2026
12 checks passed
@PierreRaybaut
PierreRaybaut deleted the fix/115-porting-bugs branch October 30, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant