Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions rascal2/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
17 changes: 14 additions & 3 deletions rascal2/dialogs/custom_file_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
59 changes: 49 additions & 10 deletions rascal2/ui/model.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
import shutil
from json import JSONDecodeError
from pathlib import Path

import ratapi as rat
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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 12 additions & 18 deletions rascal2/ui/presenter.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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})
Expand All @@ -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):
Expand Down
56 changes: 33 additions & 23 deletions rascal2/ui/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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, "Select 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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
3 changes: 1 addition & 2 deletions tests/ui/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 6 additions & 5 deletions tests/ui/test_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/")


Expand Down
1 change: 1 addition & 0 deletions tests/ui/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading