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..a060921 --- /dev/null +++ b/doc/source/changes/version_0_35_1.rst.inc @@ -0,0 +1,22 @@ +.. 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`). \ No newline at end of file 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/arrayadapter.py b/larray_editor/arrayadapter.py index ccd2bda..fd432bf 100644 --- a/larray_editor/arrayadapter.py +++ b/larray_editor/arrayadapter.py @@ -1834,22 +1834,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..8363e87 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 = [] @@ -986,11 +995,18 @@ 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 + changed_var = self.update_mapping_and_varlist(clean_ns) + assert changed_var == varname + # simple variable name (only) elif last_input_last_line in clean_ns: varname = last_input_last_line # any other statement @@ -1018,7 +1034,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/tests/test_api_larray.py b/larray_editor/tests/test_api_larray.py index d5c920c..7f94c99 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 = [] 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/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]