Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 0 additions & 13 deletions doc/release_notes/release_3.16.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
22 changes: 22 additions & 0 deletions doc/release_notes/release_3.17.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion guidata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions guidata/dataset/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

.. autofunction:: resolve_dataset_callbacks

.. autofunction:: resolve_dataset_active

Output format
-------------

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
11 changes: 5 additions & 6 deletions guidata/dataset/qtitemwidgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions guidata/locale/fr/LC_MESSAGES/guidata.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions guidata/tests/dataset/test_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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") == {}
50 changes: 50 additions & 0 deletions guidata/tests/unit/test_about.py
Original file line number Diff line number Diff line change
@@ -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 "<a href='https://github.com/JohnDoe'>John Doe</a>" in text
assert "<a href='https://github.com/MyOrganization'>MyOrganization</a>" 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
55 changes: 55 additions & 0 deletions guidata/tests/unit/test_datetime_display_callback.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions guidata/widgets/about.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<a href='https://github.com/{auth.replace(' ', '')}'>{auth}</a>"
organization = f"<a href='https://github.com/{org}'>{org}</a>"
Expand Down
Loading