From 9a8ab8f58cabb457ae9eb42517cf98d5eebde65f Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Mon, 27 Apr 2026 17:36:05 +0100 Subject: [PATCH 1/7] Added Save Project as Script to the File Menu UI --- rascal2/ui/view.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index 2420abe0..146fb5d6 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -126,6 +126,11 @@ def create_actions(self): self.save_as_action.setShortcut(QtGui.QKeySequence.StandardKey.SaveAs) self.disabled_elements.append(self.save_project_action) + self.save_as_script_action = QtGui.QAction("Save Project as &Script...", self) + self.save_as_script_action.setStatusTip("Save project as a script.") + self.save_as_script_action.setIcon(QtGui.QIcon(path_for("save-project.png"))) + self.disabled_elements.append(self.save_as_script_action) + self.undo_action = self.undo_stack.createUndoAction(self, "&Undo") self.undo_action.setStatusTip("Undo the last action") self.undo_action.setIcon(QtGui.QIcon(path_for("undo.png"))) @@ -217,6 +222,7 @@ def create_menus(self): file_menu.addSeparator() file_menu.addAction(self.save_project_action) file_menu.addAction(self.save_as_action) + file_menu.addAction(self.save_as_script_action) file_menu.addSeparator() file_menu.addAction(self.export_fits_action) file_menu.addSeparator() From c2466f5c0d5d2b5e0aec742e5fd29a55a4846c2c Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Tue, 28 Apr 2026 11:59:02 +0100 Subject: [PATCH 2/7] Connect backend to save_project_as_script to view --- rascal2/ui/model.py | 11 +++++++++++ rascal2/ui/presenter.py | 7 +++++-- rascal2/ui/view.py | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index 56b581a7..17fdaee5 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -149,6 +149,17 @@ def save_project(self, save_path): self.save_path = save_path os.chdir(save_path) + def save_project_as_script(self, save_path): + """Save the project to the save path as a script file. + + Parameters + ---------- + save_path : str + The save path of the project. + """ + script_path = save_path + "/project_script.py" + self.project.write_script(script=script_path) + def is_project_example(self): return Path(self.save_path).is_relative_to(EXAMPLES_TEMP_PATH) diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index 5e88b36d..0dee3771 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -106,7 +106,7 @@ def edit_controls(self, setting: str, value: Any): self.model.controls.model_validate({setting: value}) self.view.undo_stack.push(commands.EditControls({setting: value}, self)) - def save_project(self, save_as: bool = False): + def save_project(self, save_as: bool = False, as_script: bool = False): """Save the model. Parameters @@ -132,7 +132,10 @@ def save_project(self, save_as: bool = False): if not to_path: return False try: - self.model.save_project(to_path) + if as_script: + self.model.save_project_as_script(to_path) + else: + self.model.save_project(to_path) except OSError as err: LOGGER.error(f"Failed to save project to {to_path}.\n", exc_info=err) else: diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index 146fb5d6..454de288 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -129,6 +129,7 @@ def create_actions(self): self.save_as_script_action = QtGui.QAction("Save Project as &Script...", self) self.save_as_script_action.setStatusTip("Save project as a script.") self.save_as_script_action.setIcon(QtGui.QIcon(path_for("save-project.png"))) + self.save_as_script_action.triggered.connect(lambda: self.presenter.save_project(save_as=True, as_script=True)) self.disabled_elements.append(self.save_as_script_action) self.undo_action = self.undo_stack.createUndoAction(self, "&Undo") From fe608e5ad2ca8f9eeaa12e0d56eaa8f157880782 Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Tue, 28 Apr 2026 14:04:26 +0100 Subject: [PATCH 3/7] add Save Project as Script to test_help_menu_actions_present --- rascal2/ui/view.py | 4 +++- tests/ui/test_view.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index 454de288..e9e975cc 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -124,12 +124,14 @@ def create_actions(self): self.save_as_action.setIcon(QtGui.QIcon(path_for("save-project.png"))) self.save_as_action.triggered.connect(lambda: self.presenter.save_project(save_as=True)) self.save_as_action.setShortcut(QtGui.QKeySequence.StandardKey.SaveAs) - self.disabled_elements.append(self.save_project_action) + self.save_as_action.setEnabled(False) + self.disabled_elements.append(self.save_as_action) self.save_as_script_action = QtGui.QAction("Save Project as &Script...", self) self.save_as_script_action.setStatusTip("Save project as a script.") self.save_as_script_action.setIcon(QtGui.QIcon(path_for("save-project.png"))) self.save_as_script_action.triggered.connect(lambda: self.presenter.save_project(save_as=True, as_script=True)) + self.save_as_script_action.setEnabled(False) self.disabled_elements.append(self.save_as_script_action) self.undo_action = self.undo_stack.createUndoAction(self, "&Undo") diff --git a/tests/ui/test_view.py b/tests/ui/test_view.py index 7bb77c52..e9754f8a 100644 --- a/tests/ui/test_view.py +++ b/tests/ui/test_view.py @@ -177,6 +177,7 @@ def test_menu_element_present(test_view, submenu_name): "", "&Save", "Save To &Folder...", + "Save Project as &Script...", "", "Export Fits", "", From 17325705dc38b195c3bf2a05d04655dbae98c7cd Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Tue, 28 Apr 2026 17:08:49 +0100 Subject: [PATCH 4/7] added test_save_project_as_script to test-presenter --- rascal2/ui/model.py | 10 +++++----- rascal2/ui/presenter.py | 3 +++ tests/ui/test_presenter.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index 17fdaee5..02279aeb 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -152,11 +152,11 @@ def save_project(self, save_path): def save_project_as_script(self, save_path): """Save the project to the save path as a script file. - Parameters - ---------- - save_path : str - The save path of the project. - """ + Parameters + ---------- + save_path : str + The save path of the project. + """ script_path = save_path + "/project_script.py" self.project.write_script(script=script_path) diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index 0dee3771..5eb20187 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -114,6 +114,9 @@ def save_project(self, save_as: bool = False, as_script: bool = False): save_as : bool Whether we are saving to the existing save path or to a specified folder. + as_script: bool + Whether we are saving the project as a script or not. + Returns ------- : bool diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 3f42fe32..03727cde 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -219,6 +219,19 @@ def test_save_project(recent_projects_mock, presenter): recent_projects_mock.assert_called_with("new path/") +def test_save_project_as_script(presenter): + """Test that projects can be saved as a script, optionally saved as a new folder.""" + presenter.model.project = MagicMock() + presenter.model.project.name = "test_name" + presenter.model.controls = MagicMock() + presenter.view.project_widget.stacked_widget.currentIndex = MagicMock(return_value=0) + presenter.save_project(as_script=True) + presenter.model.project.write_script.assert_called_once() + + presenter.save_project(as_script=True, save_as=True) + assert presenter.view.undo_stack.isClean() + presenter.model.project.write_script.assert_called_with(script="new path//project_script.py") + @pytest.mark.parametrize( ["reply", "undo_clean_state", "expected"], [ From f611b1f13e6d4222a01670e661118ee112824faa Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Tue, 28 Apr 2026 17:30:26 +0100 Subject: [PATCH 5/7] added test_save_project_as_script to test_model --- tests/ui/test_model.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ui/test_model.py b/tests/ui/test_model.py index da38cf4b..624c4d5e 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -77,6 +77,20 @@ def test_save_project(empty_results, model): assert '"fitParams": []' in results +def test_save_project_as_script(model): + model.project = Project(calculation="domains", name="test project") + with TemporaryDirectory() as tmpdir: + model.save_project_as_script(tmpdir) + + script = Path(tmpdir, "project_script.py").read_text() + + assert 'name="test project"' in script + assert 'calculation="domains"' in script + assert 'model="standard layers"' in script + assert 'geometry="air/substrate"' in script + assert 'absorption="False"' in script + + def test_load_project(empty_results, model): """The load function should load the correct controls object from JSON.""" project = Project(name="test project", calculation="domains") From 5cc6069f519160943c43353c4618e9432c0a46fe Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Tue, 28 Apr 2026 17:39:27 +0100 Subject: [PATCH 6/7] ruff fix --- tests/ui/test_presenter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 03727cde..1c1d07d0 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -232,6 +232,7 @@ def test_save_project_as_script(presenter): assert presenter.view.undo_stack.isClean() presenter.model.project.write_script.assert_called_with(script="new path//project_script.py") + @pytest.mark.parametrize( ["reply", "undo_clean_state", "expected"], [ From 9b2fdb2eab04574531b1977a63cd08072d4bec7d Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Wed, 29 Apr 2026 17:11:35 +0100 Subject: [PATCH 7/7] user can now choose script file name --- rascal2/ui/model.py | 3 +-- rascal2/ui/presenter.py | 6 +++++- rascal2/ui/view.py | 2 +- tests/ui/test_model.py | 4 ++-- tests/ui/test_presenter.py | 7 ++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index 02279aeb..8f0e6235 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -157,8 +157,7 @@ def save_project_as_script(self, save_path): save_path : str The save path of the project. """ - script_path = save_path + "/project_script.py" - self.project.write_script(script=script_path) + self.project.write_script(script=save_path) def is_project_example(self): return Path(self.save_path).is_relative_to(EXAMPLES_TEMP_PATH) diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index 5eb20187..6f654463 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -136,7 +136,11 @@ def save_project(self, save_as: bool = False, as_script: bool = False): return False try: if as_script: - self.model.save_project_as_script(to_path) + filename = self.model.project.name.replace(" ", "_") + save_file = self.view.get_save_file("Save Project as Script", filename, "*.py") + if not save_file: + return + self.model.save_project_as_script(save_file) else: self.model.save_project(to_path) except OSError as err: diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index e9e975cc..0718f652 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -130,7 +130,7 @@ def create_actions(self): self.save_as_script_action = QtGui.QAction("Save Project as &Script...", self) self.save_as_script_action.setStatusTip("Save project as a script.") self.save_as_script_action.setIcon(QtGui.QIcon(path_for("save-project.png"))) - self.save_as_script_action.triggered.connect(lambda: self.presenter.save_project(save_as=True, as_script=True)) + self.save_as_script_action.triggered.connect(lambda: self.presenter.save_project(as_script=True)) self.save_as_script_action.setEnabled(False) self.disabled_elements.append(self.save_as_script_action) diff --git a/tests/ui/test_model.py b/tests/ui/test_model.py index 624c4d5e..dbf15839 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -80,9 +80,9 @@ def test_save_project(empty_results, model): def test_save_project_as_script(model): model.project = Project(calculation="domains", name="test project") with TemporaryDirectory() as tmpdir: - model.save_project_as_script(tmpdir) + model.save_project_as_script(tmpdir + "/test_script.py") - script = Path(tmpdir, "project_script.py").read_text() + script = Path(tmpdir, "test_script.py").read_text() assert 'name="test project"' in script assert 'calculation="domains"' in script diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 1c1d07d0..b51c1bb0 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -225,12 +225,9 @@ def test_save_project_as_script(presenter): presenter.model.project.name = "test_name" presenter.model.controls = MagicMock() presenter.view.project_widget.stacked_widget.currentIndex = MagicMock(return_value=0) + presenter.view.get_save_file = MagicMock(return_value="test_name.py") presenter.save_project(as_script=True) - presenter.model.project.write_script.assert_called_once() - - presenter.save_project(as_script=True, save_as=True) - assert presenter.view.undo_stack.isClean() - presenter.model.project.write_script.assert_called_with(script="new path//project_script.py") + presenter.model.project.write_script.assert_called_once_with(script="test_name.py") @pytest.mark.parametrize(