Skip to content

Commit 7182a2d

Browse files
feat(gui)squashed: allow alignment overwiriting with warning
Squashed commit of the following: commit 08d66df Author: Dusten Hubbard <dusten@utexas.edu> Date: Fri Jul 31 08:15:32 2026 -0500 feat(alignments): import alignments from another series, and confirm overwrites Alignments > Import alignments offered .txt and SWiFT only. Taking an alignment from another series was reachable only through Series > Import > from series, the whole-series merge dialog, so a user looking for an alignment import did not find it. Add a "From another series (.jser)..." source that opens a one-tab dialog over the same Series.importTransforms call, and put it first in the submenu. Importing an alignment under a name the series already uses was rejected outright, which meant an alignment could not be updated in place. Allow it for alignments and confirm first, naming the alignments that would be replaced. Palettes and brightness/contrast profiles keep the rejection. Also renames the Alignments import submenu's attr_name from importmenu to importalignmentsmenu. Series > Import already claims importmenu, and populateMenuBar setattr()s by that key, so the two submenus were writing over each other on the main window. Adds tests/ with 24 tests over the menu row, the collision decision, the prompt wording, and the handler's exit paths, plus a test extra and a testpaths pin so collection does not reach packaging/smoke_test.py.
1 parent 0a9dce2 commit 7182a2d

7 files changed

Lines changed: 729 additions & 19 deletions

File tree

PyReconstruct/modules/gui/dialog/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@
1818
from .shortcuts import ShortcutsDialog
1919
from .backup_comment import BackupCommentDialog
2020
from .table_columns import TableColumnsDialog
21-
from .import_series import ImportSeriesDialog
21+
from .import_series import ImportSeriesDialog, ImportAlignmentsDialog
2222
from .malformed_contours import MalformedContoursDialog
23-
from .copy_to_sections import CopyToSectionsDialog, format_copy_result
23+
from .copy_to_sections import CopyToSectionsDialog, format_copy_result

PyReconstruct/modules/gui/dialog/import_series.py

Lines changed: 171 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from .helper import BrowseWidget, MultiInput, BorderedWidget, RadioButtonGroup, resizeLineEdit
2525
from .quick_dialog import getLayout
26-
from PyReconstruct.modules.gui.utils import notify
26+
from PyReconstruct.modules.gui.utils import notify, notifyConfirm
2727
from PyReconstruct.modules.datatypes import Series
2828

2929

@@ -143,9 +143,10 @@ def __init__(self, parent : QWidget, series : Series, other : Series):
143143
"alignments",
144144
MultiImportAs(
145145
self,
146-
other.alignments,
147-
series.alignments,
148-
"alignment"
146+
other.alignments,
147+
series.alignments,
148+
"alignment",
149+
allow_overwrite=True,
149150
)
150151
))
151152

@@ -229,6 +230,71 @@ def exec(self):
229230
return None, False
230231

231232

233+
class ImportAlignmentsDialog(QDialog):
234+
"""Import alignments, and nothing else, from another series.
235+
236+
The same alignments are reachable through ImportSeriesDialog's Alignments
237+
tab, which is the whole-series merge: seven tabs, six of them about
238+
something else. A user who only wants a colleague's alignment has to find it
239+
in there, so Alignments > Import alignments offers this one-tab version too.
240+
Both run the same MultiImportAs widget and therefore the same overwrite
241+
prompt.
242+
"""
243+
244+
def __init__(self, parent : QWidget, series : Series, other : Series):
245+
"""Create the import-alignments dialog.
246+
247+
Params:
248+
parent (QWidget): the parent widget
249+
series (Series): the current series obj
250+
other (Series): the series importing from
251+
"""
252+
super().__init__(parent)
253+
self.setWindowTitle("Import Alignments")
254+
255+
vlayout = QVBoxLayout()
256+
vlayout.setSpacing(10)
257+
258+
vlayout.addWidget(QLabel(
259+
f"Import alignments from {os.path.basename(other.jser_fp)}:",
260+
self,
261+
))
262+
263+
self.import_widget = MultiImportAs(
264+
self,
265+
other.alignments,
266+
series.alignments,
267+
"alignment",
268+
allow_overwrite=True,
269+
default_to_source=True,
270+
)
271+
vlayout.addWidget(self.import_widget)
272+
273+
QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel
274+
buttonbox = QDialogButtonBox(QBtn)
275+
buttonbox.accepted.connect(self.accept)
276+
buttonbox.rejected.connect(self.reject)
277+
vlayout.addWidget(buttonbox)
278+
279+
self.setLayout(vlayout)
280+
self.response = None
281+
282+
def accept(self):
283+
"""Run when user clicks OK."""
284+
response, confirmed = self.import_widget.getResponse()
285+
if not confirmed:
286+
return # stay open: the widget already said why
287+
self.response = response
288+
super().accept()
289+
290+
def exec(self):
291+
"""Run the dialog."""
292+
confirmed = super().exec()
293+
if confirmed:
294+
return self.response, True
295+
return None, False
296+
297+
232298
class ImportTracesWidget(QWidget):
233299

234300
def __init__(self, parent, series : Series, other : Series):
@@ -494,17 +560,53 @@ def getResponse(self):
494560
return None, False
495561

496562

563+
def collidingImportNames(entries, existing) -> list:
564+
"""Return the target names in entries that already exist, in order.
565+
566+
Kept as a plain function (no Qt) because it is the whole decision behind the
567+
overwrite prompt: warn only about names that really are already taken, and
568+
name them. Duplicates are collapsed so a two-row import onto one existing
569+
name reads as one collision.
570+
571+
Params:
572+
entries (list): (source name, target name) pairs
573+
existing (iterable): the names the series already uses
574+
Returns:
575+
(list): the colliding target names, first-seen order, deduped
576+
"""
577+
taken = set(existing)
578+
collisions = []
579+
for _source, target in entries:
580+
if target in taken and target not in collisions:
581+
collisions.append(target)
582+
return collisions
583+
584+
497585
class ImportAs(QWidget):
498586

499-
def __init__(self, parent=None, combo_items=[]):
500-
"""Create the import __ as __ widget."""
587+
def __init__(self, parent=None, combo_items=[], default_to_source=False):
588+
"""Create the import __ as __ widget.
589+
590+
Params:
591+
parent (QWidget): the parent widget
592+
combo_items (list): the names available to import
593+
default_to_source (bool): True if the target name should
594+
prefill with (and follow) the selected source name
595+
"""
501596
super().__init__(parent)
502597

503598
# set up the inputs
504599
self.input_1 = QComboBox(self)
505600
self.input_1.addItems(combo_items)
506601
self.input_2 = QLineEdit("", self)
507602

603+
if default_to_source:
604+
# importing an alignment under its own name is the common case, so
605+
# prefill it and keep following the combo until the user types
606+
self.input_2.setText(self.input_1.currentText())
607+
self.input_1.currentTextChanged.connect(self._followSource)
608+
self.input_2.textEdited.connect(self._stopFollowingSource)
609+
508610
# set up the layout
509611
hlayout = QHBoxLayout()
510612
hlayout.addWidget(QLabel("Import", self))
@@ -513,7 +615,18 @@ def __init__(self, parent=None, combo_items=[]):
513615
hlayout.addWidget(self.input_2)
514616

515617
self.setLayout(hlayout)
516-
618+
619+
def _followSource(self, text):
620+
"""Mirror the selected source name into the target field."""
621+
self.input_2.setText(text)
622+
623+
def _stopFollowingSource(self, _text):
624+
"""Stop mirroring once the user has typed a name of their own."""
625+
try:
626+
self.input_1.currentTextChanged.disconnect(self._followSource)
627+
except RuntimeError:
628+
pass
629+
517630
def getResponse(self):
518631
"""Get the user response."""
519632
return (
@@ -524,19 +637,42 @@ def getResponse(self):
524637

525638
class MultiImportAs(QWidget):
526639

527-
def __init__(self, parent : QWidget, other_items, self_items, name : str):
528-
"""Create the multi line edit widget."""
640+
def __init__(
641+
self,
642+
parent : QWidget,
643+
other_items,
644+
self_items,
645+
name : str,
646+
allow_overwrite : bool = False,
647+
default_to_source : bool = False,
648+
):
649+
"""Create the multi line edit widget.
650+
651+
Params:
652+
parent (QWidget): the parent widget
653+
other_items (iterable): names available in the other series
654+
self_items (iterable): names already in the current series
655+
name (str): what one item is called, for messages
656+
allow_overwrite (bool): True if a name that already exists may
657+
be replaced after the user confirms it. False rejects the
658+
name outright, which is the behavior for palettes and
659+
brightness/contrast profiles.
660+
default_to_source (bool): True if each row's target name should
661+
prefill from its source name
662+
"""
529663
super().__init__(parent)
530664

531665
vbl = QVBoxLayout()
532666
self.input_layout = QVBoxLayout()
533667
self.other_items = other_items
534668
self.self_items = self_items
535669
self.name = name
670+
self.allow_overwrite = allow_overwrite
671+
self.default_to_source = default_to_source
536672

537673
# create the inputs
538674
self.inputs = []
539-
w = ImportAs(self, self.other_items)
675+
w = ImportAs(self, self.other_items, self.default_to_source)
540676
self.input_layout.addWidget(w)
541677
self.inputs.append(w)
542678
vbl.addLayout(self.input_layout)
@@ -556,7 +692,7 @@ def __init__(self, parent : QWidget, other_items, self_items, name : str):
556692

557693
def add(self):
558694
"""Add a line edit row to the field."""
559-
w = ImportAs(self, self.other_items)
695+
w = ImportAs(self, self.other_items, self.default_to_source)
560696
self.input_layout.addWidget(w)
561697
self.inputs.append(w)
562698

@@ -587,16 +723,37 @@ def accept(self):
587723
if not new_name:
588724
notify("Please enter a valid name.")
589725
return False
590-
if new_name in self.self_items:
726+
if new_name in self.self_items and not self.allow_overwrite:
591727
notify(f"{self.name.capitalize()} name already exists in current series.")
592728
return False
593729
if new_name in new_names:
594730
notify(f"Cannot import multiple {self.name}s as the same name.")
595731
return False
596732
new_names.add(new_name)
597-
733+
734+
collisions = collidingImportNames(entries, self.self_items)
735+
if collisions and not self.confirmOverwrite(collisions):
736+
return False
737+
598738
return True
599-
739+
740+
def confirmOverwrite(self, collisions : list) -> bool:
741+
"""Ask before replacing names that already exist in this series.
742+
743+
Only reached when allow_overwrite is set and a target name really is
744+
taken, so the prompt never fires on an import that adds new names.
745+
"""
746+
listed = "\n".join(f" {name}" for name in collisions)
747+
plural = "s" if len(collisions) > 1 else ""
748+
return notifyConfirm(
749+
f"This series already has the following {self.name}{plural}:\n\n"
750+
f"{listed}\n\n"
751+
f"Importing under {'these names' if plural else 'this name'} will "
752+
f"replace the existing {self.name}{plural} on every section.\n\n"
753+
"Continue?",
754+
yn=True,
755+
)
756+
600757
def getResponse(self):
601758
"""Get the user response"""
602759
if self.accept():

PyReconstruct/modules/gui/main/main_imports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
ShortcutsDialog,
5757
BackupCommentDialog,
5858
ImportSeriesDialog,
59+
ImportAlignmentsDialog,
5960
)
6061

6162
from PyReconstruct.modules.gui.popup import (

PyReconstruct/modules/gui/main/main_window.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,58 @@ def seriesModified(self, modified=True):
873873
if self.field:
874874
self.checkActions()
875875

876+
def importAlignmentsFromSeries(self, jser_fp : str = None):
877+
"""Import alignments from another series.
878+
879+
Same import that ImportSeriesDialog's Alignments tab performs, reached
880+
from Alignments > Import alignments so that all three alignment sources
881+
(.jser, .txt, SWiFT) sit together.
882+
883+
Params:
884+
jser_fp (str): the filepath for the series to import from
885+
"""
886+
if jser_fp is None:
887+
jser_fp = FileDialog.get(
888+
"file",
889+
self,
890+
"Select Series",
891+
filter="*.jser"
892+
)
893+
if not jser_fp: return
894+
895+
self.saveAllData()
896+
897+
o_series = Series.openJser(jser_fp)
898+
899+
try:
900+
if not checkMag(self.series, o_series):
901+
return
902+
903+
import_as, confirmed = ImportAlignmentsDialog(
904+
self, self.series, o_series
905+
).exec()
906+
if not confirmed or not import_as:
907+
return
908+
909+
self.series.importTransforms(
910+
o_series,
911+
import_as,
912+
self.field.series_states
913+
)
914+
finally:
915+
o_series.close()
916+
917+
# the alignment submenus list the series' alignments by name
918+
self.createContextMenus()
919+
920+
# reload the section
921+
self.field.reload()
922+
923+
# refresh the data and lists
924+
self.field.table_manager.recreateTables()
925+
926+
notify("Alignments imported successfully.")
927+
876928
def importTransforms(self, tforms_fp : str = None):
877929
"""Import transforms from a text file.
878930

PyReconstruct/modules/gui/main/menubar.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,18 @@ def return_alignments_menu(self):
275275
("changealignment_act", "Modify alignments", self.series, self.modifyAlignments),
276276
None,
277277
{
278-
"attr_name": "importmenu",
278+
"attr_name": "importalignmentsmenu",
279279
"text": "Import alignments",
280280
"opts":
281281
[
282-
("importtransforms_act", ".txt file", "", self.importTransforms),
283-
("import_swift_transforms_act", "SWiFT project", "", self.importSwiftTransforms),
282+
## "From another series" is first because it is the common
283+
## case (taking a colleague's alignment) and because it was
284+
## previously only reachable through Series > Import > from
285+
## series, where a user looking for an alignment import does
286+
## not think to look.
287+
("import_jser_alignments_act", "From another series (.jser)...", "", self.importAlignmentsFromSeries),
288+
("importtransforms_act", "From .txt file...", "", self.importTransforms),
289+
("import_swift_transforms_act", "From SWiFT project...", "", self.importSwiftTransforms),
284290
]
285291
},
286292
None,

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ dependencies = [
4343
"packaging",
4444
]
4545

46+
[project.optional-dependencies]
47+
# Test-only, not shipped to end users. The suite drives the widgets through
48+
# PySide6 directly (a module-scoped QApplication fixture), so pytest is the
49+
# only addition.
50+
test = [
51+
"pytest>=8",
52+
]
53+
4654
[project.urls]
4755
Homepage = "https://github.com/SynapseWeb/PyReconstruct"
4856

@@ -55,6 +63,13 @@ include = ["PyReconstruct*"]
5563
[tool.setuptools.package-data]
5664
PyReconstruct = ["assets/**/*", "assets/welcome_series/.welcome/*"]
5765

66+
[tool.pytest.ini_options]
67+
# Collection is pinned to tests/ because packaging/smoke_test.py matches
68+
# pytest's default *_test.py pattern while being a standalone script: its module
69+
# body builds a QApplication and renders a VTK scene, which is not something a
70+
# collection pass should do.
71+
testpaths = ["tests"]
72+
5873
[tool.setuptools_scm]
5974
# Tag-derived version. Writes PyReconstruct/_version.py at build time so the
6075
# frozen app can read its version without git or installed package metadata.

0 commit comments

Comments
 (0)