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
4 changes: 3 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ VENV_DIR=
# Python path for development (sibling packages)
PYTHONPATH=.
# Locale (e.g. fr)
LANG=
LANG=
# Debug mode (0 or 1)
GUIDATA_DEBUG=0
14 changes: 14 additions & 0 deletions doc/release_notes/release_3.14.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Version 3.14 #

## guidata Version 3.14.4 ##

🛠️ Bug fixes:

* **`DataSetEditGroupBox` / computed items**: Fixed input field corruption when typing into a `LineEditWidget` whose `DataSet` contains other items declared via `set_computed(...)` (or any `display.callback`) — typing multiple characters in a row (e.g. `52` after selecting all) was silently truncated and re-interpreted between keystrokes (producing `5.02` instead of `52`, or `0.0.25` instead of `0.25`). The reactive update of computed siblings was recursively re-entering the same callback with a different exclusion target and overwriting the field the user was editing. Bug introduced in v3.13.0 (commit `0af365e`, "Add support for computed properties in datasets") (fixes [Issue #104](https://github.com/PlotPyStack/guidata/issues/104))
* **High-DPI display scaling**: Enabled automatic high-DPI scaling attributes (`AA_EnableHighDpiScaling`, `AA_UseHighDpiPixmaps`, `HighDpiScaleFactorRoundingPolicy.PassThrough`) before `QApplication` creation in `qapplication()` — on Qt5, UI elements were not scaled on high-DPI monitors (150%/200%/300% Windows display scaling), making text and widgets appear disproportionately small. Qt6 already enables these by default, so the calls are no-ops there. This single change resolves a large portion of visible symptoms downstream (PlotPy, PythonQwt, DataLab) (partial fix for [Issue #101](https://github.com/PlotPyStack/guidata/issues/101))
* **`CheckBoxWidget` clipping**: Fixed checkbox visual clipping when the checkbox text is empty — in mixed-widget grid layouts (especially at high DPI), the checkbox's natural height was smaller than other input widgets (`QComboBox`, `QLineEdit`), causing it to be cut off. The minimum height is now aligned to a standard input widget height
* **`gbuild` CLI command**: Fixed `gbuild` entry point referencing a `main` function that did not exist in `securebuild.py` — running the `gbuild` command after installing guidata raised an `AttributeError`. The argument parsing code was moved from a top-level `if __name__` block into a proper `main()` function (fixes [Issue #99](https://github.com/PlotPyStack/guidata/issues/99))
* **Cleanup utility (`gclean`)**: Fixed `clean_wix_installer_files` removing git-tracked files (e.g. `.bmp`, `.wxs` templates) when their extension matched generated artefact patterns — tracked files are now excluded from glob-based removal

♻️ Internal changes:

* **Debug environment variable**: Renamed debug environment variable from `DEBUG` to `GUIDATA_DEBUG` to avoid collisions with unrelated tooling or third-party conventions that also use the generic `DEBUG` name

## guidata Version 3.14.3 ##

🛠️ Bug fixes:
Expand Down
28 changes: 27 additions & 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.14.3"
__version__ = "3.14.4"


# Dear (Debian, RPM, ...) package makers, please feel free to customize the
Expand All @@ -19,6 +19,31 @@
import guidata.config # noqa: E402, F401


def _configure_high_dpi():
"""Configure high-DPI scaling attributes before QApplication creation.

Must be called before QApplication is instantiated.
Under Qt6 these attributes are enabled by default (the calls are no-ops).
"""
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication

# Qt.AA_EnableHighDpiScaling: opt-in to automatic scaling on Qt 5.6+
if hasattr(Qt, "AA_EnableHighDpiScaling"):
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

# Qt.AA_UseHighDpiPixmaps: render QIcon/QPixmap at device pixel ratio
if hasattr(Qt, "AA_UseHighDpiPixmaps"):
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)

# HighDpiScaleFactorRoundingPolicy.PassThrough: avoid rounding artefacts
# on fractional scaling factors (e.g. 125%, 150%)
if hasattr(Qt, "HighDpiScaleFactorRoundingPolicy"):
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)


def qapplication():
"""
Return QApplication instance
Expand All @@ -28,6 +53,7 @@ def qapplication():

app = QApplication.instance()
if not app:
_configure_high_dpi()
app = QApplication([])
install_translator(app)
from guidata import qthelpers
Expand Down
61 changes: 52 additions & 9 deletions guidata/dataset/qtitemwidgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,15 @@ def __init__(
)
self.group.setLayout(self.layout)

def get(self) -> None:
"""Update widget contents from data item value"""
self.edit.update_widgets()
def get(self, except_this_one: AbstractDataSetWidget | None = None) -> None:
"""Update widget contents from data item value

Args:
except_this_one: widget to skip when refreshing nested widgets
(forwarded to `update_widgets` so that the currently-edited
widget is not overwritten while the user is typing).
"""
self.edit.update_widgets(except_this_one=except_this_one)

def set(self) -> None:
"""Update data item value from widget contents"""
Expand Down Expand Up @@ -396,10 +402,21 @@ def __init__(
raise
self.widgets.append(widget)

def get(self) -> None:
"""Update widget contents from data item value"""
def get(self, except_this_one: AbstractDataSetWidget | None = None) -> None:
"""Update widget contents from data item value

Args:
except_this_one: widget to skip when refreshing nested widgets
(forwarded to children so that the currently-edited widget is
not overwritten while the user is typing).
"""
for widget in self.widgets:
widget.get()
if widget is except_this_one:
continue
if isinstance(widget, (GroupWidget, TabGroupWidget)):
widget.get(except_this_one=except_this_one)
else:
widget.get()

def set(self) -> None:
"""Update data item value from widget contents"""
Expand Down Expand Up @@ -448,12 +465,31 @@ def _display_callback(widget: AbstractDataSetWidget, value):
if widget.contains_computed_items() or cb is not None:
top_level_layout = widget.retrieve_top_level_layout()
if widget.build_mode:
# During widget refresh cascades, only persist this widget's
# current value to the dataset. Do NOT trigger another sibling
# refresh, otherwise the cascade re-enters here from each
# `setText` call (via `textChanged` -> `line_edit_changed`) and
# ends up overwriting whichever widget the user is currently
# typing in (e.g. typing "5" in `dx` becomes "5.0" because
# `xmin`'s recomputed `setText` re-triggers a refresh of `dx`).
widget.set()
else:
top_level_layout.update_dataitems()
return
top_level_layout.update_dataitems()
if cb is not None:
cb(widget.item.instance, widget.item.item, value)
top_level_layout.update_widgets(except_this_one=widget)
# Set `build_mode` on all sibling widgets while we refresh them, so
# the cascading `setText` calls below do not re-enter this function
# and recursively call `update_widgets` with a different
# `except_this_one`.
all_widgets = top_level_layout.get_terminal_widgets()
previous_modes = [(w, w.build_mode) for w in all_widgets]
for w in all_widgets:
w.build_mode = True
try:
top_level_layout.update_widgets(except_this_one=widget)
finally:
for w, mode in previous_modes:
w.build_mode = mode


class LineEditWidget(AbstractDataSetWidget):
Expand Down Expand Up @@ -605,6 +641,13 @@ def __init__(
super().__init__(item, parent_layout)
self.checkbox = QCheckBox(self.item.get_prop_value("display", "text"))
self.checkbox.setToolTip(item.get_help())
if not self.item.get_prop_value("display", "text"):
# When checkbox text is empty, the widget's natural height is smaller
# than other input widgets (QComboBox, QLineEdit), causing visual
# clipping in mixed-widget grid layouts (especially at high DPI).
# Align minimum height to a standard input widget height.
ref_height = QLineEdit().sizeHint().height()
self.checkbox.setMinimumHeight(ref_height)
self.group = self.checkbox

self.store = self.item.get_prop("display", "store", None)
Expand Down
13 changes: 11 additions & 2 deletions guidata/dataset/qtwidgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,19 @@ def update_widgets(
"""Refresh the content of all widgets

Args:
except_this_one: widget to skip
except_this_one: widget to skip (recursively, including widgets
nested in `GroupWidget` or `TabGroupWidget` containers)
"""
for widget in self.widgets:
if widget is not except_this_one:
if widget is except_this_one:
continue
if isinstance(widget, (GroupWidget, TabGroupWidget)):
# Forward `except_this_one` so that the currently-edited widget
# is preserved even when nested inside a group/tab container
# (otherwise typing into a nested field would re-render it from
# the model on every keystroke, e.g. "5" -> "5.0").
widget.get(except_this_one=except_this_one)
else:
widget.get()

def widget_value_changed(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion guidata/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from contextlib import contextmanager
from typing import Any, Generator

DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
DEBUG = os.environ.get("GUIDATA_DEBUG", "").lower() in ("1", "true")
PARSE = os.environ.get("GUIDATA_PARSE_ARGS", "").lower() in ("1", "true")


Expand Down
18 changes: 9 additions & 9 deletions guidata/locale/fr/LC_MESSAGES/guidata.po
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,12 @@ msgstr "les tableaux %s"
msgid "%s are currently not supported"
msgstr "Attention: %s ne sont pas pris en charge"

msgid "NumPy array"
msgstr "Tableau NumPy"

msgid "Array editor"
msgstr "Éditeur de tableaux"

msgid "NumPy array"
msgstr "Tableau NumPy"

msgid "Record array fields:"
msgstr "Champs:"

Expand Down Expand Up @@ -368,15 +368,15 @@ msgstr "Attribut"
msgid "elements"
msgstr "éléments"

msgid "Value"
msgstr "Valeur"

msgid "Type"
msgstr "Type"

msgid "Size"
msgstr "Taille"

msgid "Value"
msgstr "Valeur"

msgid "Warning"
msgstr "Avertissement"

Expand Down Expand Up @@ -629,13 +629,13 @@ msgstr "Le format ({}) est incorrect"
msgid "Format ({}) should start with '%'"
msgstr "Le format ({}) ne doit pas commencer par '%'"

msgid "CSV Files"
msgstr "Fichiers CSV"

#, fuzzy
msgid "Export dataframe"
msgstr "DataFrame"

msgid "CSV Files"
msgstr "Fichiers CSV"

msgid "Import as"
msgstr "Importer en tant que"

Expand Down
45 changes: 40 additions & 5 deletions guidata/utils/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,32 @@
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path


def _git_tracked_files(project_root: Path) -> set[Path]:
"""Return the set of git-tracked file paths under ``project_root``.

Returns an empty set if the directory is not a git working tree or git
is unavailable; callers should treat that as "nothing is protected".
"""
try:
result = subprocess.run(
["git", "-C", str(project_root), "ls-files", "-z"],
capture_output=True,
check=True,
)
except (OSError, subprocess.CalledProcessError):
return set()
return {
(project_root / rel.decode("utf-8")).resolve()
for rel in result.stdout.split(b"\x00")
if rel
}


def get_project_root(start_path: Path | str | None = None) -> Path:
"""Find the project root directory by looking for pyproject.toml.

Expand Down Expand Up @@ -131,14 +153,19 @@ def remove_if_exists(path: Path) -> None:


def remove_glob_pattern(
pattern: str, search_root: Path, case_sensitive: bool = True
pattern: str,
search_root: Path,
case_sensitive: bool = True,
protected: set[Path] | None = None,
) -> None:
"""Remove files matching a glob pattern.

Args:
pattern: Glob pattern to match files/directories.
search_root: Root directory to search from.
case_sensitive: Whether the glob matching should be case sensitive.
protected: Set of resolved paths that must never be deleted (e.g.
git-tracked files that share an extension with generated artefacts).
"""
if not case_sensitive:
pattern = f"**/{pattern.lower()}"
Expand All @@ -147,6 +174,9 @@ def remove_glob_pattern(
else:
matches = list(search_root.glob(pattern))

if protected:
matches = [m for m in matches if m.resolve() not in protected]

if matches:
print(f" Removing {len(matches)} items matching pattern: {pattern}")
for match in matches:
Expand Down Expand Up @@ -298,12 +328,17 @@ def clean_wix_installer_files(project_root: Path, lib_name: str, mod_name: str)
print(" Cleaning WiX installer files...")
wix_dir = project_root / "wix"
if wix_dir.exists():
tracked = _git_tracked_files(project_root)
remove_if_exists(wix_dir / "bin")
remove_if_exists(wix_dir / "obj")
remove_glob_pattern("*.bmp", wix_dir)
remove_glob_pattern("*.wixpdb", wix_dir)
remove_glob_pattern(f"{lib_name}*.wxs", wix_dir, case_sensitive=False)
remove_glob_pattern(f"{mod_name}*.wxs", wix_dir, case_sensitive=False)
remove_glob_pattern("*.bmp", wix_dir, protected=tracked)
remove_glob_pattern("*.wixpdb", wix_dir, protected=tracked)
remove_glob_pattern(
f"{lib_name}*.wxs", wix_dir, case_sensitive=False, protected=tracked
)
remove_glob_pattern(
f"{mod_name}*.wxs", wix_dir, case_sensitive=False, protected=tracked
)


def clean_empty_directories(project_root: Path) -> None:
Expand Down
7 changes: 6 additions & 1 deletion guidata/utils/securebuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ def run_secure_build(
print(f"\n✅ Packages generated in: {dist_dir}")


if __name__ == "__main__":
def main() -> None:
"""Main function to parse arguments and run secure build."""
parser = argparse.ArgumentParser(
description="Securely build Python packages from a Git repository."
)
Expand All @@ -164,3 +165,7 @@ def run_secure_build(
)
args = parser.parse_args()
run_secure_build(root_path=args.root_path, prebuild_command=args.prebuild)


if __name__ == "__main__":
main()
Loading