Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
fail-fast: false
matrix:
# os: ["ubuntu-latest", "macos-latest", "windows-latest"]
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']

defaults:
run:
Expand Down
8 changes: 8 additions & 0 deletions doc/source/changes.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Change log
##########

Version 0.35.1
==============

In development.

.. include:: ./changes/version_0_35_1.rst.inc


Version 0.35
============

Expand Down
31 changes: 31 additions & 0 deletions doc/source/changes/version_0_35_1.rst.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.. py:currentmodule:: larray_editor

New features
^^^^^^^^^^^^

* added explicit support for Python 3.14.


Miscellaneous improvements
^^^^^^^^^^^^^^^^^^^^^^^^^^

* improved something.


Fixes
^^^^^

* fixed displaying DataFrames with Pandas >= 3 (closes :editor_issue:`308`).

* fixed some objects not being updated immediately when updated. This includes
updates to Pandas DataFrames done via `df.loc[key] = value` and
`df.iloc[position] = value` (closes :editor_issue:`310`).

* fixed File Explorer on Python 3.9 (closes :editor_issue:`300`).

* fixed displaying "Collection" objects which raise an error on computing
their length and/or iterating them, such as scalar Groups (closes
:editor_issue:`313`).

* avoid warnings when displaying data with any column entirely non-numeric
(including NaN). Closes :editor_issue:`311`.
2 changes: 1 addition & 1 deletion larray_editor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from larray_editor.api import * # noqa: F403

__version__ = '0.35'
__version__ = '0.35.1-dev'
30 changes: 22 additions & 8 deletions larray_editor/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from inspect import getframeinfo
from pathlib import Path

from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication, QMessageBox
import larray as la

from larray_editor.comparator import SessionComparatorWindow, ArrayComparatorWindow
Expand Down Expand Up @@ -277,7 +277,9 @@ def limit_lines(s, max_lines=10):


def qt_display_exception(exception, parent=None):
from qtpy.QtWidgets import QMessageBox
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)

# TODO: after we drop Python3.9 support, use traceback.format_exception
tb_lines = format_exception(exception)
Expand All @@ -290,7 +292,7 @@ def qt_display_exception(exception, parent=None):
msg = f"<b>Oops, something went wrong</b><p>{exception_str}</p>"
detailed_text = ''.join(tb_lines)

msg_box = QMessageBox(QMessageBox.Critical, title, msg, parent=parent)
msg_box = QMessageBox(QMessageBox.Critical, title, msg)
msg_box.setDetailedText(detailed_text)
msg_box.exec()

Expand All @@ -305,12 +307,24 @@ def _qt_except_hook(type_, value, tback):
# BaseExceptionGroup and GeneratorExit), we only print the exception
# and do *not* exit the program. For BaseExceptionGroup, this might
# not be 100% correct but I have yet to encounter such a case.
display_exception(value)


def display_exception(value):
# TODO: after we drop Python3.9 support, use traceback.print_exception
print_exception(value)
try:
qt_display_exception(value)
except Exception as e2:
err = sys.stderr
err.write('\n')
err.write('-----------------------------------------\n')
err.write('Error displaying the error in a Qt dialog\n')
err.write('-----------------------------------------\n')
err.write('\n')
# TODO: after we drop Python3.9 support, use traceback.print_exception
print_exception(value)
try:
qt_display_exception(value)
except Exception:
pass
print_exception(e2)
err.flush()


def install_except_hook():
Expand Down
132 changes: 84 additions & 48 deletions larray_editor/arrayadapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
# are probably the most likely to be re-visited) and read from the file
# if the user requests some data outside of those chunks
import collections.abc
import warnings

import logging
import sys
import os
Expand Down Expand Up @@ -615,14 +617,16 @@ def update_finite_min_max_values(self, finite_values: np.ndarray,
h_stop: int, v_stop: int):
"""can return either two floats or two arrays"""

# we need initial to support empty arrays
vmin = np.nanmin(finite_values, initial=np.nan)
vmax = np.nanmax(finite_values, initial=np.nan)
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
# we need initial to support empty arrays
vmin = np.nanmin(finite_values, initial=np.nan)
vmax = np.nanmax(finite_values, initial=np.nan)

self.vmin = (
np.nanmin([self.vmin, vmin]) if self.vmin is not None else vmin)
self.vmax = (
np.nanmax([self.vmax, vmax]) if self.vmax is not None else vmax)
self.vmin = (
np.nanmin([self.vmin, vmin]) if self.vmin is not None else vmin)
self.vmax = (
np.nanmax([self.vmax, vmax]) if self.vmax is not None else vmax)
return self.vmin, self.vmax

def get_axes_area(self):
Expand Down Expand Up @@ -969,33 +973,38 @@ def update_finite_min_max_values(self, finite_values: np.ndarray,
assert isinstance(self.vmin, dict) and isinstance(self.vmax, dict)
assert h_stop >= h_start

# per column => axis=0
local_vmin = np.nanmin(finite_values, axis=0, initial=np.nan)
local_vmax = np.nanmax(finite_values, axis=0, initial=np.nan)
num_cols = h_stop - h_start
assert local_vmin.shape == (num_cols,), \
(f"unexpected shape: {local_vmin.shape} ({finite_values.shape=}) vs "
f"{(num_cols,)} ({h_start=} {h_stop=})")
# vmin or self.vmin can both be nan (if the whole section data
# is/was nan)
global_vmin = self.vmin
global_vmax = self.vmax
vmin_slice = np.empty(num_cols, dtype=np.float64)
vmax_slice = np.empty(num_cols, dtype=np.float64)
for global_col_idx in range(h_start, h_stop):
local_col_idx = global_col_idx - h_start

col_min = np.nanmin([global_vmin.get(global_col_idx, np.nan),
local_vmin[local_col_idx]])
# update the global vmin dict inplace
global_vmin[global_col_idx] = col_min
vmin_slice[local_col_idx] = col_min

col_max = np.nanmax([global_vmax.get(global_col_idx, np.nan),
local_vmax[local_col_idx]])
# update the global vmax dict inplace
global_vmax[global_col_idx] = col_max
vmax_slice[local_col_idx] = col_max

# per column => axis=0
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)

local_vmin = np.nanmin(finite_values, axis=0, initial=np.nan)
local_vmax = np.nanmax(finite_values, axis=0, initial=np.nan)

assert local_vmin.shape == (num_cols,), \
(f"unexpected shape: {local_vmin.shape} ({finite_values.shape=}) vs "
f"{(num_cols,)} ({h_start=} {h_stop=})")
for global_col_idx in range(h_start, h_stop):
local_col_idx = global_col_idx - h_start

col_min = np.nanmin([global_vmin.get(global_col_idx, np.nan),
local_vmin[local_col_idx]])
# update the global vmin dict inplace
global_vmin[global_col_idx] = col_min
vmin_slice[local_col_idx] = col_min

col_max = np.nanmax([global_vmax.get(global_col_idx, np.nan),
local_vmax[local_col_idx]])
# update the global vmax dict inplace
global_vmax[global_col_idx] = col_max
vmax_slice[local_col_idx] = col_max
return vmin_slice, vmax_slice


Expand Down Expand Up @@ -1030,8 +1039,7 @@ def get_hlabels_values(self, start, stop):
def get_values(self, h_start, v_start, h_stop, v_stop):
parent_dir = self.data.parent

def get_file_info(p: Path) -> tuple[str, str, str, str|int]:

def get_file_info(p: Path):
is_dir = p.is_dir()
if is_dir:
# do not strip suffixes for directories
Expand Down Expand Up @@ -1274,7 +1282,14 @@ def sort_hlabel(self, row_idx, col_idx, ascending):
@adapter_for(collections.abc.Collection)
class CollectionAdapter(AbstractAdapter):
def shape2d(self):
return len(self.data), 1
# we need the try-except block because, even though Collection
# guarantees the presence of __len__, some instances (e.g. scalar
# np.array or our own scalar Groups) raise TypeError when calling it.
try:
length = len(self.data)
except TypeError:
length = 1
return length, 1

def get_hlabels_values(self, start, stop):
return [['value']]
Expand All @@ -1283,7 +1298,12 @@ def get_vlabels_values(self, start, stop):
return [[''] for i in range(start, stop)]

def get_values(self, h_start, v_start, h_stop, v_stop):
return [[v] for v in itertools.islice(self.data, v_start, v_stop)]
# like for len() above, we need to protect against instances having an
# __iter__ method but raising TypeError when calling it
try:
return [[v] for v in itertools.islice(self.data, v_start, v_stop)]
except TypeError:
return [[self.data]]


# Specific adapter just to change the label
Expand All @@ -1307,7 +1327,7 @@ def get_values(self, h_start, v_start, h_stop, v_stop):


def get_finite_numeric_values(array: np.ndarray) -> np.ndarray:
"""return a copy of array with non numeric, -inf or inf values set to nan"""
"""return a copy of array with non-numeric, -inf or inf values set to nan"""
dtype = array.dtype
finite_value = array
# TODO: there are more complex dtypes than this. Is there a way to get them all in one shot?
Expand All @@ -1325,7 +1345,7 @@ def get_finite_numeric_values(array: np.ndarray) -> np.ndarray:
elif np.issubdtype(dtype, np.bool_):
finite_value = finite_value.astype(np.int8)
elif not np.issubdtype(dtype, np.number):
# if the whole array is known to be non numeric, we do not need
# if the whole array is known to be non-numeric, we do not need
# to compute anything
return np.full(array.shape, np.nan, dtype=np.float64)

Expand All @@ -1343,18 +1363,20 @@ def get_color_value(array, global_vmin, global_vmax, axis=None):
try:
finite_value = get_finite_numeric_values(array)

vmin = np.nanmin(finite_value, axis=axis)
if global_vmin is not None:
# vmin or global_vmin can both be nan (if the whole section data is/was nan)
global_vmin = np.nanmin([global_vmin, vmin], axis=axis)
else:
global_vmin = vmin
vmax = np.nanmax(finite_value, axis=axis)
if global_vmax is not None:
# vmax or global_vmax can both be nan (if the whole section data is/was nan)
global_vmax = np.nanmax([global_vmax, vmax], axis=axis)
else:
global_vmax = vmax
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
vmin = np.nanmin(finite_value, axis=axis)
if global_vmin is not None:
# vmin or global_vmin can both be nan (if the whole section data is/was nan)
global_vmin = np.nanmin([global_vmin, vmin], axis=axis)
else:
global_vmin = vmin
vmax = np.nanmax(finite_value, axis=axis)
if global_vmax is not None:
# vmax or global_vmax can both be nan (if the whole section data is/was nan)
global_vmax = np.nanmax([global_vmax, vmax], axis=axis)
else:
global_vmax = vmax
color_value = scale_to_01range(finite_value, global_vmin, global_vmax)
except (ValueError, TypeError):
global_vmin = None
Expand Down Expand Up @@ -1834,22 +1856,36 @@ def get_hnames(self):
def get_vnames(self):
return self.data.index.names

@staticmethod
def _ensure_numpy_array(data):
"""Convert data to a numpy array if it is not already one."""
pd = sys.modules['pandas']
if isinstance(data, pd.arrays.ArrowStringArray):
return data.to_numpy()
else:
assert isinstance(data, np.ndarray)
return data

def get_vlabels_values(self, start, stop):
pd = sys.modules['pandas']
index = self.sorted_data.index[start:stop]
if isinstance(index, pd.MultiIndex):
# It seems like Pandas always returns a 1D array of tuples for
# MultiIndex.values, even if the MultiIndex has an homoneneous
# string type. That's why we do not need _ensure_numpy_array here
# list(row) because we want a list of list and not a list of tuples
return [list(row) for row in index.values]
else:
return index.values[:, np.newaxis]
return self._ensure_numpy_array(index.values)[:, np.newaxis]

def get_hlabels_values(self, start, stop):
pd = sys.modules['pandas']
index = self.sorted_data.columns[start:stop]
if isinstance(index, pd.MultiIndex):
return [index.get_level_values(i).values
return [self._ensure_numpy_array(index.get_level_values(i).values)
for i in range(index.nlevels)]
else:
return [index.values]
return [self._ensure_numpy_array(index.values)]

def get_values(self, h_start, v_start, h_stop, v_stop):
# Sadly, as of Pandas 2.2.3, the previous version of this code:
Expand Down
10 changes: 5 additions & 5 deletions larray_editor/arraywidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ def _close_adapter(adapter):

def on_clicked(self):
if not len(self._back_data):
logger.warn("Back button has no target to go to")
logger.warning("Back button has no target to go to")
return
target_data = self._back_data.pop()
data_adapter = self._back_data_adapters.pop()
Expand Down Expand Up @@ -1272,17 +1272,17 @@ def update_range(self):
max_value = total_cols - buffer_ncols + hidden_hscroll_max
logger.debug(f"update_range horizontal {total_cols=} {buffer_ncols=} {hidden_hscroll_max=} => {max_value=}")
if total_cols == 0 and max_value != 0:
logger.warn(f"empty data but {max_value=}. We let it pass for "
f"now (set it to 0).")
logger.warning(f"empty data but {max_value=}. We let it pass "
"for now (set it to 0).")
max_value = 0
else:
buffer_nrows = self.model.nrows
hidden_vscroll_max = view_data.verticalScrollBar().maximum()
max_value = total_rows - buffer_nrows + hidden_vscroll_max
logger.debug(f"update_range vertical {total_rows=} {buffer_nrows=} {hidden_vscroll_max=} => {max_value=}")
if total_rows == 0 and max_value != 0:
logger.warn(f"empty data but {max_value=}. We let it pass for "
f"now (set it to 0).")
logger.warning(f"empty data but {max_value=}. We let it pass "
"for now (set it to 0).")
max_value = 0
assert max_value >= 0, "max_value should not be negative"
value_before = self.value()
Expand Down
Loading
Loading