Browse Source

chore: add stuff

pull/588/head
Parodi, Eugenio 🌶 2 months ago
parent
commit
84df4ca0f0
  1. 191
      apps/ttkode/ttkode/plugins/_030/pytest_tree.py
  2. 121
      apps/ttkode/ttkode/plugins/_030/pytest_widget.py

191
apps/ttkode/ttkode/plugins/_030/pytest_tree.py

@ -20,6 +20,13 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
''' PyTest Tree Widget Components
This module provides specialized tree widget components for displaying pytest
test hierarchies with visual status indicators and interactive controls for
running individual tests or test groups.
'''
from __future__ import annotations from __future__ import annotations
__all__ = [ __all__ = [
@ -37,6 +44,10 @@ from _030.pytest_data import PTP_Node
from ttkode.app.ttkode import TTKodeFileWidgetItem from ttkode.app.ttkode import TTKodeFileWidgetItem
class _testStatus(IntEnum): class _testStatus(IntEnum):
''' Test status enumeration for visual indicators
Represents the current state of a test item in the tree.
'''
Pass = 0x01 Pass = 0x01
Fail = 0x02 Fail = 0x02
Undefined = 0x03 Undefined = 0x03
@ -48,9 +59,31 @@ _statusMarks = {
} }
def _toMark(status:_testStatus) -> ttk.TTkString: def _toMark(status:_testStatus) -> ttk.TTkString:
''' Convert test status to colored visual mark
:param status: the test status
:type status: _testStatus
:return: colored status mark (, x, or )
:rtype: :py:class:`TTkString`
'''
return _statusMarks.get(status, ttk.TTkString('- ')) return _statusMarks.get(status, ttk.TTkString('- '))
class PTP_TreeItem(ttk.TTkTreeWidgetItem): class PTP_TreeItem(ttk.TTkTreeWidgetItem):
''' Base tree item for pytest test nodes
Extends :py:class:`TTkTreeWidgetItem` to add test-specific functionality including
status tracking and visual indicators.
:param name: display name for the tree item
:type name: str
:param test_id: unique pytest node ID
:type test_id: str
:param node: associated pytest node data
:type node: PTP_Node
:param testStatus: initial test status
:type testStatus: _testStatus
'''
__slots__ = ('_testStatus', '_ptp_node', '_test_id') __slots__ = ('_testStatus', '_ptp_node', '_test_id')
def __init__(self, name:str, test_id:str, node:PTP_Node, testStatus:_testStatus=_testStatus.Undefined, **kwargs): def __init__(self, name:str, test_id:str, node:PTP_Node, testStatus:_testStatus=_testStatus.Undefined, **kwargs):
self._testStatus = testStatus self._testStatus = testStatus
@ -59,44 +92,150 @@ class PTP_TreeItem(ttk.TTkTreeWidgetItem):
super().__init__([ttk.TTkString(name)], **kwargs) super().__init__([ttk.TTkString(name)], **kwargs)
def test_id(self) -> str: def test_id(self) -> str:
''' Get the pytest node ID for this item
:return: the test ID path
:rtype: str
'''
return self._test_id return self._test_id
def node(self) -> PTP_Node: def node(self) -> PTP_Node:
''' Get the pytest node data
:return: the associated node data
:rtype: PTP_Node
'''
return self._ptp_node return self._ptp_node
def data(self, col, role = None) -> ttk.TTkString: def data(self, col, role = None) -> ttk.TTkString:
''' Get display data with status mark prefix
:param col: column index
:type col: int
:param role: data role (unused)
:type role: Optional[Any]
:return: formatted string with status mark
:rtype: :py:class:`TTkString`
'''
return _toMark(self._testStatus) + super().data(col, role) return _toMark(self._testStatus) + super().data(col, role)
def testStatus(self) -> _testStatus: def testStatus(self) -> _testStatus:
''' Get the current test status
:return: the test status
:rtype: _testStatus
'''
return self._testStatus return self._testStatus
def clearTestStatus(self, clearParent:bool=False, clearChildren:bool=False) -> None:
''' Clear test status to undefined
:param clearParent: whether to recursively clear parent status
:type clearParent: bool
:param clearChildren: whether to recursively clear children status
:type clearChildren: bool
'''
if self._testStatus is _testStatus.Undefined:
return
self._testStatus = _testStatus.Undefined
if clearParent:
if isinstance(self._parent, PTP_TreeItem):
self._parent.clearTestStatus(clearParent=True)
self.dataChanged.emit()
def setTestStatus(self, status:_testStatus) -> None: def setTestStatus(self, status:_testStatus) -> None:
''' Set the test status and update parent if needed
:param status: the new test status
:type status: _testStatus
'''
if status == self._testStatus: if status == self._testStatus:
return return
self._testStatus = status self._testStatus = status
if isinstance(self._parent, PTP_TreeItemPath):
self._parent._updateTestStatus(status=status)
self.dataChanged.emit() self.dataChanged.emit()
class PTP_TreeItemPath(PTP_TreeItem): class PTP_TreeItemPath(PTP_TreeItem):
pass ''' Tree item representing a test path or directory
Aggregates status from child test items - shows failure if any child fails.
'''
def _updateTestStatus(self, status:_testStatus):
''' Update status based on child test statuses
:param status: the new status to propagate
:type status: _testStatus
'''
fail = any(_c.testStatus() == _testStatus.Fail for _c in self.children() if isinstance(_c, PTP_TreeItem))
self._testStatus = _testStatus.Fail if fail else status
if isinstance(self._parent, PTP_TreeItemPath):
self._parent._updateTestStatus(status=status)
self.dataChanged.emit()
def clearTestStatus(self, clearParent:bool=False, clearChildren:bool=False) -> None:
if clearChildren:
for _c in self.children():
if isinstance(_c, PTP_TreeItem):
_c.clearTestStatus(clearChildren=True)
super().clearTestStatus(clearParent=clearParent)
class PTP_TreeItemMethod(PTP_TreeItem): class PTP_TreeItemMethod(PTP_TreeItem):
''' Tree item representing an individual test method
Leaf node in the test tree hierarchy.
'''
pass pass
@dataclass @dataclass
class _PTP_Highlight(): class _PTP_Highlight():
''' Internal state for mouse hover highlighting
:param pos: vertical position of the highlight
:type pos: int
:param run: whether mouse is over the run button area
:type run: bool
:param item: the highlighted tree item
:type item: PTP_TreeItem
'''
pos:int pos:int
run:bool run:bool
item:PTP_TreeItem item:PTP_TreeItem
@dataclass @dataclass
class PTP_Action(): class PTP_Action():
''' Action data emitted when a test run button is clicked
:param item: the test item to run
:type item: PTP_TreeItem
'''
item:PTP_TreeItem item:PTP_TreeItem
class PTP_TreeWidget(ttk.TTkTreeWidget): class PTP_TreeWidget(ttk.TTkTreeWidget):
''' Custom tree widget with interactive run buttons
Extends :py:class:`TTkTreeWidget` to add per-item run buttons that appear
on mouse hover, allowing individual test or test group execution.
::
test_module.py ...[ ]
test_pending
x test_failed
'''
__slots__ = ('_PTP_highight', 'actionPressed') __slots__ = ('_PTP_highight', 'actionPressed')
_PTP_highight:Optional[_PTP_Highlight] _PTP_highight:Optional[_PTP_Highlight]
actionPressed: ttk.pyTTkSignal
'''
This signal is emitted when a run button is clicked.
:param action: the action containing the item to run
:type action: PTP_Action
'''
def __init__(self, **kwargs): def __init__(self, **kwargs):
self._PTP_highight = None self._PTP_highight = None
@ -105,6 +244,14 @@ class PTP_TreeWidget(ttk.TTkTreeWidget):
self.mergeStyle({'default':{'hoveredColor':ttk.TTkColor.bg('#666666')}}) self.mergeStyle({'default':{'hoveredColor':ttk.TTkColor.bg('#666666')}})
def mousePressEvent(self, evt:ttk.TTkMouseEvent) -> bool: def mousePressEvent(self, evt:ttk.TTkMouseEvent) -> bool:
''' Handle mouse press events for run button activation
:param evt: the mouse event
:type evt: :py:class:`TTkMouseEvent`
:return: True if event was handled
:rtype: bool
'''
y,x = evt.y, evt.x y,x = evt.y, evt.x
w = self.width() w = self.width()
if (_item:=self.itemAt(y)) and x>=w-3: if (_item:=self.itemAt(y)) and x>=w-3:
@ -114,6 +261,14 @@ class PTP_TreeWidget(ttk.TTkTreeWidget):
return super().mousePressEvent(evt) return super().mousePressEvent(evt)
def mouseMoveEvent(self, evt:ttk.TTkMouseEvent) -> bool: def mouseMoveEvent(self, evt:ttk.TTkMouseEvent) -> bool:
''' Handle mouse movement to update hover highlighting
:param evt: the mouse event
:type evt: :py:class:`TTkMouseEvent`
:return: True if event was handled
:rtype: bool
'''
y,x = evt.y, evt.x y,x = evt.y, evt.x
w = self.width() w = self.width()
if _item:=self.itemAt(y): if _item:=self.itemAt(y):
@ -129,28 +284,54 @@ class PTP_TreeWidget(ttk.TTkTreeWidget):
return super().mouseMoveEvent(evt) return super().mouseMoveEvent(evt)
def leaveEvent(self, evt:ttk.TTkMouseEvent) -> bool: def leaveEvent(self, evt:ttk.TTkMouseEvent) -> bool:
''' Clear highlighting when mouse leaves widget
:param evt: the mouse event
:type evt: :py:class:`TTkMouseEvent`
:return: True if event was handled
:rtype: bool
'''
if self._PTP_highight is not None: if self._PTP_highight is not None:
self._PTP_highight = None self._PTP_highight = None
self.update() self.update()
super().leaveEvent(evt) super().leaveEvent(evt)
def paintEvent(self, canvas:ttk.TTkCanvas) -> None: def paintEvent(self, canvas:ttk.TTkCanvas) -> None:
''' Paint the tree with interactive run buttons on hover
:param canvas: the canvas to paint on
:type canvas: :py:class:`TTkCanvas`
'''
super().paintEvent(canvas) super().paintEvent(canvas)
style = self.currentStyle() style = self.currentStyle()
hoveredColor=style['hoveredColor'] hoveredColor=style['hoveredColor']
if _ph:=self._PTP_highight: if _ph:=self._PTP_highight:
w = self.width() w = self.width()
if _ph.run: if _ph.run:
canvas.drawText(text='...[ ]', pos=(w-6,_ph.pos), color=hoveredColor+ttk.TTkColor.YELLOW+ttk.TTkColor.BOLD) canvas.drawText(text='[ ]', pos=(w-3,_ph.pos), color=hoveredColor+ttk.TTkColor.YELLOW+ttk.TTkColor.BOLD)
canvas.drawText(text= '' , pos=(w-2,_ph.pos), color=hoveredColor+ttk.TTkColor.RED) canvas.drawText(text= '' , pos=(w-2,_ph.pos), color=hoveredColor+ttk.TTkColor.RED)
else: else:
canvas.drawText(text='...[ ]', pos=(w-6,_ph.pos), color=hoveredColor+ttk.TTkColor.YELLOW+ttk.TTkColor.BOLD) canvas.drawText(text='[ ]', pos=(w-3,_ph.pos), color=hoveredColor+ttk.TTkColor.YELLOW+ttk.TTkColor.BOLD)
canvas.drawText(text= '' , pos=(w-2,_ph.pos), color=hoveredColor+ttk.TTkColor.GREEN) canvas.drawText(text= '' , pos=(w-2,_ph.pos), color=hoveredColor+ttk.TTkColor.GREEN)
# canvas.drawText(text= '▷' , pos=(w-3,_ph.pos), color=hoveredColor+ttk.TTkColor.GREEN) # canvas.drawText(text= '▷' , pos=(w-3,_ph.pos), color=hoveredColor+ttk.TTkColor.GREEN)
class PTP_Tree(ttk.TTkTree): class PTP_Tree(ttk.TTkTree):
''' Pytest tree container with scrollable view
Wraps :py:class:`PTP_TreeWidget` in a scrollable :py:class:`TTkTree` container,
exposing the actionPressed signal for test execution.
'''
__slots__ = ('actionPressed') __slots__ = ('actionPressed')
actionPressed: ttk.pyTTkSignal
'''
This signal is emitted when a run button is clicked.
:param action: the action containing the item to run
:type action: PTP_Action
'''
def __init__(self, **kwargs): def __init__(self, **kwargs):
tw = PTP_TreeWidget(**kwargs) tw = PTP_TreeWidget(**kwargs)
super().__init__(treeWidget=tw, **kwargs) super().__init__(treeWidget=tw, **kwargs)

121
apps/ttkode/ttkode/plugins/_030/pytest_widget.py

@ -20,6 +20,12 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
''' PyTest Widget Plugin
This module provides a pytest integration widget for ttkode that allows
scanning, running, and viewing test results in a tree structure.
'''
__all__ = ['PTP_PyTestWidget'] __all__ = ['PTP_PyTestWidget']
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
@ -41,6 +47,14 @@ from .pytest_engine import PTP_Engine, PTP_TestResult, PTP_ScanResult
from .pytest_data import PTP_Node from .pytest_data import PTP_Node
def _strip_result(result: str) -> str: def _strip_result(result: str) -> str:
''' Strip common leading whitespace from multi-line test result strings
:param result: the test result string to strip
:type result: str
:return: the stripped result string
:rtype: str
'''
lines = result.splitlines() lines = result.splitlines()
indent = min(len(line) - len(line.lstrip()) for line in lines if line.strip()) indent = min(len(line) - len(line.lstrip()) for line in lines if line.strip())
result = "\n".join(line[indent:] for line in lines) result = "\n".join(line[indent:] for line in lines)
@ -53,6 +67,27 @@ _out_map = {
_error_color = ttk.TTkColor.fgbg('#FFFF00',"#FF0000") _error_color = ttk.TTkColor.fgbg('#FFFF00',"#FF0000")
class PTP_PyTestWidget(ttk.TTkContainer): class PTP_PyTestWidget(ttk.TTkContainer):
''' PTP_PyTestWidget:
A widget for integrating pytest test execution and result viewing in ttkode.
Provides a tree view of discovered tests, action buttons for scanning and running,
and displays test results with syntax-highlighted error messages.
::
[Scan] [Run]
tests/
test_module.py
test_pass
test_fail
? test_pending
:param testResults: the text edit widget to display test results
:type testResults: :py:class:`TTkTextEdit`
'''
__slots__ = ( __slots__ = (
'_test_engine', '_test_engine',
'_res_tree','_test_results') '_res_tree','_test_results')
@ -83,14 +118,30 @@ class PTP_PyTestWidget(ttk.TTkContainer):
@ttk.pyTTkSlot(PTP_Action) @ttk.pyTTkSlot(PTP_Action)
def _tree_action_pressed(self, action:PTP_Action) -> None: def _tree_action_pressed(self, action:PTP_Action) -> None:
''' Handle action button presses in the test tree
Clears previous test status and runs tests for the selected item.
:param action: the tree action containing the test item to run
:type action: PTP_Action
'''
item = action.item item = action.item
if isinstance(item, PTP_TreeItem): if isinstance(item, PTP_TreeItem):
self._mark_node(item.test_id(), outcome='undefined') item.clearTestStatus(clearChildren=True, clearParent=True)
self._test_results.clear() self._test_results.clear()
self._test_engine.run_tests(item.test_id()) self._test_engine.run_tests(item.test_id())
@ttk.pyTTkSlot(PTP_TreeItem, int) @ttk.pyTTkSlot(PTP_TreeItem, int)
def _tree_item_activated(self, item:PTP_TreeItem, _): def _tree_item_activated(self, item:PTP_TreeItem, _):
''' Handle test tree item activation
When a test method is activated, opens the corresponding file at the test line number.
:param item: the activated tree item
:type item: PTP_TreeItem
:param _: column index (unused)
:type _: int
'''
ttk.TTkLog.debug(item) ttk.TTkLog.debug(item)
if isinstance(item, PTP_TreeItemMethod): if isinstance(item, PTP_TreeItemMethod):
file = item.node().filename file = item.node().filename
@ -98,6 +149,16 @@ class PTP_PyTestWidget(ttk.TTkContainer):
ttkodeProxy.openFile(file, line) ttkodeProxy.openFile(file, line)
def _get_node_from_path(self, _n:ttk.TTkTreeWidgetItem, _p:str) -> Optional[PTP_TreeItem]: def _get_node_from_path(self, _n:ttk.TTkTreeWidgetItem, _p:str) -> Optional[PTP_TreeItem]:
''' Recursively search for a tree node by test ID path
:param _n: the tree node to search from
:type _n: :py:class:`TTkTreeWidgetItem`
:param _p: the test ID path to find
:type _p: str
:return: the matching tree item or None if not found
:rtype: Optional[PTP_TreeItem]
'''
for _c in _n.children(): for _c in _n.children():
if isinstance(_c, PTP_TreeItem) and _c.test_id() == _p: if isinstance(_c, PTP_TreeItem) and _c.test_id() == _p:
return _c return _c
@ -107,6 +168,11 @@ class PTP_PyTestWidget(ttk.TTkContainer):
@ttk.pyTTkSlot() @ttk.pyTTkSlot()
def _scan(self): def _scan(self):
''' Scan the workspace for pytest tests
Clears the current tree and results, then initiates a pytest collection
to discover all available tests and populate the tree structure.
'''
self._res_tree.clear() self._res_tree.clear()
self._test_results.clear() self._test_results.clear()
@ -123,21 +189,17 @@ class PTP_PyTestWidget(ttk.TTkContainer):
@ttk.pyTTkSlot(PTP_ScanResult) @ttk.pyTTkSlot(PTP_ScanResult)
def _add_node(_node:PTP_ScanResult)->None: def _add_node(_node:PTP_ScanResult)->None:
ttk.TTkLog.debug(_node)
self._test_results.append(_node.nodeId) self._test_results.append(_node.nodeId)
_node_id_split = _node.nodeId.split('::') _node_id_split = _node.nodeId.split('::')
_full_test_path = _node_id_split[0] _full_test_path = _node_id_split[0]
_leaves = _node_id_split[1:] _leaves = _node_id_split[1:]
_tree_node: ttk.TTkTreeWidgetItem = self._res_tree.invisibleRootItem() _tree_node: ttk.TTkTreeWidgetItem = self._res_tree.invisibleRootItem()
_full_composite_test_path = '' _full_composite_test_path = ''
ttk.TTkLog.debug(_node_id_split)
ttk.TTkLog.debug(_full_test_path)
for _test_path in _full_test_path.split('/'): for _test_path in _full_test_path.split('/'):
if _full_composite_test_path: if _full_composite_test_path:
_full_composite_test_path = '/'.join([_full_composite_test_path,_test_path]) _full_composite_test_path = '/'.join([_full_composite_test_path,_test_path])
else: else:
_full_composite_test_path = _test_path _full_composite_test_path = _test_path
ttk.TTkLog.debug(_full_composite_test_path)
_tree_node = _get_or_add_path_in_node(_n=_tree_node, _p=_full_composite_test_path, _name=_test_path, _node=_node) _tree_node = _get_or_add_path_in_node(_n=_tree_node, _p=_full_composite_test_path, _name=_test_path, _node=_node)
for _leaf in _leaves: for _leaf in _leaves:
_full_composite_test_path = '::'.join([_full_composite_test_path,_leaf]) _full_composite_test_path = '::'.join([_full_composite_test_path,_leaf])
@ -160,35 +222,55 @@ class PTP_PyTestWidget(ttk.TTkContainer):
self._test_engine.scan() self._test_engine.scan()
def _mark_node(self, _nodeid:str, outcome:str) -> None: def _mark_node(self, _nodeid:str, outcome:str) -> None:
''' Mark a test node and its ancestors with the test outcome status
Updates the visual status of tree items based on test results.
Parent nodes inherit failure status from children.
:param _nodeid: the test node ID to mark
:type _nodeid: str
:param outcome: the test outcome ("passed", "failed", "undefined")
:type outcome: str
'''
status = { status = {
'passed' : _testStatus.Pass, 'passed' : _testStatus.Pass,
'failed' : _testStatus.Fail, 'failed' : _testStatus.Fail,
}.get(outcome, _testStatus.Undefined) }.get(outcome, _testStatus.Undefined)
def _recurse_node(_n:ttk.TTkTreeWidgetItem): def _recurse_node(_n:ttk.TTkTreeWidgetItem):
for _c in _n.children(): for _c in _n.children():
# ttk.TTkLog.debug(_c.data(0))
if isinstance(_c, PTP_TreeItemPath): if isinstance(_c, PTP_TreeItemPath):
if _nodeid.startswith(_c.test_id()): if _nodeid.startswith(_c.test_id()):
if not ( _c.testStatus() == _testStatus.Fail and status == _testStatus.Pass ): _recurse_node(_c)
_c.setTestStatus(status)
elif _c.test_id().startswith(_nodeid):
_c.setTestStatus(status)
elif isinstance(_c, PTP_TreeItemMethod) and _nodeid.startswith(_c.test_id()): elif isinstance(_c, PTP_TreeItemMethod) and _nodeid.startswith(_c.test_id()):
_c.setTestStatus(status) _c.setTestStatus(status)
_recurse_node(_c)
_recurse_node(self._res_tree.invisibleRootItem()) _recurse_node(self._res_tree.invisibleRootItem())
def _clear_nodes(self, node:Optional[PTP_TreeItem] = None) -> None: def _clear_nodes(self, node:Optional[PTP_TreeItem] = None) -> None:
status = _testStatus.Undefined ''' Clear test status for all nodes in the tree
def _recurse_node(_n:ttk.TTkTreeWidgetItem):
for _c in _n.children(): Resets all test items to undefined status.
:param node: the root node to clear from, defaults to tree root
:type node: Optional[PTP_TreeItem]
'''
if node:
node.clearTestStatus(clearChildren=True, clearParent=True)
else:
for _c in self._res_tree.invisibleRootItem().children():
if isinstance(_c, PTP_TreeItem): if isinstance(_c, PTP_TreeItem):
_c.setTestStatus(status) _c.clearTestStatus(clearChildren=True)
_recurse_node(_c)
_recurse_node(node if node else self._res_tree.invisibleRootItem())
@ttk.pyTTkSlot(PTP_TestResult) @ttk.pyTTkSlot(PTP_TestResult)
def _test_updated(self, test:PTP_TestResult) -> None: def _test_updated(self, test:PTP_TestResult) -> None:
''' Handle test result updates from the test engine
Updates tree node status and displays formatted test results including
outcome, duration, captured output, and syntax-highlighted error messages.
:param test: the test result data
:type test: PTP_TestResult
'''
self._mark_node(_nodeid=test.nodeId, outcome=test.outcome) self._mark_node(_nodeid=test.nodeId, outcome=test.outcome)
_outcome = _out_map.get(test.outcome, test.outcome) _outcome = _out_map.get(test.outcome, test.outcome)
@ -211,6 +293,11 @@ class PTP_PyTestWidget(ttk.TTkContainer):
@ttk.pyTTkSlot() @ttk.pyTTkSlot()
def _run_tests(self) -> None: def _run_tests(self) -> None:
''' Run all discovered tests
Clears existing test statuses and results, then executes all tests
that were previously discovered by the scan operation.
'''
self._clear_nodes() self._clear_nodes()
self._test_results.clear() self._test_results.clear()
self._test_engine.run_all_tests() self._test_engine.run_all_tests()
Loading…
Cancel
Save