diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b205e5d..5b1b20d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/doc/source/changes.rst b/doc/source/changes.rst index f508744..88e7cef 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -1,6 +1,14 @@ Change log ########## +Version 0.35.1 +============== + +In development. + +.. include:: ./changes/version_0_35_1.rst.inc + + Version 0.35 ============ diff --git a/doc/source/changes/version_0_35_1.rst.inc b/doc/source/changes/version_0_35_1.rst.inc new file mode 100644 index 0000000..d388062 --- /dev/null +++ b/doc/source/changes/version_0_35_1.rst.inc @@ -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`. diff --git a/larray_editor/__init__.py b/larray_editor/__init__.py index 4d13519..6a99f7c 100644 --- a/larray_editor/__init__.py +++ b/larray_editor/__init__.py @@ -1,3 +1,3 @@ from larray_editor.api import * # noqa: F403 -__version__ = '0.35' +__version__ = '0.35.1-dev' diff --git a/larray_editor/api.py b/larray_editor/api.py index 9b10798..6281d9b 100644 --- a/larray_editor/api.py +++ b/larray_editor/api.py @@ -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 @@ -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) @@ -290,7 +292,7 @@ def qt_display_exception(exception, parent=None): msg = f"Oops, something went wrong
{exception_str}
" 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() @@ -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(): diff --git a/larray_editor/arrayadapter.py b/larray_editor/arrayadapter.py index ccd2bda..b22ce53 100644 --- a/larray_editor/arrayadapter.py +++ b/larray_editor/arrayadapter.py @@ -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 @@ -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): @@ -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 @@ -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 @@ -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']] @@ -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 @@ -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? @@ -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) @@ -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 @@ -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: diff --git a/larray_editor/arraywidget.py b/larray_editor/arraywidget.py index d87d70a..caa5a98 100644 --- a/larray_editor/arraywidget.py +++ b/larray_editor/arraywidget.py @@ -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() @@ -1272,8 +1272,8 @@ 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 @@ -1281,8 +1281,8 @@ def update_range(self): 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() diff --git a/larray_editor/editor.py b/larray_editor/editor.py index b59e49d..cdb5a7a 100644 --- a/larray_editor/editor.py +++ b/larray_editor/editor.py @@ -94,11 +94,20 @@ REOPEN_LAST_FILE = object() ASSIGNMENT_PATTERN = re.compile(r'[^\[\]]+[^=]=[^=].+') -SUBSET_UPDATE_PATTERN = re.compile(r'(\w+)' - r'(\.i|\.iflat|\.points|\.ipoints)?' - r'\[.+\]\s*' - r'([-+*/%&|^><]|//|\*\*|>>|<<)?' - r'=\s*[^=].*') +# This will match for: +# * variable = expr +# * variable[key] = expr +# * variable.attribute = expr +# * variable.attribute[key] = expr +# and their inplace ops counterpart +UPDATE_VARIABLE_PATTERN = re.compile( + r'(?P