Skip to content
Open
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
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):
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