PythonQwt 0.16.0__py3-none-any.whl → 0.16.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PythonQwt
3
- Version: 0.16.0
3
+ Version: 0.16.2
4
4
  Summary: Qt plotting widgets for Python
5
5
  Author-email: Pierre Raybaut <pierre.raybaut@gmail.com>
6
6
  License: PythonQwt License Agreement
@@ -1,5 +1,5 @@
1
- pythonqwt-0.16.0.dist-info/licenses/LICENSE,sha256=qjEk8TRuXmFS7QC-omINvD1UPGqWaOs6CzcCZoMEhdI,33457
2
- qwt/__init__.py,sha256=wOp2xyXj0loXQntSFHwu_OzVj09zvQ6n-BLp46BXy68,5990
1
+ pythonqwt-0.16.2.dist-info/licenses/LICENSE,sha256=qjEk8TRuXmFS7QC-omINvD1UPGqWaOs6CzcCZoMEhdI,33457
2
+ qwt/__init__.py,sha256=U3tgDHGBj6RL0pfmUJR82xLCU-f4m_uzWb3LuSIr3lI,5990
3
3
  qwt/_math.py,sha256=fNcPJcaK-ldCCFB20T2N58LEkQ1lwZY1-q5veXXhwl8,1501
4
4
  qwt/color_map.py,sha256=fLRpymqOXtLrhuPrbbCxTvNINbr2GH7pYBG4fZidrcs,11812
5
5
  qwt/column_symbol.py,sha256=0aFyQ05ryTKbaSTctkhtXa0pB_tWG444zlH3991_bt0,5760
@@ -10,8 +10,8 @@ qwt/legend.py,sha256=GBUim67HWgwLat5tOWDpK98fWcFxV-RkxWjoyWd9Gao,31313
10
10
  qwt/null_paintdevice.py,sha256=1pgbcvyRTVN32RGzuPzUOm0iUImjvPQ4l5OfJVyA3hg,9375
11
11
  qwt/painter.py,sha256=k345puF4MAoTye0WBveiTNpCNWXx1K2o62_I0PZYlIA,16536
12
12
  qwt/painter_command.py,sha256=7D3goNsX_2Ufr5kCpC3Cr1t8GoD5Yz0G1oIZJKvoGBY,8501
13
- qwt/plot.py,sha256=d8rf4F-fpQgdpTTqNKQFJh7B0FlQ4WlquooeJQRn_eo,75660
14
- qwt/plot_canvas.py,sha256=INRcxB17dvyGI7Jqi2FX4gm16EG4gzJKULj9inHkibA,30309
13
+ qwt/plot.py,sha256=XXcITYdLiAjIbT-8YfGkroNn5IhZsUs9oPvm1k_uFoE,76659
14
+ qwt/plot_canvas.py,sha256=-Yjlkt0igkrsis6z8GaT2DXOMeaR699gi5wmU9EtPH4,30835
15
15
  qwt/plot_curve.py,sha256=ThlfxUjB4yiP_Dh96P7PXusyqUfDWcRcyt31OHvu2vk,37059
16
16
  qwt/plot_directpainter.py,sha256=JCWNhCC21L1A1HZnxd-C95QNlrVJbLztmGab-X8gCGc,10165
17
17
  qwt/plot_grid.py,sha256=h-cDTzibEzQyX2g25SBg5ysyXwmONmMFy9NcuzFySYc,16607
@@ -21,12 +21,12 @@ qwt/plot_renderer.py,sha256=v-fSwicMGQBa_kQx4hJJptlVNpNsKbgWk9jjqG-r_1g,27201
21
21
  qwt/plot_series.py,sha256=Wa_Wunu-t_L8vl95RJq6__k-XA8UV2YNIsc8yNGpTT8,10654
22
22
  qwt/qthelpers.py,sha256=GpvLr96Ip8SIGwyFfs0f7ocHyKPB7PEjHBQyrpw84xw,1405
23
23
  qwt/scale_div.py,sha256=_iI5cy4s8H6wigE8xsn7W44T2-DqVADqdjQ31uJPa_I,9266
24
- qwt/scale_draw.py,sha256=iO39q7nXJRuFTYan3m5taAGqvmzCq2JX0PhySWtAYVo,41007
24
+ qwt/scale_draw.py,sha256=KYal9YOpvHjFWGkBFPVVNq_64cxM1vsHg_f_n-2nKNU,40547
25
25
  qwt/scale_engine.py,sha256=7KQB5k-JXuJMsEMQXBJBNppCN-rTN97UKQCYXdazvqQ,36674
26
26
  qwt/scale_map.py,sha256=rHmPv7e9voEFuAIInQJq60mTtIGrLMViNlgJYu20znA,9605
27
27
  qwt/scale_widget.py,sha256=p202DrRgMraGbVcubzkatvVXOLbO9CPho729hz6gqa0,27125
28
28
  qwt/symbol.py,sha256=DIXtIOgniO70pScKF-H_mee8GgBuycW3YcsopvfCRGY,39147
29
- qwt/text.py,sha256=isLS7UmbJrAmOoxhszPwrkJSRTh5WFmqVkbz_npIi94,48099
29
+ qwt/text.py,sha256=GeA18Hn0WBDwKZRwk3jAXMkOmiS-FAL9jfLri8qF0tY,47674
30
30
  qwt/toqimage.py,sha256=H2u_qDt32n7HGZakWvs3Ve0G3u04Uk2ZQYp8MuZkkmU,1723
31
31
  qwt/transform.py,sha256=bFkdmI2wDjOJrUZU6E9-sAvwoo_umXycxq6LPvD6N_c,6078
32
32
  qwt/tests/__init__.py,sha256=diBkA_H_Xuxb84tNelsNU4djTlUnKJ5HZNZr02EwnjE,1102
@@ -45,6 +45,7 @@ qwt/tests/test_errorbar.py,sha256=ecJ_YHcx1RhCS9_BKKoNYz6bOuuqapRPGL2Vp_r3O1U,10
45
45
  qwt/tests/test_eventfilter.py,sha256=31vCsCzRcoq3cyMlDAH5fthA45QqeFXKaWyhfZME6rE,16441
46
46
  qwt/tests/test_highdpi.py,sha256=Xafo016q-RX4918JddBGD60HZ4GpuYJBJokEO15md24,1053
47
47
  qwt/tests/test_image.py,sha256=yY_AplKEdm-M7uB5G50p4E68ZzNamYvGSfN5rvlVPPM,6549
48
+ qwt/tests/test_issue107_gdi_leak.py,sha256=7IgZd9vJyD0_ea4CIDJ_SjlYDwYkA94aSRWEqt4y2ew,2203
48
49
  qwt/tests/test_loadtest.py,sha256=ZTHatcGAbLyvS0BpbOWbzPD2Bjz4rckF7oxZsUVQ5KM,1812
49
50
  qwt/tests/test_logcurve.py,sha256=XOtR0asSqFfzc0VL0qfXN0fXNvxvLnz5fxmNZ2gzQWk,1129
50
51
  qwt/tests/test_mapdemo.py,sha256=e7rvB8IPKYVVii8UgdHSbOHLc3i7eB21EQNKZ151Kc8,3423
@@ -77,8 +78,8 @@ qwt/tests/data/symbol.svg,sha256=ONkkohVqpg3OO2-XEZge7UZLqqNyOQXbSpLgHXcTBFU,127
77
78
  qwt/tests/data/symbols.png,sha256=wiqRBc2MJluJ-am9LtZ0K9DHV9BnreYMiEvfzJUFzCk,55586
78
79
  qwt/tests/data/testlauncher.png,sha256=WmucObp30QYYKsVRBs5-6t1aEN0FbIWRmOVFN1gt-vQ,139676
79
80
  qwt/tests/data/vertical.png,sha256=EvyG6q6rZm6pTT08Y_nzdBYCnqmoX1QDuEQtp2pyGlw,34076
80
- pythonqwt-0.16.0.dist-info/METADATA,sha256=jjr2ZDxH0abEGbmHoFLQsXEbk8X-3FLRTRqajqSnqDk,45515
81
- pythonqwt-0.16.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
82
- pythonqwt-0.16.0.dist-info/entry_points.txt,sha256=pdPda-YcYigCi00hR4tMxWKu6byxM2x3zA8BQFDYvwI,46
83
- pythonqwt-0.16.0.dist-info/top_level.txt,sha256=KB1IBdWRWnaItyJMaECwZiEi1jWt3IvqCrRVVhMjTu8,4
84
- pythonqwt-0.16.0.dist-info/RECORD,,
81
+ pythonqwt-0.16.2.dist-info/METADATA,sha256=iy-EOwIq8Y7_5k0xwlPDLm1TuBLmomXNEZ-vl3BMPhY,45515
82
+ pythonqwt-0.16.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
83
+ pythonqwt-0.16.2.dist-info/entry_points.txt,sha256=pdPda-YcYigCi00hR4tMxWKu6byxM2x3zA8BQFDYvwI,46
84
+ pythonqwt-0.16.2.dist-info/top_level.txt,sha256=KB1IBdWRWnaItyJMaECwZiEi1jWt3IvqCrRVVhMjTu8,4
85
+ pythonqwt-0.16.2.dist-info/RECORD,,
qwt/__init__.py CHANGED
@@ -63,7 +63,7 @@ from qwt.symbol import QwtSymbol as QSbl # see deprecated section
63
63
  from qwt.text import QwtText # noqa: F401
64
64
  from qwt.toqimage import array_to_qimage as toQImage # noqa: F401
65
65
 
66
- __version__ = "0.16.0"
66
+ __version__ = "0.16.2"
67
67
  QWT_VERSION_STR = "6.1.5"
68
68
 
69
69
 
qwt/plot.py CHANGED
@@ -20,6 +20,7 @@ QwtPlotItem
20
20
  """
21
21
 
22
22
  import math
23
+ import weakref
23
24
 
24
25
  import numpy as np
25
26
  from qtpy.QtCore import QEvent, QObject, QRectF, QSize, Qt, Signal
@@ -1007,7 +1008,12 @@ class QwtPlot(QFrame):
1007
1008
  return QFrame.event(self, event)
1008
1009
 
1009
1010
  def eventFilter(self, obj, event):
1010
- if obj is self.__data.canvas:
1011
+ try:
1012
+ canvas = self.__data.canvas
1013
+ except AttributeError:
1014
+ # The plot is being destroyed: Python attributes already cleared
1015
+ return QFrame.eventFilter(self, obj, event)
1016
+ if obj is canvas:
1011
1017
  if event.type() == QEvent.Resize:
1012
1018
  self.updateCanvasMargins()
1013
1019
  elif event.type() == 178: # QEvent.ContentsRectChange:
@@ -1195,7 +1201,12 @@ class QwtPlot(QFrame):
1195
1201
  """
1196
1202
  :return: Return a minimum size hint
1197
1203
  """
1198
- hint = self.__data.layout.minimumSizeHint(self)
1204
+ try:
1205
+ layout = self.__data.layout
1206
+ except AttributeError:
1207
+ # The plot is being destroyed: Python attributes already cleared
1208
+ return QSize()
1209
+ hint = layout.minimumSizeHint(self)
1199
1210
  hint += QSize(2 * self.frameWidth(), 2 * self.frameWidth())
1200
1211
  return hint
1201
1212
 
@@ -1815,16 +1826,23 @@ class QwtPlotItem(object):
1815
1826
 
1816
1827
  :py:meth:`detach()`
1817
1828
  """
1818
- if plot is self.__data.plot:
1829
+ if plot is self.plot():
1819
1830
  return
1820
1831
 
1821
- if self.__data.plot:
1822
- self.__data.plot.attachItem(self, False)
1832
+ current = self.plot()
1833
+ if current is not None:
1834
+ current.attachItem(self, False)
1823
1835
 
1824
- self.__data.plot = plot
1836
+ # Store the parent plot as a WEAK reference: an item must not keep its
1837
+ # plot (and the plot's GDI-backed child widgets) alive. A strong
1838
+ # back-reference here forms a ``plot <-> item`` reference cycle that can
1839
+ # only be reclaimed by the cyclic garbage collector, which delays the
1840
+ # release of native GDI handles and can lead to GDI-handle exhaustion
1841
+ # on Windows when many plots are created and destroyed.
1842
+ self.__data.plot = None if plot is None else weakref.ref(plot)
1825
1843
 
1826
- if self.__data.plot:
1827
- self.__data.plot.attachItem(self, True)
1844
+ if plot is not None:
1845
+ plot.attachItem(self, True)
1828
1846
 
1829
1847
  def detach(self):
1830
1848
  """
@@ -1854,7 +1872,8 @@ class QwtPlotItem(object):
1854
1872
  """
1855
1873
  :return: attached plot
1856
1874
  """
1857
- return self.__data.plot
1875
+ ref = self.__data.plot
1876
+ return None if ref is None else ref()
1858
1877
 
1859
1878
  def z(self):
1860
1879
  """
@@ -1881,11 +1900,12 @@ class QwtPlotItem(object):
1881
1900
  :py:meth:`z()`, :py:meth:`QwtPlotDict.itemList()`
1882
1901
  """
1883
1902
  if self.__data.z != z:
1884
- if self.__data.plot:
1885
- self.__data.plot.attachItem(self, False)
1903
+ plot = self.plot()
1904
+ if plot is not None:
1905
+ plot.attachItem(self, False)
1886
1906
  self.__data.z = z
1887
- if self.__data.plot:
1888
- self.__data.plot.attachItem(self, True)
1907
+ if plot is not None:
1908
+ plot.attachItem(self, True)
1889
1909
  self.itemChanged()
1890
1910
 
1891
1911
  def setTitle(self, title):
@@ -2091,8 +2111,9 @@ class QwtPlotItem(object):
2091
2111
 
2092
2112
  :py:meth:`QwtPlot.legendChanged()`, :py:meth:`QwtPlot.autoRefresh()`
2093
2113
  """
2094
- if self.__data.plot:
2095
- self.__data.plot.autoRefresh()
2114
+ plot = self.plot()
2115
+ if plot is not None:
2116
+ plot.autoRefresh()
2096
2117
 
2097
2118
  def legendChanged(self):
2098
2119
  """
@@ -2102,8 +2123,9 @@ class QwtPlotItem(object):
2102
2123
 
2103
2124
  :py:meth:`QwtPlot.updateLegend()`, :py:meth:`itemChanged()`
2104
2125
  """
2105
- if self.testItemAttribute(QwtPlotItem.Legend) and self.__data.plot:
2106
- self.__data.plot.updateLegend(self)
2126
+ plot = self.plot()
2127
+ if self.testItemAttribute(QwtPlotItem.Legend) and plot is not None:
2128
+ plot.updateLegend(self)
2107
2129
 
2108
2130
  def setAxes(self, xAxis, yAxis):
2109
2131
  """
qwt/plot_canvas.py CHANGED
@@ -13,6 +13,7 @@ QwtPlotCanvas
13
13
  :members:
14
14
  """
15
15
 
16
+ import weakref
16
17
  from collections.abc import Sequence
17
18
 
18
19
  from qtpy.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt
@@ -442,7 +443,12 @@ class QwtPlotCanvas(QFrame):
442
443
 
443
444
  def __init__(self, plot=None):
444
445
  super(QwtPlotCanvas, self).__init__(plot)
445
- self.__plot = plot
446
+ # Store the parent plot as a WEAK reference to avoid a
447
+ # ``plot <-> canvas`` reference cycle (the plot already owns the canvas
448
+ # through Qt's parent/child relationship). A strong back-reference here
449
+ # would keep the plot alive until the cyclic garbage collector runs,
450
+ # delaying the release of native GDI handles on Windows.
451
+ self.__plot = None if plot is None else weakref.ref(plot)
446
452
  self.setFrameStyle(QFrame.Panel | QFrame.Sunken)
447
453
  self.setLineWidth(2)
448
454
  self.__data = QwtPlotCanvas_PrivateData()
@@ -456,7 +462,8 @@ class QwtPlotCanvas(QFrame):
456
462
  """
457
463
  :return: Parent plot widget
458
464
  """
459
- return self.__plot
465
+ ref = self.__plot
466
+ return None if ref is None else ref()
460
467
 
461
468
  def setPaintAttribute(self, attribute, on=True):
462
469
  """
@@ -695,7 +702,9 @@ class QwtPlotCanvas(QFrame):
695
702
  else:
696
703
  # print('**DEBUG: QwtPlotCanvas.drawCanvas')
697
704
  painter.setClipRect(self.contentsRect(), Qt.IntersectClip)
698
- self.plot().drawCanvas(painter)
705
+ plot = self.plot()
706
+ if plot is not None:
707
+ plot.drawCanvas(painter)
699
708
  painter.restore()
700
709
  if withBackground and hackStyledBackground:
701
710
  # Now paint the border on top
qwt/scale_draw.py CHANGED
@@ -24,6 +24,7 @@ from datetime import datetime
24
24
 
25
25
  from qtpy.QtCore import (
26
26
  QLineF,
27
+ QObject,
27
28
  QPoint,
28
29
  QPointF,
29
30
  QRect,
@@ -50,22 +51,11 @@ _ALIGN_TOP = int(Qt.AlignTop)
50
51
  _ALIGN_BOTTOM = int(Qt.AlignBottom)
51
52
 
52
53
 
53
- class QwtAbstractScaleDraw_PrivateData(object):
54
- # See QwtText_PrivateData: ``QObject`` inheritance is unused and the
55
- # base class' ``__init__`` is a measurable cost in tick-heavy renders.
56
- __slots__ = (
57
- "spacing",
58
- "penWidth",
59
- "minExtent",
60
- "components",
61
- "tick_length",
62
- "tick_lighter_factor",
63
- "map",
64
- "scaleDiv",
65
- "labelCache",
66
- )
54
+ class QwtAbstractScaleDraw_PrivateData(QObject):
55
+ # QObject base class restored for Qt parent/child ownership semantics.
67
56
 
68
57
  def __init__(self):
58
+ QObject.__init__(self)
69
59
  self.spacing = 4
70
60
  self.penWidth = 0
71
61
  self.minExtent = 0.0
@@ -492,20 +482,11 @@ class QwtAbstractScaleDraw(object):
492
482
  self.__data.labelCache.clear()
493
483
 
494
484
 
495
- class QwtScaleDraw_PrivateData(object):
496
- # See QwtText_PrivateData: ``QObject`` inheritance is unused and the
497
- # base class' ``__init__`` is a measurable cost in tick-heavy renders.
498
- __slots__ = (
499
- "len",
500
- "alignment",
501
- "orientation",
502
- "labelAlignment",
503
- "labelRotation",
504
- "labelAutoSize",
505
- "pos",
506
- )
485
+ class QwtScaleDraw_PrivateData(QObject):
486
+ # QObject base class restored for Qt parent/child ownership semantics.
507
487
 
508
488
  def __init__(self):
489
+ QObject.__init__(self)
509
490
  self.len = 0
510
491
  self.alignment = QwtScaleDraw.BottomScale
511
492
  # Cached orientation - kept in sync by ``QwtScaleDraw.setAlignment``
@@ -0,0 +1,72 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Licensed under the terms of the Qwt License
4
+ # (see LICENSE file for more details)
5
+
6
+ """
7
+ Test for issue 107: GDI handle exhaustion on Windows.
8
+ """
9
+
10
+ import numpy as np
11
+ import pytest
12
+ from qtpy import QtCore as QC
13
+ from qtpy import QtWidgets as QW
14
+
15
+ from qwt import QwtPlot, QwtPlotCurve
16
+ from qwt.tests.utils import TestEnvironment
17
+
18
+
19
+ def run_stress_cycle():
20
+ """Run one cycle of plot creation, rendering and destruction."""
21
+ plot = QwtPlot()
22
+ plot.resize(QC.QSize(800, 600))
23
+ plot.show()
24
+
25
+ # Create many curves with different fonts (via titles) to pressure caches
26
+ for i in range(20):
27
+ curve = QwtPlotCurve(f"Curve {i}")
28
+ x = np.linspace(0, 10, 100)
29
+ y = np.sin(x + i / 10.0)
30
+ curve.setData(x, y)
31
+ curve.attach(plot)
32
+
33
+ plot.replot()
34
+ QW.QApplication.processEvents()
35
+
36
+ plot.close()
37
+ plot.deleteLater()
38
+ QW.QApplication.processEvents()
39
+
40
+
41
+ def test_gdi_leak_stability():
42
+ """
43
+ Repeatedly create/render/destroy plots to check for GDI leak stability.
44
+ On Windows, without the fix, this would crash after a certain number of cycles
45
+ due to GDI handle exhaustion.
46
+ """
47
+ env = TestEnvironment()
48
+ if not env.unattended:
49
+ pytest.skip("This test is for CI/unattended mode only")
50
+
51
+ # Close any top-level widgets left alive by previous tests in the shared
52
+ # QApplication. This test aggressively drives ``deleteLater()`` +
53
+ # ``processEvents()`` in-process; processing deferred deletions while stale
54
+ # widgets from earlier tests are still pending triggers a Qt teardown
55
+ # fast-fail (0xC0000409) on Windows. Starting from a clean widget set makes
56
+ # the test robust inside the full suite (it already passes in isolation).
57
+ app = QW.QApplication.instance() or QW.QApplication([])
58
+ for widget in list(app.topLevelWidgets()):
59
+ widget.close()
60
+ widget.deleteLater()
61
+ app.processEvents()
62
+ app.sendPostedEvents(None, QC.QEvent.DeferredDelete)
63
+ app.processEvents()
64
+
65
+ n_cycles = 50
66
+ for i in range(n_cycles):
67
+ run_stress_cycle()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ app = QW.QApplication.instance() or QW.QApplication([])
72
+ test_gdi_leak_stability()
qwt/text.py CHANGED
@@ -46,6 +46,7 @@ QwtRichTextEngine
46
46
  import math
47
47
  import os
48
48
  import struct
49
+ from collections import OrderedDict
49
50
 
50
51
  from qtpy.QtCore import QObject, QRectF, QSize, QSizeF, Qt
51
52
  from qtpy.QtGui import (
@@ -208,66 +209,50 @@ class QwtTextEngine(object):
208
209
  pass
209
210
 
210
211
 
211
- ASCENTCACHE = {}
212
+ ASCENTCACHE = OrderedDict()
212
213
 
213
- # Module-level cache: ``id(font) -> tuple_key`` (fast path) and
214
- # ``tuple_key -> tuple_key`` (slow path). The tuple key is built from a
215
- # handful of QFont attributes that uniquely identify the *logical* font for
216
- # metrics purposes. Tick-rendering uses very few distinct fonts in practice
217
- # so both dicts stay tiny.
218
- #
219
- # This replaces the previous ``id(font) -> font.key()`` design. Two reasons:
220
- #
221
- # 1. ``QFont.key()`` is a sip dispatch that costs ~3.3 us/call on PyQt5 and
222
- # ~9.3 us/call on PyQt6 -- it became the single biggest residual hotspot
223
- # in ``QwtText.textSize`` on PyQt6.
224
- # 2. PyQt6 returns a fresh Python wrapper around the same QFont on most
225
- # calls, so ``id(font)`` changes between calls and the id-keyed fast path
226
- # misses ~92% of the time. The tuple-key second level recovers the hits
227
- # those misses would have produced, without paying for ``font.key()``.
228
- #
229
- # The tuple key uses ``(family, pixelSize-or-pointSizeF, weight, italic,
230
- # stretch, styleStrategy)``. This is what determines ``QFontMetrics`` output
231
- # in practice; if two QFonts share these values they share metrics.
214
+ _FM_CACHE_LIMIT = 256 # max QFontMetrics / QFontMetricsF / ascent entries
232
215
 
233
- _FONT_KEY_CACHE: dict = {} # id(font) -> tuple_key (fast path)
234
- _FONT_TUPLE_CACHE: dict = {} # tuple_key -> tuple_key (interning, also acts
235
- # as the "have we seen this logical font" set)
236
- _FONT_KEY_CACHE_LIMIT = 1024
237
216
 
238
-
239
- def _font_tuple_key(font):
240
- """Build a hashable tuple identifying the logical font."""
241
- px = font.pixelSize()
242
- return (
243
- font.family(),
244
- px if px > 0 else font.pointSizeF(),
245
- font.weight(),
246
- font.italic(),
247
- font.stretch(),
248
- font.styleStrategy(),
249
- )
217
+ # Single-slot, leak-free memo for ``QFont.key()``.
218
+ #
219
+ # ``QFont.key()`` is the only correct, binding-stable identity for metrics
220
+ # caching, but it is a relatively costly sip call (~1 us on PyQt5, ~1.6 us on
221
+ # PyQt6) and it is queried once per tick label. The previous implementation
222
+ # memoized it in an ``id(font) -> (font, key)`` dict which kept a *strong
223
+ # reference* to every distinct QFont wrapper it saw (up to 1024). That is a
224
+ # genuine memory / GDI-handle leak that the bounded ``.clear()`` only masked
225
+ # (see the issue #93 segfault regression).
226
+ #
227
+ # This single-slot memo keeps at most ONE QFont alive (bounded => not a leak)
228
+ # while still skipping the repeated ``font.key()`` call for the very common
229
+ # case of the same painter font being reused across an entire scale-draw pass.
230
+ # Retaining that one font is precisely what makes the ``id()`` comparison safe
231
+ # against id reuse. On a miss the real ``font.key()`` is recomputed, so the
232
+ # result is always lossless and correct.
233
+ _LAST_FONT = None
234
+ _LAST_FONT_ID = None
235
+ _LAST_FONT_KEY = None
250
236
 
251
237
 
252
238
  def font_key_cached(font):
253
- """Return a hashable cache key uniquely identifying ``font`` for metrics.
239
+ """Return ``font.key()`` using a single-slot, leak-free memo.
254
240
 
255
- The returned value is **not** ``QFont.key()`` -- it is a tuple computed
256
- from a handful of QFont attributes. It is safe to use as a dict key for
257
- metrics caches (callers in this module always compare by ``==`` only).
241
+ The returned value is the genuine ``QFont.key()`` string and is safe to use
242
+ as a dict key for metrics caches. At most one ``QFont`` is ever retained.
243
+
244
+ :param QFont font: Font
245
+ :return: ``font.key()``
258
246
  """
247
+ global _LAST_FONT, _LAST_FONT_ID, _LAST_FONT_KEY
259
248
  fid = id(font)
260
- entry = _FONT_KEY_CACHE.get(fid)
261
- if entry is not None:
262
- return entry[1]
263
- tkey = _font_tuple_key(font)
264
- # Intern: reuse the same tuple object across all id() variants so dict
265
- # lookups in caller-side caches benefit from object-identity hash hits.
266
- interned = _FONT_TUPLE_CACHE.setdefault(tkey, tkey)
267
- if len(_FONT_KEY_CACHE) >= _FONT_KEY_CACHE_LIMIT:
268
- _FONT_KEY_CACHE.clear()
269
- _FONT_KEY_CACHE[fid] = (font, interned)
270
- return interned
249
+ if fid == _LAST_FONT_ID:
250
+ return _LAST_FONT_KEY
251
+ key = font.key()
252
+ _LAST_FONT = font
253
+ _LAST_FONT_ID = fid
254
+ _LAST_FONT_KEY = key
255
+ return key
271
256
 
272
257
 
273
258
  def get_screen_resolution():
@@ -304,27 +289,31 @@ class QwtPlainTextEngine(QwtTextEngine):
304
289
 
305
290
  def __init__(self):
306
291
  self.qrectf_max = QRectF(0, 0, QWIDGETSIZE_MAX, QWIDGETSIZE_MAX)
307
- self._fm_cache = {}
308
- self._fm_cache_f = {}
309
- self._margins_cache = {}
310
- # Fast path: when textMargins is called repeatedly with the same
311
- # QFont instance, skip the (expensive) font.key() Qt call.
312
- self._margins_last_id = -1
313
- self._margins_last_value = None
292
+ self._fm_cache = OrderedDict()
293
+ self._fm_cache_f = OrderedDict()
294
+ self._margins_cache = OrderedDict()
314
295
 
315
296
  def fontmetrics(self, font):
316
297
  fid = font_key_cached(font)
317
298
  try:
318
299
  return self._fm_cache[fid]
319
300
  except KeyError:
320
- return self._fm_cache.setdefault(fid, QFontMetrics(font))
301
+ if len(self._fm_cache) >= _FM_CACHE_LIMIT:
302
+ self._fm_cache.popitem(last=False)
303
+ fm = QFontMetrics(font)
304
+ self._fm_cache[fid] = fm
305
+ return fm
321
306
 
322
307
  def fontmetrics_f(self, font):
323
308
  fid = font_key_cached(font)
324
309
  try:
325
310
  return self._fm_cache_f[fid]
326
311
  except KeyError:
327
- return self._fm_cache_f.setdefault(fid, QFontMetricsF(font))
312
+ if len(self._fm_cache_f) >= _FM_CACHE_LIMIT:
313
+ self._fm_cache_f.popitem(last=False)
314
+ fm = QFontMetricsF(font)
315
+ self._fm_cache_f[fid] = fm
316
+ return fm
328
317
 
329
318
  def heightForWidth(self, font, flags, text, width):
330
319
  """
@@ -354,12 +343,15 @@ class QwtPlainTextEngine(QwtTextEngine):
354
343
  return rect.size()
355
344
 
356
345
  def effectiveAscent(self, font):
357
- global ASCENTCACHE
358
346
  fontKey = font_key_cached(font)
359
347
  ascent = ASCENTCACHE.get(fontKey)
360
348
  if ascent is not None:
361
349
  return ascent
362
- return ASCENTCACHE.setdefault(fontKey, self.findAscent(font))
350
+ if len(ASCENTCACHE) >= _FM_CACHE_LIMIT:
351
+ ASCENTCACHE.popitem(last=False)
352
+ ascent = self.findAscent(font)
353
+ ASCENTCACHE[fontKey] = ascent
354
+ return ascent
363
355
 
364
356
  def findAscent(self, font):
365
357
  dummy = "E"
@@ -402,18 +394,14 @@ class QwtPlainTextEngine(QwtTextEngine):
402
394
  :param QFont font: Font of the text
403
395
  :return: tuple (left, right, top, bottom) representing margins
404
396
  """
405
- # Fast path: same QFont object as the previous call.
406
- font_id = id(font)
407
- if font_id == self._margins_last_id:
408
- return self._margins_last_value
409
397
  fkey = font_key_cached(font)
410
398
  cached = self._margins_cache.get(fkey)
411
399
  if cached is None:
412
400
  fm = self.fontmetrics(font)
413
401
  cached = (0, 0, fm.ascent() - self.effectiveAscent(font), fm.descent())
402
+ if len(self._margins_cache) >= _FM_CACHE_LIMIT:
403
+ self._margins_cache.popitem(last=False)
414
404
  self._margins_cache[fkey] = cached
415
- self._margins_last_id = font_id
416
- self._margins_last_value = cached
417
405
  return cached
418
406
 
419
407
  def draw(self, painter, rect, flags, text):
@@ -557,26 +545,11 @@ class QwtRichTextEngine(QwtTextEngine):
557
545
  return 0, 0, 0, 0
558
546
 
559
547
 
560
- class QwtText_PrivateData(object):
561
- # ``QObject`` was previously used as the base class but no Qt signals
562
- # or events are ever emitted from ``_PrivateData`` containers and the
563
- # ``QObject.__init__`` call dominates ``QwtText.__init__`` (it is the
564
- # single most expensive line for tick-label-heavy renders, see
565
- # https://github.com/PlotPyStack/PythonQwt/issues/93).
566
- __slots__ = (
567
- "renderFlags",
568
- "borderRadius",
569
- "borderPen",
570
- "backgroundBrush",
571
- "paintAttributes",
572
- "layoutAttributes",
573
- "textEngine",
574
- "text",
575
- "font",
576
- "color",
577
- )
548
+ class QwtText_PrivateData(QObject):
549
+ # QObject base class restored for Qt parent/child ownership semantics.
578
550
 
579
551
  def __init__(self):
552
+ QObject.__init__(self)
580
553
  self.renderFlags = Qt.AlignCenter
581
554
  self.borderRadius = 0
582
555
  self.borderPen = Qt.NoPen
@@ -594,12 +567,20 @@ class QwtText_LayoutCache(object):
594
567
  def __init__(self):
595
568
  self.textSize = None
596
569
  self.fontKey = None
570
+ # ``id(font)`` fast path for ``QwtText.textSize``. ``font`` keeps a
571
+ # strong reference to the cached font so its id cannot be reused by a
572
+ # different object while cached (guards against a stale-size false hit).
573
+ # This is a per-instance, single-slot cache: it retains at most one
574
+ # QFont per QwtText and is released with the QwtText, so unlike the old
575
+ # unbounded module-level font cache it cannot leak.
597
576
  self.fontId = -1
577
+ self.font = None
598
578
 
599
579
  def invalidate(self):
600
580
  self.textSize = None
601
581
  self.fontKey = None
602
582
  self.fontId = -1
583
+ self.font = None
603
584
 
604
585
 
605
586
  class QwtText(object):
@@ -1114,6 +1095,10 @@ class QwtText(object):
1114
1095
  """
1115
1096
  font = self.usedFont(defaultFont)
1116
1097
  cache = self.__layoutCache
1098
+ # Fast path: same font *object* as the previous call on this text.
1099
+ # ``cache.font`` keeps that font alive, so the id comparison is safe
1100
+ # against id reuse, and we skip the (relatively costly) ``font.key()``
1101
+ # call performed by ``font_key_cached``.
1117
1102
  font_id = id(font)
1118
1103
  if cache.textSize is not None and cache.fontId == font_id:
1119
1104
  sz = QSizeF(cache.textSize)
@@ -1129,6 +1114,7 @@ class QwtText(object):
1129
1114
  )
1130
1115
  cache.fontKey = fkey
1131
1116
  cache.fontId = font_id
1117
+ cache.font = font
1132
1118
  sz = QSizeF(cache.textSize)
1133
1119
  if self.__data.layoutAttributes & self.MinimumLayout:
1134
1120
  (left, right, top, bottom) = self.__data.textEngine.textMargins(font)