Skip to content
Closed
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
6 changes: 6 additions & 0 deletions doc/release_notes/release_3.14.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 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))

## 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
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