PythonQwt 0.16.1__tar.gz → 0.16.2__tar.gz

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.
Files changed (132) hide show
  1. {pythonqwt-0.16.1/PythonQwt.egg-info → pythonqwt-0.16.2}/PKG-INFO +1 -1
  2. {pythonqwt-0.16.1 → pythonqwt-0.16.2/PythonQwt.egg-info}/PKG-INFO +1 -1
  3. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/PythonQwt.egg-info/SOURCES.txt +1 -0
  4. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/__init__.py +1 -1
  5. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot.py +39 -17
  6. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_canvas.py +12 -3
  7. pythonqwt-0.16.2/qwt/tests/test_issue107_gdi_leak.py +72 -0
  8. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/text.py +65 -73
  9. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/LICENSE +0 -0
  10. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/MANIFEST.in +0 -0
  11. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/PythonQwt-tests.desktop +0 -0
  12. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/PythonQwt.egg-info/dependency_links.txt +0 -0
  13. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/PythonQwt.egg-info/entry_points.txt +0 -0
  14. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/PythonQwt.egg-info/requires.txt +0 -0
  15. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/PythonQwt.egg-info/top_level.txt +0 -0
  16. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/README.md +0 -0
  17. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/_static/PythonQwt_logo.png +0 -0
  18. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/_static/QwtPlot_example.png +0 -0
  19. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/_static/panorama.png +0 -0
  20. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/_static/symbol_path_example.png +0 -0
  21. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/conf.py +0 -0
  22. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/bodedemo.rst +0 -0
  23. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/cartesian.rst +0 -0
  24. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/cpudemo.rst +0 -0
  25. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/curvebenchmark1.rst +0 -0
  26. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/curvebenchmark2.rst +0 -0
  27. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/curvedemo1.rst +0 -0
  28. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/curvedemo2.rst +0 -0
  29. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/data.rst +0 -0
  30. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/errorbar.rst +0 -0
  31. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/eventfilter.rst +0 -0
  32. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/image.rst +0 -0
  33. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/index.rst +0 -0
  34. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/logcurve.rst +0 -0
  35. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/mapdemo.rst +0 -0
  36. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/multidemo.rst +0 -0
  37. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/simple.rst +0 -0
  38. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/examples/vertical.rst +0 -0
  39. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/index.rst +0 -0
  40. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/installation.rst +0 -0
  41. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/issue93_optimization_summary.md +0 -0
  42. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/overview.rst +0 -0
  43. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/plot_example.py +0 -0
  44. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/graphic.rst +0 -0
  45. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/index.rst +0 -0
  46. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/interval.rst +0 -0
  47. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/plot.rst +0 -0
  48. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/plot_directpainter.rst +0 -0
  49. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/plot_layout.rst +0 -0
  50. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/plot_series.rst +0 -0
  51. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/scale.rst +0 -0
  52. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/symbol.rst +0 -0
  53. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/text.rst +0 -0
  54. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/toqimage.rst +0 -0
  55. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/reference/transform.rst +0 -0
  56. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/doc/symbol_path_example.py +0 -0
  57. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/pyproject.toml +0 -0
  58. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/_math.py +0 -0
  59. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/color_map.py +0 -0
  60. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/column_symbol.py +0 -0
  61. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/dyngrid_layout.py +0 -0
  62. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/graphic.py +0 -0
  63. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/interval.py +0 -0
  64. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/legend.py +0 -0
  65. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/null_paintdevice.py +0 -0
  66. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/painter.py +0 -0
  67. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/painter_command.py +0 -0
  68. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_curve.py +0 -0
  69. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_directpainter.py +0 -0
  70. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_grid.py +0 -0
  71. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_layout.py +0 -0
  72. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_marker.py +0 -0
  73. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_renderer.py +0 -0
  74. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/plot_series.py +0 -0
  75. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/qthelpers.py +0 -0
  76. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/scale_div.py +0 -0
  77. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/scale_draw.py +0 -0
  78. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/scale_engine.py +0 -0
  79. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/scale_map.py +0 -0
  80. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/scale_widget.py +0 -0
  81. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/symbol.py +0 -0
  82. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/__init__.py +0 -0
  83. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/comparative_benchmarks.py +0 -0
  84. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/conftest.py +0 -0
  85. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/PythonQwt.svg +0 -0
  86. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/bodedemo.png +0 -0
  87. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/cartesian.png +0 -0
  88. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/cpudemo.png +0 -0
  89. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/curvebenchmark1.png +0 -0
  90. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/curvebenchmark2.png +0 -0
  91. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/curvedemo1.png +0 -0
  92. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/curvedemo2.png +0 -0
  93. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/data.png +0 -0
  94. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/errorbar.png +0 -0
  95. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/eventfilter.png +0 -0
  96. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/image.png +0 -0
  97. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/loadtest.png +0 -0
  98. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/logcurve.png +0 -0
  99. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/mapdemo.png +0 -0
  100. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/multidemo.png +0 -0
  101. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/simple.png +0 -0
  102. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/stylesheet.png +0 -0
  103. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/symbol.svg +0 -0
  104. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/symbols.png +0 -0
  105. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/testlauncher.png +0 -0
  106. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/data/vertical.png +0 -0
  107. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_backingstore.py +0 -0
  108. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_bodedemo.py +0 -0
  109. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_cartesian.py +0 -0
  110. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_cpudemo.py +0 -0
  111. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_curvebenchmark1.py +0 -0
  112. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_curvebenchmark2.py +0 -0
  113. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_curvedemo1.py +0 -0
  114. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_curvedemo2.py +0 -0
  115. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_data.py +0 -0
  116. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_errorbar.py +0 -0
  117. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_eventfilter.py +0 -0
  118. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_highdpi.py +0 -0
  119. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_image.py +0 -0
  120. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_loadtest.py +0 -0
  121. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_logcurve.py +0 -0
  122. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_mapdemo.py +0 -0
  123. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_multidemo.py +0 -0
  124. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_relativemargin.py +0 -0
  125. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_simple.py +0 -0
  126. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_stylesheet.py +0 -0
  127. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_symbols.py +0 -0
  128. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/test_vertical.py +0 -0
  129. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/tests/utils.py +0 -0
  130. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/toqimage.py +0 -0
  131. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/qwt/transform.py +0 -0
  132. {pythonqwt-0.16.1 → pythonqwt-0.16.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PythonQwt
3
- Version: 0.16.1
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PythonQwt
3
- Version: 0.16.1
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
@@ -95,6 +95,7 @@ qwt/tests/test_errorbar.py
95
95
  qwt/tests/test_eventfilter.py
96
96
  qwt/tests/test_highdpi.py
97
97
  qwt/tests/test_image.py
98
+ qwt/tests/test_issue107_gdi_leak.py
98
99
  qwt/tests/test_loadtest.py
99
100
  qwt/tests/test_logcurve.py
100
101
  qwt/tests/test_mapdemo.py
@@ -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.1"
66
+ __version__ = "0.16.2"
67
67
  QWT_VERSION_STR = "6.1.5"
68
68
 
69
69
 
@@ -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
  """
@@ -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
@@ -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()
@@ -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,67 +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:
214
+ _FM_CACHE_LIMIT = 256 # max QFontMetrics / QFontMetricsF / ascent entries
215
+
216
+
217
+ # Single-slot, leak-free memo for ``QFont.key()``.
220
218
  #
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()``.
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).
228
226
  #
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.
232
-
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
- _FM_CACHE_LIMIT = 256 # max QFontMetrics/QFontMetricsF entries per engine
238
-
239
-
240
- def _font_tuple_key(font):
241
- """Build a hashable tuple identifying the logical font."""
242
- px = font.pixelSize()
243
- return (
244
- font.family(),
245
- px if px > 0 else font.pointSizeF(),
246
- font.weight(),
247
- font.italic(),
248
- font.stretch(),
249
- font.styleStrategy(),
250
- )
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
251
236
 
252
237
 
253
238
  def font_key_cached(font):
254
- """Return a hashable cache key uniquely identifying ``font`` for metrics.
239
+ """Return ``font.key()`` using a single-slot, leak-free memo.
240
+
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.
255
243
 
256
- The returned value is **not** ``QFont.key()`` -- it is a tuple computed
257
- from a handful of QFont attributes. It is safe to use as a dict key for
258
- metrics caches (callers in this module always compare by ``==`` only).
244
+ :param QFont font: Font
245
+ :return: ``font.key()``
259
246
  """
247
+ global _LAST_FONT, _LAST_FONT_ID, _LAST_FONT_KEY
260
248
  fid = id(font)
261
- entry = _FONT_KEY_CACHE.get(fid)
262
- if entry is not None:
263
- return entry[1]
264
- tkey = _font_tuple_key(font)
265
- # Intern: reuse the same tuple object across all id() variants so dict
266
- # lookups in caller-side caches benefit from object-identity hash hits.
267
- interned = _FONT_TUPLE_CACHE.setdefault(tkey, tkey)
268
- if len(_FONT_KEY_CACHE) >= _FONT_KEY_CACHE_LIMIT:
269
- _FONT_KEY_CACHE.clear()
270
- _FONT_KEY_CACHE[fid] = (font, interned)
271
- 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
272
256
 
273
257
 
274
258
  def get_screen_resolution():
@@ -305,13 +289,9 @@ class QwtPlainTextEngine(QwtTextEngine):
305
289
 
306
290
  def __init__(self):
307
291
  self.qrectf_max = QRectF(0, 0, QWIDGETSIZE_MAX, QWIDGETSIZE_MAX)
308
- self._fm_cache = {}
309
- self._fm_cache_f = {}
310
- self._margins_cache = {}
311
- # Fast path: when textMargins is called repeatedly with the same
312
- # QFont instance, skip the (expensive) font.key() Qt call.
313
- self._margins_last_id = -1
314
- self._margins_last_value = None
292
+ self._fm_cache = OrderedDict()
293
+ self._fm_cache_f = OrderedDict()
294
+ self._margins_cache = OrderedDict()
315
295
 
316
296
  def fontmetrics(self, font):
317
297
  fid = font_key_cached(font)
@@ -319,8 +299,10 @@ class QwtPlainTextEngine(QwtTextEngine):
319
299
  return self._fm_cache[fid]
320
300
  except KeyError:
321
301
  if len(self._fm_cache) >= _FM_CACHE_LIMIT:
322
- self._fm_cache.clear()
323
- return self._fm_cache.setdefault(fid, QFontMetrics(font))
302
+ self._fm_cache.popitem(last=False)
303
+ fm = QFontMetrics(font)
304
+ self._fm_cache[fid] = fm
305
+ return fm
324
306
 
325
307
  def fontmetrics_f(self, font):
326
308
  fid = font_key_cached(font)
@@ -328,8 +310,10 @@ class QwtPlainTextEngine(QwtTextEngine):
328
310
  return self._fm_cache_f[fid]
329
311
  except KeyError:
330
312
  if len(self._fm_cache_f) >= _FM_CACHE_LIMIT:
331
- self._fm_cache_f.clear()
332
- return self._fm_cache_f.setdefault(fid, QFontMetricsF(font))
313
+ self._fm_cache_f.popitem(last=False)
314
+ fm = QFontMetricsF(font)
315
+ self._fm_cache_f[fid] = fm
316
+ return fm
333
317
 
334
318
  def heightForWidth(self, font, flags, text, width):
335
319
  """
@@ -359,14 +343,15 @@ class QwtPlainTextEngine(QwtTextEngine):
359
343
  return rect.size()
360
344
 
361
345
  def effectiveAscent(self, font):
362
- global ASCENTCACHE
363
346
  fontKey = font_key_cached(font)
364
347
  ascent = ASCENTCACHE.get(fontKey)
365
348
  if ascent is not None:
366
349
  return ascent
367
350
  if len(ASCENTCACHE) >= _FM_CACHE_LIMIT:
368
- ASCENTCACHE.clear()
369
- return ASCENTCACHE.setdefault(fontKey, self.findAscent(font))
351
+ ASCENTCACHE.popitem(last=False)
352
+ ascent = self.findAscent(font)
353
+ ASCENTCACHE[fontKey] = ascent
354
+ return ascent
370
355
 
371
356
  def findAscent(self, font):
372
357
  dummy = "E"
@@ -409,20 +394,14 @@ class QwtPlainTextEngine(QwtTextEngine):
409
394
  :param QFont font: Font of the text
410
395
  :return: tuple (left, right, top, bottom) representing margins
411
396
  """
412
- # Fast path: same QFont object as the previous call.
413
- font_id = id(font)
414
- if font_id == self._margins_last_id:
415
- return self._margins_last_value
416
397
  fkey = font_key_cached(font)
417
398
  cached = self._margins_cache.get(fkey)
418
399
  if cached is None:
419
400
  fm = self.fontmetrics(font)
420
401
  cached = (0, 0, fm.ascent() - self.effectiveAscent(font), fm.descent())
421
402
  if len(self._margins_cache) >= _FM_CACHE_LIMIT:
422
- self._margins_cache.clear()
403
+ self._margins_cache.popitem(last=False)
423
404
  self._margins_cache[fkey] = cached
424
- self._margins_last_id = font_id
425
- self._margins_last_value = cached
426
405
  return cached
427
406
 
428
407
  def draw(self, painter, rect, flags, text):
@@ -588,12 +567,20 @@ class QwtText_LayoutCache(object):
588
567
  def __init__(self):
589
568
  self.textSize = None
590
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.
591
576
  self.fontId = -1
577
+ self.font = None
592
578
 
593
579
  def invalidate(self):
594
580
  self.textSize = None
595
581
  self.fontKey = None
596
582
  self.fontId = -1
583
+ self.font = None
597
584
 
598
585
 
599
586
  class QwtText(object):
@@ -1108,6 +1095,10 @@ class QwtText(object):
1108
1095
  """
1109
1096
  font = self.usedFont(defaultFont)
1110
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``.
1111
1102
  font_id = id(font)
1112
1103
  if cache.textSize is not None and cache.fontId == font_id:
1113
1104
  sz = QSizeF(cache.textSize)
@@ -1123,6 +1114,7 @@ class QwtText(object):
1123
1114
  )
1124
1115
  cache.fontKey = fkey
1125
1116
  cache.fontId = font_id
1117
+ cache.font = font
1126
1118
  sz = QSizeF(cache.textSize)
1127
1119
  if self.__data.layoutAttributes & self.MinimumLayout:
1128
1120
  (left, right, top, bottom) = self.__data.textEngine.textMargins(font)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes