diff --git a/src/magicgui/backends/_qtpy/widgets.py b/src/magicgui/backends/_qtpy/widgets.py index 939ed3a8..f6979a8d 100644 --- a/src/magicgui/backends/_qtpy/widgets.py +++ b/src/magicgui/backends/_qtpy/widgets.py @@ -1512,8 +1512,35 @@ def _mgui_remove_row(self, row: int) -> None: def _mgui_remove_column(self, column: int) -> None: self._qwidget.removeColumn(column) + def _normalize_cell_index(self, row: int, col: int) -> tuple[int, int]: + num_rows = self._mgui_get_row_count() + num_cols = self._mgui_get_column_count() + # Offset negative indices because Qt doesn't natively support them + if row < 0: + normalized_row = row + num_rows + else: + normalized_row = row + if col < 0: + normalized_col = col + num_cols + else: + normalized_col = col + # Qt will not raise an error for attempting to index into an invalid cell, but + # normal Python does. + if ( + normalized_row < 0 + or normalized_col < 0 + or normalized_row >= num_rows + or normalized_col >= num_cols + ): + raise IndexError( + f"Index ({row}, {col}) is out of bounds for table of shape" + f" ({num_rows}, {num_cols})." + ) + return normalized_row, normalized_col + def _mgui_get_cell(self, row: int, col: int) -> Any: """Get current value of the widget.""" + row, col = self._normalize_cell_index(row, col) item = self._qwidget.item(row, col) if item: return item.data(_DATA_ROLE) @@ -1523,6 +1550,7 @@ def _mgui_get_cell(self, row: int, col: int) -> Any: def _mgui_set_cell(self, row: int, col: int, value: Any) -> None: """Set current value of the widget.""" + row, col = self._normalize_cell_index(row, col) if value is None: self._qwidget.setItem(row, col, None) self._qwidget.removeCellWidget(row, col) diff --git a/tests/test_table.py b/tests/test_table.py index 6a9ef2cb..5d2c2d19 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -171,6 +171,8 @@ def test_table_from_numpy(): INDICES = ( 1, (2, 2), + -2, + (-1, -1), (slice(None), 2), (slice(None), slice(None)), (slice(1, 3), slice(3)), @@ -181,6 +183,8 @@ def test_table_from_numpy(): VALUES = ( (7,) * 4, 6, + (14,) * 4, + 25, (7,) * 6, [[1] * 4] * 6, [[1] * 3] * 2, @@ -200,7 +204,7 @@ def test_dataview_getitem(index): assert np.allclose(table.data[index], data[index]) -@pytest.mark.parametrize("index, value", zip(INDICES, VALUES)) +@pytest.mark.parametrize("index, value", tuple(zip(INDICES, VALUES))) def test_dataview_setitem(index, value): """Test that table.data can be indexed like a numpy array.""" np = pytest.importorskip("numpy") @@ -213,6 +217,31 @@ def test_dataview_setitem(index, value): assert np.allclose(table.data.to_list(), data) +INVALID_INDICES = ((6, 0), (0, 4), (-7, 0), (0, -5)) + + +@pytest.mark.parametrize("index", INVALID_INDICES) +def test_dataview_getitem_invalid_index(index): + """Test that invalid cell indices raise IndexError.""" + np = pytest.importorskip("numpy") + data = np.arange(24).reshape(6, 4) + + table = Table(value=data) + with pytest.raises(IndexError): + table.data[index] + + +@pytest.mark.parametrize("index", INVALID_INDICES) +def test_dataview_setitem_invalid_index(index): + """Test that invalid cell indices raise IndexError.""" + np = pytest.importorskip("numpy") + data = np.arange(24).reshape(6, 4) + + table = Table(value=data) + with pytest.raises(IndexError): + table.data[index] = 999 + + def test_dataview_delitem(): """Test that table.data can be indexed like a numpy array.""" _input = _TABLE_DATA["dict"]