diff --git a/rascal2/config.py b/rascal2/config.py index 5ff8ee3..77d66c1 100644 --- a/rascal2/config.py +++ b/rascal2/config.py @@ -23,6 +23,7 @@ STATIC_PATH = SOURCE_PATH / "static" IMAGES_PATH = STATIC_PATH / "images" MATLAB_ARCH_FILE = pathlib.Path(SITE_PATH) / "matlab/engine/_arch.txt" +EXAMPLES_TEMP_PATH = pathlib.Path(get_global_settings().fileName()).parent / "examples" def handle_scaling(): diff --git a/rascal2/dialogs/custom_file_editor.py b/rascal2/dialogs/custom_file_editor.py index 7618acd..ad1f243 100644 --- a/rascal2/dialogs/custom_file_editor.py +++ b/rascal2/dialogs/custom_file_editor.py @@ -6,7 +6,7 @@ from PyQt6 import Qsci, QtGui, QtWidgets from ratapi.utils.enums import Languages -from rascal2.config import MatlabHelper +from rascal2.config import EXAMPLES_PATH, MatlabHelper def edit_file(filename: str, language: Languages, parent: QtWidgets.QWidget): @@ -122,5 +122,16 @@ def __init__(self, file, language, parent): def save_file(self): """Save and close the file.""" - self.file.write_text(self.editor.text()) - self.accept() + if self.file.is_relative_to(EXAMPLES_PATH): + message = "Files cannot be saved into the examples directory, please copy the file to another directory." + QtWidgets.QMessageBox.warning(self, "Save File", message, QtWidgets.QMessageBox.StandardButton.Ok) + return + + try: + self.file.write_text(self.editor.text()) + self.accept() + except OSError as ex: + logger = logging.getLogger("rascal_log") + message = f"Failed to save custom file to {self.file}.\n" + logger.error(message, exc_info=ex) + QtWidgets.QMessageBox.critical(self, "Save File", message, QtWidgets.QMessageBox.StandardButton.Ok) diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index e4295cd..ff2cbd3 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -1,3 +1,4 @@ +import shutil from json import JSONDecodeError from pathlib import Path @@ -5,6 +6,36 @@ import ratapi.outputs from PyQt6 import QtCore +from rascal2.config import EXAMPLES_PATH, EXAMPLES_TEMP_PATH + + +def copy_example_project(load_path): + """Copy example project to temp directory so user does not modify original. + + Non-example projects are not copied. + + Parameters + ---------- + load_path : str + The load path of the project. + + Returns + ------- + new_load_path: str + The path of the copied project if project is example otherwise the same as load_path. + """ + load_path = Path(load_path) + if load_path.is_relative_to(EXAMPLES_PATH): + if load_path.is_file(): + temp_dir = EXAMPLES_TEMP_PATH / load_path.parent.stem + shutil.copytree(load_path.parent, temp_dir, dirs_exist_ok=True) + load_path = temp_dir / load_path.name + else: + temp_dir = EXAMPLES_TEMP_PATH / load_path.name + shutil.copytree(load_path, temp_dir, dirs_exist_ok=True) + load_path = temp_dir + return str(load_path) + class MainWindowModel(QtCore.QObject): """Manages project data and communicates to view via signals. @@ -81,12 +112,22 @@ def update_project(self, new_values: dict) -> None: vars(self.project).update(new_values) self.project_updated.emit() - def save_project(self): - """Save the project to the save path.""" - self.controls.save(Path(self.save_path, "controls.json")) - self.project.save(Path(self.save_path, "project.json")) + def save_project(self, save_path): + """Save the project to the save path. + + Parameters + ---------- + save_path : str + The save path of the project. + """ + self.controls.save(Path(save_path, "controls.json")) + self.project.save(Path(save_path, "project.json")) if self.results: - self.results.save(Path(self.save_path, "results.json")) + self.results.save(Path(save_path, "results.json")) + self.save_path = save_path + + def is_project_example(self): + return Path(self.save_path).is_relative_to(EXAMPLES_TEMP_PATH) def load_project(self, load_path: str): """Load a project from a project folder. @@ -102,6 +143,8 @@ def load_project(self, load_path: str): If the project files are not in a valid format. """ + load_path = copy_example_project(load_path) + results_file = Path(load_path, "results.json") try: results = rat.Results.load(results_file) @@ -145,12 +188,8 @@ def load_r1_project(self, load_path: str): The path to the RasCAL-1 file. """ + load_path = copy_example_project(load_path) self.project = rat.utils.convert.r1_to_project(load_path) - - # TODO remove this when it is fixed in ratapi - # https://github.com/RascalSoftware/python-RAT/issues/183 - for file in self.project.custom_files: - file.path = Path(load_path).parent self.controls = rat.Controls() self.save_path = str(Path(load_path).parent) diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index ec9fbcb..3da24bc 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -1,12 +1,11 @@ import re import warnings -from pathlib import Path from typing import Any import ratapi as rat import ratapi.wrappers -from rascal2.config import EXAMPLES_PATH, MatlabHelper, get_matlab_engine +from rascal2.config import MatlabHelper, get_matlab_engine from rascal2.core import commands from rascal2.core.enums import UnsavedReply from rascal2.core.runner import LogData, RATRunner @@ -72,8 +71,9 @@ def load_r1_project(self, load_path: str): def initialise_ui(self): """Initialise UI for a project.""" + suffix = " [Example]" if self.model.is_project_example() else "" self.view.setWindowTitle( - self.view.windowTitle() + " - " + self.model.project.name, + self.view.windowTitle().split(" - ")[0] + " - " + self.model.project.name + suffix, ) self.view.init_settings_and_log(self.model.save_path) self.view.setup_mdi() @@ -98,13 +98,6 @@ def edit_controls(self, setting: str, value: Any): If the setting is changed to an invalid value. """ - # FIXME: without proper logging, - # we have to check validation in advance because PyQt doesn't return - # the exception, it just falls over in C++ - # also doing it this way stops bad changes being pushed onto the stack - # https://github.com/RascalSoftware/RasCAL-2/issues/26 - # also suppress warnings (we get warning for setting params not matching - # procedure on initialisation) to avoid clogging stdout with warnings.catch_warnings(): warnings.simplefilter("ignore") self.model.controls.model_validate({setting: value}) @@ -123,17 +116,18 @@ def save_project(self, save_as: bool = False): : bool Indicates if the project was saved. """ - # we use this isinstance rather than `is not None` - # because some PyQt signals will send bools and so on to this as a slot! - if save_as or Path(self.model.save_path).is_relative_to(EXAMPLES_PATH): + to_path = self.model.save_path + if save_as or self.model.is_project_example(): to_path = self.view.get_project_folder() if not to_path: return False - self.model.save_path = to_path - - self.model.save_project() - update_recent_projects(self.model.save_path) - self.view.undo_stack.setClean() + try: + self.model.save_project(to_path) + except OSError as err: + self.view.logging.error(f"Failed to save project to {to_path}.\n", exc_info=err) + else: + update_recent_projects(self.model.save_path) + self.view.undo_stack.setClean() return True def ask_to_save_project(self): diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index f849ebc..ffe45e1 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -2,12 +2,12 @@ from PyQt6 import QtCore, QtGui, QtWidgets -from rascal2.config import EXAMPLES_PATH, get_logger, path_for, setup_logging, setup_settings +from rascal2.config import EXAMPLES_PATH, EXAMPLES_TEMP_PATH, get_logger, path_for, setup_logging, setup_settings from rascal2.core.enums import UnsavedReply from rascal2.dialogs.about_dialog import AboutDialog from rascal2.dialogs.settings_dialog import SettingsDialog from rascal2.dialogs.startup_dialog import PROJECT_FILES, LoadDialog, LoadR1Dialog, NewProjectDialog, StartupDialog -from rascal2.settings import MDIGeometries, Settings, get_global_settings +from rascal2.settings import MDIGeometries, Settings from rascal2.widgets import ControlsWidget, PlotWidget, TerminalWidget from rascal2.widgets.project import ProjectWidget from rascal2.widgets.startup import StartUpWidget @@ -369,11 +369,7 @@ def init_settings_and_log(self, save_path: str): proj_path = Path(save_path) self.settings = setup_settings(proj_path) - if proj_path.is_relative_to(EXAMPLES_PATH): - log_path = Path(get_global_settings().fileName()).parent - else: - log_path = proj_path / "logs" - + log_path = proj_path / "logs" log_path.mkdir(parents=True, exist_ok=True) self.logging = setup_logging(log_path / "rascal.log", self.terminal_widget) @@ -421,16 +417,8 @@ def get_project_folder(self) -> str | None: """ project_folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Folder") if project_folder: - if Path(project_folder).is_relative_to(EXAMPLES_PATH): - message = "Files cannot be saved in the examples directory. Please select another directory to save in." - QtWidgets.QMessageBox.warning( - self, - "Select Folder", - message, - QtWidgets.QMessageBox.StandardButton.Ok, - QtWidgets.QMessageBox.StandardButton.Ok, - ) - return "" + while self.check_save_blacklist(project_folder): + project_folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Folder") if any(Path(project_folder, file).exists() for file in PROJECT_FILES): overwrite = self.show_confirm_dialog( @@ -463,13 +451,10 @@ def get_save_file(self, caption, directory, file_filter) -> str: The chosen file. """ save_file, _ = QtWidgets.QFileDialog.getSaveFileName(self, caption, directory, QtCore.QObject.tr(file_filter)) - - if Path(save_file).is_relative_to(EXAMPLES_PATH): - message = "Files cannot be saved in the examples directory. Please select another directory to save in." - QtWidgets.QMessageBox.warning( - self, caption, message, QtWidgets.QMessageBox.StandardButton.Ok, QtWidgets.QMessageBox.StandardButton.Ok + while self.check_save_blacklist(save_file): + save_file, _ = QtWidgets.QFileDialog.getSaveFileName( + self, caption, directory, QtCore.QObject.tr(file_filter) ) - return "" return save_file @@ -553,3 +538,28 @@ def show_unsaved_dialog(self, message: str) -> UnsavedReply: return UnsavedReply.Discard else: return UnsavedReply.Cancel + + def check_save_blacklist(self, save_path): + """Check if user selected save path is invalid i.e. in the examples or tmp directory. + + Parameters + ---------- + save_path : str + The user selected save path. + + Returns + ------- + bool + Whether the save path is invalid. + """ + if Path(save_path).is_relative_to(EXAMPLES_PATH): + message = "Files cannot be saved in the examples directory. Please select another directory to save in." + elif Path(save_path).is_relative_to(EXAMPLES_TEMP_PATH): + message = "It is not recommended to save in the tmp directory. Please select another directory to save in." + else: + return False + + QtWidgets.QMessageBox.warning( + self, "Warning", message, QtWidgets.QMessageBox.StandardButton.Ok, QtWidgets.QMessageBox.StandardButton.Ok + ) + return True diff --git a/tests/ui/test_model.py b/tests/ui/test_model.py index 3976017..67246ca 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -59,8 +59,7 @@ def test_save_project(empty_results): model.controls = Controls(procedure="dream", resampleMinAngle=0.5) model.results = empty_results with TemporaryDirectory() as tmpdir: - model.save_path = tmpdir - model.save_project() + model.save_project(tmpdir) results = Path(tmpdir, "results.json").read_text() controls = Path(tmpdir, "controls.json").read_text() diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 352a427..a272779 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -198,16 +198,17 @@ def test_load_project(model_mock, presenter, function): @patch("rascal2.ui.presenter.update_recent_projects") def test_save_project(recent_projects_mock, presenter): """Test that projects can be saved, optionally saved as a new folder.""" - presenter.model.save_project = MagicMock() + presenter.model.project = MagicMock() + presenter.model.controls = MagicMock() presenter.save_project() - presenter.model.save_project.assert_called_once() - - presenter.model.save_project.reset_mock() + presenter.model.project.save.assert_called_once() + presenter.model.controls.save.assert_called_once() presenter.save_project(save_as=True) assert presenter.model.save_path == "new path/" assert presenter.view.undo_stack.isClean() - presenter.model.save_project.assert_called_once() + assert presenter.model.project.save.call_count == 2 + assert presenter.model.controls.save.call_count == 2 recent_projects_mock.assert_called_with("new path/") diff --git a/tests/ui/test_view.py b/tests/ui/test_view.py index f3828d7..96a2c74 100644 --- a/tests/ui/test_view.py +++ b/tests/ui/test_view.py @@ -116,6 +116,7 @@ def test_set_enabled(test_view): def test_get_project_folder(mock_get_dir: MagicMock): """Test that getting a specified folder works as expected.""" view = MainWindowView() + view.check_save_blacklist = MagicMock(return_value=False) mock_overwrite = MagicMock(return_value=True) tmp = tempfile.mkdtemp()