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\w+)' + r'(?P\.\w+)?' + r'(?P\[.+\])?' + r'\s*' + r'(?P[-+*/%&|^><]|//|\*\*|>>|<<)?' + r'=\s*[^=].*' +) # = expr HISTORY_VARS_PATTERN = re.compile(r'_i?\d+') opened_secondary_windows = [] @@ -858,7 +867,8 @@ def display_item_in_new_window(self, list_item): value = self.data[varname] self.new_editor_window(value, title=varname) - def new_editor_window(self, data, title: str=None, readonly: bool=False, + @staticmethod + def new_editor_window(data, title: str=None, readonly: bool=False, cls=EditorWindow): window = cls(data, title=title, readonly=readonly) window.show() @@ -986,11 +996,22 @@ def ipython_cell_executed(self): # It would be easier to use '_' instead but that refers to the last output, not the output of the # last command. Which means that if the last command did not produce any output, _ is not modified. cur_output = user_ns['_oh'].get(cur_input_num) - setitem_pattern_match = SUBSET_UPDATE_PATTERN.match(last_input_last_line) - # setitem - if setitem_pattern_match is not None: - varname = setitem_pattern_match.group(1) - # simple variable + + # matches both setitem and setattr + update_variable_match = UPDATE_VARIABLE_PATTERN.match(last_input_last_line) + if update_variable_match is not None: + parts = update_variable_match.groupdict() + varname = parts['variable'] + if all(parts[name] is None for name in ('attribute', 'subset', 'inplaceop')): + # simple variable assignment => could be a new variable + # => must update mapping and varlist + # this returns the changed variable. While in most cases this + # will be the same as varname, it can be different if the + # assignment failed (changed_var is None) or there were + # several changed variables + self.update_mapping_and_varlist(clean_ns) + + # simple variable name (only) elif last_input_last_line in clean_ns: varname = last_input_last_line # any other statement @@ -1018,7 +1039,7 @@ def ipython_cell_executed(self): # For better or worse, _save_data() only saves "displayable data" # so changes to variables we cannot display do not concern us, # and this line should not be moved outside the if condition. - if setitem_pattern_match is not None: + if update_variable_match is not None: self.unsaved_modifications = True # TODO: this completely refreshes the array, including detecting diff --git a/larray_editor/start.py b/larray_editor/start.py index c74e4ac..2e0d5a3 100644 --- a/larray_editor/start.py +++ b/larray_editor/start.py @@ -1,32 +1,85 @@ import os import sys +from contextlib import redirect_stdout, redirect_stderr -from larray_editor.api import _show_dialog, create_edit_dialog +from pathlib import Path +from larray_editor.api import (create_edit_dialog, _show_dialog, + display_exception) +from larray_editor.utils import common_ancestor + + +def protected_main(): + args = sys.argv[1:] + + # Note that we do not check for --help or --version (which would have been + # nice) because we cannot output anything to the console anyway. This is + # because our entry point uses project.gui-scripts (which internally use + # pythonw.exe) and thus do not support printing to the console. + if len(args) == 1 and args[0] in {"%1", "%*"}: + # workaround for menuinst issue which requires %1 or %* to support file + # associations but then uses the literal string "%*" in the desktop/ + # menu shortcuts + args = [] + + paths = [Path(p) for p in args] + absolute_paths = [p.resolve() for p in paths] + ancestor = common_ancestor(absolute_paths) if len(paths) >= 2 else None + + def get_varname(p, ancestor): + import re + if ancestor is not None and p.exists(): + rel_path = p.relative_to(ancestor) + name = str(rel_path.with_suffix('')) + else: + name = p.stem + if not name: + return 'path' + # Replace invalid characters with underscores + name = re.sub(r'[^0-9a-zA-Z_]', '_', name) + if not name or name[0].isdigit() or name[0] == '_': + # using '_' makes the variable hidden + name = "path" + name + return name + + # This is an odd way to display errors, but it is the simplest way to do it + # without printing to the console. + obj = { + get_varname(abspath, ancestor): + abspath if abspath.exists() + else [f"'{p}' is not a valid file or directory."] + for p, abspath in zip(paths, absolute_paths) + } -def call_edit(obj): # we do not use edit() so that we can have display_caller_info=False - _show_dialog("Viewer", create_edit_dialog, obj=obj, - display_caller_info=False, add_larray_functions=True) + _show_dialog("Viewer", + create_edit_dialog, + obj=obj, + display_caller_info=False, + add_larray_functions=True) def main(): - args = sys.argv[1:] - if len(args) > 1: - print(f"Usage: {sys.argv[0]} [file_path]") - sys.exit() - elif len(args) == 1: - obj = args[0] - else: - obj = {} if os.name == 'nt': - stderr_path = os.path.join(os.getenv("TEMP"), "stderr-" + os.path.basename(sys.argv[0])) + arg0 = os.path.basename(sys.argv[0]) + stderr_path = os.path.join(os.getenv("TEMP"), arg0 + "-stderr") with open(os.devnull, "w") as out, open(stderr_path, "w") as err: - sys.stdout = out - sys.stderr = err - call_edit(obj) + with redirect_stdout(out), redirect_stderr(err): + # Cannot use install_except_hook() nor rely on the except + # hook set within _show_dialog(), because by the time + # the except hook is called by an unhandled exception + # the redirected stderr is already closed by the context + # manager and nothing is logged. + try: + protected_main() + # This purposefully does not catch/logs KeyboardInterrupt or + # SystemExit exceptions + except Exception as e: + display_exception(e) + return 1 else: - call_edit(obj) + protected_main() + return 0 if __name__ == '__main__': diff --git a/larray_editor/tests/test_api_larray.py b/larray_editor/tests/test_api_larray.py index d5c920c..e869ccb 100644 --- a/larray_editor/tests/test_api_larray.py +++ b/larray_editor/tests/test_api_larray.py @@ -34,7 +34,8 @@ array_signed_int = array.array('l', [1, 2, 3, 4, 5]) array_signed_int_empty = array.array('l') # should show as hello alpha and omega -array_unicode = array.array('w', 'hello \u03B1 and \u03C9') +unicode_typecode = 'w' if sys.version_info >= (3, 13) else 'u' +array_unicode = array.array(unicode_typecode, 'hello \u03B1 and \u03C9') # list list_empty = [] @@ -148,6 +149,7 @@ def test_plot_returning_ax_and_using_show(): sex = la.Axis('sex=M,F') geo = la.Axis(['A11', 'A25', 'A51', 'A21'], 'geo') +la_scalar_group = sex['M'] la_float_4d_many_digits = la.random.normal(axes=(age, geo, sex, lipro)) la_float_4d_many_digits['P01', 'A11', 0] = la.nan la_int_1d = la.ndtest(age) diff --git a/larray_editor/tests/test_inplace_modification_pattern.py b/larray_editor/tests/test_inplace_modification_pattern.py index d139809..4001cfc 100644 --- a/larray_editor/tests/test_inplace_modification_pattern.py +++ b/larray_editor/tests/test_inplace_modification_pattern.py @@ -1,32 +1,51 @@ -from larray_editor.editor import SUBSET_UPDATE_PATTERN +from larray_editor.editor import UPDATE_VARIABLE_PATTERN def test_pattern(): - assert SUBSET_UPDATE_PATTERN.match('arr1[1] = 2') - assert SUBSET_UPDATE_PATTERN.match('arr1[1]= 2') - assert SUBSET_UPDATE_PATTERN.match('arr1[1]=2') - assert SUBSET_UPDATE_PATTERN.match("arr1['a'] = arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[func(mapping['a'])] = arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1.i[0, 0] = arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1.iflat[0, 0] = arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1.points[0, 0] = arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1.ipoints[0, 0] = arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] += arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] -= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] *= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] /= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] %= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] //= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] **= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] &= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] |= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] ^= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] >>= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0] <<= arr2") - assert SUBSET_UPDATE_PATTERN.match("arr1[0]") is None - assert SUBSET_UPDATE_PATTERN.match("arr1.method()") is None - assert SUBSET_UPDATE_PATTERN.match("arr1[0].method()") is None - assert SUBSET_UPDATE_PATTERN.match("arr1[0].method(arg=thing)") is None - assert SUBSET_UPDATE_PATTERN.match("arr1[0].method(arg==thing)") is None - # this test fails but I don't think it is possible to fix it with regex - # assert SUBSET_UPDATE_PATTERN.match("arr1[func('[]=0')].method()") is None + matching_patterns = [ + 'arr1[1] = 2', + 'arr1[1]= 2', + 'arr1[1]=2', + "arr1['a'] = arr2", + "arr1[func(mapping['a'])] = arr2", + "arr1.i[0, 0] = arr2", + "arr1.iflat[0, 0] = arr2", + "arr1.points[0, 0] = arr2", + "arr1.ipoints[0, 0] = arr2", + "arr1[0] += arr2", + "arr1[0] -= arr2", + "arr1[0] *= arr2", + "arr1[0] /= arr2", + "arr1[0] %= arr2", + "arr1[0] //= arr2", + "arr1[0] **= arr2", + "arr1[0] &= arr2", + "arr1[0] |= arr2", + "arr1[0] ^= arr2", + "arr1[0] >>= arr2", + "arr1[0] <<= arr2", + "arr1.data[1] = 2", + "arr1.data = np.array([1, 2, 3])" + ] + for pattern in matching_patterns: + match = UPDATE_VARIABLE_PATTERN.match(pattern) + assert match is not None and match.group('variable') == 'arr1' + + for pattern in [ + "df.loc[1] = 2", + "df.iloc[1] = 2" + ]: + match = UPDATE_VARIABLE_PATTERN.match(pattern) + assert match is not None and match.group('variable') == 'df' + + # no match + for pattern in [ + "arr1[0]", + "arr1.method()", + "arr1[0].method()", + "arr1[0].method(arg=thing)", + "arr1[0].method(arg==thing)", + # this test fails but I don't think it is possible to fix it with regex + # "arr1[func('[]=0')].method()" + ]: + assert UPDATE_VARIABLE_PATTERN.match(pattern) is None diff --git a/larray_editor/utils.py b/larray_editor/utils.py index 5073312..dcb8f41 100644 --- a/larray_editor/utils.py +++ b/larray_editor/utils.py @@ -789,21 +789,26 @@ def data_frac_digits(data: np.ndarray, max_frac_digits: int = 99): def num_int_digits(value): """ Number of integer digits. Completely ignores the fractional part. - Does not take sign into account. + Does not take the sign into account. Accepts scalar or array-like values. Examples -------- + >>> num_int_digits(0) + 1 >>> num_int_digits(1) 1 >>> num_int_digits(99) 2 >>> num_int_digits(-99.1) 2 - >>> num_int_digits(np.array([1, 99, -99.1])) - array([1, 2, 2]) + >>> num_int_digits(np.array([1, 99, 0, -99.1])) + array([1, 2, 1, 2]) """ value = abs(value) - log10 = np.where(value > 0, np.log10(value), 0) + # avoid a warning for log10(0) and log10(negative) because we will handle + # these cases with np.where anyway + with np.errstate(divide='ignore'): + log10 = np.where(value > 0, np.log10(value), 0) res = np.where(np.isinf(log10), MAX_INT_DIGITS, # maximum(..., 1) because there must be at least one # integer digit (the 0 in 0.00..X) @@ -938,4 +943,15 @@ def list_drives(): except ImportError: logger.warning("Unable to list drives: on Python < 3.12," "this needs the 'win32api' module") - return [] \ No newline at end of file + return [] + + +def common_ancestor(paths): + """Compute the common ancestor directory of a iterable of Path objects.""" + try: + absolute_paths = [p.resolve() for p in paths if p.exists()] + return Path(os.path.commonpath(absolute_paths)) + except ValueError: + # This can happen if there aren't any existing path, or paths + # refer to different drives on Windows + return None diff --git a/pyproject.toml b/pyproject.toml index 5885a72..5df7622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ [project] name = "larray-editor" -version = "0.35" +version = "0.35.1-dev" description = "Graphical User Interface for LArray library" readme = { file = "README.rst", content-type = "text/x-rst" } @@ -67,7 +67,11 @@ excel = ["xlwings"] # (=PyTables) to load the example datasets from larray hdf5 = ["tables"] +# project.gui-scripts create .exe files on Windows (like project.scripts would) +# but which call pythonw.exe internally instead of python.exe and thus do not +# open a console when launched [project.gui-scripts] +# name_of_executable = "module:function" larray-editor = "larray_editor.start:main" [project.urls]