diff --git a/doc/release_notes/release_3.16.md b/doc/release_notes/release_3.16.md index a45fb2b..86dac3e 100644 --- a/doc/release_notes/release_3.16.md +++ b/doc/release_notes/release_3.16.md @@ -1,18 +1,5 @@ # Version 3.16 # -## Unreleased ## - -* Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. - -✨ New features: - -* **`get_pixmap` — sharp images with display scaling** — the new `guidata.configtools.get_pixmap` function loads an image as a pixmap at the resolution of the display: when the display scaling is greater than 100% (e.g. 125%, 150%), the high-resolution variants of the image following Qt's `@Nx` naming convention (e.g. `logo@2x.png` next to `logo.png`) are used, so that the image remains sharp instead of being upscaled. `get_image_label` (and thus `get_image_layout`) now relies on it. - -🛠 Bug fixes: - -* **Text not scaled with display scaling on Windows (Qt5)** — with a Windows display scaling greater than 100% (e.g. 125%), the text of most widgets (labels, buttons, data set editors, ...) kept the size it has at 100%, while menus and status bars were scaled, because Qt5 derives its default font from a legacy system font which ignores the display scaling. Applications created with `guidata.qapplication` (or `qt_app_context`) now use the message font of the system as default font (typically "Segoe UI", 9pt), like with Qt6: the text follows the display scaling and the Windows text size setting. -* **Application startup blocked by a corrupted translation file** — when a `.mo` translation catalog was corrupted or unreadable (e.g. truncated during installation), importing guidata failed with an error such as `OSError: Bad magic number`. The same applied to any application relying on `guidata.configtools.get_translation`, like PlotPy, Sigima or DataLab, so the application could not start at all. The interface now falls back to English, and a `RuntimeWarning` identifies the faulty catalog (fixes [Issue #119](https://github.com/PlotPyStack/guidata/issues/119)). - ## guidata Version 3.16.0 ## ✨ New features: diff --git a/doc/release_notes/release_3.17.md b/doc/release_notes/release_3.17.md new file mode 100644 index 0000000..368d33d --- /dev/null +++ b/doc/release_notes/release_3.17.md @@ -0,0 +1,22 @@ +# Version 3.17 # + +## guidata Version 3.17.0 ## + +* Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. + +✨ New features: + +* **`get_pixmap` — sharp images with display scaling** — the new `guidata.configtools.get_pixmap` function loads an image as a pixmap at the resolution of the display: when the display scaling is greater than 100% (e.g. 125%, 150%), the high-resolution variants of the image following Qt's `@Nx` naming convention (e.g. `logo@2x.png` next to `logo.png`) are used, so that the image remains sharp instead of being upscaled. `get_image_label` (and thus `get_image_layout`) now relies on it. + +🛠 Bug fixes: + +* **Text not scaled with display scaling on Windows (Qt5)** — with a Windows display scaling greater than 100% (e.g. 125%), the text of most widgets (labels, buttons, data set editors, ...) kept the size it has at 100%, while menus and status bars were scaled, because Qt5 derives its default font from a legacy system font which ignores the display scaling. Applications created with `guidata.qapplication` (or `qt_app_context`) now use the message font of the system as default font (typically "Segoe UI", 9pt), like with Qt6: the text follows the display scaling and the Windows text size setting. +* **Application startup blocked by a corrupted translation file** — when a `.mo` translation catalog was corrupted or unreadable (e.g. truncated during installation), importing guidata failed with an error such as `OSError: Bad magic number`. The same applied to any application relying on `guidata.configtools.get_translation`, like PlotPy, Sigima or DataLab, so the application could not start at all. The interface now falls back to English, and a `RuntimeWarning` identifies the faulty catalog (fixes [Issue #119](https://github.com/PlotPyStack/guidata/issues/119)). +* **Error when editing a dictionary field in a JSON Schema based form** — in a form generated with `guidata.dataset.dataset_to_schema` (e.g. in a web frontend), editing a `DictItem` field failed with `TypeError: DictItem.__dictedit() missing 1 required positional argument: 'parent'`: the button opening the dictionary editor in the Qt interface was handled as a callback updating the other fields. `DictItem` fields are no longer flagged with `x-guidata-has-callback`, and `resolve_dataset_callbacks` returns an empty mapping for them (fixes [Issue #123](https://github.com/PlotPyStack/guidata/issues/123)). +* **Date/time callback never called** — the `display` callback of a `DateTimeItem` was never called when the date or time was changed in a dataset editor, so that the values computed from it were not updated. The callback is now called on each change, with the new value of the item (a `datetime.datetime` object), like for the other items (fixes [Issue #123](https://github.com/PlotPyStack/guidata/issues/123)). +* **Plain-text "about" information** — `guidata.widgets.about.about(html=False)` (and `AboutInfo.about(html=False)`) failed with `UnboundLocalError` instead of returning the information about the package as plain text (fixes [Issue #123](https://github.com/PlotPyStack/guidata/issues/123)). +* **French translations of the DataFrame editor** — when entering a number format not starting with `%`, the French error message stated the opposite of the actual rule ("must not start with '%'"), and the title of the export dialog box was shown in English. Both are now correctly translated (fixes [Issue #123](https://github.com/PlotPyStack/guidata/issues/123)). + +â„šī¸ Other changes: + +* **Documentation** — `guidata.dataset.resolve_dataset_active`, which evaluates the active state of the items of a dataset for JSON Schema based frontends, is now documented in the API reference. diff --git a/guidata/__init__.py b/guidata/__init__.py index e92f13d..7daaaba 100644 --- a/guidata/__init__.py +++ b/guidata/__init__.py @@ -8,7 +8,7 @@ and application development tools for Qt. """ -__version__ = "3.16.0" +__version__ = "3.17.0" # Dear (Debian, RPM, ...) package makers, please feel free to customize the diff --git a/guidata/dataset/jsonschema.py b/guidata/dataset/jsonschema.py index adbc505..8c8b508 100644 --- a/guidata/dataset/jsonschema.py +++ b/guidata/dataset/jsonschema.py @@ -30,6 +30,8 @@ .. autofunction:: resolve_dataset_callbacks +.. autofunction:: resolve_dataset_active + Output format ------------- @@ -84,7 +86,7 @@ import inspect import mimetypes import os -from collections.abc import Collection +from collections.abc import Callable, Collection from typing import TYPE_CHECKING, Any import guidata.dataset.dataitems as gdi @@ -227,7 +229,7 @@ def _apply_display_callbacks(instance: gdt.DataSet) -> None: """ for item in instance.get_items(): try: - callback = item.get_prop_value("display", instance, "callback", None) + callback = _get_reactive_callback(item, instance) except Exception: # pylint: disable=broad-except callback = None if callback is None: @@ -294,13 +296,14 @@ def resolve_dataset_callbacks(instance: gdt.DataSet, item_name: str) -> dict[str Returns: A JSON-serialisable mapping of property name → value for every non-group item, reflecting the post-callback state. Empty when - the item carries no ``display`` callback. + the item carries no reactive ``display`` callback (e.g. a + :class:`DictItem`, whose callback opens the Qt dictionary editor). Raises: KeyError: If *item_name* is not an item of *instance*. """ item = _get_item(instance, item_name) - callback = item.get_prop_value("display", instance, "callback", None) + callback = _get_reactive_callback(item, instance) if callback is None: return {} callback(instance, item, item.get_value(instance)) @@ -762,7 +765,7 @@ def _add_common_keys(item: gdt.DataItem, prop: dict[str, Any], order: int) -> No # the read-only ``operation`` preview). Flag it so the frontend can # round-trip through :func:`resolve_dataset_callbacks` on edit, # mirroring the Qt ``_display_callback`` cascade. - if item.get_prop("display", "callback", None) is not None: + if _get_reactive_callback(item) is not None: prop["x-guidata-has-callback"] = True prop["x-guidata-name"] = name prop["x-guidata-order"] = order @@ -841,3 +844,28 @@ def _get_item(instance: gdt.DataSet, name: str) -> gdt.DataItem: if item.get_name() == name: return item raise KeyError(name) + + +def _get_reactive_callback( + item: gdt.DataItem, instance: gdt.DataSet | None = None +) -> Callable[..., Any] | None: + """Return the reactive ``display`` callback of *item*, if any. + + A reactive callback has the ``callback(instance, item, value)`` signature + and recomputes sibling items when *item* changes. The ``display`` callback + of a :class:`DictItem` is not reactive: it opens the Qt dictionary editor + from a button, with a different signature, so it is ignored. + + Args: + item: The data item. + instance: The :class:`DataSet` instance used to evaluate the callback + property, or ``None`` to return the property declared on *item*. + + Returns: + The reactive callback, or ``None``. + """ + if isinstance(item, gdi.DictItem): + return None + if instance is None: + return item.get_prop("display", "callback", None) + return item.get_prop_value("display", instance, "callback", None) diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index dc07df4..743d1c6 100644 --- a/guidata/dataset/qtitemwidgets.py +++ b/guidata/dataset/qtitemwidgets.py @@ -807,17 +807,16 @@ def __init__( self.dateedit = self.group = QDateTimeEdit() self.dateedit.setCalendarPopup(True) self.dateedit.setToolTip(item.get_help()) - self.dateedit.dateTimeChanged.connect( # type:ignore - lambda value: self.notify_value_change() - ) + self.dateedit.dateTimeChanged.connect(self.date_changed) # type:ignore fmt = self.item.get_prop("display", "format", None) if fmt: qt_fmt = convert_date_format(fmt) self.dateedit.setDisplayFormat(qt_fmt) - def date_changed(self, value): - """Date changed""" - _display_callback(self, value) + def date_changed(self, _value) -> None: + """Date/time changed (the ``QDateTime`` argument is ignored: the callback + receives the Python value of the widget)""" + _display_callback(self, self.value()) self.notify_value_change() def get(self) -> None: diff --git a/guidata/locale/fr/LC_MESSAGES/guidata.po b/guidata/locale/fr/LC_MESSAGES/guidata.po index f4dd025..b50687d 100644 --- a/guidata/locale/fr/LC_MESSAGES/guidata.po +++ b/guidata/locale/fr/LC_MESSAGES/guidata.po @@ -645,14 +645,13 @@ msgstr "Le format ({}) est incorrect" #, python-brace-format msgid "Format ({}) should start with '%'" -msgstr "Le format ({}) ne doit pas commencer par '%'" +msgstr "Le format ({}) doit commencer par '%'" msgid "CSV Files" msgstr "Fichiers CSV" -#, fuzzy msgid "Export dataframe" -msgstr "DataFrame" +msgstr "Exporter le DataFrame" msgid "Import as" msgstr "Importer en tant que" diff --git a/guidata/tests/dataset/test_jsonschema.py b/guidata/tests/dataset/test_jsonschema.py index 7dd63f0..b505c1b 100644 --- a/guidata/tests/dataset/test_jsonschema.py +++ b/guidata/tests/dataset/test_jsonschema.py @@ -681,3 +681,21 @@ class P(gds.DataSet): # An item without a callback yields an empty mapping (no-op). assert resolve_dataset_callbacks(P(), "preview") == {} + + +def test_dict_item_editor_callback_is_not_reactive(): + """The ``DictItem`` editor callback is not exposed as a reactive callback. + + ``DictItem`` uses its ``display`` callback to open the Qt dictionary + editor (with a different signature): it must neither be flagged nor run + by the off-GUI callback resolution. + """ + + class P(gds.DataSet): + meta = gds.DictItem("Meta", default={"k": 1}) + + props = dataset_to_schema(P)["properties"] + assert "x-guidata-has-callback" not in props["meta"] + payload = dataset_to_schema_with_values(P()) + assert payload["values"]["meta"] == {"k": 1} + assert resolve_dataset_callbacks(P(), "meta") == {} diff --git a/guidata/tests/unit/test_about.py b/guidata/tests/unit/test_about.py new file mode 100644 index 0000000..9e04693 --- /dev/null +++ b/guidata/tests/unit/test_about.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see guidata/LICENSE for details) + +""" +Test the text about a package + +``AboutInfo.about`` (and ``about`` for guidata) returns the text about a package, +either as HTML (e.g. for the about dialog box) or as plain text. +""" + +from __future__ import annotations + +import pytest + +from guidata.widgets.about import AboutInfo, about + + +def _get_info() -> AboutInfo: + """Return information about a test package""" + return AboutInfo( + "mypackage", "1.2.3", "My description", "John Doe", 2020, "MyOrganization" + ) + + +def test_about_plain_text() -> None: + """Plain-text output contains the package information without HTML""" + text = _get_info().about(html=False) + assert text.startswith("mypackage 1.2.3\nMy description\n") + for info in ("John Doe", "2020", "MyOrganization", "Copyright"): + assert info in text + assert "<" not in text + + +def test_about_html() -> None: + """HTML output contains links to the author and the organization""" + text = _get_info().about(html=True) + assert "John Doe" in text + assert "MyOrganization" in text + assert "\n" not in text + + +@pytest.mark.parametrize("html", [True, False]) +def test_guidata_about(html: bool) -> None: + """Text about guidata, complete or limited to the copyright""" + assert "Pierre Raybaut" in about(html=html) + text = about(html=html, copyright_only=True) + assert "Copyright" in text + assert "Pierre Raybaut" not in text diff --git a/guidata/tests/unit/test_datetime_display_callback.py b/guidata/tests/unit/test_datetime_display_callback.py new file mode 100644 index 0000000..09dec3b --- /dev/null +++ b/guidata/tests/unit/test_datetime_display_callback.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see guidata/LICENSE for details) + +""" +Test the display callback of DateTimeItem + +When the date/time is changed in a dataset editor, the ``display`` callback of a +``DateTimeItem`` must be called with the Python value of the item (``datetime``). +""" + +from __future__ import annotations + +import datetime + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from guidata.dataset import DataSet +from guidata.dataset.dataitems import DateTimeItem +from guidata.dataset.qtwidgets import DataSetEditLayout +from guidata.qthelpers import qt_app_context + + +def test_datetime_display_callback() -> None: + """Changing the date/time calls the display callback with a datetime""" + calls = [] + + def callback(instance: DataSet, _item: DateTimeItem, value) -> None: + """Record the callback value and the dataset value""" + calls.append((value, instance.dtime)) + + class _DateTimeDataSet(DataSet): + """Dataset with a date/time item having a display callback""" + + dtime = DateTimeItem( + "Date/time", default=datetime.datetime(2010, 10, 10, 10, 10) + ).set_prop("display", callback=callback) + + with qt_app_context(): + dialog = QW.QDialog() + dataset = _DateTimeDataSet() + layout = DataSetEditLayout(dialog, dataset, QW.QGridLayout(dialog)) + try: + (widget,) = layout.get_terminal_widgets() + calls.clear() + widget.dateedit.setDateTime( + QC.QDateTime(QC.QDate(2024, 1, 2), QC.QTime(3, 4)) + ) + expected = datetime.datetime(2024, 1, 2, 3, 4) + assert calls == [(expected, expected)] + assert type(calls[0][0]) is datetime.datetime + finally: + dialog.deleteLater() diff --git a/guidata/widgets/about.py b/guidata/widgets/about.py index 7d80db4..4348a6a 100644 --- a/guidata/widgets/about.py +++ b/guidata/widgets/about.py @@ -114,6 +114,7 @@ def about( Text about this package """ auth, year, org = self.author, self.year, self.organization + author, organization = auth, org if html: author = f"{auth}" organization = f"{org}"