From 84df4ca0f0280255041702f54b249739b0768f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Mon, 26 Jan 2026 17:55:49 +0000 Subject: [PATCH] chore: add stuff --- .../ttkode/ttkode/plugins/_030/pytest_tree.py | 191 +++++++++++++++++- .../ttkode/plugins/_030/pytest_widget.py | 121 +++++++++-- 2 files changed, 290 insertions(+), 22 deletions(-) diff --git a/apps/ttkode/ttkode/plugins/_030/pytest_tree.py b/apps/ttkode/ttkode/plugins/_030/pytest_tree.py index 65afe7a8..6b82083b 100644 --- a/apps/ttkode/ttkode/plugins/_030/pytest_tree.py +++ b/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 # 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 __all__ = [ @@ -37,6 +44,10 @@ from _030.pytest_data import PTP_Node from ttkode.app.ttkode import TTKodeFileWidgetItem class _testStatus(IntEnum): + ''' Test status enumeration for visual indicators + + Represents the current state of a test item in the tree. + ''' Pass = 0x01 Fail = 0x02 Undefined = 0x03 @@ -48,9 +59,31 @@ _statusMarks = { } 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('- ')) 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') def __init__(self, name:str, test_id:str, node:PTP_Node, testStatus:_testStatus=_testStatus.Undefined, **kwargs): self._testStatus = testStatus @@ -59,44 +92,150 @@ class PTP_TreeItem(ttk.TTkTreeWidgetItem): super().__init__([ttk.TTkString(name)], **kwargs) def test_id(self) -> str: + ''' Get the pytest node ID for this item + + :return: the test ID path + :rtype: str + ''' return self._test_id def node(self) -> PTP_Node: + ''' Get the pytest node data + + :return: the associated node data + :rtype: PTP_Node + ''' return self._ptp_node 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) def testStatus(self) -> _testStatus: + ''' Get the current test status + + :return: the test status + :rtype: _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: + ''' Set the test status and update parent if needed + + :param status: the new test status + :type status: _testStatus + ''' if status == self._testStatus: return self._testStatus = status + if isinstance(self._parent, PTP_TreeItemPath): + self._parent._updateTestStatus(status=status) self.dataChanged.emit() 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): + ''' Tree item representing an individual test method + + Leaf node in the test tree hierarchy. + ''' pass @dataclass 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 run:bool item:PTP_TreeItem @dataclass 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 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') _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): self._PTP_highight = None @@ -105,6 +244,14 @@ class PTP_TreeWidget(ttk.TTkTreeWidget): self.mergeStyle({'default':{'hoveredColor':ttk.TTkColor.bg('#666666')}}) 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 w = self.width() if (_item:=self.itemAt(y)) and x>=w-3: @@ -114,6 +261,14 @@ class PTP_TreeWidget(ttk.TTkTreeWidget): return super().mousePressEvent(evt) 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 w = self.width() if _item:=self.itemAt(y): @@ -129,28 +284,54 @@ class PTP_TreeWidget(ttk.TTkTreeWidget): return super().mouseMoveEvent(evt) 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: self._PTP_highight = None self.update() super().leaveEvent(evt) 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) style = self.currentStyle() hoveredColor=style['hoveredColor'] if _ph:=self._PTP_highight: w = self.width() if _ph.run: - canvas.drawText(text='...[ ]', pos=(w-6,_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-3,_ph.pos), color=hoveredColor+ttk.TTkColor.YELLOW+ttk.TTkColor.BOLD) + canvas.drawText(text= '▶' , pos=(w-2,_ph.pos), color=hoveredColor+ttk.TTkColor.RED) else: - canvas.drawText(text='...[ ]', pos=(w-6,_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-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-3,_ph.pos), color=hoveredColor+ttk.TTkColor.GREEN) 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') + 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): tw = PTP_TreeWidget(**kwargs) super().__init__(treeWidget=tw, **kwargs) diff --git a/apps/ttkode/ttkode/plugins/_030/pytest_widget.py b/apps/ttkode/ttkode/plugins/_030/pytest_widget.py index 85d26bb6..ca7f4863 100644 --- a/apps/ttkode/ttkode/plugins/_030/pytest_widget.py +++ b/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 # 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'] 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 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() indent = min(len(line) - len(line.lstrip()) for line in lines if line.strip()) result = "\n".join(line[indent:] for line in lines) @@ -53,6 +67,27 @@ _out_map = { _error_color = ttk.TTkColor.fgbg('#FFFF00',"#FF0000") 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__ = ( '_test_engine', '_res_tree','_test_results') @@ -83,14 +118,30 @@ class PTP_PyTestWidget(ttk.TTkContainer): @ttk.pyTTkSlot(PTP_Action) 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 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_engine.run_tests(item.test_id()) @ttk.pyTTkSlot(PTP_TreeItem, int) 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) if isinstance(item, PTP_TreeItemMethod): file = item.node().filename @@ -98,6 +149,16 @@ class PTP_PyTestWidget(ttk.TTkContainer): ttkodeProxy.openFile(file, line) 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(): if isinstance(_c, PTP_TreeItem) and _c.test_id() == _p: return _c @@ -107,6 +168,11 @@ class PTP_PyTestWidget(ttk.TTkContainer): @ttk.pyTTkSlot() 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._test_results.clear() @@ -123,21 +189,17 @@ class PTP_PyTestWidget(ttk.TTkContainer): @ttk.pyTTkSlot(PTP_ScanResult) def _add_node(_node:PTP_ScanResult)->None: - ttk.TTkLog.debug(_node) self._test_results.append(_node.nodeId) _node_id_split = _node.nodeId.split('::') _full_test_path = _node_id_split[0] _leaves = _node_id_split[1:] _tree_node: ttk.TTkTreeWidgetItem = self._res_tree.invisibleRootItem() _full_composite_test_path = '' - ttk.TTkLog.debug(_node_id_split) - ttk.TTkLog.debug(_full_test_path) for _test_path in _full_test_path.split('/'): if _full_composite_test_path: _full_composite_test_path = '/'.join([_full_composite_test_path,_test_path]) else: _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) for _leaf in _leaves: _full_composite_test_path = '::'.join([_full_composite_test_path,_leaf]) @@ -160,35 +222,55 @@ class PTP_PyTestWidget(ttk.TTkContainer): self._test_engine.scan() 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 = { 'passed' : _testStatus.Pass, 'failed' : _testStatus.Fail, }.get(outcome, _testStatus.Undefined) def _recurse_node(_n:ttk.TTkTreeWidgetItem): for _c in _n.children(): - # ttk.TTkLog.debug(_c.data(0)) if isinstance(_c, PTP_TreeItemPath): if _nodeid.startswith(_c.test_id()): - if not ( _c.testStatus() == _testStatus.Fail and status == _testStatus.Pass ): - _c.setTestStatus(status) - elif _c.test_id().startswith(_nodeid): - _c.setTestStatus(status) + _recurse_node(_c) elif isinstance(_c, PTP_TreeItemMethod) and _nodeid.startswith(_c.test_id()): _c.setTestStatus(status) - _recurse_node(_c) + _recurse_node(self._res_tree.invisibleRootItem()) def _clear_nodes(self, node:Optional[PTP_TreeItem] = None) -> None: - status = _testStatus.Undefined - def _recurse_node(_n:ttk.TTkTreeWidgetItem): - for _c in _n.children(): + ''' Clear test status for all nodes in the tree + + 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): - _c.setTestStatus(status) - _recurse_node(_c) - _recurse_node(node if node else self._res_tree.invisibleRootItem()) + _c.clearTestStatus(clearChildren=True) @ttk.pyTTkSlot(PTP_TestResult) 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) _outcome = _out_map.get(test.outcome, test.outcome) @@ -211,6 +293,11 @@ class PTP_PyTestWidget(ttk.TTkContainer): @ttk.pyTTkSlot() 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._test_results.clear() self._test_engine.run_all_tests() \ No newline at end of file