From 181c2f72217a2062052d4c43b2edb9f6b4549d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 23 Sep 2025 13:06:32 -0400 Subject: [PATCH 1/6] Add option to save files atomically --- qtapputils/managers/fileio.py | 143 ++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 34 deletions(-) diff --git a/qtapputils/managers/fileio.py b/qtapputils/managers/fileio.py index 83acfa9..d890745 100644 --- a/qtapputils/managers/fileio.py +++ b/qtapputils/managers/fileio.py @@ -11,7 +11,9 @@ from typing import Callable # ---- Standard imports +import os import os.path as osp +import uuid # ---- Third party imports from qtpy.QtCore import QObject @@ -20,7 +22,7 @@ class SaveFileManager(QObject): def __init__(self, namefilters: dict, onsave: Callable, - parent: QWidget = None): + parent: QWidget = None, atomic: str = None): """ A manager to save files. @@ -28,8 +30,7 @@ def __init__(self, namefilters: dict, onsave: Callable, ---------- namefilters : dict A dictionary containing the file filters to use in the - 'Save As' file dialog. Here is an example of a correctly - formated namefilters dictionary: + 'Save As' file dialog. For example: namefilters = { '.pdf': 'Portable Document Format (*.pdf)', @@ -39,17 +40,44 @@ def __init__(self, namefilters: dict, onsave: Callable, } Note that the first entry in the dictionary will be used as the - default name filter to use in the 'Save As' dialog. + default name filter in the 'Save As' dialog. onsave : Callable - The callable that is used to save the file. + The callable that is used to save the file. This should be a + function that takes the output filename as its first argument, + and writes the file contents to disk. parent: QWidget, optional The parent widget to use for the 'Save As' file dialog. + atomic: str + Whether to save the file atomically. """ super().__init__() self.parent = parent self.namefilters = namefilters self.onsave = onsave + self.atomic = atomic + def _get_new_save_filename(self, filename): + root, ext = osp.splitext(filename) + if ext not in self.namefilters: + ext = next(iter(self.namefilters)) + filename += ext + + filename, filefilter = QFileDialog.getSaveFileName( + self.parent, + "Save As", + filename, + ';;'.join(self.namefilters.values()), + self.namefilters[ext]) + + if filename: + # Make sure the filename has the right extension. + ext = dict(map(reversed, self.namefilters.items()))[filefilter] + if not filename.endswith(ext): + filename += ext + + return filename + + # ---- Public methods def save_file(self, filename: str, *args, **kwargs) -> str: """ Save in provided filename. @@ -57,25 +85,87 @@ def save_file(self, filename: str, *args, **kwargs) -> str: Parameters ---------- filename : str - The abosulte path where to save the file. + The absolute path where to save the file. Returns ------- filename : str The absolute path where the file was successfully saved. Returns - 'None' if the saving operation was cancelled or was unsuccessfull. + 'None' if the saving operation was cancelled or was unsuccessful. """ - try: - self.onsave(filename, *args, **kwargs) - except PermissionError: - QMessageBox.warning( - self.parent, - 'File in Use', - ("The save file operation cannot be completed because the " - "file is in use by another application or user."), - QMessageBox.Ok) - filename = self.save_file_as(filename, *args, **kwargs) - return filename + file_in_use_msg = ( + "The save file operation cannot be completed because " + "the file is in use by another application or user." + ) + save_except_msg = ( + 'An unexpected error occurred while saving the file:' + '

' + '{}: {}' + ) + + if self.atomic: + while True: + + destdir = osp.dirname(filename) + while True: + tempname = osp.join( + destdir, + f'.temp_{str(uuid.uuid4())[:8]}_' + f'{osp.basename(filename)}' + ) + if not osp.exists(tempname): + break + + try: + self.onsave(tempname, *args, **kwargs) + os.replace(tempname, filename) + return filename + except PermissionError: + QMessageBox.warning( + self.parent, 'File in Use', + file_in_use_msg, QMessageBox.Ok) + + filename = self._get_new_save_filename(filename) + + if not filename: + return None + except Exception as error: + message = save_except_msg.format( + '#CC0000', type(error).__name__, error) + QMessageBox.critical( + self.parent, 'Save Error', message, QMessageBox.Ok) + return None + finally: + if osp.exists(tempname): + try: + os.remove(tempname) + except Exception: + pass + else: + while True: + try: + self.onsave(filename, *args, **kwargs) + return filename + except PermissionError: + QMessageBox.warning( + self.parent, 'File in Use', + file_in_use_msg, QMessageBox.Ok) + + filename = self._get_new_save_filename(filename) + + if not filename: + return None + + except Exception as error: + message = save_except_msg.format( + '#CC0000', type(error).__name__, error) + QMessageBox.critical( + self.parent, + 'Save Error', + message, + QMessageBox.Ok) + + return None def save_file_as(self, filename: str, *args, **kwargs) -> str: """ @@ -92,21 +182,6 @@ def save_file_as(self, filename: str, *args, **kwargs) -> str: The absolute path where the file was successfully saved. Returns 'None' if the saving operation was cancelled or was unsuccessfull. """ - root, ext = osp.splitext(filename) - if ext not in self.namefilters: - ext = next(iter(self.namefilters)) - filename += ext - - filename, filefilter = QFileDialog.getSaveFileName( - self.parent, - "Save As", - filename, - ';;'.join(self.namefilters.values()), - self.namefilters[ext]) + filename = self._get_new_save_filename(filename) if filename: - # Make sur the filename has the right extension. - ext = dict(map(reversed, self.namefilters.items()))[filefilter] - if not filename.endswith(ext): - filename += ext - return self.save_file(filename, *args, **kwargs) From a0e1e4891628454953dadd95049d9eefa9ca55ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 23 Sep 2025 15:45:54 -0400 Subject: [PATCH 2/6] Rework atomic save error handling logic --- qtapputils/managers/fileio.py | 65 +++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/qtapputils/managers/fileio.py b/qtapputils/managers/fileio.py index d890745..a0b64cd 100644 --- a/qtapputils/managers/fileio.py +++ b/qtapputils/managers/fileio.py @@ -22,7 +22,7 @@ class SaveFileManager(QObject): def __init__(self, namefilters: dict, onsave: Callable, - parent: QWidget = None, atomic: str = None): + parent: QWidget = None, atomic: bool = False): """ A manager to save files. @@ -56,6 +56,24 @@ def __init__(self, namefilters: dict, onsave: Callable, self.onsave = onsave self.atomic = atomic + def _cleanup_tempfile(self, tempname): + if osp.exists(tempname): + try: + os.remove(tempname) + except Exception: + pass + + def _get_valid_tempname(self, filename): + destdir = osp.dirname(filename) + while True: + tempname = osp.join( + destdir, + f'.temp_{str(uuid.uuid4())[:8]}_' + f'{osp.basename(filename)}' + ) + if not osp.exists(tempname): + return tempname + def _get_new_save_filename(self, filename): root, ext = osp.splitext(filename) if ext not in self.namefilters: @@ -105,42 +123,31 @@ def save_file(self, filename: str, *args, **kwargs) -> str: if self.atomic: while True: - - destdir = osp.dirname(filename) - while True: - tempname = osp.join( - destdir, - f'.temp_{str(uuid.uuid4())[:8]}_' - f'{osp.basename(filename)}' - ) - if not osp.exists(tempname): - break - + tempname = self._get_valid_tempname(filename) try: self.onsave(tempname, *args, **kwargs) - os.replace(tempname, filename) - return filename - except PermissionError: - QMessageBox.warning( - self.parent, 'File in Use', - file_in_use_msg, QMessageBox.Ok) - - filename = self._get_new_save_filename(filename) - - if not filename: - return None + try: + os.replace(tempname, filename) + return filename + except PermissionError: + QMessageBox.warning( + self.parent, 'File in Use', + file_in_use_msg, QMessageBox.Ok) + + filename = self._get_new_save_filename(filename) + + if not filename: + return None except Exception as error: message = save_except_msg.format( '#CC0000', type(error).__name__, error) QMessageBox.critical( self.parent, 'Save Error', message, QMessageBox.Ok) + return None finally: - if osp.exists(tempname): - try: - os.remove(tempname) - except Exception: - pass + self._cleanup_tempfile(tempname) + else: while True: try: @@ -180,7 +187,7 @@ def save_file_as(self, filename: str, *args, **kwargs) -> str: ------- filename : str The absolute path where the file was successfully saved. Returns - 'None' if the saving operation was cancelled or was unsuccessfull. + 'None' if the saving operation was cancelled or was unsuccessful. """ filename = self._get_new_save_filename(filename) if filename: From e8661af434155213724244c70ab65bb5de166698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 24 Sep 2025 11:15:37 -0400 Subject: [PATCH 3/6] Make 'save_file' method code more readable --- qtapputils/managers/fileio.py | 110 +++++++++++++++++----------------- 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/qtapputils/managers/fileio.py b/qtapputils/managers/fileio.py index a0b64cd..19adbc8 100644 --- a/qtapputils/managers/fileio.py +++ b/qtapputils/managers/fileio.py @@ -56,13 +56,6 @@ def __init__(self, namefilters: dict, onsave: Callable, self.onsave = onsave self.atomic = atomic - def _cleanup_tempfile(self, tempname): - if osp.exists(tempname): - try: - os.remove(tempname) - except Exception: - pass - def _get_valid_tempname(self, filename): destdir = osp.dirname(filename) while True: @@ -98,7 +91,7 @@ def _get_new_save_filename(self, filename): # ---- Public methods def save_file(self, filename: str, *args, **kwargs) -> str: """ - Save in provided filename. + ave file to the provided filename, with atomic write option. Parameters ---------- @@ -108,71 +101,80 @@ def save_file(self, filename: str, *args, **kwargs) -> str: Returns ------- filename : str - The absolute path where the file was successfully saved. Returns - 'None' if the saving operation was cancelled or was unsuccessful. + The absolute path where the file was successfully saved. + Returns None if save was cancelled or unsuccessful. """ - file_in_use_msg = ( - "The save file operation cannot be completed because " - "the file is in use by another application or user." + def _show_warning(message: str): + QMessageBox.warning( + self.parent, 'Save Error', message, QMessageBox.Ok + ) + + def _show_critical(error: Exception): + msg = (f'An unexpected error occurred while saving the file:' + f'

' + f'{type(error).__name__}: ' + f'{error}') + QMessageBox.critical( + self.parent, 'Save Error', msg, QMessageBox.Ok + ) + + write_permission_msg = ( + "You do not have write permission for this location.\n\n" + "Please choose a different location and try again." ) - save_except_msg = ( - 'An unexpected error occurred while saving the file:' - '

' - '{}: {}' + overwrite_error_msg = ( + "The save operation could not be completed because:\n\n" + "- You do not have write permission for the selected location" + ", or\n" + "- The file is currently in use by another application.\n\n" + "Please choose a different location or ensure the file is not " + "open in another program and try again." ) - if self.atomic: - while True: - tempname = self._get_valid_tempname(filename) - try: + while True: + file_exists = osp.exists(filename) + tempname = None + + try: + if self.atomic: + tempname = self._get_valid_tempname(filename) self.onsave(tempname, *args, **kwargs) try: os.replace(tempname, filename) return filename except PermissionError: - QMessageBox.warning( - self.parent, 'File in Use', - file_in_use_msg, QMessageBox.Ok) + if file_exists: + _show_warning(overwrite_error_msg) + else: + _show_warning(write_permission_msg) filename = self._get_new_save_filename(filename) - if not filename: return None - except Exception as error: - message = save_except_msg.format( - '#CC0000', type(error).__name__, error) - QMessageBox.critical( - self.parent, 'Save Error', message, QMessageBox.Ok) - - return None - finally: - self._cleanup_tempfile(tempname) - - else: - while True: - try: + else: self.onsave(filename, *args, **kwargs) return filename - except PermissionError: - QMessageBox.warning( - self.parent, 'File in Use', - file_in_use_msg, QMessageBox.Ok) - filename = self._get_new_save_filename(filename) + except PermissionError: + if self.atomic or not file_exists: + _show_warning(write_permission_msg) + else: + _show_warning(overwrite_error_msg) - if not filename: - return None + filename = self._get_new_save_filename(filename) + if not filename: + return None - except Exception as error: - message = save_except_msg.format( - '#CC0000', type(error).__name__, error) - QMessageBox.critical( - self.parent, - 'Save Error', - message, - QMessageBox.Ok) + except Exception as error: + _show_critical(error) + return None - return None + finally: + if self.atomic and osp.exists(tempname): + try: + os.remove(tempname) + except Exception: + pass def save_file_as(self, filename: str, *args, **kwargs) -> str: """ From d4f77e23587b74cf0eb31c78ad244a4ab450c9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 24 Sep 2025 11:23:39 -0400 Subject: [PATCH 4/6] Improve docstring --- qtapputils/managers/fileio.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qtapputils/managers/fileio.py b/qtapputils/managers/fileio.py index 19adbc8..6e17dab 100644 --- a/qtapputils/managers/fileio.py +++ b/qtapputils/managers/fileio.py @@ -47,8 +47,10 @@ def __init__(self, namefilters: dict, onsave: Callable, and writes the file contents to disk. parent: QWidget, optional The parent widget to use for the 'Save As' file dialog. - atomic: str - Whether to save the file atomically. + atomic: bool, optional + Whether to save files atomically (write to a temp file then move). + Defaults to False for backward compatibility. For better data + integrity, consider setting atomic=True. """ super().__init__() self.parent = parent From c134bbc07dde951daa313e06d852c6d09419b321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 24 Sep 2025 16:25:52 -0400 Subject: [PATCH 5/6] Expand test_fileio --- qtapputils/managers/tests/test_fileio.py | 223 +++++++++++++++++------ 1 file changed, 171 insertions(+), 52 deletions(-) diff --git a/qtapputils/managers/tests/test_fileio.py b/qtapputils/managers/tests/test_fileio.py index ee6dc30..e13a06d 100644 --- a/qtapputils/managers/tests/test_fileio.py +++ b/qtapputils/managers/tests/test_fileio.py @@ -12,11 +12,14 @@ """ # ---- Standard imports +import os import os.path as osp +import stat +from unittest.mock import MagicMock, patch # ---- Third party imports import pytest -from qtpy.QtWidgets import QFileDialog, QMessageBox +from qtpy.QtWidgets import QFileDialog, QMessageBox, QWidget # ---- Local imports from qtapputils.managers import SaveFileManager @@ -27,96 +30,212 @@ # ============================================================================= # ---- Fixtures # ============================================================================= -@pytest.fixture -def savefile_manager(qtbot): - def onsave(filename, filecontent): - if 'blocked' in filename: - raise PermissionError +# Dummy onsave function for success +def dummy_onsave_success(filename, *args, **kwargs): + with open(filename, "w") as f: + f.write("data") - with open(filename, 'w') as f: - f.write(filecontent) - manager = SaveFileManager( - namefilters={ - '.txt': 'Text File (*.txt)', - '.docx': 'Microsoft Word Document (*.docx)' - }, - onsave=onsave - ) - return manager +# Dummy onsave function for permission error +def dummy_onsave_permission_error(filename, *args, **kwargs): + raise PermissionError("No write permission") + + +# Dummy onsave function for generic error +def dummy_onsave_generic_error(filename, *args, **kwargs): + raise RuntimeError("Unexpected error") + + +NAMEFILTERS = { + ".txt": "Text Files (*.txt)", + ".csv": "CSV Files (*.csv)" + } # ============================================================================= # ---- Tests # ============================================================================= -def test_save_file(savefile_manager, qtbot, tmp_path): - """ - Test that saving a file is working as expected. - """ - filename = osp.join(tmp_path, 'test_savefile.txt') - assert not osp.exists(filename) +@pytest.mark.parametrize('atomic', [True, False]) +def test_save_file_success(tmp_path, atomic): + """Test successful file save in atomic and non-atomic modes.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_success, + parent=QWidget(), + atomic=atomic + ) - returned_filename = savefile_manager.save_file(filename, FILECONTENT) + filename = osp.join(tmp_path, "output.txt") + result = manager.save_file(filename) + assert result == filename + assert osp.exists(filename) + with open(filename) as f: + assert f.read() == "data" + + +@pytest.mark.parametrize('atomic', [True, False]) +def test_save_file_as_success(tmp_path, mocker, atomic): + """Test successful file save using the 'Save As' dialog.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_success, + parent=QWidget(), + atomic=atomic + ) + filename = osp.join(tmp_path, "newfile.csv") + qfdialog_patcher = mocker.patch.object( + QFileDialog, + 'getSaveFileName', + return_value=(filename, "CSV Files (*.csv)") + ) + + suggested = osp.join(tmp_path, "suggested.txt") + result = manager.save_file_as(suggested) + + assert qfdialog_patcher.call_count == 1 + assert result == filename assert osp.exists(filename) - assert filename == returned_filename - with open(filename, 'r') as f: - assert FILECONTENT == f.read() + assert not osp.exists(suggested) -def test_save_file_error(savefile_manager, qtbot, tmp_path, mocker): - """ - Test that selecting a new file when an error is raised is - working as expected. - """ +@pytest.mark.parametrize('atomic', [True, False]) +def test_save_file_permission_error(tmp_path, mocker, atomic): + """Test handling of PermissionError during save with user cancel.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_permission_error, + parent=QWidget(), + atomic=atomic + ) + + filename = osp.join(tmp_path, "fail.txt") + + # Mock QMessageBox so no GUI appears. qmsgbox_patcher = mocker.patch.object( QMessageBox, 'warning', return_value=QMessageBox.Ok ) - filename = osp.join(tmp_path, 'test_savefile') - assert not osp.exists(filename) - + # Mock file dialog to simulate cancel. qfdialog_patcher = mocker.patch.object( QFileDialog, 'getSaveFileName', - return_value=(filename, 'Text File (*.txt)') + return_value=(None, None) ) - returned_filename = savefile_manager.save_file( - filename + '_blocked', FILECONTENT) + result = manager.save_file(str(filename)) + assert result is None assert qfdialog_patcher.call_count == 1 assert qmsgbox_patcher.call_count == 1 - assert returned_filename == filename + '.txt' - assert osp.exists(filename + '.txt') + assert not osp.exists(filename) + + +@pytest.mark.parametrize('atomic', [True, False]) +def test_save_file_generic_exception(tmp_path, mocker, atomic): + """Test handling of generic exception during save.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_generic_error, + parent=QWidget(), + atomic=atomic + ) + filename = osp.join(tmp_path, "fail.txt") -def test_save_file_error_cancelled(savefile_manager, qtbot, tmp_path, mocker): - """ - Test that cancelling the saving process when an error is raised is - working as expected. - """ + # Mock QMessageBox so no GUI appears. qmsgbox_patcher = mocker.patch.object( - QMessageBox, 'warning', return_value=QMessageBox.Ok + QMessageBox, 'critical', return_value=QMessageBox.Ok ) - # Test that cancelling the saving process when an error is raised is - # working as expected. + result = manager.save_file(str(filename)) + assert result is None + assert not osp.exists(filename) + assert qmsgbox_patcher.call_count == 1 - filename = osp.join(tmp_path, 'test_savefile_blocked.txt') + +@pytest.mark.parametrize('atomic', [True, False]) +def test_extension_added_if_missing(tmp_path, mocker, atomic): + """Test automatic extension addition when missing in file name.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_success, + parent=QWidget(), + atomic=atomic + ) + + # Simulate user picking CSV filter, but filename without extension. + filename = osp.join(tmp_path, "nofile") + + qfdialog_patcher = mocker.patch.object( + QFileDialog, + 'getSaveFileName', + return_value=(filename, "CSV Files (*.csv)") + ) + + result = manager.save_file_as(filename) + + assert result == filename + '.csv' + assert osp.exists(filename + '.csv') + assert qfdialog_patcher.call_count == 1 + + +@pytest.mark.parametrize('atomic', [True, False]) +def test_save_file_as_cancel(tmp_path, mocker, atomic): + """Test 'Save As' dialog cancel returns None and does not save.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_success, + parent=QWidget(), + atomic=atomic + ) + + qfdialog_patcher = mocker.patch.object( + QFileDialog, + 'getSaveFileName', + return_value=("", "") + ) + + filename = osp.join(tmp_path, "suggested.txt") + result = manager.save_file_as(filename) + + assert result is None + assert qfdialog_patcher.call_count == 1 assert not osp.exists(filename) + +def test_atomic_save_replace_permission_error(tmp_path, mocker): + """Test atomic save when os.replace raises PermissionError.""" + manager = SaveFileManager( + namefilters=NAMEFILTERS, + onsave=dummy_onsave_success, + parent=QWidget(), + atomic=True + ) + filename = osp.join(tmp_path, "file.txt") + + # Patch os.replace to raise PermissionError. + mocker.patch( + "os.replace", side_effect=PermissionError("Permission denied") + ) + + # Patch QMessageBox.warning so no GUI appears + qmsgbox_patcher = mocker.patch.object( + QMessageBox, 'warning', return_value=QMessageBox.Ok + ) + + # Patch the file dialog to simulate cancel. qfdialog_patcher = mocker.patch.object( QFileDialog, 'getSaveFileName', - return_value=(None, None) + return_value=("", "") ) - returned_filename = savefile_manager.save_file(filename, FILECONTENT) + result = manager.save_file(filename) + + assert result is None assert qfdialog_patcher.call_count == 1 assert qmsgbox_patcher.call_count == 1 - assert returned_filename is None - assert not osp.exists(filename) if __name__ == "__main__": From 6d2d2ccdb83ffb95b6bc7b4fdc8a3e08f6fe4eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 24 Sep 2025 16:33:25 -0400 Subject: [PATCH 6/6] Try to fix tests on Azure --- qtapputils/managers/tests/test_fileio.py | 35 ++++++++++++++---------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/qtapputils/managers/tests/test_fileio.py b/qtapputils/managers/tests/test_fileio.py index e13a06d..990711d 100644 --- a/qtapputils/managers/tests/test_fileio.py +++ b/qtapputils/managers/tests/test_fileio.py @@ -53,16 +53,23 @@ def dummy_onsave_generic_error(filename, *args, **kwargs): } +@pytest.fixture +def parent(qtbot): + parent = QWidget() + qtbot.addWidget(parent) + return parent + + # ============================================================================= # ---- Tests # ============================================================================= @pytest.mark.parametrize('atomic', [True, False]) -def test_save_file_success(tmp_path, atomic): +def test_save_file_success(tmp_path, atomic, parent): """Test successful file save in atomic and non-atomic modes.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_success, - parent=QWidget(), + parent=parent, atomic=atomic ) @@ -75,12 +82,12 @@ def test_save_file_success(tmp_path, atomic): @pytest.mark.parametrize('atomic', [True, False]) -def test_save_file_as_success(tmp_path, mocker, atomic): +def test_save_file_as_success(tmp_path, mocker, atomic, parent): """Test successful file save using the 'Save As' dialog.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_success, - parent=QWidget(), + parent=parent, atomic=atomic ) @@ -101,12 +108,12 @@ def test_save_file_as_success(tmp_path, mocker, atomic): @pytest.mark.parametrize('atomic', [True, False]) -def test_save_file_permission_error(tmp_path, mocker, atomic): +def test_save_file_permission_error(tmp_path, mocker, atomic, parent): """Test handling of PermissionError during save with user cancel.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_permission_error, - parent=QWidget(), + parent=parent, atomic=atomic ) @@ -132,12 +139,12 @@ def test_save_file_permission_error(tmp_path, mocker, atomic): @pytest.mark.parametrize('atomic', [True, False]) -def test_save_file_generic_exception(tmp_path, mocker, atomic): +def test_save_file_generic_exception(tmp_path, mocker, atomic, parent): """Test handling of generic exception during save.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_generic_error, - parent=QWidget(), + parent=parent, atomic=atomic ) @@ -155,12 +162,12 @@ def test_save_file_generic_exception(tmp_path, mocker, atomic): @pytest.mark.parametrize('atomic', [True, False]) -def test_extension_added_if_missing(tmp_path, mocker, atomic): +def test_extension_added_if_missing(tmp_path, mocker, atomic, parent): """Test automatic extension addition when missing in file name.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_success, - parent=QWidget(), + parent=parent, atomic=atomic ) @@ -181,12 +188,12 @@ def test_extension_added_if_missing(tmp_path, mocker, atomic): @pytest.mark.parametrize('atomic', [True, False]) -def test_save_file_as_cancel(tmp_path, mocker, atomic): +def test_save_file_as_cancel(tmp_path, mocker, atomic, parent): """Test 'Save As' dialog cancel returns None and does not save.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_success, - parent=QWidget(), + parent=parent, atomic=atomic ) @@ -204,12 +211,12 @@ def test_save_file_as_cancel(tmp_path, mocker, atomic): assert not osp.exists(filename) -def test_atomic_save_replace_permission_error(tmp_path, mocker): +def test_atomic_save_replace_permission_error(tmp_path, mocker, parent): """Test atomic save when os.replace raises PermissionError.""" manager = SaveFileManager( namefilters=NAMEFILTERS, onsave=dummy_onsave_success, - parent=QWidget(), + parent=parent, atomic=True ) filename = osp.join(tmp_path, "file.txt")