From 54305ee5b70a5761625b16702f451bcdaa8d23f6 Mon Sep 17 00:00:00 2001 From: maurerv Date: Fri, 25 Sep 2026 11:38:20 +0200 Subject: [PATCH 1/2] Updated uses PyPi as version authority --- src/mosaic/dialogs/update.py | 611 ++++++++++++++------- src/mosaic/gui.py | 13 +- src/mosaic/onboarding/chapters/basics.py | 6 - src/mosaic/settings.py | 3 +- src/mosaic/stylesheets.py | 17 + src/mosaic/widgets/status_indicator.py | 27 - src/mosaic/widgets/update_pill.py | 12 +- src/mosaic/widgets/viewport_placeholder.py | 2 +- tests/gui/test_update_dialog.py | 158 ++++++ 9 files changed, 608 insertions(+), 241 deletions(-) create mode 100644 tests/gui/test_update_dialog.py diff --git a/src/mosaic/dialogs/update.py b/src/mosaic/dialogs/update.py index 5d8f2dd3..b146cd2b 100644 --- a/src/mosaic/dialogs/update.py +++ b/src/mosaic/dialogs/update.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from sys import executable, argv +from sys import argv, executable from qtpy.QtCore import Qt, QThread, QUrl, Signal from qtpy.QtGui import QDesktopServices @@ -15,251 +15,480 @@ QDialog, QHBoxLayout, QLabel, + QProgressBar, QPushButton, - QTextBrowser, QVBoxLayout, + QWidget, ) -from ..stylesheets import Colors, Typography -from ..widgets import MosaicMessageBox from ..__version__ import __version__ +from ..stylesheets import Colors, Typography + +__all__ = ["UpdateChecker", "UpdateDialog", "newer_version", "release_exists"] +PACKAGE_NAME = "mosaic-gui" +_PYPI_API = f"https://pypi.org/pypi/{PACKAGE_NAME}/json" +_GITHUB_RELEASE_API = ( + "https://api.github.com/repos/KosinskiLab/mosaic/releases/tags/v{version}" +) _GITHUB_RELEASE_URL = "https://github.com/KosinskiLab/mosaic/releases/tag/v{version}" -_GITHUB_LATEST_API = "https://api.github.com/repos/KosinskiLab/mosaic/releases/latest" + +_MONO = "SF Mono, Menlo, Consolas, monospace" + + +def _fetch_json(url: str, timeout: float = 5.0) -> dict | None: + """Return the decoded JSON body of *url*, or None if it cannot be read. + + Parameters + ---------- + url : str + Endpoint to request. + timeout : float, optional + Socket timeout in seconds. + + Returns + ------- + dict or None + Decoded payload, or None on any network or decoding failure. + """ + import json + import urllib.request + + try: + request = urllib.request.Request(url) + request.add_header("User-Agent", "Mosaic-Update-Checker") + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode()) + except Exception: + return None + + +def _installable_versions(payload: dict) -> list[str]: + """Version strings from a PyPI payload that still have usable files.""" + releases = payload.get("releases") + if not releases: + version = (payload.get("info") or {}).get("version") + return [version] if version else [] + + versions = [] + for version, files in releases.items(): + if not files: + continue + if all(file.get("yanked") for file in files): + continue + versions.append(version) + return versions + + +def newer_version(payload: dict, current: str) -> str | None: + """Highest stable release in *payload* that supersedes *current*. + + Parameters + ---------- + payload : dict + Decoded response of the PyPI JSON API. + current : str + Version currently installed. + + Returns + ------- + str or None + The version string to offer, or None when already up to date. + """ + from packaging.version import InvalidVersion, Version + + try: + installed = Version(str(current)) + except InvalidVersion: + return None + + best = None + for raw in _installable_versions(payload): + try: + candidate = Version(raw) + except InvalidVersion: + continue + if candidate.is_prerelease or candidate.is_devrelease: + continue + if candidate <= installed: + continue + if best is None or candidate > best[0]: + best = (candidate, raw) + return None if best is None else best[1] + + +def release_exists(version: str) -> bool: + """Whether GitHub carries a tagged release, and thus notes, for *version*.""" + return _fetch_json(_GITHUB_RELEASE_API.format(version=version)) is not None class UpdateChecker(QThread): - """Background thread to check GitHub for the latest release.""" + """Background check of PyPI for a newer release. + + Parameters + ---------- + current_version : str, optional + Version to compare against, defaults to the running one. + parent : QObject, optional + Parent object. + """ - update_available = Signal(str, str) + update_available = Signal(str, bool) def __init__(self, current_version: str = __version__, parent=None): super().__init__(parent) self.current_version = str(current_version) - self.api_url = _GITHUB_LATEST_API def run(self): - import json - import urllib.request - from packaging import version + payload = _fetch_json(_PYPI_API) + if payload is None: + return - try: - req = urllib.request.Request(self.api_url) - req.add_header("User-Agent", "Mosaic-Update-Checker") + latest = newer_version(payload, self.current_version) + if latest is None: + return - with urllib.request.urlopen(req, timeout=5) as response: - data = json.loads(response.read().decode()) + self.update_available.emit(latest, release_exists(latest)) - latest = str(data.get("tag_name", "")).lstrip("v") - if not latest: - return None - latest_v = version.parse(latest) - if latest_v.is_prerelease: - return None - if latest_v <= version.parse(self.current_version): - return None +class _InstallWorker(QThread): + """Runs ``pip install -U`` out of the GUI thread.""" - notes = data.get("body") or "" - self.update_available.emit(latest, notes) - except Exception: - pass # Dont bother handling network issues + completed = Signal(bool, str) + + def __init__(self, parent=None): + super().__init__(parent) + self._process = None + + def run(self): + from subprocess import PIPE, Popen + + try: + self._process = Popen( + [executable, "-m", "pip", "install", "-U", PACKAGE_NAME], + stdout=PIPE, + stderr=PIPE, + text=True, + ) + _, stderr = self._process.communicate() + self.completed.emit(self._process.returncode == 0, stderr or "") + except Exception as error: + self.completed.emit(False, str(error)) + + def cancel(self): + """Ask pip to stop; the run emits its failure through ``completed``.""" + if self._process is not None and self._process.poll() is None: + self._process.terminate() class UpdateDialog(QDialog): - """Lightweight update dialog with rendered release notes.""" + """Small update window in the shape of a system software updater. + + Parameters + ---------- + current_version : str + Version currently installed. + latest_version : str + Version offered by PyPI. + has_changelog : bool, optional + Whether a GitHub release exists for *latest_version*. + parent : QWidget, optional + Parent widget. + """ + + WIDTH = 400 + + #: ``role -> (attribute, label, handler, kind)`` for every action button. + _BUTTONS = { + "changelog": ("changelog_button", "Changelog", "_open_changelog", "quiet"), + "cancel": ("cancel_button", "Cancel", "reject", "flat"), + "install": ("install_button", "Install", "_on_install_clicked", "default"), + "stop": ("cancel_button", "Cancel", "_on_cancel_install", "flat"), + "later": ("cancel_button", "Later", "accept", "flat"), + "restart": ("restart_button", "Restart now", "_restart_application", "default"), + "copy": ("copy_button", "Copy command", "_copy_command", "quiet"), + "close": ("cancel_button", "Close", "reject", "flat"), + } + + #: ``state -> (window title, body text, leading roles, trailing roles)``. + _STATES = { + "idle": ( + "Update available", + "A newer release is available on PyPI.", + ("changelog",), + ("cancel", "install"), + ), + "installing": ( + "Installing update", + "Fetching {package} {version} from PyPI.", + ("progress",), + ("stop",), + ), + "success": ( + "Update installed", + "Restart Mosaic to use the new version.", + (), + ("later", "restart"), + ), + "failed": ( + "Update failed", + "Run the command below in a terminal instead.", + ("copy",), + ("close",), + ), + } def __init__( self, current_version: str, latest_version: str, - release_notes: str = "", + has_changelog: bool = False, parent=None, ): super().__init__(parent) - self.setWindowTitle("Update available") self.setModal(True) - self.setMinimumSize(520, 400) - self.resize(650, 550) - self._current = current_version - self._latest = latest_version - self._notes = (release_notes or "").strip() - self._release_url = _GITHUB_RELEASE_URL.format(version=latest_version) - self.update_result = None + self._current = str(current_version) + self._latest = str(latest_version) + self._has_changelog = bool(has_changelog) + self._worker = None + + self.state = "idle" root = QVBoxLayout(self) - root.setContentsMargins(28, 24, 28, 20) - root.setSpacing(6) - - header = QLabel( - f"" - f"Mosaic {latest_version} available " - f"" - f"(you have {current_version})" + root.setContentsMargins(24, 22, 24, 18) + root.setSpacing(0) + + self.subline = self._label("") + self.subline.setWordWrap(True) + root.addWidget(self.subline) + root.addSpacing(18) + + self.installed_label = self._version_value(self._current, emphasis=False) + self.available_label = self._version_value(self._latest, emphasis=True) + root.addWidget(self._version_table()) + root.addSpacing(26) + + self.command_box = self._command_box() + self.command_box.setVisible(False) + root.addWidget(self.command_box) + + self._root = root + self._action_row = QHBoxLayout() + self._action_row.setContentsMargins(0, 0, 0, 0) + self._action_row.setSpacing(8) + root.addLayout(self._action_row) + + self.setFixedWidth(self.WIDTH) + self.setStyleSheet(f"QDialog {{ background: {Colors.SURFACE}; }}") + self._apply_state("idle") + + + def _label( + self, text: str, role: str = "secondary", size: int | None = None + ) -> QLabel: + color = { + "primary": Colors.TEXT_PRIMARY, + "secondary": Colors.TEXT_SECONDARY, + "muted": Colors.TEXT_MUTED, + "error": Colors.ERROR, + }[role] + label = QLabel(text) + label.setStyleSheet( + f"color: {color}; background: transparent;" + f"font-size: {Typography.BODY if size is None else size}px;" ) - header.setTextFormat(Qt.TextFormat.RichText) - header.setStyleSheet(f"font-size: {Typography.BODY}px;") - root.addWidget(header) + return label + + def _version_value(self, text: str, emphasis: bool) -> QLabel: + label = QLabel(text) + weight = 600 if emphasis else 400 + color = Colors.TEXT_PRIMARY if emphasis else Colors.TEXT_SECONDARY + label.setStyleSheet( + f"color: {color}; background: transparent;" + f"font-family: {_MONO}; font-size: {Typography.BODY}px;" + f"font-weight: {weight};" + ) + return label + + def _version_table(self) -> QWidget: + table = QWidget() + table.setStyleSheet("background: transparent;") + rows = QVBoxLayout(table) + rows.setContentsMargins(0, 0, 0, 0) + rows.setSpacing(6) + + for caption, value in ( + ("Installed", self.installed_label), + ("Available", self.available_label), + ): + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(0) + key = self._label(caption, role="muted", size=Typography.LABEL) + key.setFixedWidth(84) + row.addWidget(key) + row.addWidget(value) + row.addStretch(1) + rows.addLayout(row) + return table + + def _flat_style(self, quiet: bool = False) -> str: + return f""" + QPushButton {{ + background: transparent; + color: {Colors.TEXT_SECONDARY}; + border: none; + border-radius: {Colors.RADIUS}px; + padding: 4px {8 if quiet else 14}px; + font-size: {Typography.BODY}px; + }} + QPushButton:hover {{ + background: {Colors.BG_HOVER}; + color: {Colors.TEXT_PRIMARY}; + }} + QPushButton:pressed {{ background: {Colors.BG_PRESSED}; }} + QPushButton:disabled {{ + background: transparent; + color: {Colors.BORDER_HOVER}; + }} + QPushButton:focus {{ outline: none; }} + """ - if self._notes: - root.addSpacing(14) - root.addWidget(self._build_notes_view(), 1) - else: - root.addStretch(1) - - root.addSpacing(16) - - btn_row = QHBoxLayout() - btn_row.setSpacing(8) - - notes_btn = QPushButton("Open on GitHub") - notes_btn.setCursor(Qt.CursorShape.PointingHandCursor) - notes_btn.clicked.connect(self._open_release_notes) - notes_btn.setStyleSheet(self._link_btn_style()) - btn_row.addWidget(notes_btn) - btn_row.addStretch() - - skip_btn = QPushButton("Skip") - skip_btn.setCursor(Qt.CursorShape.PointingHandCursor) - skip_btn.clicked.connect(self._on_skip_clicked) - skip_btn.setStyleSheet(self._secondary_btn_style()) - btn_row.addWidget(skip_btn) - - update_btn = QPushButton("Update Now") - update_btn.setCursor(Qt.CursorShape.PointingHandCursor) - update_btn.setDefault(True) - update_btn.clicked.connect(self._on_update_clicked) - update_btn.setStyleSheet(self._primary_btn_style()) - btn_row.addWidget(update_btn) - - root.addLayout(btn_row) - self.setStyleSheet(f"QDialog {{ background: {Colors.SURFACE}; }}") + def _command_box(self) -> QWidget: + """Container for the manual pip command, revealed when pip fails.""" + box = QWidget() + box.setStyleSheet("background: transparent;") + layout = QVBoxLayout(box) + layout.setContentsMargins(0, 0, 0, 14) + layout.setSpacing(0) - def _build_notes_view(self) -> QTextBrowser: - view = QTextBrowser() - view.setOpenExternalLinks(True) - view.setLineWrapMode(QTextBrowser.LineWrapMode.WidgetWidth) - view.setMarkdown(self._notes) - view.setMinimumHeight(280) - view.setStyleSheet( + self.command_label = QLabel( + f"{executable} -m pip install -U {PACKAGE_NAME}" + ) + self.command_label.setWordWrap(True) + self.command_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + self.command_label.setStyleSheet( f""" - QTextBrowser {{ - background: palette(base); - border: 1px solid {Colors.BORDER_DARK}; - border-radius: 6px; - padding: 10px 12px; - color: {Colors.TEXT_PRIMARY}; - font-size: {Typography.BODY}px; + QLabel {{ + background: {Colors.BG_TERTIARY}; + color: {Colors.TEXT_SECONDARY}; + border-radius: {Colors.RADIUS}px; + padding: 8px 10px; + font-family: {_MONO}; + font-size: {Typography.SMALL}px; }} """ ) - return view + layout.addWidget(self.command_label) + return box + + def _progress_bar(self) -> QProgressBar: + bar = QProgressBar() + bar.setRange(0, 0) + bar.setTextVisible(False) + bar.setFixedHeight(3) + bar.setFixedWidth(160) + return bar + + def _clear_actions(self): + for attr, *_ in self._BUTTONS.values(): + setattr(self, attr, None) + + while self._action_row.count(): + item = self._action_row.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + + def _make_widget(self, role: str) -> QWidget: + """Build one action-row entry from its entry in ``_BUTTONS``.""" + if role == "progress": + return self._progress_bar() + + attr, label, handler, kind = self._BUTTONS[role] + button = QPushButton(label) + button.setFixedHeight(28) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(getattr(self, handler)) + + if kind == "default": + button.setDefault(True) + else: + button.setStyleSheet(self._flat_style(quiet=kind == "quiet")) - def _on_skip_clicked(self): - from ..settings import Settings + if role == "changelog" and not self._has_changelog: + button.setEnabled(False) + button.setToolTip("This release has no GitHub release notes") + button.setCursor(Qt.CursorShape.ArrowCursor) - Settings.ui.skipped_version = self._latest - self.reject() + setattr(self, attr, button) + return button - def _open_release_notes(self): - QDesktopServices.openUrl(QUrl(self._release_url)) + def _apply_state(self, state: str, detail: str = ""): + title, text, leading, trailing = self._STATES[state] - def _on_update_clicked(self): - self.accept() - self._run_update() + self.state = state + self._clear_actions() + self.setWindowTitle(title) + self.subline.setText( + detail or text.format(package=PACKAGE_NAME, version=self._latest) + ) + self.command_box.setVisible(state == "failed") - def _run_update(self): - from subprocess import run + for role in leading: + self._action_row.addWidget(self._make_widget(role)) + self._action_row.addStretch(1) + for role in trailing: + self._action_row.addWidget(self._make_widget(role)) - try: - result = run( - [executable, "-m", "pip", "install", "-U", "mosaic-gui"], - capture_output=True, - text=True, - timeout=120, - ) + self._root.activate() + self.setFixedSize(self.WIDTH, self._root.sizeHint().height()) - if result.returncode == 0: - MosaicMessageBox.information( - self.parent(), - "Update Successful", - "Mosaic has been updated successfully!\n\n" - "The application will now restart to use the new version.", - ) - self.update_result = "success" - self._restart_application() - else: - MosaicMessageBox.warning( - self.parent(), - "Update Failed", - f"The update failed. Please run manually in your terminal:\n\n" - f"{executable} -m pip install -U mosaic-gui\n\n" - f"Error: {result.stderr}", - ) - except Exception as e: - MosaicMessageBox.warning( - self.parent(), - "Update Failed", - f"Could not run update command.\n\n" - f"Please run manually in your terminal:\n" - f"{executable} -m pip install -U mosaic-gui\n\n" - f"Error: {str(e)}", - ) + def _open_changelog(self): + QDesktopServices.openUrl( + QUrl(_GITHUB_RELEASE_URL.format(version=self._latest)) + ) + + def _copy_command(self): + clipboard = QApplication.clipboard() + if clipboard is not None: + clipboard.setText(self.command_label.text()) + + def _on_install_clicked(self): + self._apply_state("installing") + self._start_install() + + def _start_install(self): + self._worker = _InstallWorker(self) + self._worker.completed.connect(self._on_install_finished) + self._worker.start() + return None + + def _on_cancel_install(self): + if self._worker is not None: + self._worker.cancel() + + def _on_install_finished(self, succeeded: bool, message: str): + if succeeded: + return self._apply_state("success") + + detail = (message or "").strip().splitlines() + return self._apply_state("failed", detail[-1] if detail else "") def _restart_application(self): from subprocess import Popen - app = QApplication.instance() + self.accept() Popen([executable] + argv) - app.quit() - - def _primary_btn_style(self) -> str: - return f""" - QPushButton {{ - background: transparent; - color: {Colors.PRIMARY}; - border: 1px solid {Colors.BORDER_DARK}; - border-radius: 6px; - padding: 7px 18px; - font-size: {Typography.BODY}px; - font-weight: 500; - }} - QPushButton:hover {{ - border-color: {Colors.PRIMARY}; - background: {Colors.alpha("PRIMARY", 0.08)}; - }} - QPushButton:pressed {{ background: {Colors.alpha("PRIMARY", 0.14)}; }} - QPushButton:focus {{ outline: none; }} - """ - - def _secondary_btn_style(self) -> str: - return f""" - QPushButton {{ - background: transparent; - color: {Colors.TEXT_SECONDARY}; - border: 1px solid {Colors.BORDER_DARK}; - border-radius: 6px; - padding: 7px 18px; - font-size: {Typography.BODY}px; - }} - QPushButton:hover {{ - border-color: {Colors.BORDER_HOVER}; - color: {Colors.TEXT_PRIMARY}; - }} - QPushButton:focus {{ outline: none; }} - """ + QApplication.instance().quit() - def _link_btn_style(self) -> str: - return f""" - QPushButton {{ - background: transparent; - border: none; - color: {Colors.TEXT_SECONDARY}; - padding: 6px 4px; - font-size: {Typography.BODY}px; - }} - QPushButton:hover {{ color: {Colors.PRIMARY}; }} - QPushButton:focus {{ outline: none; }} - """ + def closeEvent(self, event): + if self._worker is not None and self._worker.isRunning(): + self._worker.cancel() + self._worker.wait(2000) + super().closeEvent(event) diff --git a/src/mosaic/gui.py b/src/mosaic/gui.py index b72de1e9..d2d778cd 100644 --- a/src/mosaic/gui.py +++ b/src/mosaic/gui.py @@ -1695,18 +1695,15 @@ def _check_for_updates(self): self.update_checker.update_available.connect(self._on_update_available) self.update_checker.start() - def _on_update_available(self, latest_version: str, release_notes: str): - from .settings import Settings + def _on_update_available(self, latest_version: str, has_changelog: bool): + self.update_pill.show_update(latest_version, has_changelog) + self._show_update_dialog(latest_version, has_changelog) - self.update_pill.show_update(latest_version, release_notes) - if Settings.ui.skipped_version != latest_version: - self._show_update_dialog(latest_version, release_notes) - - def _show_update_dialog(self, latest_version: str, release_notes: str = ""): + def _show_update_dialog(self, latest_version: str, has_changelog: bool = False): from .dialogs import UpdateDialog from .__version__ import __version__ - UpdateDialog(__version__, latest_version, release_notes, parent=self).exec() + UpdateDialog(__version__, latest_version, has_changelog, parent=self).exec() def _read_files_worker(cdata, filenames, file_parameters): diff --git a/src/mosaic/onboarding/chapters/basics.py b/src/mosaic/onboarding/chapters/basics.py index c287e634..798fd84c 100644 --- a/src/mosaic/onboarding/chapters/basics.py +++ b/src/mosaic/onboarding/chapters/basics.py @@ -90,12 +90,6 @@ def _download(self, parent) -> bool: bar.setValue(0) bar.setTextVisible(False) bar.setFixedHeight(6) - bar.setStyleSheet( - f"QProgressBar {{ border: none; background-color: {Colors.BG_TERTIARY};" - "border-radius: 3px; }" - f"QProgressBar::chunk {{ background-color: {Colors.PRIMARY};" - "border-radius: 3px; }" - ) pct_label = QLabel("0%") pct_label.setAlignment( diff --git a/src/mosaic/settings.py b/src/mosaic/settings.py index c46b8271..101b01cd 100644 --- a/src/mosaic/settings.py +++ b/src/mosaic/settings.py @@ -119,7 +119,6 @@ class UISettings: theme_mode: str = "system" always_open_as_volume: bool = False onboarding_done: bool = False - skipped_version: str = "" @dataclass @@ -176,7 +175,7 @@ def __init__(self): def _migrate_vtk_presets(self): """Migrate removed or unrecognised vtk presets to balanced.""" if self.vtk.preset in ("ultra", "balanced"): - return None + return self.vtk.preset = "balanced" if self.vtk.point_budget <= 0: self.vtk.point_budget = int(2e6) diff --git a/src/mosaic/stylesheets.py b/src/mosaic/stylesheets.py index f34a2a00..072fc0fe 100644 --- a/src/mosaic/stylesheets.py +++ b/src/mosaic/stylesheets.py @@ -32,6 +32,8 @@ class Typography: "CAPTION": 0.77, } + MONO: str = "SF Mono, Menlo, Consolas, monospace" + # Defaults assume macOS base of 13 px DISPLAY: int = 22 BODY: int = 13 @@ -496,6 +498,20 @@ def _build_QSlider_style(): """ +def _build_QProgressBar_style(): + return f""" + QProgressBar {{ + border: none; + background-color: {Colors.BG_TERTIARY}; + border-radius: 3px; + }} + QProgressBar::chunk {{ + background-color: {Colors.PRIMARY}; + border-radius: 3px; + }} +""" + + def _build_QMessageBox_style(): return f""" QMessageBox {{ @@ -666,6 +682,7 @@ def _build_QDockWidget_style(): _build_QComboBox_style, _build_QCheckBox_style, _build_QSlider_style, + _build_QProgressBar_style, _build_QGroupBox_style, _build_QListWidget_style, _build_QScrollArea_style, diff --git a/src/mosaic/widgets/status_indicator.py b/src/mosaic/widgets/status_indicator.py index 26bb19c7..9b9983b7 100644 --- a/src/mosaic/widgets/status_indicator.py +++ b/src/mosaic/widgets/status_indicator.py @@ -692,20 +692,6 @@ def _setup_status_bar_style(self): QStatusBar::item {{ border: none; }} """ ) - if hasattr(self, "progress_bar"): - self.progress_bar.setStyleSheet( - f""" - QProgressBar {{ - border: none; - background-color: {Colors.BORDER_DARK}; - border-radius: 3px; - }} - QProgressBar::chunk {{ - background-color: {Colors.PRIMARY}; - border-radius: 3px; - }} - """ - ) if hasattr(self, "task_button"): self.task_button.setIcon(icon("ph.caret-up", role="muted")) self.task_button.setStyleSheet( @@ -768,19 +754,6 @@ def _setup_status_bar(self): self.progress_bar.setMaximumHeight(6) self.progress_bar.setTextVisible(False) self.progress_bar.setVisible(False) - self.progress_bar.setStyleSheet( - f""" - QProgressBar {{ - border: none; - background-color: {Colors.BORDER_DARK}; - border-radius: 3px; - }} - QProgressBar::chunk {{ - background-color: {Colors.PRIMARY}; - border-radius: 3px; - }} - """ - ) self.progress_count = QLabel() self.progress_count.setFixedWidth(35) diff --git a/src/mosaic/widgets/update_pill.py b/src/mosaic/widgets/update_pill.py index 43b64015..c1b402a0 100644 --- a/src/mosaic/widgets/update_pill.py +++ b/src/mosaic/widgets/update_pill.py @@ -21,7 +21,7 @@ class UpdatePill(QPushButton): Parent widget. """ - update_clicked = Signal(str, str) + update_clicked = Signal(str, bool) def __init__(self, parent=None): super().__init__("Update Mosaic", parent) @@ -29,19 +29,19 @@ def __init__(self, parent=None): self.setFixedHeight(26) self.setVisible(False) self._latest = "" - self._notes = "" + self._has_changelog = False self.clicked.connect(self._emit_clicked) self._on_theme_changed() - def show_update(self, latest: str, notes: str = "") -> None: - """Reveal the pill, recording version and notes for the click handler.""" + def show_update(self, latest: str, has_changelog: bool = False) -> None: + """Reveal the pill, recording what the click handler needs to pass on.""" self._latest = latest - self._notes = notes + self._has_changelog = has_changelog self.setVisible(True) def _emit_clicked(self) -> None: if self._latest: - self.update_clicked.emit(self._latest, self._notes) + self.update_clicked.emit(self._latest, self._has_changelog) def _on_theme_changed(self) -> None: self.setStyleSheet( diff --git a/src/mosaic/widgets/viewport_placeholder.py b/src/mosaic/widgets/viewport_placeholder.py index 34014df6..61a0763e 100644 --- a/src/mosaic/widgets/viewport_placeholder.py +++ b/src/mosaic/widgets/viewport_placeholder.py @@ -176,7 +176,7 @@ def paintEvent(self, event): def _apply_theme(self): self._applying_theme = True - mono = "SF Mono, Menlo, Consolas, monospace" + mono = Typography.MONO self.setStyleSheet( f""" ViewportPlaceholder {{ diff --git a/tests/gui/test_update_dialog.py b/tests/gui/test_update_dialog.py new file mode 100644 index 00000000..be0deb6a --- /dev/null +++ b/tests/gui/test_update_dialog.py @@ -0,0 +1,158 @@ +from unittest.mock import patch + +import pytest + +from mosaic.dialogs.update import UpdateDialog, newer_version, release_exists + + +def _payload(*versions, info_version=None, yanked=(), files=True): + """Minimal shape of the PyPI JSON payload the checker reads.""" + releases = {} + for version in versions: + if not files: + releases[version] = [] + continue + releases[version] = [{"yanked": version in yanked}] + return {"info": {"version": info_version or (versions[-1] if versions else "")}, + "releases": releases} + + +class TestNewerVersion: + def test_picks_highest_release_above_current(self): + payload = _payload("1.4.0", "1.5.2", "1.6.0") + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_release_order_in_payload_is_irrelevant(self): + payload = _payload("1.6.0", "1.10.0", "1.9.0") + assert newer_version(payload, "1.5.2") == "1.10.0" + + def test_returns_none_when_current_is_latest(self): + assert newer_version(_payload("1.4.0", "1.6.0"), "1.6.0") is None + + def test_returns_none_when_current_is_ahead(self): + assert newer_version(_payload("1.4.0", "1.6.0"), "1.7.0.dev0") is None + + def test_skips_prereleases(self): + payload = _payload("1.6.0", "1.7.0rc1", "1.7.0b2") + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_skips_dev_releases(self): + payload = _payload("1.6.0", "1.7.0.dev3") + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_skips_fully_yanked_releases(self): + payload = _payload("1.6.0", "1.7.0", yanked=("1.7.0",)) + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_keeps_partially_yanked_releases(self): + payload = { + "info": {"version": "1.7.0"}, + "releases": {"1.7.0": [{"yanked": True}, {"yanked": False}]}, + } + assert newer_version(payload, "1.5.2") == "1.7.0" + + def test_skips_releases_without_files(self): + payload = _payload("1.6.0", "1.7.0") + payload["releases"]["1.7.0"] = [] + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_ignores_unparsable_version_strings(self): + payload = _payload("1.6.0", "not-a-version") + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_falls_back_to_info_version_without_releases(self): + payload = {"info": {"version": "1.6.0"}} + assert newer_version(payload, "1.5.2") == "1.6.0" + + def test_returns_none_for_unparsable_current_version(self): + assert newer_version(_payload("1.6.0"), "not-a-version") is None + + def test_returns_none_for_empty_payload(self): + assert newer_version({}, "1.5.2") is None + + +class TestReleaseExists: + def test_true_when_tag_endpoint_resolves(self): + with patch("mosaic.dialogs.update._fetch_json", return_value={"tag_name": "v1.6.0"}): + assert release_exists("1.6.0") is True + + def test_false_when_tag_endpoint_is_missing(self): + with patch("mosaic.dialogs.update._fetch_json", return_value=None): + assert release_exists("1.6.0") is False + + +@pytest.fixture +def dialog(qapp): + dialogs = [] + + def build(current="1.5.2", latest="1.6.0", has_changelog=True): + widget = UpdateDialog(current, latest, has_changelog=has_changelog) + dialogs.append(widget) + return widget + + yield build + for widget in dialogs: + widget.deleteLater() + + +class TestUpdateDialog: + def test_changelog_enabled_when_release_exists(self, dialog): + assert dialog(has_changelog=True).changelog_button.isEnabled() + + def test_changelog_disabled_without_release(self, dialog): + widget = dialog(has_changelog=False) + assert not widget.changelog_button.isEnabled() + assert widget.changelog_button.toolTip() + + def test_state_is_announced_in_the_window_title(self, dialog): + widget = dialog() + assert widget.windowTitle() == "Update available" + widget._apply_state("installing") + assert widget.windowTitle() == "Installing update" + widget._on_install_finished(True, "") + assert widget.windowTitle() == "Update installed" + + def test_failure_detail_replaces_the_body_text(self, dialog): + widget = dialog() + widget._on_install_finished(False, "ERROR: no matching distribution") + assert widget.windowTitle() == "Update failed" + assert widget.subline.text() == "ERROR: no matching distribution" + + def test_shows_both_versions(self, dialog): + widget = dialog(current="1.5.2", latest="1.6.0") + assert widget.installed_label.text() == "1.5.2" + assert widget.available_label.text() == "1.6.0" + + def test_changelog_opens_github_release_page(self, dialog): + widget = dialog(latest="1.6.0") + with patch("mosaic.dialogs.update.QDesktopServices.openUrl") as open_url: + widget.changelog_button.click() + assert open_url.call_count == 1 + assert open_url.call_args[0][0].toString().endswith("/releases/tag/v1.6.0") + + def test_cancel_closes_without_recording_state(self, dialog): + from mosaic.settings import Settings + + widget = dialog() + widget.cancel_button.click() + assert not widget.isVisible() + assert not hasattr(Settings.ui, "skipped_version") + + def test_install_switches_to_progress_state(self, dialog): + widget = dialog() + with patch.object(widget, "_start_install"): + widget.install_button.click() + assert widget.state == "installing" + assert widget.install_button is None + + def test_failed_install_surfaces_the_manual_command(self, dialog): + widget = dialog() + widget._on_install_finished(False, "no network") + assert widget.state == "failed" + assert "pip install -U mosaic-gui" in widget.command_label.text() + + def test_successful_install_offers_restart(self, dialog): + widget = dialog() + widget._on_install_finished(True, "") + assert widget.state == "success" + assert widget.restart_button.isEnabled() From 3f11d6cd9782dac75ba5788e4e3f370ef4338fd2 Mon Sep 17 00:00:00 2001 From: maurerv Date: Fri, 25 Sep 2026 11:46:47 +0200 Subject: [PATCH 2/2] ruff --- pyproject.toml | 14 ++++ src/mosaic/animation/_utils.py | 2 +- src/mosaic/animation/animations.py | 27 ++++--- src/mosaic/animation/compose.py | 54 ++++++------- src/mosaic/animation/settings.py | 23 +++--- src/mosaic/animation/timeline.py | 6 +- src/mosaic/commands/__init__.py | 4 +- src/mosaic/commands/parser.py | 14 ++-- src/mosaic/commands/registry.py | 22 ++--- src/mosaic/commands/repl.py | 12 ++- src/mosaic/commands/session.py | 40 ++++----- src/mosaic/commands/theme.py | 4 +- src/mosaic/container.py | 18 ++--- src/mosaic/dialogs/__init__.py | 10 +-- src/mosaic/dialogs/backmapping.py | 21 ++--- src/mosaic/dialogs/batch_rename.py | 11 ++- src/mosaic/dialogs/cropping.py | 14 ++-- src/mosaic/dialogs/export.py | 18 ++--- src/mosaic/dialogs/histogram.py | 20 +++-- src/mosaic/dialogs/import_data.py | 10 +-- src/mosaic/dialogs/matching.py | 23 +++--- src/mosaic/dialogs/properties.py | 23 +++--- src/mosaic/dialogs/property_analysis.py | 73 ++++++++--------- src/mosaic/dialogs/update.py | 15 +--- src/mosaic/dts/_utils.py | 41 +++++----- src/mosaic/dts/analysis.py | 12 +-- src/mosaic/dts/analysis_panel.py | 34 ++++---- src/mosaic/dts/compute_panel.py | 19 ++--- src/mosaic/dts/configure_panel.py | 31 ++++--- src/mosaic/dts/dts_dialog.py | 32 ++++---- src/mosaic/dts/equilibration_dialog.py | 6 +- src/mosaic/dts/screening.py | 69 ++++++++-------- src/mosaic/formats/_utils.py | 4 +- src/mosaic/formats/parser.py | 28 ++++--- src/mosaic/formats/reader.py | 2 +- src/mosaic/formats/records.py | 30 +++---- src/mosaic/formats/session.py | 13 ++- src/mosaic/formats/stream.py | 2 +- src/mosaic/formats/writer.py | 8 +- src/mosaic/geometry.py | 31 ++++--- src/mosaic/gui.py | 90 ++++++++++----------- src/mosaic/icons.py | 15 ++-- src/mosaic/interactor.py | 20 ++--- src/mosaic/meshing/coarse_graining.py | 14 ++-- src/mosaic/meshing/hmff.py | 17 ++-- src/mosaic/meshing/repair.py | 9 +-- src/mosaic/meshing/texture.py | 13 ++- src/mosaic/meshing/utils.py | 56 +++++++------ src/mosaic/meshing/volume.py | 16 ++-- src/mosaic/onboarding/__init__.py | 2 +- src/mosaic/onboarding/base.py | 5 +- src/mosaic/onboarding/chapters/__init__.py | 2 +- src/mosaic/onboarding/chapters/basics.py | 9 ++- src/mosaic/onboarding/controller.py | 2 +- src/mosaic/onboarding/overlay.py | 24 +++--- src/mosaic/operations.py | 33 ++++---- src/mosaic/parallel.py | 52 ++++++------ src/mosaic/parametrization.py | 27 +++---- src/mosaic/pipeline/builder.py | 36 ++++----- src/mosaic/pipeline/executor.py | 15 ++-- src/mosaic/pipeline/operations.py | 2 +- src/mosaic/pipeline/widgets.py | 30 ++++--- src/mosaic/properties.py | 15 ++-- src/mosaic/registry.py | 22 ++--- src/mosaic/scripts/app.py | 20 ++--- src/mosaic/scripts/pipeline.py | 15 ++-- src/mosaic/sculpt/__init__.py | 1 - src/mosaic/sculpt/adapter.py | 5 +- src/mosaic/sculpt/controller.py | 31 ++++--- src/mosaic/sculpt/mode.py | 5 +- src/mosaic/sculpt/overlay.py | 3 +- src/mosaic/sculpt/session.py | 43 +++++----- src/mosaic/sculpt/shortcuts.py | 5 +- src/mosaic/sculpt/tools.py | 14 ++-- src/mosaic/segmentation/membrain.py | 10 +-- src/mosaic/settings.py | 12 +-- src/mosaic/styles.py | 4 +- src/mosaic/stylesheets.py | 14 ++-- src/mosaic/swaps.py | 18 ++--- src/mosaic/tabs/__init__.py | 4 +- src/mosaic/tabs/development.py | 26 +++--- src/mosaic/tabs/intelligence.py | 24 +++--- src/mosaic/tabs/model.py | 7 +- src/mosaic/tabs/segmentation.py | 16 ++-- src/mosaic/tree_state.py | 13 ++- src/mosaic/undo.py | 9 +-- src/mosaic/utils.py | 34 ++++---- src/mosaic/viewport.py | 6 +- src/mosaic/widgets/appsettings.py | 17 ++-- src/mosaic/widgets/camera_hud.py | 4 +- src/mosaic/widgets/cards.py | 10 +-- src/mosaic/widgets/colors.py | 23 +++--- src/mosaic/widgets/container_list.py | 36 ++++----- src/mosaic/widgets/dialog_footer.py | 13 ++- src/mosaic/widgets/dock.py | 11 ++- src/mosaic/widgets/path_selector.py | 34 ++++---- src/mosaic/widgets/ribbon.py | 21 ++--- src/mosaic/widgets/sculpt_palette_hud.py | 2 +- src/mosaic/widgets/search_widget.py | 3 +- src/mosaic/widgets/segmented_control.py | 6 +- src/mosaic/widgets/settings.py | 15 ++-- src/mosaic/widgets/sidebar.py | 7 +- src/mosaic/widgets/sliders.py | 18 ++--- src/mosaic/widgets/status_indicator.py | 23 +++--- src/mosaic/widgets/tabs.py | 14 ++-- src/mosaic/widgets/theme_toggle.py | 2 +- src/mosaic/widgets/trajectory_player.py | 8 +- src/mosaic/widgets/viewport_placeholder.py | 10 +-- src/mosaic/widgets/volume_viewer.py | 19 +++-- src/mosaic/widgets/volume_viewer_hud.py | 12 +-- src/mosaic/widgets/vtk_widgets.py | 8 +- tests/commands/test_dispatch.py | 10 +-- tests/commands/test_measure_filter.py | 8 +- tests/commands/test_parser.py | 20 ++--- tests/commands/test_registry.py | 25 +----- tests/commands/test_session.py | 17 ---- tests/commands/test_theme.py | 1 - tests/core/test_container.py | 3 +- tests/core/test_geometry.py | 5 +- tests/core/test_properties.py | 9 +-- tests/core/test_registry.py | 12 +-- tests/core/test_settings.py | 8 +- tests/core/test_tree_state.py | 2 - tests/formats/test_containers.py | 1 - tests/formats/test_roundtrip.py | 6 +- tests/formats/test_stream.py | 6 +- tests/formats/test_writer.py | 20 +---- tests/gui/test_batch_navigator.py | 5 +- tests/gui/test_icons.py | 2 +- tests/gui/test_import_dialog.py | 1 - tests/gui/test_io_tasks.py | 34 ++++---- tests/gui/test_screenshot_manager.py | 16 ++-- tests/gui/test_update_dialog.py | 10 ++- tests/operations/test_operations.py | 4 +- tests/operations/test_parametrization.py | 6 +- tests/operations/test_resolution_helpers.py | 2 +- tests/pipeline/test_executor.py | 11 +-- tests/pipeline/test_pipeline.py | 31 ++++--- tests/screenshots/conftest.py | 9 +-- tests/screenshots/test_core_ui.py | 3 +- tests/screenshots/test_dialogs.py | 3 +- tests/screenshots/test_tabs.py | 1 - tests/sculpt/test_adapter.py | 12 +-- tests/sculpt/test_hittest.py | 1 + tests/sculpt/test_shortcuts.py | 3 +- tests/sculpt/test_undo_integration.py | 1 - tests/sculpt/test_wiring_smoke.py | 20 ++--- tests/test_normal_fabrication.py | 5 +- tests/test_swaps.py | 2 +- tests/widgets/test_volume_viewer_modes.py | 2 +- 150 files changed, 1120 insertions(+), 1238 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6d7028e2..ac73a270 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,3 +100,17 @@ PYQT5 = false PYSIDE2 = false PYQT6 = true PYSIDE6 = false + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +# Subsets of B and PLR, the only ones that need no ignore list. +select = ["B006", "B008", "E", "F", "I", "PLR1722", "RUF013", "RUF022", "SIM102", "UP"] +ignore = [ + "E501", # line-too-long + "E741", # ambiguous-variable-name +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] diff --git a/src/mosaic/animation/_utils.py b/src/mosaic/animation/_utils.py index 13ececf0..283bb215 100644 --- a/src/mosaic/animation/_utils.py +++ b/src/mosaic/animation/_utils.py @@ -11,7 +11,7 @@ import numpy as np from PIL import Image -from qtpy.QtGui import QImage, QGuiApplication +from qtpy.QtGui import QGuiApplication, QImage from qtpy.QtWidgets import QFileDialog from vtkmodules.util import numpy_support from vtkmodules.vtkRenderingCore import vtkWindowToImageFilter diff --git a/src/mosaic/animation/animations.py b/src/mosaic/animation/animations.py index c5a055cd..31d3263b 100644 --- a/src/mosaic/animation/animations.py +++ b/src/mosaic/animation/animations.py @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod from enum import Enum -from typing import List, Dict, Any +from typing import Any -from vtk import vtkTransform from qtpy.QtWidgets import QDialog +from vtk import vtkTransform from ..stylesheets import Colors, Typography @@ -44,7 +44,7 @@ def _init_parameters(self) -> None: pass @abstractmethod - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: """Return a list of setting definitions for the UI""" pass @@ -180,7 +180,7 @@ def update_parameters(self, **kwargs): return super().update_parameters(**kwargs) - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: n_frames = getattr(self, "_trajectory", None) n_frames = n_frames.frames if n_frames is not None else 0 available = self._available_trajectories() @@ -271,7 +271,7 @@ def _get_axis_size(self, axis=None): return 0 return shape[_mapping.get(axis.lower(), 0)] - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: projection = [ self.volume_viewer.primary.project_selector.itemText(i) for i in range(self.volume_viewer.primary.project_selector.count()) @@ -396,7 +396,7 @@ def _init_parameters(self) -> None: self.stop_frame = 180 self._transform = vtkTransform() - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: return [ { "label": "axis", @@ -484,13 +484,13 @@ class ActorSelectionDialog(QDialog): def __init__(self, cdata, current_selection=None, parent=None): from qtpy.QtWidgets import ( - QVBoxLayout, + QFrame, + QGroupBox, QHBoxLayout, - QTreeWidget, QLabel, - QFrame, QPushButton, - QGroupBox, + QTreeWidget, + QVBoxLayout, ) from mosaic.widgets import DialogFooter @@ -604,7 +604,7 @@ def _init_parameters(self) -> None: self._original_opacities = {} self._cached_actors = None - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: return [ { "label": "start_opacity", @@ -775,6 +775,7 @@ def _clear_waypoints(self): def _init_spline(self): """Initialize the spline curve from waypoints.""" import numpy as np + from mosaic.parametrization import SplineCurve waypoints = self.parameters.get("waypoints", []) @@ -911,7 +912,7 @@ def cleanup_preview(self): except Exception: self._path_actors.clear() - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: n = len(self.parameters.get("waypoints", [])) count_text = f"{n} waypoint{'s' if n != 1 else ''} defined" return [ @@ -1015,7 +1016,7 @@ def _init_parameters(self) -> None: ) self._initial_distance = None - def get_settings(self) -> List[Dict[str, Any]]: + def get_settings(self) -> list[dict[str, Any]]: return [ { "label": "zoom_factor", diff --git a/src/mosaic/animation/compose.py b/src/mosaic/animation/compose.py index c5cae94c..ec913fdb 100644 --- a/src/mosaic/animation/compose.py +++ b/src/mosaic/animation/compose.py @@ -8,43 +8,43 @@ import json from dataclasses import dataclass -from typing import Dict, List, Any +from typing import Any from uuid import uuid4 import imageio -from vtkmodules.vtkRenderingCore import vtkWindowToImageFilter -from qtpy.QtCore import Qt, QTimer, QSize +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QKeySequence, QShortcut from qtpy.QtWidgets import ( + QApplication, + QCheckBox, QDialog, - QWidget, - QVBoxLayout, - QHBoxLayout, + QFileDialog, QGridLayout, - QPushButton, - QToolButton, - QSpinBox, QGroupBox, - QFileDialog, - QApplication, - QSizePolicy, - QCheckBox, + QHBoxLayout, QMessageBox, + QPushButton, + QSizePolicy, + QSpinBox, + QToolButton, + QVBoxLayout, + QWidget, ) -from .timeline import TimelineWidget -from .animations import AnimationType, BaseAnimation -from .settings import AnimationSettings, ExportDialog +from vtkmodules.vtkRenderingCore import vtkWindowToImageFilter + +from ..icons import icon +from ..stylesheets import Colors +from ..utils import Throttle +from ..widgets import MosaicMessageBox from ._utils import ( FrameWriter, capture_frame, read_frame, scaled_device_pixel_attributes, ) - -from ..icons import icon -from ..utils import Throttle -from ..stylesheets import Colors -from ..widgets import MosaicMessageBox +from .animations import AnimationType, BaseAnimation +from .settings import AnimationSettings, ExportDialog +from .timeline import TimelineWidget @dataclass @@ -66,7 +66,7 @@ class AnimationComposerDialog(QDialog): def __init__(self, vtk_widget, volume_viewer=None, cdata=None, parent=None): super().__init__(parent) - self.tracks: List[Track] = [] + self.tracks: list[Track] = [] self.selected_track = None self.current_frame = 0 @@ -393,7 +393,7 @@ def _on_track_moved(self, track_id: str, new_frame: int): self.properties_panel.global_start_spin.setValue(new_frame) self.timeline.update() - def _on_animation_changed(self, changes: Dict[str, Any]): + def _on_animation_changed(self, changes: dict[str, Any]): self.timeline.update() def _get_total_frames(self) -> int: @@ -645,9 +645,7 @@ def export_animation(self): render_height = height * magnification render_window.SetSize(render_width, render_height) - pixel_scale = ( - render_height / original_size[1] if original_size[1] else 1 - ) + pixel_scale = render_height / original_size[1] if original_size[1] else 1 with scaled_device_pixel_attributes(render_window, pixel_scale): render_window.Render() @@ -749,7 +747,7 @@ def save_project(self): except Exception as e: MosaicMessageBox.warning(self, "Save Error", f"Failed to save project: {e}") - def _serialize_parameters(self, params: Dict) -> Dict: + def _serialize_parameters(self, params: dict) -> dict: """Convert parameters to JSON-serializable format.""" serialized = {} for key, value in params.items(): @@ -787,7 +785,7 @@ def load_project(self): self.toggle_play() try: - with open(filename, "r") as f: + with open(filename) as f: project_data = json.load(f) except Exception as e: MosaicMessageBox.warning( diff --git a/src/mosaic/animation/settings.py b/src/mosaic/animation/settings.py index 87d76ccb..3cfff9f6 100644 --- a/src/mosaic/animation/settings.py +++ b/src/mosaic/animation/settings.py @@ -1,24 +1,25 @@ -from qtpy.QtCore import Signal, Qt +from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( - QWidget, - QDialog, - QVBoxLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QSpinBox, QCheckBox, QComboBox, - QGroupBox, - QFormLayout, + QDialog, QDoubleSpinBox, + QFormLayout, QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, QPushButton, QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, ) -from mosaic.widgets import create_setting_widget + from mosaic.icons import icon from mosaic.stylesheets import Colors +from mosaic.widgets import create_setting_widget class AnimationSettings(QGroupBox): diff --git a/src/mosaic/animation/timeline.py b/src/mosaic/animation/timeline.py index 5e6c7e9e..42edcc45 100644 --- a/src/mosaic/animation/timeline.py +++ b/src/mosaic/animation/timeline.py @@ -1,6 +1,6 @@ -from qtpy.QtCore import Qt, QRect, Signal -from qtpy.QtWidgets import QWidget, QScrollArea, QApplication -from qtpy.QtGui import QPainter, QColor, QPen, QFont, QMouseEvent +from qtpy.QtCore import QRect, Qt, Signal +from qtpy.QtGui import QColor, QFont, QMouseEvent, QPainter, QPen +from qtpy.QtWidgets import QApplication, QScrollArea, QWidget from ..icons import icon from ..stylesheets import Colors, Typography diff --git a/src/mosaic/commands/__init__.py b/src/mosaic/commands/__init__.py index 4ac9bd9d..d84a7224 100644 --- a/src/mosaic/commands/__init__.py +++ b/src/mosaic/commands/__init__.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from .session import Session from .registry import CommandRegistry +from .session import Session -__all__ = ["Session", "CommandRegistry"] +__all__ = ["CommandRegistry", "Session"] diff --git a/src/mosaic/commands/parser.py b/src/mosaic/commands/parser.py index 46503bcc..a2bc769d 100644 --- a/src/mosaic/commands/parser.py +++ b/src/mosaic/commands/parser.py @@ -13,15 +13,15 @@ import re import shlex from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any -__all__ = ["ParsedCommand", "parse_command", "format_value", "format_kwargs"] +__all__ = ["ParsedCommand", "format_kwargs", "format_value", "parse_command"] _TARGET_RE = re.compile(r"^#\d+(-\d+)?$") _SPECIAL_TARGETS = {"@last", "*"} -def _split_tokens(text: str) -> List[str]: +def _split_tokens(text: str) -> list[str]: """Split a command line into shell-like tokens. Quotes group tokens on every platform. On Windows the backslash is kept @@ -169,9 +169,9 @@ class ParsedCommand: """ verb: str - targets: List[str] = field(default_factory=list) - args: List[str] = field(default_factory=list) - kwargs: Dict[str, Any] = field(default_factory=dict) + targets: list[str] = field(default_factory=list) + args: list[str] = field(default_factory=list) + kwargs: dict[str, Any] = field(default_factory=dict) def resolve_positional(self, params) -> None: """Map remaining positional *args* into *kwargs* by parameter order. @@ -205,7 +205,7 @@ def resolve_positional(self, params) -> None: self.args = remaining -def parse_command(text: str) -> Optional[ParsedCommand]: +def parse_command(text: str) -> ParsedCommand | None: """Parse a text command into a :class:`ParsedCommand`. Parameters diff --git a/src/mosaic/commands/registry.py b/src/mosaic/commands/registry.py index 5e5b4a4c..db1b2d66 100644 --- a/src/mosaic/commands/registry.py +++ b/src/mosaic/commands/registry.py @@ -9,8 +9,8 @@ Author: Valentin Maurer """ +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Dict, List, Optional from rich.columns import Columns from rich.console import Group @@ -19,9 +19,9 @@ from rich.table import Table from rich.text import Text +from ..formats.session import is_session_file from ..registry import _UNSET from .parser import ParsedCommand -from ..formats.session import is_session_file from .theme import BOX_PANEL, BOX_TABLE, get_console __all__ = ["Command", "CommandRegistry"] @@ -53,7 +53,7 @@ class Command: class CommandRegistry: """Global registry mapping verb strings to :class:`Command` handlers.""" - _commands: Dict[str, Command] = {} + _commands: dict[str, Command] = {} @classmethod def register( @@ -146,12 +146,12 @@ def dispatch(cls, session, parsed: ParsedCommand): return cmd.handler(session, parsed) @classmethod - def get(cls, name: str) -> Optional[Command]: + def get(cls, name: str) -> Command | None: """Retrieve a command by name.""" return cls._commands.get(name) @classmethod - def list_commands(cls) -> List[Command]: + def list_commands(cls) -> list[Command]: """Return all registered commands sorted by name.""" return sorted(cls._commands.values(), key=lambda c: c.name) @@ -184,13 +184,13 @@ def _resolve_targets(session, parsed: ParsedCommand): def _is_target_ref(value: str) -> bool: """Check if *value* looks like a geometry reference (``#0``, ``@last``, etc.).""" - from .parser import _TARGET_RE, _SPECIAL_TARGETS + from .parser import _SPECIAL_TARGETS, _TARGET_RE parts = [v.strip() for v in value.split(",")] return all(_TARGET_RE.match(v) or v in _SPECIAL_TARGETS for v in parts) -def _resolve_kwargs(session, kwargs: Dict[str, object]) -> Dict[str, object]: +def _resolve_kwargs(session, kwargs: dict[str, object]) -> dict[str, object]: """Replace geometry references in kwarg values with resolved Geometry objects. Recognises ``#N``, ``#N-M``, ``@last``, ``*``, and comma-separated @@ -689,7 +689,7 @@ def _cmd_help(session, parsed: ParsedCommand): cmd.name, Text(cmd.description), Text(), _usage_line(cmd.usage) ) - groups: Dict[str, List[Command]] = {} + groups: dict[str, list[Command]] = {} for cmd in CommandRegistry.list_commands(): groups.setdefault(cmd.group or "Other", []).append(cmd) @@ -1010,9 +1010,10 @@ def _cmd_dts_screen(session, parsed: ParsedCommand): def _cmd_dts_analysis(session, parsed: ParsedCommand): - import numpy as np from pathlib import Path + import numpy as np + if not parsed.args: return _registry_method_listing("dts-analysis") @@ -1061,8 +1062,8 @@ def _cmd_dts_analysis(session, parsed: ParsedCommand): kwargs.setdefault("reference_label", label) kwargs["invert"] = bool(kwargs.pop("invert", False)) - from ..dts._utils import resolve_trajectory_dir, find_dts_file, parse_dts_content from ..dts import compute + from ..dts._utils import find_dts_file, parse_dts_content, resolve_trajectory_dir traj_dir = resolve_trajectory_dir(run_path) if traj_dir is None: @@ -1441,7 +1442,6 @@ def _register_builtins(): CommandRegistry.register(name, handler, desc, usage, group=group) for attr_name in dir(GeometryOperations): - func = getattr(GeometryOperations, attr_name) if attr_name.startswith("_") or attr_name == "register" or not callable(func): continue diff --git a/src/mosaic/commands/repl.py b/src/mosaic/commands/repl.py index 5dff4854..99e78291 100644 --- a/src/mosaic/commands/repl.py +++ b/src/mosaic/commands/repl.py @@ -8,11 +8,10 @@ import re from pathlib import Path -from typing import Optional from .parser import parse_command -from .session import Session from .registry import CommandRegistry, _error_panel +from .session import Session from .theme import get_console, render_to_text try: @@ -36,7 +35,7 @@ def __init__(self, session: Session): self._session = session self._matches = [] - def complete(self, text: str, state: int) -> Optional[str]: + def complete(self, text: str, state: int) -> str | None: if state == 0: self._matches = self._build_matches(text) if state < len(self._matches): @@ -194,9 +193,7 @@ class MosaicREPL: commands are not logged to disk. """ - def __init__( - self, session: Optional[Session] = None, log_file: Optional[str] = None - ): + def __init__(self, session: Session | None = None, log_file: str | None = None): self.session = session or Session() self._log_file = log_file self._console = get_console() @@ -240,8 +237,8 @@ def run(self) -> None: except Exception as exc: output = _error_panel(str(exc)) if output: - from rich.table import Table from rich.columns import Columns + from rich.table import Table if isinstance(output, (Table, Columns)): self._console.print() @@ -395,6 +392,7 @@ def _build_prompt() -> str: from rich.console import Console from rich.text import Text + from .theme import MOSAIC_THEME buf = StringIO() diff --git a/src/mosaic/commands/session.py b/src/mosaic/commands/session.py index c7ab8f34..97f262ba 100644 --- a/src/mosaic/commands/session.py +++ b/src/mosaic/commands/session.py @@ -13,14 +13,13 @@ import os import pickle import warnings -from uuid import uuid4 from concurrent.futures import ProcessPoolExecutor, as_completed -from typing import List, Optional +from uuid import uuid4 import numpy as np from ..container import DataContainer -from ..tree_state import TreeStateData, TreeState +from ..tree_state import TreeState, TreeStateData __all__ = ["Session"] @@ -69,7 +68,7 @@ def __init__(self, quiet: bool = False): self._file_sections: dict = {} self._order: list = [] self._last_results: list = [] - self._log: List[str] = [] + self._log: list[str] = [] self._counter: int = 0 self.metadata: dict = {} self.quiet: bool = quiet @@ -159,7 +158,7 @@ def resolve(self, spec: str) -> list: raise ValueError(f"Invalid target specifier: {spec!r}") - def resolve_many(self, specs: List[str]) -> list: + def resolve_many(self, specs: list[str]) -> list: """Resolve multiple specifiers, preserving order and deduplicating.""" seen, result = set(), [] for spec in specs: @@ -178,7 +177,7 @@ def open( persist=True, segmentation=False, **kwargs, - ) -> List[int]: + ) -> list[int]: """Load geometries from a file. Parameters @@ -486,19 +485,22 @@ def _run_parallel(self, func, geometries, operation_name, **kwargs): errors = [] from rich.progress import ( + BarColumn, + MofNCompleteColumn, Progress, SpinnerColumn, - BarColumn, TextColumn, TimeRemainingColumn, - MofNCompleteColumn, ) + from .theme import get_console console = get_console() if self.quiet: from io import StringIO + from rich.console import Console + from .theme import MOSAIC_THEME console = Console(file=StringIO(), theme=MOSAIC_THEME) @@ -533,7 +535,7 @@ def _run_parallel(self, func, geometries, operation_name, **kwargs): pool.shutdown(wait=False) return results, errors - def apply(self, operation_name: str, geometries: List, **kwargs) -> List: + def apply(self, operation_name: str, geometries: list, **kwargs) -> list: """Apply a :class:`~mosaic.operations.GeometryOperations` function. Parameters @@ -615,7 +617,7 @@ def apply(self, operation_name: str, geometries: List, **kwargs) -> List: self._last_results = created return created - def measure(self, property_name: str, geometries: List, **kwargs) -> List: + def measure(self, property_name: str, geometries: list, **kwargs) -> list: """Compute a property via :class:`~mosaic.properties.GeometryProperties`. Parameters @@ -668,7 +670,7 @@ def measure(self, property_name: str, geometries: List, **kwargs) -> List: def filter( self, - geometries: List, + geometries: list, prop_name: str, lower=None, upper=None, @@ -842,10 +844,10 @@ def _match_filter(value: str, pattern: str) -> bool: def list_filtered( self, - type: Optional[str] = None, - name: Optional[str] = None, - group: Optional[str] = None, - visible: Optional[bool] = None, + type: str | None = None, + name: str | None = None, + group: str | None = None, + visible: bool | None = None, ) -> list: """Return matching geometries as ``(index, geometry)`` pairs. @@ -886,7 +888,7 @@ def list_filtered( return entries - def remove(self, geometries: List) -> int: + def remove(self, geometries: list) -> int: """Remove geometries from the session. Parameters @@ -916,7 +918,7 @@ def remove(self, geometries: List) -> int: ] return count - def merge(self, geometries: List, name: Optional[str] = None) -> "Geometry": + def merge(self, geometries: list, name: str | None = None) -> "Geometry": """Merge multiple geometries into one. Parameters @@ -956,7 +958,7 @@ def merge(self, geometries: List, name: Optional[str] = None) -> "Geometry": self._last_results = [merged] return merged - def group(self, geometries: List, name: str) -> str: + def group(self, geometries: list, name: str) -> str: """Assign geometries to a named group. Parameters @@ -1017,7 +1019,7 @@ def group(self, geometries: List, name: str) -> str: return group_id - def ungroup(self, geometries: List) -> int: + def ungroup(self, geometries: list) -> int: """Remove geometries from their groups. Parameters diff --git a/src/mosaic/commands/theme.py b/src/mosaic/commands/theme.py index 1c20ada6..011eb160 100644 --- a/src/mosaic/commands/theme.py +++ b/src/mosaic/commands/theme.py @@ -15,11 +15,11 @@ from ..stylesheets import Colors __all__ = [ + "BOX_PANEL", + "BOX_TABLE", "MOSAIC_THEME", "get_console", "render_to_text", - "BOX_TABLE", - "BOX_PANEL", ] BOX_TABLE = SIMPLE_HEAD diff --git a/src/mosaic/container.py b/src/mosaic/container.py index f3ff5698..2b3a4986 100644 --- a/src/mosaic/container.py +++ b/src/mosaic/container.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from typing import List, Tuple, Union, Dict +from typing import Union from .geometry import BASE_COLOR @@ -72,7 +72,7 @@ def add(self, points=None, color=None, **kwargs): self.data.append(geometry) return len(self.data) - 1 - def remove(self, uuids_or_geometries: Union[List[str], List["Geometry"]]): + def remove(self, uuids_or_geometries: list[str] | list["Geometry"]): """Remove geometries at specified indices or by geometry objects. Parameters @@ -98,7 +98,7 @@ def uuid_to_index(self, uuid: str) -> int: return index return None - def get(self, index_or_uuid: Union[int, str]): + def get(self, index_or_uuid: int | str): """ Retrieve the Geometry object by index or UUID. @@ -145,7 +145,7 @@ def update(self, uuid_or_geometry, new_geometry) -> bool: self.data[index] = new_geometry return True - def highlight_points(self, uuid_or_geometry, point_ids: set, color: Tuple[float]): + def highlight_points(self, uuid_or_geometry, point_ids: set, color: tuple[float]): """Highlight specific points in a cloud. Parameters @@ -164,7 +164,7 @@ def highlight_points(self, uuid_or_geometry, point_ids: set, color: Tuple[float] color = geometry._appearance.get("highlight_color", (0.8, 0.2, 0.2)) geometry.color_points(point_ids, color) - def highlight(self, uuids_or_geometries: Union[List[str], List["Geometry"]]): + def highlight(self, uuids_or_geometries: list[str] | list["Geometry"]): """Highlight specified geometries. Parameters @@ -187,7 +187,7 @@ def highlight(self, uuids_or_geometries: Union[List[str], List["Geometry"]]): self._highlighted_uuids = set(uuids) def update_appearance( - self, uuids_or_geometries: Union[List[str], List["Geometry"]], parameters: Dict + self, uuids_or_geometries: list[str] | list["Geometry"], parameters: dict ) -> bool: """Update appearance parameters for specified geometries. @@ -204,7 +204,7 @@ def update_appearance( True if full render required (actor was replaced) """ from .formats.parser import load_density - from .geometry import VolumeGeometry, SegmentationGeometry + from .geometry import SegmentationGeometry, VolumeGeometry uuids = self._to_uuids(uuids_or_geometries) geometries = [self.get(x) for x in uuids if self.get(x) is not None] @@ -285,9 +285,7 @@ def _to_uuid(self, uuid_or_geometry: Union[str, "Geometry"]) -> str: uuid_or_geometry = uuid_or_geometry.uuid return uuid_or_geometry - def _to_uuids( - self, uuids_or_geometries: Union[List[str], List["Geometry"]] - ) -> List[str]: + def _to_uuids(self, uuids_or_geometries: list[str] | list["Geometry"]) -> list[str]: """Convert list of UUIDs or Geometries to UUIDs. Parameters diff --git a/src/mosaic/dialogs/__init__.py b/src/mosaic/dialogs/__init__.py index 406e626d..c8ea0473 100644 --- a/src/mosaic/dialogs/__init__.py +++ b/src/mosaic/dialogs/__init__.py @@ -1,11 +1,11 @@ +from ..dts import DTSScreeningDialog +from .backmapping import MeshMappingDialog +from .batch_rename import BatchRenameDialog +from .cropping import DistanceCropDialog from .export import ExportDialog from .histogram import HistogramDialog -from .cropping import DistanceCropDialog from .import_data import ImportDataDialog -from .backmapping import MeshMappingDialog from .matching import TemplateMatchingDialog -from .update import UpdateChecker, UpdateDialog from .properties import GeometryPropertiesDialog -from .batch_rename import BatchRenameDialog from .property_analysis import PropertyAnalysisDialog -from ..dts import DTSScreeningDialog +from .update import UpdateChecker, UpdateDialog diff --git a/src/mosaic/dialogs/backmapping.py b/src/mosaic/dialogs/backmapping.py index 306afd51..75e53468 100644 --- a/src/mosaic/dialogs/backmapping.py +++ b/src/mosaic/dialogs/backmapping.py @@ -1,22 +1,23 @@ from pathlib import Path from qtpy.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, - QDialog, - QLabel, + QCheckBox, QComboBox, - QPushButton, - QWidget, + QDialog, QDoubleSpinBox, - QScrollArea, - QGroupBox, QFrame, + QGroupBox, + QHBoxLayout, + QLabel, QLineEdit, - QCheckBox, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, ) + from ..icons import icon -from ..widgets import DialogFooter, PathSelector, MosaicMessageBox +from ..widgets import DialogFooter, MosaicMessageBox, PathSelector class MeshMappingRow(QWidget): diff --git a/src/mosaic/dialogs/batch_rename.py b/src/mosaic/dialogs/batch_rename.py index fa0044c1..8dab738e 100644 --- a/src/mosaic/dialogs/batch_rename.py +++ b/src/mosaic/dialogs/batch_rename.py @@ -7,19 +7,18 @@ """ import re -from typing import List from qtpy.QtWidgets import ( + QCheckBox, QDialog, - QVBoxLayout, + QGridLayout, QHBoxLayout, QLabel, QLineEdit, - QGridLayout, - QCheckBox, + QPushButton, QStackedWidget, + QVBoxLayout, QWidget, - QPushButton, ) from ..stylesheets import Colors, Typography @@ -48,7 +47,7 @@ class BatchRenameDialog(QDialog): MODES = ["Replace", "Format"] - def __init__(self, names: List[str], parent=None): + def __init__(self, names: list[str], parent=None): super().__init__(parent) self.names = names self.result_names = list(names) diff --git a/src/mosaic/dialogs/cropping.py b/src/mosaic/dialogs/cropping.py index 626f4b98..f5e9e7c1 100644 --- a/src/mosaic/dialogs/cropping.py +++ b/src/mosaic/dialogs/cropping.py @@ -8,21 +8,22 @@ from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( - QVBoxLayout, QDialog, - QLabel, QDoubleSpinBox, + QFrame, + QGroupBox, QHBoxLayout, + QLabel, QPushButton, QTreeWidget, - QGroupBox, - QFrame, + QVBoxLayout, ) + from ..icons import icon from ..stylesheets import Colors, Typography +from ..widgets import MosaicMessageBox from ..widgets.container_list import ContainerTreeWidget, StyledTreeWidgetItem from ..widgets.segmented_control import SegmentedControl -from ..widgets import MosaicMessageBox class DistanceCropDialog(QDialog): @@ -250,9 +251,10 @@ def _apply_preview(self, params): params : dict Crop parameters with sources, targets, distance, keep_smaller. """ - from ..properties import GeometryProperties import numpy as np + from ..properties import GeometryProperties + sources = params["sources"] targets = params["targets"] distance = params["distance"] diff --git a/src/mosaic/dialogs/export.py b/src/mosaic/dialogs/export.py index b1e3c1c7..28a274eb 100644 --- a/src/mosaic/dialogs/export.py +++ b/src/mosaic/dialogs/export.py @@ -1,23 +1,23 @@ -from typing import Dict, List from os.path import splitext from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( + QCheckBox, QDialog, - QVBoxLayout, + QFileDialog, + QGroupBox, QHBoxLayout, QLabel, + QLineEdit, QPushButton, + QVBoxLayout, QWidget, - QGroupBox, - QCheckBox, - QLineEdit, - QFileDialog, ) + from ..icons import icon as _icon +from ..stylesheets import Colors, Typography from ..widgets import DialogFooter, create_setting_widget, get_widget_value from ..widgets.message_box import MosaicMessageBox -from ..stylesheets import Colors, Typography LABEL_WIDTH = 80 @@ -88,7 +88,7 @@ def __init__( parent=None, parameters={}, enabled_categories=None, - names: List[str] = None, + names: list[str] = None, ): super().__init__(parent) @@ -393,7 +393,7 @@ def update_advanced_settings(self): self.settings_layout.addStretch() - def get_current_settings(self) -> Dict: + def get_current_settings(self) -> dict: """Extract current settings from the setting widgets""" settings = {} diff --git a/src/mosaic/dialogs/histogram.py b/src/mosaic/dialogs/histogram.py index 890cfea4..a0195549 100644 --- a/src/mosaic/dialogs/histogram.py +++ b/src/mosaic/dialogs/histogram.py @@ -8,24 +8,22 @@ import numpy as np import pyqtgraph as pg +from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QColor, QDoubleValidator -from qtpy.QtCore import Qt, Signal, QSize - -from ..utils import Throttle - from qtpy.QtWidgets import ( - QWidget, - QDialog, - QVBoxLayout, - QLineEdit, - QLabel, - QSpinBox, - QSizePolicy, QComboBox, + QDialog, QGridLayout, QGroupBox, + QLabel, + QLineEdit, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, ) +from ..utils import Throttle from ..widgets.sliders import DualHandleSlider diff --git a/src/mosaic/dialogs/import_data.py b/src/mosaic/dialogs/import_data.py index 57e3cd14..0b6aea25 100644 --- a/src/mosaic/dialogs/import_data.py +++ b/src/mosaic/dialogs/import_data.py @@ -1,12 +1,12 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QDialog, - QVBoxLayout, + QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QPushButton, - QGridLayout, - QGroupBox, + QVBoxLayout, ) from ..formats._utils import read_density_header, read_star_header @@ -24,10 +24,10 @@ def __init__(self, parent=None): def setup_ui(self): from ..icons import ( dialog_accept_icon, - dialog_reject_icon, + dialog_apply_icon, dialog_next_icon, dialog_previous_icon, - dialog_apply_icon, + dialog_reject_icon, ) self.setWindowTitle("Import Parameters") diff --git a/src/mosaic/dialogs/matching.py b/src/mosaic/dialogs/matching.py index fdae5ce7..870e5d51 100644 --- a/src/mosaic/dialogs/matching.py +++ b/src/mosaic/dialogs/matching.py @@ -1,27 +1,28 @@ import textwrap from os import makedirs -from os.path import join, splitext, basename +from os.path import basename, join, splitext import numpy as np -from tme import Density from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, QDialog, - QVBoxLayout, + QDoubleSpinBox, + QFormLayout, + QGridLayout, + QGroupBox, QLabel, QLineEdit, - QGroupBox, - QCheckBox, - QComboBox, QScrollArea, QSpinBox, - QDoubleSpinBox, + QVBoxLayout, QWidget, - QGridLayout, - QFormLayout, ) +from tme import Density + from ..icons import icon from ..stylesheets import Colors, Typography -from ..widgets import PathSelector, DialogFooter, TabWidget, MosaicMessageBox +from ..widgets import DialogFooter, MosaicMessageBox, PathSelector, TabWidget class InputDataTab(QWidget): @@ -713,7 +714,7 @@ def accept(self): # Set up environment. Modify as needed # source activate your_env - if [ "{preprocess['skip_preprocessing']}" = "True" ]; then + if [ "{preprocess["skip_preprocessing"]}" = "True" ]; then echo "Skip preprocessing - using existing template" else echo "Preprocessing template..." diff --git a/src/mosaic/dialogs/properties.py b/src/mosaic/dialogs/properties.py index 54c4d348..0cf23eb7 100644 --- a/src/mosaic/dialogs/properties.py +++ b/src/mosaic/dialogs/properties.py @@ -8,27 +8,28 @@ from os.path import exists -from qtpy.QtCore import Signal, Qt - -from mosaic.geometry import BASE_COLOR -from mosaic.utils import Throttle +from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, QDialog, - QPushButton, QFileDialog, - QWidget, - QLabel, QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, ) + +from mosaic.geometry import BASE_COLOR +from mosaic.utils import Throttle + from ..icons import icon from ..widgets import ( + ColorPickerRow, DialogFooter, + SliderRow, create_setting_widget, get_widget_value, - ColorPickerRow, - SliderRow, ) from ..widgets.segmented_control import SegmentedControl diff --git a/src/mosaic/dialogs/property_analysis.py b/src/mosaic/dialogs/property_analysis.py index 6b04c42c..cfbc3fb0 100644 --- a/src/mosaic/dialogs/property_analysis.py +++ b/src/mosaic/dialogs/property_analysis.py @@ -7,50 +7,50 @@ """ import warnings +from dataclasses import dataclass +from typing import Any + import numpy as np -from qtpy.QtCore import Qt, QSize, QTimer +import pyqtgraph as pg +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QComboBox, QDialog, - QVBoxLayout, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QGroupBox, QHBoxLayout, + QHeaderView, QLabel, - QComboBox, QListWidget, - QGroupBox, - QCheckBox, - QSpinBox, QPushButton, - QFormLayout, - QWidget, + QSizePolicy, + QSpinBox, + QStackedWidget, QTableWidget, - QHeaderView, QTableWidgetItem, QTreeWidgetItem, - QFileDialog, - QDoubleSpinBox, - QStackedWidget, - QAbstractItemView, - QSizePolicy, + QVBoxLayout, + QWidget, ) -import pyqtgraph as pg -from dataclasses import dataclass -from typing import Any, Dict, Optional - -from ..utils import Throttle -from ..stylesheets import Colors from ..icons import icon as _icon -from ..widgets.settings import get_widget_value +from ..stylesheets import Colors +from ..utils import Throttle from ..widgets import ( - ContainerTreeWidget, - StyledListWidgetItem, ColorMapSelector, + ContainerTreeWidget, HistogramRangeSlider, + MosaicMessageBox, + StyledListWidgetItem, TabWidget, generate_gradient_colors, - MosaicMessageBox, ) from ..widgets.segmented_control import SegmentedControl +from ..widgets.settings import get_widget_value def to_numeric(arr): @@ -67,9 +67,9 @@ class CacheEntry: """Single cache entry storing a computed value with its context.""" value: Any - parameters: Dict[str, Any] + parameters: dict[str, Any] geometry_uuid: str - model_id: Optional[int] + model_id: int | None point_count: int @@ -77,9 +77,9 @@ class PropertyCache: """Cache for computed geometry properties.""" def __init__(self): - self._entries: Dict[str, CacheEntry] = {} + self._entries: dict[str, CacheEntry] = {} - def get(self, geometry, parameters: Dict[str, Any]) -> Optional[Any]: + def get(self, geometry, parameters: dict[str, Any]) -> Any | None: """Get cached value if still valid, None otherwise.""" entry = self._entries.get(geometry.uuid) if entry is None: @@ -97,7 +97,7 @@ def get(self, geometry, parameters: Dict[str, Any]) -> Optional[Any]: return entry.value - def set(self, geometry, parameters: Dict[str, Any], value: Any): + def set(self, geometry, parameters: dict[str, Any], value: Any): """Store a computed value with its computation context.""" model_id = id(geometry.model) if geometry.model is not None else None self._entries[geometry.uuid] = CacheEntry( @@ -108,7 +108,7 @@ def set(self, geometry, parameters: Dict[str, Any], value: Any): point_count=geometry.points.shape[0], ) - def get_value(self, geometry_uuid: str) -> Optional[Any]: + def get_value(self, geometry_uuid: str) -> Any | None: """Get cached value by UUID without validation (for display).""" entry = self._entries.get(geometry_uuid) if entry is None: @@ -121,7 +121,7 @@ def clear(self): """Clear all cached entries.""" self._entries.clear() - def _parameters_equal(self, cached: Dict, current: Dict) -> bool: + def _parameters_equal(self, cached: dict, current: dict) -> bool: """Check if two parameter dicts are equivalent.""" if set(cached.keys()) != set(current.keys()): return False @@ -363,8 +363,7 @@ def _build_tomogram_options(dlg): steps=80, ) offset_slider.setToolTip( - "Offset along surface normals in voxels. " - "Positive = outward, negative = inward." + "Offset along surface normals in voxels. Positive = outward, negative = inward." ) offset_slider.valueChanged.connect(dlg._preview_throttle) layout.addRow(offset_slider) @@ -1218,7 +1217,7 @@ def _setup_statistics_tab(self): def _current_metric(self) -> str: return self.property_combo.currentText() - def _update_property_list(self, category: Optional[str] = None) -> None: + def _update_property_list(self, category: str | None = None) -> None: category = category or self.category_segments.currentText() previous = self.property_combo.currentText() @@ -1235,7 +1234,7 @@ def _update_property_list(self, category: Optional[str] = None) -> None: self._update_options(self.property_combo.currentText()) return None - def _update_options(self, property_name: Optional[str] = None) -> None: + def _update_options(self, property_name: str | None = None) -> None: if property_name is None: property_name = self._current_metric() @@ -1761,7 +1760,9 @@ def _update_tab(self): lambda: ( self._update_plot() if current_tab_index == 1 - else self._update_statistics() if current_tab_index == 2 else None + else self._update_statistics() + if current_tab_index == 2 + else None ), ) diff --git a/src/mosaic/dialogs/update.py b/src/mosaic/dialogs/update.py index b146cd2b..1739436a 100644 --- a/src/mosaic/dialogs/update.py +++ b/src/mosaic/dialogs/update.py @@ -34,8 +34,6 @@ ) _GITHUB_RELEASE_URL = "https://github.com/KosinskiLab/mosaic/releases/tag/v{version}" -_MONO = "SF Mono, Menlo, Consolas, monospace" - def _fetch_json(url: str, timeout: float = 5.0) -> dict | None: """Return the decoded JSON body of *url*, or None if it cannot be read. @@ -284,7 +282,6 @@ def __init__( self.setStyleSheet(f"QDialog {{ background: {Colors.SURFACE}; }}") self._apply_state("idle") - def _label( self, text: str, role: str = "secondary", size: int | None = None ) -> QLabel: @@ -307,7 +304,7 @@ def _version_value(self, text: str, emphasis: bool) -> QLabel: color = Colors.TEXT_PRIMARY if emphasis else Colors.TEXT_SECONDARY label.setStyleSheet( f"color: {color}; background: transparent;" - f"font-family: {_MONO}; font-size: {Typography.BODY}px;" + f"font-family: {Typography.MONO}; font-size: {Typography.BODY}px;" f"font-weight: {weight};" ) return label @@ -364,9 +361,7 @@ def _command_box(self) -> QWidget: layout.setContentsMargins(0, 0, 0, 14) layout.setSpacing(0) - self.command_label = QLabel( - f"{executable} -m pip install -U {PACKAGE_NAME}" - ) + self.command_label = QLabel(f"{executable} -m pip install -U {PACKAGE_NAME}") self.command_label.setWordWrap(True) self.command_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse @@ -378,7 +373,7 @@ def _command_box(self) -> QWidget: color: {Colors.TEXT_SECONDARY}; border-radius: {Colors.RADIUS}px; padding: 8px 10px; - font-family: {_MONO}; + font-family: {Typography.MONO}; font-size: {Typography.SMALL}px; }} """ @@ -450,9 +445,7 @@ def _apply_state(self, state: str, detail: str = ""): self.setFixedSize(self.WIDTH, self._root.sizeHint().height()) def _open_changelog(self): - QDesktopServices.openUrl( - QUrl(_GITHUB_RELEASE_URL.format(version=self._latest)) - ) + QDesktopServices.openUrl(QUrl(_GITHUB_RELEASE_URL.format(version=self._latest))) def _copy_command(self): clipboard = QApplication.clipboard() diff --git a/src/mosaic/dts/_utils.py b/src/mosaic/dts/_utils.py index 4a09f4e9..37b6f03a 100644 --- a/src/mosaic/dts/_utils.py +++ b/src/mosaic/dts/_utils.py @@ -6,11 +6,10 @@ Author: Valentin Maurer """ -import re import json +import re import warnings from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union import numpy as np @@ -22,7 +21,7 @@ class _ParameterParser: PARAM_PATTERN = re.compile(r"\{\{(\w+):([^}]+)\}\}") @classmethod - def parse_template(cls, template_content: str) -> Tuple[str, Dict[str, List]]: + def parse_template(cls, template_content: str) -> tuple[str, dict[str, list]]: """Parse a template and extract ``{{name:range}}`` parameter definitions.""" parameters = {} @@ -36,7 +35,7 @@ def replace_param(match): return template_processed, parameters @classmethod - def _parse_parameter_definition(cls, param_def: str) -> List: + def _parse_parameter_definition(cls, param_def: str) -> list: if ":" in param_def: parts = param_def.split(":") if len(parts) != 3: @@ -61,14 +60,14 @@ def _parse_parameter_definition(cls, param_def: str) -> List: return [param_def.strip()] -def parse_xvg(path: str) -> Optional[Tuple[List[str], np.ndarray, Dict[str, str]]]: +def parse_xvg(path: str) -> tuple[list[str], np.ndarray, dict[str, str]] | None: """Parse an .xvg file into (column_names, data, metadata) or None.""" xvg_path = Path(path) if not xvg_path.exists(): return None column_names, data_lines, metadata = [], [], {} - with open(xvg_path, "r") as f: + with open(xvg_path) as f: for line in f: line = line.strip() if not line: @@ -110,7 +109,7 @@ def sanitize_label(label: str) -> str: return re.sub(r"[^a-zA-Z0-9_-]", "_", label).strip("_").lower() -def find_dts_file(run_dir) -> Optional[Path]: +def find_dts_file(run_dir) -> Path | None: """Return the DTS config file for *run_dir*. Prefers ``input.dts``; falls back to the first ``*.dts`` file found. @@ -122,7 +121,7 @@ def find_dts_file(run_dir) -> Optional[Path]: return next(iter(sorted(run_path.glob("*.dts"))), None) -def resolve_trajectory_dir(run_dir) -> Optional[Path]: +def resolve_trajectory_dir(run_dir) -> Path | None: """Return the first existing trajectory directory for a DTS run. Parses ``input.dts`` for ``VisualizationFormat`` and @@ -211,10 +210,10 @@ def build_trajectory_frames( drop_pbc : bool Remove faces that wrap across periodic boundaries. """ - from ..meshing import to_open3d - from ..parametrization import TriangularMesh from ..formats.parser import VertexPropertyContainer + from ..meshing import to_open3d from ..parallel import report_progress + from ..parametrization import TriangularMesh total = len(list_trajectory_files(trajectory_dir)) @@ -249,7 +248,7 @@ def build_trajectory_frames( return frames -def parse_time_series(run_dir: str) -> Optional[Tuple[List[str], np.ndarray]]: +def parse_time_series(run_dir: str) -> tuple[list[str], np.ndarray] | None: """Parse ``dts-en.xvg`` energy time series from a DTS run directory.""" result = parse_xvg(str(Path(run_dir) / "dts-en.xvg")) if result is None: @@ -258,7 +257,7 @@ def parse_time_series(run_dir: str) -> Optional[Tuple[List[str], np.ndarray]]: return columns, data -def parse_run_time_series(run_dir: str) -> List[Dict]: +def parse_run_time_series(run_dir: str) -> list[dict]: """Scan a run directory for all .xvg time series files.""" run_path = Path(run_dir) @@ -288,7 +287,7 @@ def parse_run_time_series(run_dir: str) -> List[Dict]: return series -def load_screen_results(screen_dir: str) -> Dict: +def load_screen_results(screen_dir: str) -> dict: """Load all results from a screen, trajectory collection, or single run.""" from .screening import get_screen_status @@ -301,7 +300,7 @@ def load_screen_results(screen_dir: str) -> Dict: parameter_names = [] if summary_path.exists(): - with open(summary_path, "r") as f: + with open(summary_path) as f: summary = json.load(f) parameter_names = summary.get("parameters", []) @@ -315,7 +314,7 @@ def load_screen_results(screen_dir: str) -> Dict: params = entry.get("parameters", {}) params_file = run_dir / "params.json" if params_file.exists(): - with open(params_file, "r") as f: + with open(params_file) as f: params = json.load(f) runs.append( @@ -430,7 +429,7 @@ def extract_metric_series(screen_results, metric_display, selected_run_ids=None) } -def extract_volume_path(dts_content: str) -> Optional[Union[str, List[str]]]: +def extract_volume_path(dts_content: str) -> str | list[str] | None: """Extract volume path(s) from DTS config content. Checks for a ``{{volume_path:...}}`` screening placeholder first, @@ -465,7 +464,7 @@ def extract_screening_placeholder(value: str): return None -def parse_screening_ranges(text: str) -> Dict[str, List]: +def parse_screening_ranges(text: str) -> dict[str, list]: """Extract ``{{name:range}}`` placeholders from text and parse values.""" result = {} for match in re.finditer(r"\{\{(\w+):([^}]+)\}\}", text): @@ -485,7 +484,7 @@ def parse_screening_ranges(text: str) -> Dict[str, List]: } -def _parse_filter_line(line: str, known: Dict): +def _parse_filter_line(line: str, known: dict): """Parse a single ``;@filter`` line into *known* under ``_filters``.""" filters = known.setdefault("_filters", {}) for token in line[len(";@filter") :].split(): @@ -499,9 +498,9 @@ def _parse_filter_line(line: str, known: Dict): filters[mapped] = val.strip() -def parse_filter_directives(dts_content: str) -> Dict[str, float]: +def parse_filter_directives(dts_content: str) -> dict[str, float]: """Parse ``; @filter`` directives from DTS content.""" - known: Dict = {} + known: dict = {} for line in dts_content.splitlines(): stripped = line.strip() if stripped.startswith(";@filter"): @@ -638,7 +637,7 @@ def _parse_val(raw): return known, extra_lines -def collect_vertex_properties(run_dir: str) -> Dict[str, np.ndarray]: +def collect_vertex_properties(run_dir: str) -> dict[str, np.ndarray]: """Collect per-frame per-vertex properties from ``{run_dir}/mosaic/``. Returns diff --git a/src/mosaic/dts/analysis.py b/src/mosaic/dts/analysis.py index 7c83905e..04636890 100644 --- a/src/mosaic/dts/analysis.py +++ b/src/mosaic/dts/analysis.py @@ -7,16 +7,15 @@ """ from pathlib import Path -from typing import Dict, Optional import numpy as np from ._utils import ( + iter_frames, + list_trajectory_files, parse_xvg, - write_xvg, sanitize_label, - list_trajectory_files, - iter_frames, + write_xvg, ) __all__ = ["compute"] @@ -105,10 +104,10 @@ def compute( *, scale: float, offset: np.ndarray, - output_dir: Optional[str] = None, + output_dir: str | None = None, force: bool = False, **kwargs, -) -> Dict: +) -> dict: """Run a trajectory analysis. Parameters @@ -504,6 +503,7 @@ def _compute_hmff(trajectory_dir, scale, offset, output_dir=None, **kwargs): Run directory containing ``input.dts``. """ from scipy.interpolate import RegularGridInterpolator + from ..formats.parser import load_density from ..parallel import report_progress from ._utils import find_dts_file, parse_dts_content diff --git a/src/mosaic/dts/analysis_panel.py b/src/mosaic/dts/analysis_panel.py index 7b1ac49c..a5985a3f 100644 --- a/src/mosaic/dts/analysis_panel.py +++ b/src/mosaic/dts/analysis_panel.py @@ -7,35 +7,35 @@ """ import re -from typing import Callable, Optional +from collections.abc import Callable import numpy as np +import pyqtgraph as pg from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, - QLabel, + QAbstractItemView, QComboBox, QCompleter, - QLineEdit, - QPushButton, QFormLayout, - QWidget, QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, QSizePolicy, QStackedWidget, QTableWidget, QTableWidgetItem, - QHeaderView, - QAbstractItemView, + QVBoxLayout, + QWidget, ) -import pyqtgraph as pg +from ..dts._utils import collect_available_metrics, extract_metric_series from ..icons import icon -from ..utils import Throttle from ..stylesheets import Colors, Typography +from ..utils import Throttle from ..widgets import ColorMapSelector, generate_gradient_colors -from ..dts._utils import collect_available_metrics, extract_metric_series class AnalysisPanel(QWidget): @@ -54,7 +54,7 @@ class AnalysisPanel(QWidget): def __init__( self, - get_selected_run_ids: Optional[Callable] = None, + get_selected_run_ids: Callable | None = None, parent=None, ): super().__init__(parent) @@ -63,7 +63,7 @@ def __init__( self._screen_dir = None self._lod_active = False - self._lod_downsample: Optional[int] = None + self._lod_downsample: int | None = None self._lod_timer = QTimer(self) self._lod_timer.setSingleShot(True) self._lod_timer.setInterval(self._LOD_IDLE_MS) @@ -91,7 +91,7 @@ def _build_ui(self): self._plot_item.vb.sigRangeChangedManually.connect(self._on_user_interaction) self._placeholder = QLabel( - "No time series data available.\n" "Select a metric to display." + "No time series data available.\nSelect a metric to display." ) self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self._placeholder.setStyleSheet( @@ -239,6 +239,7 @@ def update_run(self, screen_dir: str, run_id: str): time-series files are re-parsed; the rest of the cached data is kept. """ from pathlib import Path + from ..dts._utils import parse_run_time_series self._screen_dir = screen_dir @@ -575,7 +576,8 @@ def showEvent(self, event): def _save_derived(self): """Write the derived metric as .xvg into each run's mosaic/ folder.""" from pathlib import Path - from ..dts._utils import write_xvg, sanitize_label, parse_xvg + + from ..dts._utils import parse_xvg, sanitize_label, write_xvg parsed = self._parse_derived() if parsed is None: diff --git a/src/mosaic/dts/compute_panel.py b/src/mosaic/dts/compute_panel.py index e783eb97..97bdb5be 100644 --- a/src/mosaic/dts/compute_panel.py +++ b/src/mosaic/dts/compute_panel.py @@ -6,19 +6,20 @@ Author: Valentin Maurer """ -from typing import Callable, Optional +from collections.abc import Callable from qtpy.QtCore import Qt from qtpy.QtWidgets import ( - QHBoxLayout, - QLabel, - QComboBox, - QSpinBox, QCheckBox, - QPushButton, + QComboBox, QFormLayout, QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QSpinBox, ) + from ..icons import icon from ..widgets import MosaicMessageBox @@ -46,9 +47,9 @@ def __init__( self, get_mesh_transform: Callable, cdata=None, - get_run_ids: Optional[Callable] = None, - get_run_dir: Optional[Callable] = None, - on_complete: Optional[Callable] = None, + get_run_ids: Callable | None = None, + get_run_dir: Callable | None = None, + on_complete: Callable | None = None, parent=None, ): super().__init__("Compute", parent) diff --git a/src/mosaic/dts/configure_panel.py b/src/mosaic/dts/configure_panel.py index 944f668b..ec192789 100644 --- a/src/mosaic/dts/configure_panel.py +++ b/src/mosaic/dts/configure_panel.py @@ -7,39 +7,38 @@ """ from pathlib import Path -from typing import Dict, List import numpy as np +import pyqtgraph as pg from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, - QLabel, - QComboBox, QCheckBox, - QPushButton, + QComboBox, QFormLayout, - QWidget, QGroupBox, - QScrollArea, + QHBoxLayout, + QLabel, QLineEdit, QPlainTextEdit, + QPushButton, + QScrollArea, QStackedWidget, + QVBoxLayout, + QWidget, ) -import pyqtgraph as pg -from ..widgets import PathSelector, generate_gradient_colors, MosaicMessageBox from ..icons import icon -from ..widgets.settings import create_setting_widget, get_widget_value, set_widget_value from ..stylesheets import Colors, Typography - +from ..widgets import MosaicMessageBox, PathSelector, generate_gradient_colors +from ..widgets.settings import create_setting_widget, get_widget_value, set_widget_value from ._utils import ( COUPLING_DEFS as _COUPLING_DEFS, - parse_screening_ranges, +) +from ._utils import ( parse_dts_content, + parse_screening_ranges, ) - _EXTRA_CONFIG_PLACEHOLDER = ( "# Fixed: Kappa = 25.0 0 0\n" "# Screen: Kappa = {{kappa:25.0:35.0:5.0}} 0 0\n" @@ -667,7 +666,7 @@ def _toggle_filter_inputs(self, state): if label is not None: label.setVisible(show) - def _get_screen_params(self) -> Dict[str, str]: + def _get_screen_params(self) -> dict[str, str]: params = {} for key, cb in self._screen_cbs.items(): if cb.isChecked(): @@ -676,7 +675,7 @@ def _get_screen_params(self) -> Dict[str, str]: params[key] = text return params - def _get_all_screened_values(self) -> Dict[str, List]: + def _get_all_screened_values(self) -> dict[str, list]: result = {} for key, range_str in self._get_screen_params().items(): try: diff --git a/src/mosaic/dts/dts_dialog.py b/src/mosaic/dts/dts_dialog.py index afeaab12..09f66c8f 100644 --- a/src/mosaic/dts/dts_dialog.py +++ b/src/mosaic/dts/dts_dialog.py @@ -10,31 +10,31 @@ from pathlib import Path import numpy as np +import pyqtgraph as pg from qtpy.QtCore import Qt from qtpy.QtWidgets import ( - QVBoxLayout, + QDialog, + QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QPushButton, - QWidget, - QDialog, - QGroupBox, + QSizePolicy, + QSplitter, QTableWidget, - QHeaderView, QTableWidgetItem, - QSplitter, - QSizePolicy, + QVBoxLayout, + QWidget, ) -import pyqtgraph as pg -from ._utils import find_dts_file, parse_dts_content from ..icons import icon, icon_button -from .configure_panel import ConfigurePanel -from .compute_panel import ComputePanel -from .analysis_panel import AnalysisPanel from ..stylesheets import Colors, Typography +from ..widgets import MosaicMessageBox, PathSelector, SearchWidget, TabWidget from ..widgets.settings import get_widget_value -from ..widgets import PathSelector, SearchWidget, TabWidget, MosaicMessageBox +from ._utils import find_dts_file, parse_dts_content +from .analysis_panel import AnalysisPanel +from .compute_panel import ComputePanel +from .configure_panel import ConfigurePanel __all__ = ["DTSScreeningDialog"] @@ -370,8 +370,8 @@ def _open_run_dir(self, run_id: str): run_dir = self._resolve_run_dir(run_id) if not run_dir.exists(): return None - from qtpy.QtGui import QDesktopServices from qtpy.QtCore import QUrl + from qtpy.QtGui import QDesktopServices QDesktopServices.openUrl(QUrl.fromLocalFile(str(run_dir))) @@ -390,12 +390,12 @@ def _import_run_trajectory(self, run_id: str): "No trajectory found. Check if .dts file or TrajTSI directory are present.", ) + from ..parallel import submit_io_task from ._utils import ( - list_trajectory_files, build_trajectory_frames, collect_vertex_properties, + list_trajectory_files, ) - from ..parallel import submit_io_task files = list_trajectory_files(str(traj_dir)) if not files: diff --git a/src/mosaic/dts/equilibration_dialog.py b/src/mosaic/dts/equilibration_dialog.py index 9631cb10..adaac8f7 100644 --- a/src/mosaic/dts/equilibration_dialog.py +++ b/src/mosaic/dts/equilibration_dialog.py @@ -11,17 +11,17 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QDialog, - QVBoxLayout, QGridLayout, - QLabel, QGroupBox, + QLabel, QPushButton, + QVBoxLayout, QWidget, ) from ..icons import icon from ..stylesheets import Colors -from ..widgets import DialogFooter, PathSelector, MosaicMessageBox +from ..widgets import DialogFooter, MosaicMessageBox, PathSelector from ..widgets.settings import create_setting_widget, get_widget_value, set_widget_value diff --git a/src/mosaic/dts/screening.py b/src/mosaic/dts/screening.py index 8a71f261..26e6500b 100644 --- a/src/mosaic/dts/screening.py +++ b/src/mosaic/dts/screening.py @@ -6,23 +6,22 @@ Author: Valentin Maurer """ -import re import json +import re import shutil import textwrap import warnings -from pathlib import Path -from os import makedirs from datetime import datetime from itertools import product -from typing import Dict, List, Optional, Union +from os import makedirs +from pathlib import Path import numpy as np from ..parallel import report_progress from ._utils import _ParameterParser -__all__ = ["generate_screen", "extend_screen", "get_screen_status"] +__all__ = ["extend_screen", "generate_screen", "get_screen_status"] _SENTINEL = ".done" @@ -45,9 +44,9 @@ def run_status(run_dir: Path) -> str: def _filter_single_volume( volume_path: str, output_path: str, - lowpass_cutoff: Optional[float], - highpass_cutoff: Optional[float], - plane_norm: Optional[str], + lowpass_cutoff: float | None, + highpass_cutoff: float | None, + plane_norm: str | None, ) -> str: """Apply bandpass filtering to a single volume. @@ -107,13 +106,13 @@ def _filter_single_volume( def _prepare_volume( - volume_path: Union[str, List[str]], + volume_path: str | list[str], output_dir: str, use_filters: bool = False, - lowpass_cutoff: Optional[float] = None, - highpass_cutoff: Optional[float] = None, - plane_norm: Optional[str] = None, -) -> List[str]: + lowpass_cutoff: float | None = None, + highpass_cutoff: float | None = None, + plane_norm: str | None = None, +) -> list[str]: """Apply bandpass filtering to volume(s) if requested. Parameters @@ -154,13 +153,13 @@ def _prepare_volume( def _build_dts_template( - volume_path: Optional[str], + volume_path: str | None, mesh_scale: str, mesh_offset: str, - hmff_params: Dict, - sim_params: Dict, + hmff_params: dict, + sim_params: dict, extra_config: str = "", - coupling_params: Optional[Dict] = None, + coupling_params: dict | None = None, ) -> str: """Build a DTS config template with screening placeholders. @@ -262,9 +261,9 @@ def _build_dts_template( def _expand_screen( screen_dir: Path, template_content: str, - param_values: Dict[str, List], - existing_runs: Optional[List[Dict]] = None, -) -> List[Dict]: + param_values: dict[str, list], + existing_runs: list[dict] | None = None, +) -> list[dict]: """Generate new parameter combinations and create run directories. Parameters @@ -322,9 +321,9 @@ def _expand_screen( def _write_summary( screen_dir: Path, template_file: str, - parameters: Dict[str, List], - runs: List[Dict], -) -> Dict: + parameters: dict[str, list], + runs: list[dict], +) -> dict: """Write ``screen_summary.json``. Parameters @@ -369,7 +368,7 @@ def _write_summary( def _finalize_runs( screen_dir: Path, - summary: Dict, + summary: dict, mesh_name: str = "", ) -> None: """Write run.sh for every run, then write launcher scripts. @@ -399,7 +398,7 @@ def _finalize_runs( _write_launcher_scripts(screen_dir, summary) -def _write_run_script(run_dir: Path, params: Dict, mesh_name: str = "") -> None: +def _write_run_script(run_dir: Path, params: dict, mesh_name: str = "") -> None: """Write a run.sh script for a single screen run. Parameters @@ -458,7 +457,7 @@ def _pending_runs_preamble(output_dir: Path) -> str: ) -def _write_launcher_scripts(output_dir: Path, summary: Dict) -> None: +def _write_launcher_scripts(output_dir: Path, summary: dict) -> None: """Write local and SLURM launcher scripts for a screen.""" preamble = _pending_runs_preamble(output_dir) @@ -583,12 +582,12 @@ def _is_run_directory(path: Path) -> bool: return run_status(path) == "available" or (path / "input.dts").exists() -def _run_info(run_dir: Path) -> Dict: +def _run_info(run_dir: Path) -> dict: """Build a status dict for a single run directory.""" params = {} params_file = run_dir / "params.json" if params_file.exists(): - with open(params_file, "r") as f: + with open(params_file) as f: params = json.load(f) return { @@ -602,7 +601,7 @@ def generate_screen( output_dir: str, mesh: str, dts_content: str, -) -> Dict: +) -> dict: """Generate a DTS parameter screen from a DTS config template. Parameters @@ -627,7 +626,7 @@ def generate_screen( may contain ``{{name:range}}`` screening placeholders and ``;@filter`` directives for volume filtering. """ - from ._utils import parse_filter_directives, extract_volume_path + from ._utils import extract_volume_path, parse_filter_directives volume_path = extract_volume_path(dts_content) filter_params = parse_filter_directives(dts_content) @@ -678,7 +677,7 @@ def generate_screen( merged_parameters = dict(parameters) summary_path = screen_dir / "screen_summary.json" if summary_path.exists(): - with open(summary_path, "r") as f: + with open(summary_path) as f: old_summary = json.load(f) existing_runs = old_summary["runs"] merged_parameters = _merge_parameters(old_summary, parameters) @@ -699,7 +698,7 @@ def generate_screen( } -def extend_screen(screen_dir: str, new_screen_params: Dict[str, str]) -> Dict: +def extend_screen(screen_dir: str, new_screen_params: dict[str, str]) -> dict: """Extend an existing screen with new parameter combinations. Parameters @@ -716,7 +715,7 @@ def extend_screen(screen_dir: str, new_screen_params: Dict[str, str]) -> Dict: """ screen_path = Path(screen_dir) - with open(screen_path / "screen_summary.json", "r") as f: + with open(screen_path / "screen_summary.json") as f: summary = json.load(f) dts_content = (screen_path / "screen.dts").read_text() @@ -752,7 +751,7 @@ def extend_screen(screen_dir: str, new_screen_params: Dict[str, str]) -> Dict: } -def get_screen_status(screen_dir: str) -> List[Dict]: +def get_screen_status(screen_dir: str) -> list[dict]: """Check status of all runs in a screen or trajectory directory. Parameters @@ -778,7 +777,7 @@ def get_screen_status(screen_dir: str) -> List[Dict]: summary_path = root / "screen_summary.json" if summary_path.exists(): - with open(summary_path, "r") as f: + with open(summary_path) as f: summary = json.load(f) runs = sorted(summary["runs"], key=lambda r: _natural_sort_key(r["run_id"])) return [_run_info(root / r["run_id"]) for r in runs] diff --git a/src/mosaic/formats/_utils.py b/src/mosaic/formats/_utils.py index 78078ef3..d65381a7 100644 --- a/src/mosaic/formats/_utils.py +++ b/src/mosaic/formats/_utils.py @@ -1,8 +1,6 @@ import pickle - +from os.path import basename, splitext from typing import Any -from os.path import splitext, basename - __all__ = [ "CompatibilityUnpickler", diff --git a/src/mosaic/formats/parser.py b/src/mosaic/formats/parser.py index a0772c9d..343a521d 100644 --- a/src/mosaic/formats/parser.py +++ b/src/mosaic/formats/parser.py @@ -8,12 +8,11 @@ import struct import warnings +from gzip import open as gzip_open from io import BytesIO from string import ascii_lowercase -from typing import Dict import numpy as np -from gzip import open as gzip_open from .records import ( GeometryData, @@ -65,8 +64,9 @@ def _read_orientations(filename: str): dict Dictionary containing vertices, normals, and quaternions. """ - from tme import Orientations from scipy.spatial.transform import Rotation + from tme import Orientations + from ..utils import NORMAL_REFERENCE data = Orientations.from_file(filename) @@ -183,11 +183,11 @@ def read_txt(filename: str): vertices, normals, quaternions = [], [], [] for cluster in data: cols = ("x", "y", "z") - vertices.append((np.hstack([cluster[k][:, None] for k in cols]))) + vertices.append(np.hstack([cluster[k][:, None] for k in cols])) try: cols = ("nx", "ny", "nz") - normals.append((np.hstack([cluster[k][:, None] for k in cols]))) - except Exception as e: + normals.append(np.hstack([cluster[k][:, None] for k in cols])) + except Exception: continue if len(normals) == 0: @@ -210,7 +210,7 @@ def read_tsv(filename: str) -> GeometryDataContainer: GeometryDataContainer Parsed geometry data container. """ - with open(filename, mode="r") as infile: + with open(filename) as infile: header = infile.readline() if "euler" not in header: return read_txt(filename) @@ -239,7 +239,7 @@ def read_tsi(filename: str) -> GeometryDataContainer: try: if "inclusions" in data: - inclusions = np.zeros((len(data["vertices"]))) + inclusions = np.zeros(len(data["vertices"])) inclusion_type = data["inclusions"][:, 1] inclusion_vert = data["inclusions"][:, 2].astype(int) inclusions[inclusion_vert] = inclusion_type @@ -501,7 +501,7 @@ def points_from_flat_array(arr, dims, max_cluster=10000): return [coords[bounds[i] : bounds[i + 1]] for i in range(len(bounds) - 1)] -def _read_tsi_file(file_path: str) -> Dict: +def _read_tsi_file(file_path: str) -> dict: """ Reads a topology file [1]_. @@ -524,7 +524,7 @@ def _read_tsi_file(file_path: str) -> Dict: _keys = ("version", "box", "n_vertices", "vertices", "n_faces", "faces") ret = {k: None for k in _keys} - with open(file_path, mode="r", encoding="utf-8") as infile: + with open(file_path, encoding="utf-8") as infile: data = [x.strip() for x in infile.read().split("\n") if len(x.strip())] # Version prefix @@ -564,7 +564,7 @@ def _read_tsi_file(file_path: str) -> Dict: return ret -def _read_vtu_file(file_path: str) -> Dict: +def _read_vtu_file(file_path: str) -> dict: """ Parse a VTK XML UnstructuredGrid file into a dictionary of numpy arrays. @@ -602,7 +602,7 @@ def _read_vtu_file(file_path: str) -> Dict: connectivity = connectivity.reshape(-1, int(strides[0])) pd = grid.GetPointData() - point_data: Dict[str, np.ndarray] = {} + point_data: dict[str, np.ndarray] = {} for i in range(pd.GetNumberOfArrays()): arr = vtk_to_numpy(pd.GetArray(i)) if arr.ndim == 2 and arr.shape[1] == 1: @@ -668,11 +668,13 @@ def read_ndjson(filename: str) -> GeometryDataContainer: Parsed geometry data container. """ import json + from scipy.spatial.transform import Rotation + from ..utils import NORMAL_REFERENCE records = [] - with open(filename, "r") as f: + with open(filename) as f: for line in f: line = line.strip() if line: diff --git a/src/mosaic/formats/reader.py b/src/mosaic/formats/reader.py index ad96131a..61fd87eb 100644 --- a/src/mosaic/formats/reader.py +++ b/src/mosaic/formats/reader.py @@ -25,7 +25,7 @@ def is_likely_density_map(filepath: str, max_cluster: int = 10000) -> bool: if not is_volume_file(filepath): return False - from .parser import read_mrc_dtype, read_mrc_flat, load_density + from .parser import load_density, read_mrc_dtype, read_mrc_flat dtype = read_mrc_dtype(filepath) if dtype is not None and not np.issubdtype(dtype, np.integer): diff --git a/src/mosaic/formats/records.py b/src/mosaic/formats/records.py index d90a518a..6a324d0a 100644 --- a/src/mosaic/formats/records.py +++ b/src/mosaic/formats/records.py @@ -8,7 +8,7 @@ import warnings from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any import numpy as np @@ -52,8 +52,8 @@ class GeometryData: faces: np.ndarray = None quaternions: np.ndarray = None vertex_properties: "VertexPropertyContainer" = None - shape: List[int] = None - sampling: List[float] = (1, 1, 1) + shape: list[int] = None + sampling: list[float] = (1, 1, 1) model: Any = None @@ -80,16 +80,16 @@ class GeometryDataContainer: Sampling rates along each axis, by default (1, 1, 1). """ - vertices: List[np.ndarray] = None - normals: List[np.ndarray] = None - faces: List[np.ndarray] = None - quaternions: List[np.ndarray] = None - vertex_properties: List["VertexPropertyContainer"] = None - shape: List[int] = None - sampling: List[float] = (1, 1, 1) + vertices: list[np.ndarray] = None + normals: list[np.ndarray] = None + faces: list[np.ndarray] = None + quaternions: list[np.ndarray] = None + vertex_properties: list["VertexPropertyContainer"] = None + shape: list[int] = None + sampling: list[float] = (1, 1, 1) def __post_init__(self): - from ..utils import compute_bounding_box, NORMAL_REFERENCE + from ..utils import NORMAL_REFERENCE, compute_bounding_box dtype_map = { "vertices": np.float32, @@ -149,7 +149,7 @@ def __getitem__(self, index: int) -> GeometryData: ) @staticmethod - def _to_dtype(data: List[np.ndarray], dtype=np.float32): + def _to_dtype(data: list[np.ndarray], dtype=np.float32): try: n_elements = len(data) except Exception: @@ -173,7 +173,7 @@ class VertexPropertyContainer: Dictionary mapping property names to vertex data arrays. """ - def __init__(self, properties: Optional[Dict[str, np.ndarray]] = None): + def __init__(self, properties: dict[str, np.ndarray] | None = None): """ Initialize vertex property container. @@ -219,7 +219,7 @@ def properties(self): """List available vertex properties.""" return list(self._properties.keys()) - def get_property(self, name: str, default: Any = None) -> Optional[np.ndarray]: + def get_property(self, name: str, default: Any = None) -> np.ndarray | None: """Get property data by name.""" return self._properties.get(name, default) @@ -257,7 +257,7 @@ def copy(self) -> "VertexPropertyContainer": @classmethod def merge( - cls, containers: List["VertexPropertyContainer"] + cls, containers: list["VertexPropertyContainer"] ) -> "VertexPropertyContainer": """ Merge multiple property containers. diff --git a/src/mosaic/formats/session.py b/src/mosaic/formats/session.py index 46965ee6..cebea676 100644 --- a/src/mosaic/formats/session.py +++ b/src/mosaic/formats/session.py @@ -16,9 +16,8 @@ """ import json -import struct import pickle -from typing import Dict, Optional +import struct from ._utils import CompatibilityUnpickler @@ -32,7 +31,7 @@ def is_session_file(filepath: str) -> bool: return filepath.lower().endswith(_SESSION_EXTENSIONS) -def _read_index(filepath: str) -> Optional[Dict]: +def _read_index(filepath: str) -> dict | None: """Read the file index, returning ``None`` for legacy pickle files.""" with open(filepath, "rb") as fh: first_four = fh.read(4) @@ -43,7 +42,7 @@ def _read_index(filepath: str) -> Optional[Dict]: return json.loads(fh.read(index_len).decode("utf-8")) -def read_session_index(filepath: str) -> Dict: +def read_session_index(filepath: str) -> dict: """Read the session file index. Returns ``{"format_version": 0}`` for legacy pure-pickle files. @@ -62,7 +61,7 @@ def read_session_index(filepath: str) -> Dict: return index if index is not None else {"format_version": 0} -def read_session_section(filepath: str, section: str) -> Optional[bytes]: +def read_session_section(filepath: str, section: str) -> bytes | None: """Read raw bytes for a named section without decoding. Parameters @@ -90,7 +89,7 @@ def read_session_section(filepath: str, section: str) -> Optional[bytes]: return fh.read(info["size"]) -def read_session_meta(filepath: str) -> Dict: +def read_session_meta(filepath: str) -> dict: """Read session metadata without loading geometry data. Parameters @@ -109,7 +108,7 @@ def read_session_meta(filepath: str) -> Dict: return json.loads(data.decode("utf-8")) -def open_session(filepath: str) -> Dict: +def open_session(filepath: str) -> dict: """Read session state, handling both legacy and indexed formats. Parameters diff --git a/src/mosaic/formats/stream.py b/src/mosaic/formats/stream.py index 632225bb..6388ae68 100644 --- a/src/mosaic/formats/stream.py +++ b/src/mosaic/formats/stream.py @@ -8,8 +8,8 @@ from collections import OrderedDict -import vtk import numpy as np +import vtk from vtkmodules.util import numpy_support from vtkmodules.util.vtkAlgorithm import VTKPythonAlgorithmBase diff --git a/src/mosaic/formats/writer.py b/src/mosaic/formats/writer.py index c2305ec4..2c5be095 100644 --- a/src/mosaic/formats/writer.py +++ b/src/mosaic/formats/writer.py @@ -1,13 +1,12 @@ import json import warnings -from typing import Dict, List import numpy as np from .records import GeometryData -def _coerce_records(geometries) -> List[GeometryData]: +def _coerce_records(geometries) -> list[GeometryData]: """Build per-geometry records for the writers. Parameters @@ -90,7 +89,7 @@ def _ensure_quaternions(rec): np.ndarray Quaternion array, scalar-first. """ - from ..utils import normals_to_rot, NORMAL_REFERENCE + from ..utils import NORMAL_REFERENCE, normals_to_rot if rec.quaternions is not None: return rec.quaternions @@ -132,6 +131,7 @@ def _write_orientations( Forwarded to ``Orientations.to_file``. """ from tme import Orientations + from ..utils import quat_to_euler # scipy.spatial.transform.Rotation threading breaks pipelines @@ -154,7 +154,7 @@ def _write_orientations( orientations.to_file(path, file_format=file_format, **kwargs) -def write_topology_file(file_path: str, data: Dict, tsi_format: bool = False) -> None: +def write_topology_file(file_path: str, data: dict, tsi_format: bool = False) -> None: """ Write a topology file [1]_. diff --git a/src/mosaic/geometry.py b/src/mosaic/geometry.py index 40da332a..d5586c4e 100644 --- a/src/mosaic/geometry.py +++ b/src/mosaic/geometry.py @@ -7,27 +7,26 @@ """ import warnings -from uuid import uuid4 from contextlib import contextmanager -from typing import Dict, List, Optional, Tuple +from uuid import uuid4 import numpy as np -from vtkmodules.vtkCommonCore import vtkPoints, vtkLookupTable -from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkPolyData from vtkmodules.util import numpy_support +from vtkmodules.vtkCommonCore import vtkLookupTable, vtkPoints +from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkPolyData from . import lod from .stylesheets import Colors -from .utils import normals_to_rot, apply_quat, NORMAL_REFERENCE +from .utils import NORMAL_REFERENCE, apply_quat, normals_to_rot __all__ = [ - "GeometryData", "Geometry", - "VolumeGeometry", - "SegmentationGeometry", + "GeometryData", "GeometryTrajectory", - "merge_geometries", + "SegmentationGeometry", + "VolumeGeometry", "get_render_scale", + "merge_geometries", "render_scale", ] @@ -172,7 +171,7 @@ def points(self, value): self.polydata.Modified() @property - def normals(self) -> Optional[np.ndarray]: + def normals(self) -> np.ndarray | None: n = self.polydata.GetPointData().GetNormals() if n is None or n.GetNumberOfTuples() == 0: return None @@ -194,7 +193,7 @@ def has_normals(self) -> bool: return n is not None and n.GetNumberOfTuples() > 0 @property - def quaternions(self) -> Optional[np.ndarray]: + def quaternions(self) -> np.ndarray | None: arr = self.polydata.GetPointData().GetArray("OrientationQuaternion") if arr is None or arr.GetNumberOfTuples() == 0: return None @@ -805,7 +804,7 @@ def _set_faces(self, faces): """Set triangular face connectivity. Delegates to GeometryData.""" self._geometry_data.set_faces(faces) - def set_color(self, color: Tuple[int] = None): + def set_color(self, color: tuple[int] = None): """ Set uniform color for all points in the geometry. @@ -843,7 +842,7 @@ def set_appearance( ambient: float = None, diffuse: float = None, specular: float = None, - base_color: Tuple[float] = None, + base_color: tuple[float] = None, **kwargs, ): """ @@ -1044,7 +1043,7 @@ def _configure_scalar_mapper( if use_point: mapper.SetScalarModeToUsePointData() - def color_points(self, point_ids: set, color: Tuple[float]): + def color_points(self, point_ids: set, color: tuple[float]): """ Color specific points in the geometry using set_scalars backend. @@ -1084,7 +1083,7 @@ def swap_data( faces=None, quaternions=None, model=None, - meta: Dict = None, + meta: dict = None, **kwargs, ): """ @@ -2218,7 +2217,7 @@ class GeometryTrajectory(Geometry): Additional keyword arguments passed to parent Geometry class. """ - def __init__(self, trajectory: List[Dict], **kwargs): + def __init__(self, trajectory: list[dict], **kwargs): super().__init__(**kwargs) self._trajectory = trajectory diff --git a/src/mosaic/gui.py b/src/mosaic/gui.py index d2d778cd..69e4c5bc 100644 --- a/src/mosaic/gui.py +++ b/src/mosaic/gui.py @@ -8,70 +8,68 @@ import os import sys -from typing import List from os.path import exists -import vtk import numpy as np +import vtk from qtpy.QtCore import ( - Qt, QEvent, QSize, + Qt, QTimer, ) -from qtpy.QtWidgets import ( - QMainWindow, - QVBoxLayout, - QGridLayout, - QWidget, - QStackedWidget, - QSplitter, - QFileDialog, - QMenu, - QPushButton, - QDockWidget, - QFrame, - QMessageBox, - QDialog, -) -from .widgets import MosaicMessageBox from qtpy.QtGui import ( QAction, - QGuiApplication, QActionGroup, + QDragEnterEvent, + QGuiApplication, QKeySequence, QShortcut, - QDragEnterEvent, +) +from qtpy.QtWidgets import ( + QDialog, + QDockWidget, + QFileDialog, + QFrame, + QGridLayout, + QMainWindow, + QMenu, + QMessageBox, + QPushButton, + QSplitter, + QStackedWidget, + QVBoxLayout, + QWidget, ) from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor +from .animation._utils import ScreenshotManager from .data import MosaicData +from .dialogs import ImportDataDialog from .icons import icon +from .parallel import BackgroundTaskManager from .settings import Settings -from .undo import STACK from .stylesheets import Colors -from .animation._utils import ScreenshotManager -from .parallel import BackgroundTaskManager -from .tabs import SegmentationTab, ModelTab, IntelligenceTab, DevelopmentTab -from .dialogs import ImportDataDialog +from .tabs import DevelopmentTab, IntelligenceTab, ModelTab, SegmentationTab +from .undo import STACK from .widgets import ( AxesWidget, + BoundingBoxManager, + CursorModeHandler, + LegendWidget, + MosaicMessageBox, + ObjectBrowserSidebar, RibbonToolBar, + ScaleBarWidget, + StatusIndicator, TabBar, TrajectoryPlayer, - LegendWidget, - ScaleBarWidget, - ObjectBrowserSidebar, UpdatePill, ViewerModes, - StatusIndicator, - CursorModeHandler, - BoundingBoxManager, ) from .widgets.dock import toggle_dock -from .widgets.volume_viewer_hud import VolumeViewerHUD from .widgets.viewport_placeholder import ViewportPlaceholder, default_actions - +from .widgets.volume_viewer_hud import VolumeViewerHUD # From v1.3.2 these are no longer configurable in favor of vtk recommended defaults DEPTH_PEEL_OCCLUSION_RATIO = 0.1 @@ -79,7 +77,6 @@ class App(QMainWindow): - def __init__(self): super().__init__() self.setWindowState(Qt.WindowNoState) @@ -261,8 +258,8 @@ def dropEvent(self, event): self._handle_import(paths) def _handle_import(self, file_paths): - from .formats.session import is_session_file from .formats.reader import is_volume_file + from .formats.session import is_session_file session_files = [f for f in file_paths if is_session_file(f)] volume_files = [ @@ -296,7 +293,7 @@ def sizeHint(self): def show(self): """Override show to restore saved geometry or use default size.""" if sys.platform == "darwin": - from .stylesheets import _get_nswindow, _apply_macos_titlebar + from .stylesheets import _apply_macos_titlebar, _get_nswindow ns_win = _get_nswindow(self) if ns_win: @@ -1225,8 +1222,8 @@ def _handle_session_bounds(checked): def open_batch_pipeline(self): """Open the PipelineBuilderDialog dialog.""" from .parallel import submit_task_batch - from .pipeline.executor import execute_run from .pipeline.builder import PipelineBuilderDialog + from .pipeline.executor import execute_run dialog = PipelineBuilderDialog(self) if dialog.exec() != QDialog.DialogCode.Accepted: @@ -1300,9 +1297,10 @@ def toggle_selection_menu(self): self.status_indicator.update_status(target="Models") def _animate(self): - from .widgets.dock import create_or_toggle_dock from mosaic.animation.compose import AnimationComposerDialog + from .widgets.dock import create_or_toggle_dock + dialog = AnimationComposerDialog( self.vtk_widget, self.volume_viewer, self.cdata ) @@ -1482,7 +1480,7 @@ def close_session(self, render: bool = True): if render: self.set_camera_view("z") - def _open_files(self, filenames: List[str]): + def _open_files(self, filenames: list[str]): from .formats.session import is_session_file if isinstance(filenames, str): @@ -1586,13 +1584,14 @@ def open_files(self): def _capture_thumbnail(self): """Capture a PNG thumbnail cropped to visible data.""" + from qtpy.QtCore import QBuffer, QIODevice + from qtpy.QtGui import QImage + from .animation._utils import ( - compute_crop_context, capture_cropped, + compute_crop_context, restore_window_size, ) - from qtpy.QtGui import QImage - from qtpy.QtCore import QBuffer, QIODevice try: rw = self.vtk_widget.GetRenderWindow() @@ -1687,8 +1686,8 @@ def _open_recent_file(self): return self._open_files([file_path]) def _check_for_updates(self): - from .dialogs import UpdateChecker from .__version__ import __version__ + from .dialogs import UpdateChecker # We assign the thread to keep it alive self.update_checker = UpdateChecker(__version__, parent=self) @@ -1700,8 +1699,8 @@ def _on_update_available(self, latest_version: str, has_changelog: bool): self._show_update_dialog(latest_version, has_changelog) def _show_update_dialog(self, latest_version: str, has_changelog: bool = False): - from .dialogs import UpdateDialog from .__version__ import __version__ + from .dialogs import UpdateDialog UpdateDialog(__version__, latest_version, has_changelog, parent=self).exec() @@ -1727,6 +1726,7 @@ def _read_files_worker(cdata, filenames, file_parameters): ``(filename, exception_or_None)`` per input file, in input order. """ from pathlib import Path + from .parallel import report_progress results = [] diff --git a/src/mosaic/icons.py b/src/mosaic/icons.py index 17b4d78c..bda249eb 100644 --- a/src/mosaic/icons.py +++ b/src/mosaic/icons.py @@ -9,13 +9,12 @@ """ import warnings -from typing import Optional, Tuple import qtawesome as qta from .stylesheets import Colors -__all__ = ["icon", "icon_pixmap", "icon_button"] +__all__ = ["icon", "icon_button", "icon_pixmap"] def _role_color(role: str) -> str: @@ -37,9 +36,9 @@ def _role_color(role: str) -> str: def _resolve_colors( role: str, - color: Optional[str], - color_disabled: Optional[str], -) -> Tuple[str, str]: + color: str | None, + color_disabled: str | None, +) -> tuple[str, str]: """Resolve (enabled_color, disabled_color) for an icon.""" enabled = color if color is not None else _role_color(role) disabled = color_disabled if color_disabled is not None else Colors.ICON @@ -50,8 +49,8 @@ def icon( name: str, *, role: str = "muted", - color: Optional[str] = None, - color_disabled: Optional[str] = None, + color: str | None = None, + color_disabled: str | None = None, **qta_kwargs, ): """Build a QIcon. @@ -86,7 +85,7 @@ def icon_pixmap( size: int, *, role: str = "muted", - color: Optional[str] = None, + color: str | None = None, **qta_kwargs, ): """Build a QPixmap of the given size. Uses the same fallback as ``icon``.""" diff --git a/src/mosaic/interactor.py b/src/mosaic/interactor.py index 5f8ebe93..3734541b 100644 --- a/src/mosaic/interactor.py +++ b/src/mosaic/interactor.py @@ -8,16 +8,14 @@ """ import numpy as np -from typing import Dict - +from qtpy.QtCore import QObject, QSignalBlocker, Qt, Signal from qtpy.QtGui import QAction -from qtpy.QtWidgets import QListWidget, QMenu, QDialog -from qtpy.QtCore import Qt, QObject, QSignalBlocker, Signal +from qtpy.QtWidgets import QDialog, QListWidget, QMenu -from .widgets import MosaicMessageBox from .formats.writer import write_geometries +from .swaps import GeometrySubset, GeometrySwap, apply_changes, record +from .widgets import MosaicMessageBox from .widgets.container_list import StyledTreeWidgetItem -from .swaps import GeometrySwap, GeometrySubset, apply_changes, record __all__ = ["DataContainerInteractor"] @@ -171,7 +169,7 @@ def add(self, *args, **kwargs): return ret def add_selection( - self, selected_point_ids: Dict[str, np.ndarray], add: bool = True + self, selected_point_ids: dict[str, np.ndarray], add: bool = True ) -> int: """Add new cloud from selected points. @@ -422,9 +420,10 @@ def _extract_trajectory_frame(self, trajectory): self.render() def _handle_export(self, *args, **kwargs): - from .dialogs import ExportDialog from qtpy.QtWidgets import QApplication + from .dialogs import ExportDialog + geometries = self.get_selected_geometries() enabled_categories = ["pointcloud", "volume"] @@ -444,6 +443,7 @@ def _handle_export(self, *args, **kwargs): def _wrap_export(self, export_data): from os.path import splitext + from .geometry import Geometry, GeometryTrajectory file_path = export_data.pop("file_path", None) @@ -559,6 +559,7 @@ def on_parameters_changed(parameters): def _show_batch_rename_dialog(self) -> int: from qtpy.QtWidgets import QApplication + from .dialogs import BatchRenameDialog items = self.data_list.selected_items() @@ -630,13 +631,12 @@ def highlight_clusters_from_selected_points(self): return self.set_selection_by_uuid(list(self.point_selection.keys())) def change_representation(self, representation: str): - from .geometry import Geometry, VolumeGeometry, SegmentationGeometry + from .geometry import Geometry, SegmentationGeometry, VolumeGeometry if not len(geometries := self.get_selected_geometries()): return -1 for geometry in geometries: - if representation == "segmentation": if isinstance(geometry, SegmentationGeometry): continue diff --git a/src/mosaic/meshing/coarse_graining.py b/src/mosaic/meshing/coarse_graining.py index 08017ae6..e39549fe 100644 --- a/src/mosaic/meshing/coarse_graining.py +++ b/src/mosaic/meshing/coarse_graining.py @@ -1,14 +1,12 @@ import textwrap import warnings -from typing import Dict, List -from os.path import join, basename +from os.path import basename, join import numpy as np -from ..utils import NORMAL_REFERENCE, find_closest_points, normals_to_rot - -from . import remesh, center_mesh, compute_scale_factor_lower, scale from ..formats.writer import write_topology_file +from ..utils import NORMAL_REFERENCE, find_closest_points, normals_to_rot +from . import center_mesh, compute_scale_factor_lower, remesh, scale __all__ = ["mesh_to_cg"] @@ -16,7 +14,7 @@ def mesh_to_cg( geometry_data, output_directory: str, - inclusions: List[Dict], + inclusions: list[dict], edge_length: float = 40.0, include_normals: bool = True, flip_normals: bool = True, @@ -177,8 +175,8 @@ def mesh_to_cg( f""" martinize2 \\ -f ${inclusion_var} \\ - -x {inclusion['name']}.pdb \\ - -o {inclusion['name']}.top \\ + -x {inclusion["name"]}.pdb \\ + -o {inclusion["name"]}.top \\ -p backbone \\ -elastic \\ -maxwarn 2 \\ diff --git a/src/mosaic/meshing/hmff.py b/src/mosaic/meshing/hmff.py index 4e7a8e26..b590cdb6 100644 --- a/src/mosaic/meshing/hmff.py +++ b/src/mosaic/meshing/hmff.py @@ -1,27 +1,20 @@ -import shutil -import warnings -import textwrap from os import makedirs -from typing import Dict -from os.path import join -import numpy as np - -from ..parallel import report_progress from ..formats.writer import write_topology_file from ..meshing.utils import ( + center_mesh, + compute_edge_lengths, + compute_scale_factor_lower, equilibrate_edges, remesh, - compute_edge_lengths, scale, - compute_scale_factor_lower, - center_mesh, ) +from ..parallel import report_progress __all__ = ["equilibrate_fit"] -def equilibrate_fit(geometry, directory: str, parameters: Dict): +def equilibrate_fit(geometry, directory: str, parameters: dict): makedirs(directory, exist_ok=True) mesh_base = geometry.model.mesh diff --git a/src/mosaic/meshing/repair.py b/src/mosaic/meshing/repair.py index fb5dd686..ab0815a8 100644 --- a/src/mosaic/meshing/repair.py +++ b/src/mosaic/meshing/repair.py @@ -11,8 +11,6 @@ Author: Valentin Maurer """ -from typing import Tuple - import igl import numpy as np import scipy.sparse @@ -20,10 +18,10 @@ _epsilon = 1e-16 __all__ = [ + "bridge_boundaries", + "close_holes", "fair_mesh", "get_ring_vertices", - "close_holes", - "bridge_boundaries", "triangulation_refine_leipa", ] @@ -192,6 +190,7 @@ def bridge_boundaries(vs: np.ndarray, fs: np.ndarray, alpha: float) -> np.ndarra Face indices with bridging faces appended. """ from scipy.spatial import cKDTree + from ..parametrization import AlphaShape fs = np.asarray(fs, dtype=np.int64) @@ -541,7 +540,7 @@ def triangulation_refine_leipa( def _robust_laplacian( vs, fs, mollify_factor=1e-5 -) -> Tuple[scipy.sparse.csc_matrix, scipy.sparse.csc_matrix]: +) -> tuple[scipy.sparse.csc_matrix, scipy.sparse.csc_matrix]: """ Get a laplacian with intrinsic Delaunay triangulation and intrinsic mollification. diff --git a/src/mosaic/meshing/texture.py b/src/mosaic/meshing/texture.py index d50df4d4..03dfb258 100644 --- a/src/mosaic/meshing/texture.py +++ b/src/mosaic/meshing/texture.py @@ -7,10 +7,9 @@ """ from functools import lru_cache -from typing import Tuple, Optional -import vtk import numpy as np +import vtk from numpy.typing import NDArray from vtk.util import numpy_support @@ -21,7 +20,7 @@ def compute_uv_coords( vertices: NDArray, triangles: NDArray -) -> Tuple[NDArray, NDArray, NDArray]: +) -> tuple[NDArray, NDArray, NDArray]: """ Compute UV coordinates using xatlas. @@ -54,7 +53,7 @@ def _compute_sampling_map( tri_verts: NDArray, tri_normals: NDArray, scaled_uvs: NDArray, -) -> Tuple[NDArray, NDArray]: +) -> tuple[NDArray, NDArray]: """Precompute per-pixel 3D positions and normals.""" from skimage.draw import polygon @@ -97,7 +96,7 @@ def _compute_sampling_map( return positions, normals, np.where(valid_mask) -def _compute_barycentric(points: NDArray, triangle: NDArray) -> Optional[NDArray]: +def _compute_barycentric(points: NDArray, triangle: NDArray) -> NDArray | None: """Compute barycentric coordinates for points relative to triangle.""" v0, v1, v2 = triangle[0], triangle[1], triangle[2] d00 = np.dot(v1 - v0, v1 - v0) @@ -301,8 +300,8 @@ def _create_vtk_texture(self) -> None: def update( self, normal_offset: float = 0.0, - colormap: Optional[str] = "gray", - scalar_range: Optional[Tuple[float, float]] = None, + colormap: str | None = "gray", + scalar_range: tuple[float, float] | None = None, gamma: float = 1.0, quantiles: bool = False, ) -> NDArray: diff --git a/src/mosaic/meshing/utils.py b/src/mosaic/meshing/utils.py index aa38fff6..76c66592 100644 --- a/src/mosaic/meshing/utils.py +++ b/src/mosaic/meshing/utils.py @@ -7,28 +7,26 @@ """ import sys -import h5py -import warnings import textwrap - +import warnings from subprocess import run -from typing import List, Dict from tempfile import NamedTemporaryFile +import h5py import numpy as np __all__ = [ - "to_open3d", + "center_mesh", "compute_edge_lengths", - "scale", - "remesh", - "merge_meshes", - "equilibrate_edges", "compute_scale_factor", "compute_scale_factor_lower", - "center_mesh", - "to_tsi", + "equilibrate_edges", "fill_mesh", + "merge_meshes", + "remesh", + "scale", + "to_open3d", + "to_tsi", ] @@ -109,9 +107,9 @@ def remesh(mesh, target_edge_length=None, n_iter=100, feature=None): def merge_meshes( - vertices: List[np.ndarray], - faces: List[np.ndarray], - normals: List[np.ndarray] = None, + vertices: list[np.ndarray], + faces: list[np.ndarray], + normals: list[np.ndarray] = None, ): if len(vertices) != len(faces): raise ValueError("Length of vertex and face list needs to match.") @@ -186,9 +184,9 @@ def equilibrate_edges(mesh, lower_bound, upper_bound, steps=2000, **kwargs): [BONDS] bond_type = Edge - r = {default_args['bond_r']} - lc0 = {default_args['lc0']} - lc1 = {default_args['lc1']} + r = {default_args["bond_r"]} + lc0 = {default_args["lc0"]} + lc1 = {default_args["lc1"]} [SURFACEREPULSION] n_search = cell-list @@ -198,17 +196,17 @@ def equilibrate_edges(mesh, lower_bound, upper_bound, steps=2000, **kwargs): r = 2 [ENERGY] - kappa_a = {default_args['kappa_a']} - kappa_b = {default_args['kappa_b']} - kappa_c = {default_args['kappa_c']} - kappa_v = {default_args['kappa_v']} - kappa_t = {default_args['kappa_t']} - kappa_r = {default_args['kappa_r']} - area_fraction = {default_args['area_fraction']} - volume_fraction = {default_args['volume_fraction']} - curvature_fraction = {default_args['curvature_fraction']} - continuation_delta = {default_args['continuation_delta']} - continuation_lambda = {default_args['continuation_lambda']} + kappa_a = {default_args["kappa_a"]} + kappa_b = {default_args["kappa_b"]} + kappa_c = {default_args["kappa_c"]} + kappa_v = {default_args["kappa_v"]} + kappa_t = {default_args["kappa_t"]} + kappa_r = {default_args["kappa_r"]} + area_fraction = {default_args["area_fraction"]} + volume_fraction = {default_args["volume_fraction"]} + curvature_fraction = {default_args["curvature_fraction"]} + continuation_delta = {default_args["continuation_delta"]} + continuation_lambda = {default_args["continuation_lambda"]} [MINIMIZATION] maxiter = {steps} @@ -296,7 +294,7 @@ def center_mesh(mesh, center: bool = True, margin=20): return data, offset -def to_tsi(vertices, faces, margin: int = 0) -> Dict: +def to_tsi(vertices, faces, margin: int = 0) -> dict: vertices = np.asarray(vertices) faces = np.asarray(faces) diff --git a/src/mosaic/meshing/volume.py b/src/mosaic/meshing/volume.py index ef4a559f..49686905 100644 --- a/src/mosaic/meshing/volume.py +++ b/src/mosaic/meshing/volume.py @@ -10,9 +10,8 @@ Author: Valentin Maurer """ -from typing import Tuple, List from os import listdir, makedirs -from os.path import join, basename +from os.path import basename, join from tempfile import TemporaryDirectory import numpy as np @@ -20,11 +19,11 @@ from .utils import merge_meshes, to_open3d __all__ = [ - "mesh_volume", - "simplify_mesh", "MeshCreator", "MeshMerger", "MeshSimplifier", + "mesh_volume", + "simplify_mesh", ] @@ -49,8 +48,8 @@ def __init__( self, data_path: str, output_dir: str, - shape: Tuple[int, int, int], - offset: Tuple[int, int, int], + shape: tuple[int, int, int], + offset: tuple[int, int, int], **kwargs, ): """ @@ -86,6 +85,7 @@ def __init__( def execute(self): from zmesh import Mesher + from ..formats.parser import load_density self._volume = load_density(self.data_path, use_memmap=True) @@ -253,7 +253,7 @@ def _execute(task): return task.execute() -def _split_volume(shape, box) -> List: +def _split_volume(shape, box) -> list: sx, sy, sz = np.ceil(np.divide(shape, box)).astype(int) sxy = sx * sy num_tasks = sxy * sz @@ -270,7 +270,7 @@ def _split_volume(shape, box) -> List: def mesh_volume( volume_path: str, output_dir: str = None, - shape: Tuple[int, int, int] = (448, 448, 448), + shape: tuple[int, int, int] = (448, 448, 448), num_workers: int = 8, closed_dataset_edges: bool = True, max_simplification_error: float = 40, diff --git a/src/mosaic/onboarding/__init__.py b/src/mosaic/onboarding/__init__.py index 2eceb1b1..02003458 100644 --- a/src/mosaic/onboarding/__init__.py +++ b/src/mosaic/onboarding/__init__.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from .chapters import get_chapter, all_chapters +from .chapters import all_chapters, get_chapter def launch_onboarding(main_window, chapter_id: str): diff --git a/src/mosaic/onboarding/base.py b/src/mosaic/onboarding/base.py index c91a217a..5ec3f338 100644 --- a/src/mosaic/onboarding/base.py +++ b/src/mosaic/onboarding/base.py @@ -6,10 +6,11 @@ Author: Valentin Maurer """ -from typing import Callable, Literal +from collections.abc import Callable from dataclasses import dataclass, field +from typing import Literal -__all__ = ["OnboardingStep", "OnboardingChapter"] +__all__ = ["OnboardingChapter", "OnboardingStep"] @dataclass diff --git a/src/mosaic/onboarding/chapters/__init__.py b/src/mosaic/onboarding/chapters/__init__.py index 776b8fb1..268e9375 100644 --- a/src/mosaic/onboarding/chapters/__init__.py +++ b/src/mosaic/onboarding/chapters/__init__.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from ..base import OnboardingStep, OnboardingChapter +from ..base import OnboardingChapter, OnboardingStep CHAPTERS: dict[str, type[OnboardingChapter]] = {} diff --git a/src/mosaic/onboarding/chapters/basics.py b/src/mosaic/onboarding/chapters/basics.py index 798fd84c..59aa20d6 100644 --- a/src/mosaic/onboarding/chapters/basics.py +++ b/src/mosaic/onboarding/chapters/basics.py @@ -21,8 +21,8 @@ QVBoxLayout, ) -from . import register from ..base import OnboardingChapter, OnboardingStep +from . import register __all__ = ["BasicsChapter"] @@ -53,8 +53,8 @@ def __init__(self): self._data_dir: Path | None = None def _download(self, parent) -> bool: - from mosaic.widgets import MosaicMessageBox from mosaic.stylesheets import Colors, Typography + from mosaic.widgets import MosaicMessageBox assert self._data_dir is not None n = len(_DOWNLOADS) @@ -82,7 +82,7 @@ def _download(self, parent) -> bool: status = QLabel("Starting") status.setStyleSheet( - f"color: {Colors.TEXT_MUTED};" f"font-size: {Typography.SMALL}px;" + f"color: {Colors.TEXT_MUTED};font-size: {Typography.SMALL}px;" ) bar = QProgressBar() @@ -96,7 +96,7 @@ def _download(self, parent) -> bool: Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) pct_label.setStyleSheet( - f"color: {Colors.TEXT_MUTED};" f"font-size: {Typography.CAPTION}px;" + f"color: {Colors.TEXT_MUTED};font-size: {Typography.CAPTION}px;" ) cancel_btn = QPushButton("Cancel") @@ -668,6 +668,7 @@ def teardown(self, main_window) -> None: return None import shutil + from mosaic.widgets import MosaicMessageBox box = MosaicMessageBox(main_window) diff --git a/src/mosaic/onboarding/controller.py b/src/mosaic/onboarding/controller.py index d7db1f8f..470df571 100644 --- a/src/mosaic/onboarding/controller.py +++ b/src/mosaic/onboarding/controller.py @@ -10,8 +10,8 @@ from qtpy.QtCore import QObject, Signal -from .overlay import SpotlightOverlay from .base import OnboardingChapter, OnboardingStep +from .overlay import SpotlightOverlay __all__ = ["OnboardingController"] diff --git a/src/mosaic/onboarding/overlay.py b/src/mosaic/onboarding/overlay.py index 46cebada..7a86a0db 100644 --- a/src/mosaic/onboarding/overlay.py +++ b/src/mosaic/onboarding/overlay.py @@ -7,33 +7,33 @@ """ from qtpy.QtCore import ( - Qt, - Signal, - QRect, - QRectF, + QCoreApplication, + QEvent, QPoint, QPointF, - QEvent, + QRect, + QRectF, + Qt, QTimer, - QCoreApplication, + Signal, ) from qtpy.QtGui import ( - QPainter, + QBrush, QColor, + QMouseEvent, + QPainter, QPainterPath, - QBrush, QPen, - QMouseEvent, QRegion, ) from qtpy.QtWidgets import ( QApplication, - QWidget, - QVBoxLayout, + QFrame, QHBoxLayout, QLabel, QPushButton, - QFrame, + QVBoxLayout, + QWidget, ) __all__ = ["SpotlightOverlay"] diff --git a/src/mosaic/operations.py b/src/mosaic/operations.py index 2b6c0d67..07d1cc59 100644 --- a/src/mosaic/operations.py +++ b/src/mosaic/operations.py @@ -6,35 +6,34 @@ Author: Valentin Maurer """ -from typing import List, Optional +from typing import Optional import numpy as np +from . import meshing from .registry import ( - Param, - Method, - MethodRegistry, - operation, + _BIDIRECTIONAL, _DECORATED_OPERATIONS, - _K_NEIGHBORS, _FAIRING_PARAMS, _HOLE_SIZE, + _K_NEIGHBORS, _NORMAL_OFFSET, - _BIDIRECTIONAL, + Method, + MethodRegistry, + Param, + operation, ) - -from . import meshing from .utils import ( - statistical_outlier_removal, - eigenvalue_outlier_removal, + birch_clustering, com_cluster_points, - find_closest_points, connected_components, - envelope_components, - leiden_clustering, dbscan_clustering, - birch_clustering, + eigenvalue_outlier_removal, + envelope_components, + find_closest_points, kmeans_clustering, + leiden_clustering, + statistical_outlier_removal, ) __all__ = ["GeometryOperations"] @@ -138,8 +137,8 @@ def skeletonize(geometry, method: str = "core", sigma: float = 1.0, **kwargs): """ from .geometry import GeometryData from .parametrization import AlphaShape - from .utils import skeletonize as _skeletonize from .utils import points_to_volume, volume_to_points + from .utils import skeletonize as _skeletonize method = MethodRegistry.resolve_method("skeletonize", method) methods = ("core", "outer", "boundary", "outer_hull") @@ -523,7 +522,7 @@ def cluster( use_points: bool = True, use_normals: bool = False, **kwargs, -) -> List: +) -> list: """ Partition points into clusters. diff --git a/src/mosaic/parallel.py b/src/mosaic/parallel.py index aca83fb3..c8953c5f 100644 --- a/src/mosaic/parallel.py +++ b/src/mosaic/parallel.py @@ -6,36 +6,37 @@ Author: Valentin Maurer """ +import concurrent import io +import multiprocessing +import queue import sys +import threading import uuid -import queue import warnings -import threading -import concurrent -import multiprocessing -from enum import Enum -from typing import Callable, Any, Dict, Optional +from collections.abc import Callable from dataclasses import dataclass +from enum import Enum +from typing import Any -from .settings import Settings -from qtpy.QtCore import QObject, Signal, QTimer +from qtpy.QtCore import QObject, QTimer, Signal +from .settings import Settings __all__ = [ "_init_worker", "_wrap_task", "report_progress", + "submit_io_task", "submit_task", "submit_task_batch", - "submit_io_task", ] # Worker-side globals (set by initializer, used by worker functions) -_worker_queue: Optional[multiprocessing.Queue] = None -_worker_task_id: Optional[str] = None -_original_stdout: Optional[io.TextIOBase] = None -_original_stderr: Optional[io.TextIOBase] = None +_worker_queue: "multiprocessing.Queue | None" = None +_worker_task_id: str | None = None +_original_stdout: io.TextIOBase | None = None +_original_stderr: io.TextIOBase | None = None # I/O worker-side thread-local context (set by _wrap_io_task). _IO_WORKER_CONTEXT = threading.local() @@ -57,8 +58,8 @@ class WorkerMessage: task_id: str type: MessageType value: Any - current: Optional[int] = None - total: Optional[int] = None + current: int | None = None + total: int | None = None class QueueStream(io.TextIOBase): @@ -171,7 +172,7 @@ def _init_worker_with_queue(progress_queue: multiprocessing.Queue): sys.stderr = QueueStream(progress_queue, MessageType.STDERR, _original_stderr) -def _format_captured_warnings(warning_list) -> Optional[str]: +def _format_captured_warnings(warning_list) -> str | None: """ Format a list of captured warnings into the wire-format string. @@ -189,7 +190,7 @@ def _format_captured_warnings(warning_list) -> Optional[str]: return warning_msg.rstrip() if warning_msg else None -def _run_with_warning_capture(func, *args, **kwargs) -> Dict[str, Any]: +def _run_with_warning_capture(func, *args, **kwargs) -> dict[str, Any]: """ Execute ``func`` with warnings captured, returning the completion dict. @@ -263,6 +264,7 @@ def _default_handler(task_id, task_name, msg, is_warning=False): def _flush_messages(): from qtpy.QtWidgets import QMessageBox + from .widgets import MosaicMessageBox if not _pending_messages: @@ -337,17 +339,17 @@ def __init__(self): # Task tracking self.task_queue: list = [] - self.task_info: Dict[str, Dict[str, Any]] = {} - self.futures: Dict[str, concurrent.futures.Future] = {} - self.io_futures: Dict[str, concurrent.futures.Future] = {} + self.task_info: dict[str, dict[str, Any]] = {} + self.futures: dict[str, concurrent.futures.Future] = {} + self.io_futures: dict[str, concurrent.futures.Future] = {} # Batch limits - self.batch_limits: Dict[str, int] = {} - self.batch_running: Dict[str, set] = {} + self.batch_limits: dict[str, int] = {} + self.batch_running: dict[str, set] = {} # Output accumulation - self._task_stdout: Dict[str, list] = {} - self._task_stderr: Dict[str, list] = {} + self._task_stdout: dict[str, list] = {} + self._task_stderr: dict[str, list] = {} self._initialize() @@ -765,7 +767,7 @@ def _check_completed_tasks(self): if executor_broken: self._initialize() - def get_task_output(self, task_id: str) -> Dict[str, str]: + def get_task_output(self, task_id: str) -> dict[str, str]: """ Get accumulated stdout/stderr for a task. diff --git a/src/mosaic/parametrization.py b/src/mosaic/parametrization.py index b903da44..f00b02eb 100644 --- a/src/mosaic/parametrization.py +++ b/src/mosaic/parametrization.py @@ -12,25 +12,24 @@ """ import warnings -from typing import Tuple from abc import ABC, abstractmethod import igl import numpy as np -from scipy import optimize, interpolate +from scipy import interpolate, optimize from . import meshing, utils __all__ = [ - "Sphere", - "Ellipsoid", - "Cylinder", "RBF", + "AlphaShape", "BallPivoting", + "Cylinder", + "Ellipsoid", + "FlyingEdges", "PoissonMesh", - "AlphaShape", "ShrinkWrap", - "FlyingEdges", + "Sphere", "SplineCurve", ] @@ -409,7 +408,7 @@ def __init__( @staticmethod def _compute_initial_guess( positions: np.ndarray, - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Compute initial guess for cylinder parameters using PCA. @@ -663,7 +662,7 @@ class RBF(Parametrization): 2D interpolation grid ranges. """ - def __init__(self, rbf: type, direction: str, grid: Tuple): + def __init__(self, rbf: type, direction: str, grid: tuple): self.rbf = rbf self.grid = grid self.direction = direction @@ -879,8 +878,8 @@ def to_file(self, file_path): ext = splitext(file_path)[1].lower() if ext in (".tsi", ".q"): - from .meshing.utils import to_tsi from .formats.writer import write_topology_file + from .meshing.utils import to_tsi data = to_tsi(self.vertices, self.triangles, margin=20) return write_topology_file(file_path, data, tsi_format=(ext == ".tsi")) @@ -1139,7 +1138,7 @@ def add_projections( projections: np.ndarray, triangle_indices: np.ndarray, return_indices: bool = False, - ) -> Tuple["TriangularMesh", np.ndarray]: + ) -> tuple["TriangularMesh", np.ndarray]: """ Add projected points to the mesh by splitting triangles. @@ -1259,7 +1258,7 @@ def geodesic_distance( source_vertices: np.ndarray = None, k: int = 1, return_indices: bool = False, - ) -> Tuple[np.ndarray, np.ndarray]: + ) -> tuple[np.ndarray, np.ndarray]: """ Compute geodesic distance from target vertices to their k-nearest source vertices on the mesh. @@ -1321,7 +1320,7 @@ class BallPivoting(TriangularMesh): def fit( cls, positions: np.ndarray, - radii: Tuple[float] = (5.0,), + radii: tuple[float] = (5.0,), max_hole_size: float = None, target_edge_length: float = None, smoothness: float = 0.0, @@ -2020,7 +2019,7 @@ def _grid_faces(rows: int, cols: int) -> np.ndarray: return np.vstack([tri_a, tri_b]).astype(np.int32) -def merge(models: Tuple[Parametrization]) -> Parametrization: +def merge(models: tuple[Parametrization]) -> Parametrization: # Right now this only really makes sense for meshes if not len(models): return None diff --git a/src/mosaic/pipeline/builder.py b/src/mosaic/pipeline/builder.py index b5ab9a00..4186abf1 100644 --- a/src/mosaic/pipeline/builder.py +++ b/src/mosaic/pipeline/builder.py @@ -9,31 +9,29 @@ import json import uuid +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont from qtpy.QtWidgets import ( + QCheckBox, QDialog, - QVBoxLayout, + QFileDialog, + QGroupBox, QHBoxLayout, QLabel, QPushButton, - QWidget, - QGroupBox, QScrollArea, QSpinBox, - QFileDialog, - QCheckBox, + QVBoxLayout, + QWidget, ) -from qtpy.QtCore import Qt -from qtpy.QtGui import QFont - -from .executor import generate_runs -from .widgets import OperationCardWidget, PipelineTreeWidget -from .operations import OPERATION_CATEGORIES, PIPELINE_PRESETS from ..settings import Settings +from ..stylesheets import Colors, Typography from ..widgets import MosaicMessageBox from ..widgets.settings import format_tooltip -from ..stylesheets import Colors, Typography - +from .executor import generate_runs +from .operations import OPERATION_CATEGORIES, PIPELINE_PRESETS +from .widgets import OperationCardWidget, PipelineTreeWidget __all__ = ["PipelineBuilderDialog"] @@ -238,9 +236,9 @@ def _create_library(self): op_name, op_info, category["color"] ) op_btn.clicked.connect( - lambda checked, n=op_name, i=op_info, c=category[ - "color" - ]: self._add_card(n, i, c) + lambda checked, n=op_name, i=op_info, c=category["color"]: ( + self._add_card(n, i, c) + ) ) layout.addWidget(op_btn) self._library_buttons[(category_id, op_name)] = op_btn @@ -414,9 +412,9 @@ def _load_config(self): return None try: - with open(filename, "r") as f: + with open(filename) as f: config = json.load(f) - except (json.JSONDecodeError, IOError) as e: + except (OSError, json.JSONDecodeError) as e: MosaicMessageBox.warning( self, "Load Error", f"Failed to load configuration: {str(e)}" ) @@ -481,7 +479,7 @@ def _export_config(self): MosaicMessageBox.information( self, "Export Success", f"Configuration exported to {filename}" ) - except IOError as e: + except OSError as e: MosaicMessageBox.warning( self, "Export Error", f"Failed to export configuration: {str(e)}" ) diff --git a/src/mosaic/pipeline/executor.py b/src/mosaic/pipeline/executor.py index 7964c06b..19346d58 100644 --- a/src/mosaic/pipeline/executor.py +++ b/src/mosaic/pipeline/executor.py @@ -8,13 +8,11 @@ import os import shlex -from typing import List, Tuple -from ..commands.parser import format_value, format_kwargs -from ..registry import MethodRegistry +from ..commands.parser import format_kwargs, format_value from ..parallel import report_progress -from ._utils import topological_sort, strip_filepath - +from ..registry import MethodRegistry +from ._utils import strip_filepath, topological_sort __all__ = ["compile_run", "execute_run", "generate_runs"] @@ -137,7 +135,7 @@ def generate_runs(pipeline_config): return runs -def compile_run(run_config: dict) -> List[Tuple[str, str]]: +def compile_run(run_config: dict) -> list[tuple[str, str]]: """Translate a pipeline run config into ``(op_id, script_line)`` pairs. Parameters @@ -151,7 +149,7 @@ def compile_run(run_config: dict) -> List[Tuple[str, str]]: Each element is ``(operation_id, script_line)`` where *script_line* is a valid Mosaic REPL command. """ - steps: List[Tuple[str, str]] = [] + steps: list[tuple[str, str]] = [] for op in run_config["operations"]: op_id = op["operation_id"] @@ -327,8 +325,7 @@ def execute_run( if all_exist and found_export: print( - f"Skipping run {run_config['run_id']}: " - "all output files already exist" + f"Skipping run {run_config['run_id']}: all output files already exist" ) return None diff --git a/src/mosaic/pipeline/operations.py b/src/mosaic/pipeline/operations.py index 4ad99472..d3f4ddf0 100644 --- a/src/mosaic/pipeline/operations.py +++ b/src/mosaic/pipeline/operations.py @@ -6,9 +6,9 @@ Author: Valentin Maurer """ +import mosaic.operations # noqa: F401 # registers geometry operations from mosaic.registry import MethodRegistry from mosaic.stylesheets import Colors -import mosaic.operations # noqa: F401 # registers geometry operations from mosaic.tabs.model import SAMPLE_SETTINGS CLUSTER_SETTINGS = MethodRegistry.settings_dict("cluster") diff --git a/src/mosaic/pipeline/widgets.py b/src/mosaic/pipeline/widgets.py index ae8e2bda..d0fff680 100644 --- a/src/mosaic/pipeline/widgets.py +++ b/src/mosaic/pipeline/widgets.py @@ -7,38 +7,36 @@ """ import uuid -from qtpy.QtGui import QFont -from qtpy.QtCore import Qt, Signal, QTimer +from qtpy.QtCore import Qt, QTimer, Signal +from qtpy.QtGui import QFont from qtpy.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QFileDialog, QFrame, - QVBoxLayout, - QHBoxLayout, QGridLayout, + QHBoxLayout, QLabel, - QPushButton, - QWidget, QLineEdit, - QFileDialog, + QPushButton, + QSizePolicy, QTreeWidget, QTreeWidgetItem, - QSizePolicy, - QCheckBox, - QAbstractItemView, + QVBoxLayout, + QWidget, ) from ..dialogs import ImportDataDialog +from ..stylesheets import Colors, Typography +from ..widgets.container_list import ContainerListWidget, StyledTreeWidgetItem from ..widgets.settings import ( create_setting_widget, + format_tooltip, get_widget_value, set_widget_value, - format_tooltip, ) -from ..widgets.container_list import ContainerListWidget, StyledTreeWidgetItem - - -from ._utils import strip_filepath, natural_sort_key -from ..stylesheets import Colors, Typography +from ._utils import natural_sort_key, strip_filepath _COLS = 3 # logical parameter columns (actual grid columns = _COLS * 2) diff --git a/src/mosaic/properties.py b/src/mosaic/properties.py index 997dd8a7..4a73807f 100644 --- a/src/mosaic/properties.py +++ b/src/mosaic/properties.py @@ -1,8 +1,9 @@ import warnings +from collections.abc import Callable from functools import lru_cache, wraps -from typing import Callable, List, Union import numpy as np + from .geometry import Geometry __all__ = ["GeometryProperties", "export_property_csv"] @@ -150,7 +151,7 @@ def mesh_statistics(fit, stat_type: str = "Vertex Count", **kwargs): def distance( geometry: Geometry, - queries: List[Union[np.ndarray, Geometry]] = [], + queries: list[np.ndarray | Geometry] = [], k: int = 1, k_start: int = 1, aggregation: str = "mean", @@ -331,7 +332,7 @@ def _projection_normals(geometry: Geometry, projection: str): def projected_angle( geometry: Geometry, - queries: List[Geometry], + queries: list[Geometry], projection: str = "closest", **kwargs, ): @@ -384,7 +385,7 @@ def projected_angle( def projected_curvature( geometry: Geometry, - queries: List[Geometry], + queries: list[Geometry], curvature: str, radius: int, projection: str = "closest", @@ -435,7 +436,7 @@ def projected_curvature( def geodesic_distance( geometry: Geometry, - queries: List[Geometry], + queries: list[Geometry], k: int = 1, k_start=1, aggregation: str = "mean", @@ -532,7 +533,7 @@ def _load_tomogram_cached(file_path: str): def thickness( geometry: Geometry, - queries: List[Union[np.ndarray, Geometry]] = [], + queries: list[np.ndarray | Geometry] = [], smoothing_radius: float = 0.0, *args, **kwargs, @@ -602,8 +603,8 @@ def thickness( vertex_thickness /= vertex_counts if smoothing_radius > 0: - from scipy.spatial import cKDTree from scipy.sparse import csr_matrix + from scipy.spatial import cKDTree tree = cKDTree(model.vertices) dist_matrix = tree.sparse_distance_matrix(tree, max_distance=smoothing_radius) diff --git a/src/mosaic/registry.py b/src/mosaic/registry.py index fbb13bf2..248b2696 100644 --- a/src/mosaic/registry.py +++ b/src/mosaic/registry.py @@ -13,15 +13,15 @@ import re from dataclasses import dataclass, replace -from typing import Any, Dict, Optional, Tuple +from typing import Any __all__ = [ - "Param", + "_DECORATED_OPERATIONS", "Method", - "Operation", "MethodRegistry", + "Operation", + "Param", "operation", - "_DECORATED_OPERATIONS", ] @@ -146,7 +146,7 @@ class Method: display_name: str internal_name: str - params: Tuple[Param, ...] = () + params: tuple[Param, ...] = () description: str = "" gui: bool = True @@ -170,8 +170,8 @@ class Operation: """ name: str - common_params: Tuple[Param, ...] = () - methods: Tuple[Method, ...] = () + common_params: tuple[Param, ...] = () + methods: tuple[Method, ...] = () method_param_name: str = "method" description: str = "" title: str = "Settings" @@ -200,7 +200,7 @@ def build_usage(self, method_name: str = None) -> str: parts.append("[targets]") return " ".join(parts) - def get_method(self, name: str) -> Optional[Method]: + def get_method(self, name: str) -> Method | None: """Look up by *display_name* or *internal_name* (case-insensitive).""" low = name.lower() for m in self.methods: @@ -251,7 +251,7 @@ def to_settings_dict(self) -> dict: class MethodRegistry: """Global registry of operations with method sub-dispatch.""" - _operations: Dict[str, Operation] = {} + _operations: dict[str, Operation] = {} @classmethod def register(cls, op: Operation): @@ -259,7 +259,7 @@ def register(cls, op: Operation): cls._operations[op.name] = op @classmethod - def get(cls, name: str) -> Optional[Operation]: + def get(cls, name: str) -> Operation | None: """Return the :class:`Operation` registered under *name*.""" return cls._operations.get(name) @@ -283,7 +283,7 @@ def resolve_method(cls, op_name: str, display_name: str) -> str: return op.display_to_internal(display_name) @classmethod - def all_operations(cls) -> Dict[str, Operation]: + def all_operations(cls) -> dict[str, Operation]: """Return all registered operations.""" return dict(cls._operations) diff --git a/src/mosaic/scripts/app.py b/src/mosaic/scripts/app.py index 0eea7f73..3301d5ea 100755 --- a/src/mosaic/scripts/app.py +++ b/src/mosaic/scripts/app.py @@ -6,31 +6,33 @@ Author: Valentin Maurer """ -import sys -import signal + import argparse -from importlib_resources import files +import signal +import sys +from importlib_resources import files +from qtpy.QtCore import QLocale, Qt, QTimer +from qtpy.QtGui import QFont, QFontDatabase, QFontInfo, QIcon from qtpy.QtWidgets import QApplication -from qtpy.QtCore import Qt, QLocale, QTimer -from qtpy.QtGui import QIcon, QFont, QFontDatabase, QFontInfo from mosaic import __version__ from mosaic.stylesheets import ( Colors, Typography, + build_appstyle, build_global_stylesheet, build_qt_palette, install_macos_titlebar_filter, - build_appstyle, ) def _prompt_first_launch(window): from qtpy.QtWidgets import QMessageBox - from mosaic.widgets import MosaicMessageBox - from mosaic.settings import Settings + from mosaic.onboarding import launch_onboarding + from mosaic.settings import Settings + from mosaic.widgets import MosaicMessageBox box = MosaicMessageBox(window) box.setWindowTitle("Welcome to Mosaic") @@ -59,7 +61,7 @@ def main(): ) args = parser.parse_args() - from mosaic.onboarding import launch_onboarding, all_chapters + from mosaic.onboarding import all_chapters, launch_onboarding chapters = {ch.id: ch for ch in all_chapters()} diff --git a/src/mosaic/scripts/pipeline.py b/src/mosaic/scripts/pipeline.py index f35cc962..c04aa026 100755 --- a/src/mosaic/scripts/pipeline.py +++ b/src/mosaic/scripts/pipeline.py @@ -7,13 +7,13 @@ Author: Valentin Maurer """ -import sys -import json import argparse -from pathlib import Path +import json +import sys from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path -from mosaic.pipeline.executor import generate_runs, execute_run +from mosaic.pipeline.executor import execute_run, generate_runs def run_wrapper(run_config, skip_complete: bool = False): @@ -66,7 +66,7 @@ def main(): print(f"Error: {args.config} not found", file=sys.stderr) return 1 - with open(args.config, mode="r") as ifile: + with open(args.config) as ifile: pipeline_config = json.load(ifile) try: @@ -88,7 +88,7 @@ def main(): if args.index is not None: if args.index < 0 or args.index >= len(runs): print( - f"Error: index {args.index} out of range [0, {len(runs)-1}]", + f"Error: index {args.index} out of range [0, {len(runs) - 1}]", file=sys.stderr, ) return 1 @@ -120,8 +120,7 @@ def main(): except Exception as e: failed += 1 print( - f"[{completed}/{total}] {run['run_id']}: " - f"Unexpected error - {e}", + f"[{completed}/{total}] {run['run_id']}: Unexpected error - {e}", file=sys.stderr, ) diff --git a/src/mosaic/sculpt/__init__.py b/src/mosaic/sculpt/__init__.py index 57f2174a..9f65a5a7 100644 --- a/src/mosaic/sculpt/__init__.py +++ b/src/mosaic/sculpt/__init__.py @@ -7,5 +7,4 @@ from .controller import Brush, EventKind, SculptController - __all__ = ["Brush", "EventKind", "SculptController"] diff --git a/src/mosaic/sculpt/adapter.py b/src/mosaic/sculpt/adapter.py index d1a91f7a..91643a7f 100644 --- a/src/mosaic/sculpt/adapter.py +++ b/src/mosaic/sculpt/adapter.py @@ -5,13 +5,10 @@ Author: Valentin Maurer """ -from typing import Tuple - import numpy as np import vtk from vtkmodules.util import numpy_support - __all__ = [ "PolyDataPointWriter", "read_geometry_arrays", @@ -20,7 +17,7 @@ ] -def read_geometry_arrays(geometry) -> Tuple[np.ndarray, np.ndarray]: +def read_geometry_arrays(geometry) -> tuple[np.ndarray, np.ndarray]: """Return ``(vs, fs)`` as numpy arrays for a Mosaic ``Geometry``. Faces come from ``geometry.model.triangles``; sculpt only ever binds to diff --git a/src/mosaic/sculpt/controller.py b/src/mosaic/sculpt/controller.py index f52b6a81..f2d42e75 100644 --- a/src/mosaic/sculpt/controller.py +++ b/src/mosaic/sculpt/controller.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from enum import Enum, auto -from typing import Optional, Tuple +from typing import Optional import numpy as np import vtk @@ -25,10 +25,10 @@ ) from .overlay import SculptOverlay from .session import ( - SculptSession, - SculptTarget, TOOL_BY_HOTKEY, TOOL_BY_ID, + SculptSession, + SculptTarget, ) from .tools import ( ActiveSet, @@ -38,7 +38,6 @@ smooth_stamp, ) - __all__ = ["Brush", "EventKind", "MeshHit", "SculptController"] @@ -80,7 +79,7 @@ def pick_mesh( def _radius_defaults( vs: np.ndarray, fs: np.ndarray -) -> Tuple[float, Tuple[float, float]]: +) -> tuple[float, tuple[float, float]]: """Pick a default brush radius + slider bounds from mesh scale.""" mean_edge = float(_edge_lengths(vs, fs).mean()) if len(fs) else 0.0 diag = float(np.linalg.norm(vs.max(axis=0) - vs.min(axis=0))) if len(vs) else 0.0 @@ -104,20 +103,20 @@ def _radius_defaults( class SculptController: - def __init__(self, brush: Optional[Brush] = None) -> None: + def __init__(self, brush: Brush | None = None) -> None: self.brush = brush or Brush() # Track whether the user explicitly chose a radius. If they didn't, # ``attach_geometry`` picks a sensible default from the mesh's average # edge length so the brush is meaningfully sized on any mesh scale. self._radius_set_by_user = brush is not None - self.session: Optional[SculptSession] = None + self.session: SculptSession | None = None self._geometry = None - self._writer: Optional[PolyDataPointWriter] = None + self._writer: PolyDataPointWriter | None = None self._renderer = None self._mesh_actor = None - self._picker: Optional[vtk.vtkCellPicker] = None - self._overlay: Optional[SculptOverlay] = None + self._picker: vtk.vtkCellPicker | None = None + self._overlay: SculptOverlay | None = None self._render_callback = None # Resolves a geometry uuid to the object currently holding it, mirroring @@ -128,12 +127,12 @@ def __init__(self, brush: Optional[Brush] = None) -> None: # sculpted edit. None means "no container wired" (headless/tests), in # which case we fall back to the actively-bound geometry. self._resolve_geometry = None - self._last_cursor_world: Optional[np.ndarray] = None - self._tint_color: Tuple[float, float, float] = TOOL_BY_ID["view"].color + self._last_cursor_world: np.ndarray | None = None + self._tint_color: tuple[float, float, float] = TOOL_BY_ID["view"].color # Avoid round-tripping ``set_color()`` every move when already cleared. self._tint_active: bool = False - self.radius_bounds: Tuple[float, float] = (0.5, 500.0) + self.radius_bounds: tuple[float, float] = (0.5, 500.0) self.smooth_strength: float = _DEFAULT_SMOOTH_STRENGTH def attach_geometry(self, geometry) -> None: @@ -496,7 +495,7 @@ def refresh_cursor_radius(self) -> None: self._request_render() return None - def _pick(self, x: int, y: int) -> Optional[MeshHit]: + def _pick(self, x: int, y: int) -> MeshHit | None: if self._renderer is None or self._picker is None or self._mesh_actor is None: return None return pick_mesh(self._picker, self._renderer, x, y, self._mesh_actor) @@ -526,7 +525,7 @@ def _brush_active(self, hit: MeshHit) -> ActiveSet: def _screen_delta_to_world( self, cursor_x: int, cursor_y: int, anchor: np.ndarray - ) -> Optional[np.ndarray]: + ) -> np.ndarray | None: if self._renderer is None: return None self._renderer.SetWorldPoint( @@ -569,7 +568,7 @@ def _world_radius_to_pixels( def _apply_tool_color(self, tool: str) -> None: spec = TOOL_BY_ID.get(tool) - rgb: Tuple[float, float, float] = spec.color if spec else (1.0, 1.0, 1.0) + rgb: tuple[float, float, float] = spec.color if spec else (1.0, 1.0, 1.0) self._tint_color = rgb if self._overlay is not None: self._overlay.set_tool_color(rgb) diff --git a/src/mosaic/sculpt/mode.py b/src/mosaic/sculpt/mode.py index cd204f9b..0aa61d09 100644 --- a/src/mosaic/sculpt/mode.py +++ b/src/mosaic/sculpt/mode.py @@ -5,8 +5,6 @@ Author: Valentin Maurer """ -from typing import Optional - import vtk from qtpy.QtCore import Qt from qtpy.QtGui import QCursor @@ -14,7 +12,6 @@ from .controller import EventKind from .shortcuts import SculptShortcutFilter - __all__ = ["SculptInteractorStyle", "SculptMode", "sync_palette"] @@ -48,7 +45,7 @@ def __init__(self, viewport, models_pane, sculpt_hud, controller) -> None: self._hud = sculpt_hud self.controller = controller self.style = None - self.camera_style: Optional[vtk.vtkInteractorStyleTrackballCamera] = None + self.camera_style: vtk.vtkInteractorStyleTrackballCamera | None = None self.shortcut_filter = None self._models_listener = None diff --git a/src/mosaic/sculpt/overlay.py b/src/mosaic/sculpt/overlay.py index 1bf96faf..7ea75afb 100644 --- a/src/mosaic/sculpt/overlay.py +++ b/src/mosaic/sculpt/overlay.py @@ -5,12 +5,11 @@ Author: Valentin Maurer """ -from typing import Sequence +from collections.abc import Sequence import numpy as np import vtk - __all__ = ["SculptOverlay"] diff --git a/src/mosaic/sculpt/session.py b/src/mosaic/sculpt/session.py index 5b41082c..a51637f1 100644 --- a/src/mosaic/sculpt/session.py +++ b/src/mosaic/sculpt/session.py @@ -13,22 +13,21 @@ """ from dataclasses import dataclass -from typing import NamedTuple, Optional, Tuple +from typing import NamedTuple import igl import numpy as np from .tools import build_adjacency, compute_boundary_mask - __all__ = [ - "PositionRecord", - "SnapshotRecord", - "SculptTarget", - "SculptSession", "TOOLS", "TOOL_BY_HOTKEY", "TOOL_BY_ID", + "PositionRecord", + "SculptSession", + "SculptTarget", + "SnapshotRecord", "ToolSpec", ] @@ -58,10 +57,10 @@ class ToolSpec(NamedTuple): id: str hotkey: str label: str - color: Tuple[float, float, float] + color: tuple[float, float, float] -TOOLS: Tuple[ToolSpec, ...] = ( +TOOLS: tuple[ToolSpec, ...] = ( ToolSpec("view", "1", "1 View", (0.75, 0.78, 0.82)), ToolSpec("grab", "2", "2 Grab", (0.98, 0.75, 0.18)), ToolSpec("smooth", "3", "3 Smooth", (0.18, 0.62, 0.78)), @@ -72,7 +71,7 @@ class ToolSpec(NamedTuple): TOOL_BY_HOTKEY: dict[str, str] = {t.hotkey: t.id for t in TOOLS} -def _opt_copy(arr, dtype) -> Optional[np.ndarray]: +def _opt_copy(arr, dtype) -> np.ndarray | None: return None if arr is None else np.asarray(arr, dtype=dtype).copy() @@ -85,8 +84,8 @@ def __init__(self, vs: np.ndarray, fs: np.ndarray) -> None: self.vs = np.ascontiguousarray(vs, dtype=np.float64) self.fs = np.ascontiguousarray(fs, dtype=np.int64) self._adjacency = None - self._normals: Optional[np.ndarray] = None - self._boundary_mask: Optional[np.ndarray] = None + self._normals: np.ndarray | None = None + self._boundary_mask: np.ndarray | None = None @property def n(self) -> int: @@ -134,11 +133,11 @@ def __init__(self, target: SculptTarget) -> None: self.target = target self.tool: str = "view" self.in_stroke: bool = False - self._stroke_snapshot: Optional[np.ndarray] = None - self.anchor_world: Optional[np.ndarray] = None - self.locked_indices: Optional[np.ndarray] = None - self.locked_weights: Optional[np.ndarray] = None - self.rest_positions: Optional[np.ndarray] = None + self._stroke_snapshot: np.ndarray | None = None + self.anchor_world: np.ndarray | None = None + self.locked_indices: np.ndarray | None = None + self.locked_weights: np.ndarray | None = None + self.rest_positions: np.ndarray | None = None self._painted_boundary: set = set() def set_tool(self, tool: str) -> None: @@ -148,9 +147,9 @@ def set_tool(self, tool: str) -> None: def begin_stroke( self, - anchor_world: Optional[np.ndarray] = None, - locked_indices: Optional[np.ndarray] = None, - locked_weights: Optional[np.ndarray] = None, + anchor_world: np.ndarray | None = None, + locked_indices: np.ndarray | None = None, + locked_weights: np.ndarray | None = None, ) -> None: self.in_stroke = True self._stroke_snapshot = self.target.vs.copy() @@ -185,7 +184,7 @@ def cancel_stroke(self) -> None: self._reset_stroke_fields() return None - def commit_stroke(self) -> Optional[PositionRecord]: + def commit_stroke(self) -> PositionRecord | None: """Compute the stroke diff, finish the stroke, return the record.""" if not self.in_stroke or self._stroke_snapshot is None: self._reset_stroke_fields() @@ -199,7 +198,7 @@ def commit_stroke(self) -> Optional[PositionRecord]: ) diff = np.linalg.norm(after - before, axis=1) changed = np.where(diff > 1e-9)[0].astype(np.int64) - record: Optional[PositionRecord] = None + record: PositionRecord | None = None if changed.size > 0: record = PositionRecord( indices=changed, @@ -227,7 +226,7 @@ def apply_topology_change( def grab_state( self, - ) -> Optional[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None: """Return ``(indices, weights, rest, anchor)`` for Grab, or None. The four Grab fields are populated by ``begin_stroke`` together and diff --git a/src/mosaic/sculpt/shortcuts.py b/src/mosaic/sculpt/shortcuts.py index 66abafd3..92595c2b 100644 --- a/src/mosaic/sculpt/shortcuts.py +++ b/src/mosaic/sculpt/shortcuts.py @@ -5,13 +5,10 @@ Author: Valentin Maurer """ -from typing import Optional - from qtpy.QtCore import QEvent, QObject, Qt from .session import TOOLS - __all__ = ["SculptShortcutFilter"] @@ -57,7 +54,7 @@ def _on_key(self, event) -> bool: if key == Qt.Key.Key_Escape: return False - tool: Optional[str] = _TOOL_FOR_KEY.get(key) + tool: str | None = _TOOL_FOR_KEY.get(key) if tool is not None: self._set_tool(tool) return True diff --git a/src/mosaic/sculpt/tools.py b/src/mosaic/sculpt/tools.py index 5fb8e4d1..898bc606 100644 --- a/src/mosaic/sculpt/tools.py +++ b/src/mosaic/sculpt/tools.py @@ -6,7 +6,6 @@ """ from dataclasses import dataclass -from typing import List, Optional, Tuple import igl import numpy as np @@ -14,16 +13,15 @@ from mosaic.meshing.repair import _close_hole, fair_mesh, triangulation_refine_leipa - __all__ = [ "ActiveSet", "brush_query", "build_adjacency", "compute_boundary_mask", - "wendland", "grab_apply", - "smooth_stamp", "patch_touched_loops", + "smooth_stamp", + "wendland", ] @@ -62,8 +60,8 @@ def brush_query( vs: np.ndarray, hit_point: np.ndarray, radius: float, - vertex_normals: Optional[np.ndarray] = None, - camera_position: Optional[np.ndarray] = None, + vertex_normals: np.ndarray | None = None, + camera_position: np.ndarray | None = None, ) -> ActiveSet: """Return the ActiveSet for ``hit_point`` against ``vs``. @@ -198,7 +196,7 @@ def patch_touched_loops( density_factor: float = float(np.sqrt(2)), n_ring: int = 2, smoothness: float = 1.0, -) -> Optional[Tuple[np.ndarray, np.ndarray]]: +) -> tuple[np.ndarray, np.ndarray] | None: """Close the boundary loop the user painted most heavily. When a Patch stroke crosses vertices from several open boundary loops @@ -213,7 +211,7 @@ def patch_touched_loops( } if not touched: return None - best_loop: Optional[List[int]] = None + best_loop: list[int] | None = None best_overlap = 0 for loop in igl.boundary_loop_all(fs): loop_list = [int(v) for v in loop] diff --git a/src/mosaic/segmentation/membrain.py b/src/mosaic/segmentation/membrain.py index ccf763cd..c42c2a32 100644 --- a/src/mosaic/segmentation/membrain.py +++ b/src/mosaic/segmentation/membrain.py @@ -1,10 +1,8 @@ import warnings - -from shutil import which +from os.path import basename, exists, join, splitext from pathlib import Path -from typing import Tuple +from shutil import which from subprocess import run -from os.path import splitext, join, basename, exists import numpy as np @@ -81,8 +79,8 @@ def run_membrainseg( out_folder: str = None, window_size: int = 160, clustering: bool = True, - input_sampling_rate: Tuple[float] = None, - output_sampling_rate: Tuple[float] = None, + input_sampling_rate: tuple[float] = None, + output_sampling_rate: tuple[float] = None, test_time_augmentation: bool = True, ): from ..formats.parser import load_density diff --git a/src/mosaic/settings.py b/src/mosaic/settings.py index 101b01cd..34a2c08a 100644 --- a/src/mosaic/settings.py +++ b/src/mosaic/settings.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass, fields -from typing import Tuple, get_origin, Dict +from typing import get_origin from qtpy.QtCore import QSettings, QThread @@ -79,7 +79,7 @@ def __init__(self, category: str, dataclass: dataclass): self._fields.append(field.name) - def get_settings(self) -> Dict: + def get_settings(self) -> dict: """Return all settings associated with the class""" return {field_name: getattr(self, field_name) for field_name in self._fields} @@ -88,8 +88,8 @@ def get_settings(self) -> Dict: class RenderingSettings: """VTK rendering configuration.""" - background_color: Tuple[float, float, float] = (0.094, 0.094, 0.106) - background_color_alt: Tuple[float, float, float] = (1.0, 1.0, 1.0) + background_color: tuple[float, float, float] = (0.094, 0.094, 0.106) + background_color_alt: tuple[float, float, float] = (1.0, 1.0, 1.0) use_gradient_background: bool = False target_fps: float = 30.0 parallel_worker: int = max(1, min(8, QThread.idealThreadCount() - 1)) @@ -106,13 +106,13 @@ class UISettings: """User interface configuration.""" window_geometry: bytes = b"" - window_size_ratio: Tuple[float, float] = (0.9, 0.9) + window_size_ratio: tuple[float, float] = (0.9, 0.9) splitter_ratio: float = 0.85 tab_height: int = 40 tab_border_color: str = "#6b7280" tab_active_color: str = "rgba(99, 102, 241, 1.0)" menu_border_color: str = "#6b7280" - recent_files: Tuple[str, ...] = () + recent_files: tuple[str, ...] = () max_recent_files: int = 10 auto_save_session: bool = False auto_save_interval: int = 300 diff --git a/src/mosaic/styles.py b/src/mosaic/styles.py index 36d28f8d..d33f1b44 100644 --- a/src/mosaic/styles.py +++ b/src/mosaic/styles.py @@ -6,9 +6,9 @@ Author: Valentin Maurer """ +import numpy as np import vtk from vtkmodules.util import numpy_support -import numpy as np from mosaic.viewport import hardware_picking @@ -162,8 +162,8 @@ def handle_face_selection(self): def create_new_face(self): from .geometry import Geometry + from .meshing import merge_meshes, to_open3d from .parametrization import TriangularMesh - from .meshing import to_open3d, merge_meshes sampling, appearance, points, geoms = 1, {}, [], [] for geometry, point_id in self.selected_points: diff --git a/src/mosaic/stylesheets.py b/src/mosaic/stylesheets.py index 072fc0fe..3aa3ecf1 100644 --- a/src/mosaic/stylesheets.py +++ b/src/mosaic/stylesheets.py @@ -13,11 +13,11 @@ __all__ = [ "Colors", "Typography", - "switch_theme", "build_appstyle", - "build_qt_palette", "build_global_stylesheet", + "build_qt_palette", "install_macos_titlebar_filter", + "switch_theme", ] @@ -99,6 +99,7 @@ def apply_palette(cls, palette: dict): def alpha(cls, token, value): """Return a palette color at the given alpha as an rgba() string.""" import re + from qtpy.QtGui import QColor raw = getattr(cls, token) @@ -839,6 +840,7 @@ def _update_macos_titlebar_color(ns_win): try: import ctypes import ctypes.util + from qtpy.QtGui import QColor objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")) @@ -900,8 +902,8 @@ def install_macos_titlebar_filter(): if sys.platform != "darwin": return None - from qtpy.QtWidgets import QApplication from qtpy.QtCore import QEvent, QObject + from qtpy.QtWidgets import QApplication class _MacOSTitleBarFilter(QObject): """Event filter that styles the native title bar of every top-level window.""" @@ -928,7 +930,7 @@ def eventFilter(self, obj, event): def build_appstyle(): - from qtpy.QtWidgets import QProxyStyle, QComboBox, QMenu + from qtpy.QtWidgets import QComboBox, QMenu, QProxyStyle class MosaicStyle(QProxyStyle): def __init__(self): @@ -947,7 +949,7 @@ def polish(self, obj): @staticmethod def _install_menu_popup(combo): def _show_popup(combo=combo): - from qtpy.QtCore import Qt, QPoint + from qtpy.QtCore import QPoint, Qt if (old := getattr(combo, "_popup_menu", None)) is not None: old.deleteLater() @@ -960,7 +962,7 @@ def _show_popup(combo=combo): # "QWidgetWindow ... must be a top level window" warnings. menu = QMenu(combo.window() or combo) menu.setStyleSheet( - "QMenu { padding: 0px 0px; }" "QMenu::item { padding: 4px 7px; }" + "QMenu { padding: 0px 0px; }QMenu::item { padding: 4px 7px; }" ) menu.setWindowFlags( menu.windowFlags() diff --git a/src/mosaic/swaps.py b/src/mosaic/swaps.py index 5954ade7..03845c10 100644 --- a/src/mosaic/swaps.py +++ b/src/mosaic/swaps.py @@ -11,20 +11,20 @@ """ from dataclasses import dataclass -from typing import List, Optional +from typing import Optional import numpy as np __all__ = [ - "GeometrySwap", "GeometrySubset", - "restore_geometry", - "undo_subset_removal", - "redo_subset_removal", + "GeometrySwap", "apply_changes", - "record", "build_changes", "place", + "record", + "redo_subset_removal", + "restore_geometry", + "undo_subset_removal", ] @@ -88,7 +88,7 @@ def redo_subset_removal(target, record) -> None: return None -def apply_changes(target, changes: List, *, undo: bool) -> None: +def apply_changes(target, changes: list, *, undo: bool) -> None: """Apply each change to ``target`` in the given direction.""" for change in changes: if isinstance(change, GeometrySubset): @@ -103,7 +103,7 @@ def apply_changes(target, changes: List, *, undo: bool) -> None: return None -def record(target, changes: List, label: str) -> None: +def record(target, changes: list, label: str) -> None: """Register one undo entry for an already-applied set of changes. The caller has performed the forward mutation; this only wires undo/redo @@ -119,7 +119,7 @@ def record(target, changes: List, label: str) -> None: return None -def build_changes(*, add=(), remove=()) -> List[GeometrySwap]: +def build_changes(*, add=(), remove=()) -> list[GeometrySwap]: """Map add/remove geometries to a flat swap list (removals first). Parameters diff --git a/src/mosaic/tabs/__init__.py b/src/mosaic/tabs/__init__.py index 74261888..4001b970 100644 --- a/src/mosaic/tabs/__init__.py +++ b/src/mosaic/tabs/__init__.py @@ -1,4 +1,4 @@ -from .model import ModelTab from .development import DevelopmentTab -from .segmentation import SegmentationTab from .intelligence import IntelligenceTab +from .model import ModelTab +from .segmentation import SegmentationTab diff --git a/src/mosaic/tabs/development.py b/src/mosaic/tabs/development.py index 0fb279e6..dd7da30d 100644 --- a/src/mosaic/tabs/development.py +++ b/src/mosaic/tabs/development.py @@ -1,19 +1,19 @@ import time import numpy as np -from qtpy.QtCore import Qt, QEvent, QTimer +from qtpy.QtCore import QEvent, Qt, QTimer from qtpy.QtWidgets import ( - QWidget, - QVBoxLayout, + QFileDialog, QHBoxLayout, - QSpinBox, QLabel, - QFileDialog, QMessageBox, + QSpinBox, + QVBoxLayout, + QWidget, ) -from ..widgets.ribbon import create_button from ..widgets import MosaicMessageBox +from ..widgets.ribbon import create_button class PerformanceMonitor: @@ -59,10 +59,10 @@ def print_stats(self, label="Performance"): _HAS_ANNOTATION = True try: - from ..models.slice_viewer import AnnotationOverlayController from ..models.dialog import AnnotationDialog - from ..models.volume import AnnotationVolume from ..models.labels import LabelManager + from ..models.slice_viewer import AnnotationOverlayController + from ..models.volume import AnnotationVolume except Exception: _HAS_ANNOTATION = False @@ -724,6 +724,7 @@ def _save_patch(output_dir, idx, img, mask): Mask values: 0 (bg) → black, 1 (fg) → white, 2 (ignore) → gray. """ import os + from PIL import Image def to_uint8(arr): @@ -742,8 +743,9 @@ def to_uint8(arr): Image.fromarray(mask_vis).save(os.path.join(output_dir, f"{idx:04d}_mask.png")) def _train_model(self): + import atexit import os - import atexit, shutil + import shutil result = self._prepare_manifest(action="train") if result is None: @@ -769,7 +771,9 @@ def _cleanup_tmp(): from ..parallel import submit_task def _on_complete(result): - import torch, io + import io + + import torch config = result["config"] config["label_name"] = label_name @@ -896,7 +900,7 @@ def _on_complete(logits): else np.ones(3) ) geom = Geometry(points=coords * sampling, sampling_rate=sampling) - geom._meta["name"] = f"Prediction" + geom._meta["name"] = "Prediction" self.cdata.data.add(geom) self.cdata.data.render() diff --git a/src/mosaic/tabs/intelligence.py b/src/mosaic/tabs/intelligence.py index 847cb009..ba2e897e 100644 --- a/src/mosaic/tabs/intelligence.py +++ b/src/mosaic/tabs/intelligence.py @@ -1,13 +1,12 @@ import functools -from typing import Union -from os.path import exists, basename, normpath +from os.path import basename, exists, normpath import numpy as np -from qtpy.QtWidgets import QWidget, QVBoxLayout, QApplication, QFileDialog +from qtpy.QtWidgets import QApplication, QFileDialog, QVBoxLayout, QWidget from ..parallel import submit_task -from ..widgets.ribbon import create_button from ..widgets import MosaicMessageBox +from ..widgets.ribbon import create_button class IntelligenceTab(QWidget): @@ -80,6 +79,7 @@ def show_ribbon(self): def _match_template(self): from qtpy.QtWidgets import QApplication + from ..dialogs import TemplateMatchingDialog dialog = TemplateMatchingDialog(parent=QApplication.activeWindow()) @@ -121,16 +121,17 @@ def _import_trajectory( self, directory: str = "", scale: float = 1.0, - offset: Union[str, float] = 0.0, + offset: str | float = 0.0, drop_pbc: bool = False, **kwargs, ): + from qtpy.QtWidgets import QApplication + from ..dts._utils import ( - list_trajectory_files, build_trajectory_frames, + list_trajectory_files, ) from ..parallel import submit_io_task - from qtpy.QtWidgets import QApplication if not directory: return MosaicMessageBox.warning( @@ -190,18 +191,19 @@ def _on_trajectory_loaded(self, frames, scale: float, name: str): self.cdata.models.render() def _screen_parameters(self): - from ..dialogs import DTSScreeningDialog - from qtpy.QtWidgets import QApplication + from ..dialogs import DTSScreeningDialog + dialog = DTSScreeningDialog(self.cdata, parent=QApplication.activeWindow()) dialog.show() self._screen_dialog = dialog def _map_fit(self): - from ..dialogs import MeshMappingDialog from qtpy.QtWidgets import QApplication + from ..dialogs import MeshMappingDialog + fits = self.cdata.format_datalist("models", mesh_only=True) clusters = self.cdata.format_datalist("data") dialog = MeshMappingDialog( @@ -227,8 +229,8 @@ def _map_fit(self): ) def _run_membrain(self, *args, **kwargs): - from ..gui import App from ..formats import open_file + from ..gui import App from ..segmentation import run_membrainseg def _callback(output_name: str): diff --git a/src/mosaic/tabs/model.py b/src/mosaic/tabs/model.py index 3dbb35ac..669911fc 100644 --- a/src/mosaic/tabs/model.py +++ b/src/mosaic/tabs/model.py @@ -1,14 +1,14 @@ +import warnings from functools import partial -import warnings import numpy as np -from qtpy.QtWidgets import QWidget, QVBoxLayout +from qtpy.QtWidgets import QVBoxLayout, QWidget from .. import meshing from .. import operations as _operations # noqa: F401 # registers geometry operations +from ..parallel import submit_task, submit_task_batch from ..registry import MethodRegistry from ..widgets.ribbon import create_button -from ..parallel import submit_task, submit_task_batch def _repair_mesh( @@ -46,7 +46,6 @@ def _repair_mesh( hole_fids = np.arange(n_original_fs, len(new_fs)) if not (smoothness == 0 and curvature_weight == 0 and pressure == 0): - new_vs, new_fs, _ = meshing.repair.triangulation_refine_leipa( vs, new_fs, hole_fids, np.sqrt(2) ) diff --git a/src/mosaic/tabs/segmentation.py b/src/mosaic/tabs/segmentation.py index d382b6a1..5e3b80fd 100644 --- a/src/mosaic/tabs/segmentation.py +++ b/src/mosaic/tabs/segmentation.py @@ -1,18 +1,18 @@ from functools import partial -from typing import List, Tuple, Literal +from typing import Literal -import vtk import numpy as np -from qtpy.QtCore import Qt, QEvent +import vtk +from qtpy.QtCore import QEvent, Qt from qtpy.QtWidgets import ( - QWidget, QVBoxLayout, + QWidget, ) from ..registry import MethodRegistry from ..utils import Throttle -from ..widgets.ribbon import create_button from ..widgets import MosaicMessageBox +from ..widgets.ribbon import create_button class SegmentationTab(QWidget): @@ -482,7 +482,7 @@ def align_to_axis(self, widget, axis: Literal["x", "y", "z"]): widget.SetOrigin(origin) self._update_selection() - def _setup_plane_widget(self, color: Tuple[float, float, float]): + def _setup_plane_widget(self, color: tuple[float, float, float]): """Setup an interactive widget for the plane.""" widget = vtk.vtkImplicitPlaneWidget() widget.SetInteractor( @@ -528,7 +528,7 @@ def callback(obj, event): widget.AddObserver("InteractionEvent", callback) return widget - def _get_scene_bounds(self) -> List[float]: + def _get_scene_bounds(self) -> list[float]: """Get the bounds of all visible geometry in the scene.""" bounds = [float("inf"), float("-inf")] * 3 @@ -573,8 +573,8 @@ def _ensure_tagged(self, geometry): def _update_selection(self): """Update point selection based on current plane positions.""" - from vtkmodules.vtkFiltersPoints import vtkExtractPoints from vtkmodules.util.numpy_support import vtk_to_numpy + from vtkmodules.vtkFiltersPoints import vtkExtractPoints self.data.point_selection.clear() diff --git a/src/mosaic/tree_state.py b/src/mosaic/tree_state.py index 0d7388f7..f5941dcb 100644 --- a/src/mosaic/tree_state.py +++ b/src/mosaic/tree_state.py @@ -9,7 +9,6 @@ Author: Valentin Maurer """ -from typing import Dict, List from dataclasses import dataclass, field __all__ = ["TreeState", "TreeStateData"] @@ -20,11 +19,11 @@ class TreeState: """Legacy tree structure (deprecated - kept for backward compatibility).""" #: {'Group 1': ['uuid1', 'uuid2'], ...} - groups: Dict[str, List[str]] = field(default_factory=dict) + groups: dict[str, list[str]] = field(default_factory=dict) #: {'Group 1', 'uuid1', ...} - root_order: Dict[str, int] = field(default_factory=dict) + root_order: dict[str, int] = field(default_factory=dict) #: ['uuid3', 'uuid4', ...] - root_items: List[str] = field(default_factory=list) + root_items: list[str] = field(default_factory=list) def get_all_uuids(self): """Get all UUIDs currently in the tree.""" @@ -52,11 +51,11 @@ class TreeStateData: """Minimal tree structure tracking.""" #: Maps group UUIDs to list of geometry UUIDs - groups: Dict[str, List[str]] = field(default_factory=dict) + groups: dict[str, list[str]] = field(default_factory=dict) #: Maps group UUIDs to display names - group_names: Dict[str, str] = field(default_factory=dict) + group_names: dict[str, str] = field(default_factory=dict) #: Top-level items in display order (mix of group UUIDs and geometry UUIDs) - root_items: List[str] = field(default_factory=list) + root_items: list[str] = field(default_factory=list) def get_all_uuids(self): """Get all UUIDs currently in the tree.""" diff --git a/src/mosaic/undo.py b/src/mosaic/undo.py index 52368de6..230b7fe6 100644 --- a/src/mosaic/undo.py +++ b/src/mosaic/undo.py @@ -11,11 +11,10 @@ """ from collections import deque +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Optional - -__all__ = ["UndoEntry", "UndoStack", "STACK"] +__all__ = ["STACK", "UndoEntry", "UndoStack"] @dataclass(frozen=True) @@ -47,7 +46,7 @@ def push_pair( """Build an :class:`UndoEntry` from its parts and push it.""" self.push(UndoEntry(label=label, undo=undo, redo=redo)) - def undo(self) -> Optional[UndoEntry]: + def undo(self) -> UndoEntry | None: if not self._undo: return None entry = self._undo.pop() @@ -55,7 +54,7 @@ def undo(self) -> Optional[UndoEntry]: self._redo.append(entry) return entry - def redo(self) -> Optional[UndoEntry]: + def redo(self) -> UndoEntry | None: if not self._redo: return None entry = self._redo.pop() diff --git a/src/mosaic/utils.py b/src/mosaic/utils.py index f78f27a8..6149a77a 100644 --- a/src/mosaic/utils.py +++ b/src/mosaic/utils.py @@ -6,31 +6,31 @@ Author: Valentin Maurer """ -from typing import List, Optional, Callable +from collections.abc import Callable import numpy as np __all__ = [ - "points_to_volume", - "volume_to_points", + "NORMAL_REFERENCE", + "Throttle", + "apply_quat", + "birch_clustering", + "cmap_to_vtkctf", + "com_cluster_points", + "compute_bounding_box", + "compute_normals", "connected_components", - "envelope_components", "dbscan_clustering", - "birch_clustering", "eigenvalue_outlier_removal", - "statistical_outlier_removal", + "envelope_components", "find_closest_points", "find_closest_points_cutoff", - "com_cluster_points", - "compute_normals", - "compute_bounding_box", - "cmap_to_vtkctf", "get_cmap", "normals_to_rot", - "apply_quat", - "NORMAL_REFERENCE", + "points_to_volume", "skeletonize", - "Throttle", + "statistical_outlier_removal", + "volume_to_points", ] NORMAL_REFERENCE = (0, 0, 1) @@ -153,7 +153,7 @@ def volume_to_points( volume, sampling_rate, reverse_order: bool = False, - max_cluster: Optional[int] = None, + max_cluster: int | None = None, ): """ Convert volumetric segmentation to point clouds. @@ -226,8 +226,8 @@ def volume_to_points( def _get_adjacency_matrix(points, symmetric: bool = False, eps: float = 0.0): - from scipy.spatial import KDTree from scipy.sparse import coo_matrix + from scipy.spatial import KDTree # Leafsize needs to be tuned depending on the structure of the input data. # Points typically originates from voxel membrane segmentation on regular grids. @@ -319,8 +319,8 @@ def leiden_clustering(data, resolution_parameter: float = -7.3, **kwargs): ndarray Cluster labels. """ - import leidenalg import igraph as ig + import leidenalg adjacency = _get_adjacency_matrix(data, eps=0.1) @@ -664,7 +664,7 @@ def com_cluster_points(positions: np.ndarray, cutoff: float) -> np.ndarray: return np.array(clusters) -def compute_bounding_box(points: List[np.ndarray]) -> List[float]: +def compute_bounding_box(points: list[np.ndarray]) -> list[float]: """ Compute the axis-aligned bounding box enclosing a collection of point clouds. diff --git a/src/mosaic/viewport.py b/src/mosaic/viewport.py index 91903aa2..8f3117bb 100644 --- a/src/mosaic/viewport.py +++ b/src/mosaic/viewport.py @@ -10,11 +10,11 @@ from contextlib import contextmanager -import vtk import numpy as np -from qtpy.QtCore import Qt, QObject, QEvent, QTimer, Signal +import vtk +from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal -__all__ = ["ViewportInteractor", "PropIdBuffer", "hardware_picking"] +__all__ = ["PropIdBuffer", "ViewportInteractor", "hardware_picking"] @contextmanager diff --git a/src/mosaic/widgets/appsettings.py b/src/mosaic/widgets/appsettings.py index b7fcc491..81dbfac2 100644 --- a/src/mosaic/widgets/appsettings.py +++ b/src/mosaic/widgets/appsettings.py @@ -10,27 +10,26 @@ from collections import OrderedDict -from qtpy.QtGui import QPainter, QColor, QPen, QBrush, QPainterPath -from qtpy.QtCore import Qt, Signal, QRectF, QThread +from qtpy.QtCore import QRectF, Qt, QThread, Signal +from qtpy.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen from qtpy.QtWidgets import ( + QCheckBox, QFrame, - QVBoxLayout, QHBoxLayout, - QWidget, QLabel, QPushButton, - QCheckBox, QScrollArea, + QVBoxLayout, + QWidget, ) -from mosaic.settings import Settings -from mosaic.settings import QUALITY_PRESETS +from mosaic.icons import icon from mosaic.lod import LOD_DISABLED +from mosaic.settings import QUALITY_PRESETS, Settings from mosaic.stylesheets import Colors, Typography -from mosaic.icons import icon -from mosaic.widgets.sliders import SliderRow from mosaic.widgets.colors import ColorPickerRow from mosaic.widgets.segmented_control import SegmentedControl +from mosaic.widgets.sliders import SliderRow def _rgb(hex_str: str) -> tuple: diff --git a/src/mosaic/widgets/camera_hud.py b/src/mosaic/widgets/camera_hud.py index 5664a192..b971c915 100644 --- a/src/mosaic/widgets/camera_hud.py +++ b/src/mosaic/widgets/camera_hud.py @@ -7,8 +7,8 @@ """ import numpy as np -from qtpy.QtCore import Qt, QEvent, QPoint, QCoreApplication -from qtpy.QtGui import QColor, QPen, QPainter +from qtpy.QtCore import QCoreApplication, QEvent, QPoint, Qt +from qtpy.QtGui import QColor, QPainter, QPen from qtpy.QtWidgets import QVBoxLayout, QWidget from ..stylesheets import Typography diff --git a/src/mosaic/widgets/cards.py b/src/mosaic/widgets/cards.py index e5272831..1b8eaf48 100644 --- a/src/mosaic/widgets/cards.py +++ b/src/mosaic/widgets/cards.py @@ -6,15 +6,15 @@ Author: Valentin Maurer """ +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QPainter, QPainterPath, QPixmap from qtpy.QtWidgets import ( - QVBoxLayout, - QLabel, QFrame, - QWidget, + QLabel, QSizePolicy, + QVBoxLayout, + QWidget, ) -from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import QPixmap, QPainter, QPainterPath from ..stylesheets import Colors, Typography diff --git a/src/mosaic/widgets/colors.py b/src/mosaic/widgets/colors.py index 644dae2c..daf96766 100644 --- a/src/mosaic/widgets/colors.py +++ b/src/mosaic/widgets/colors.py @@ -6,32 +6,31 @@ Author: Valentin Maurer """ -from typing import Dict, List, Optional - -from qtpy.QtCore import Qt, Signal, QPointF, QRect -from qtpy.QtGui import QColor, QPainter, QLinearGradient, QPen +from qtpy.QtCore import QPointF, QRect, Qt, Signal +from qtpy.QtGui import QColor, QLinearGradient, QPainter, QPen from qtpy.QtWidgets import ( - QWidget, - QPushButton, QColorDialog, - QVBoxLayout, QHBoxLayout, QLabel, QMenu, + QPushButton, + QVBoxLayout, + QWidget, QWidgetAction, ) + from ..icons import icon from ..stylesheets import Colors, Typography __all__ = [ "ColorMapSelector", - "ColorSwatch", "ColorPickerRow", + "ColorSwatch", "generate_gradient_colors", ] # Default colormap categories -DEFAULT_COLORMAP_CATEGORIES: Dict[str, List[str]] = { +DEFAULT_COLORMAP_CATEGORIES: dict[str, list[str]] = { "Sequential": [ "viridis", "plasma", @@ -66,7 +65,7 @@ } -def generate_gradient_colors(cmap_name: str, n_colors: int = 10) -> List[QColor]: +def generate_gradient_colors(cmap_name: str, n_colors: int = 10) -> list[QColor]: """Generate a list of QColors from a matplotlib colormap.""" from ..utils import get_cmap @@ -162,8 +161,8 @@ class ColorMapSelector(QPushButton): def __init__( self, - categories: Optional[Dict[str, List[str]]] = None, - default: Optional[str] = None, + categories: dict[str, list[str]] | None = None, + default: str | None = None, parent=None, ): super().__init__(parent) diff --git a/src/mosaic/widgets/container_list.py b/src/mosaic/widgets/container_list.py index c5d472da..f894ad5b 100644 --- a/src/mosaic/widgets/container_list.py +++ b/src/mosaic/widgets/container_list.py @@ -1,37 +1,37 @@ from uuid import uuid4 -from typing import Dict, List, Union -from qtpy.QtGui import QColor, QIcon, QPainter from qtpy.QtCore import ( - Qt, - QRect, QEvent, QItemSelection, QItemSelectionModel, + QRect, + Qt, Signal, ) +from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import ( + QAbstractItemView, + QApplication, + QFileDialog, QFrame, - QVBoxLayout, QHBoxLayout, - QWidget, - QTreeWidget, - QTreeWidgetItem, - QApplication, - QStyledItemDelegate, - QStyle, - QAbstractItemView, - QPushButton, QLabel, - QFileDialog, QMenu, QMessageBox, + QPushButton, + QStyle, + QStyledItemDelegate, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, ) + from ..icons import icon as _icon_factory -from ..tree_state import TreeState, TreeStateData +from ..pipeline._utils import natural_sort_key, strip_filepath from ..stylesheets import Colors, Typography +from ..tree_state import TreeState, TreeStateData from .message_box import MosaicMessageBox -from ..pipeline._utils import natural_sort_key, strip_filepath class SelectionIndicator(QWidget): @@ -244,7 +244,7 @@ def to_state(self) -> TreeStateData: state.groups[group_uuid].append(uuid) return state - def apply_state(self, state: Union[TreeStateData, TreeState], uuid_to_items: Dict): + def apply_state(self, state: TreeStateData | TreeState, uuid_to_items: dict): """Apply tree structure to existing items. Parameters @@ -504,7 +504,7 @@ def _set_selection( def set_selection( self, - uuids: List[str], + uuids: list[str], selection_flag=QItemSelectionModel.SelectionFlag.ClearAndSelect, ): """ diff --git a/src/mosaic/widgets/dialog_footer.py b/src/mosaic/widgets/dialog_footer.py index cb868e18..84ecdbe8 100644 --- a/src/mosaic/widgets/dialog_footer.py +++ b/src/mosaic/widgets/dialog_footer.py @@ -1,22 +1,21 @@ -from typing import Tuple - from qtpy.QtWidgets import ( - QLabel, + QDialog, + QFrame, QHBoxLayout, + QLabel, QPushButton, - QFrame, - QDialog, ) + from ..stylesheets import Colors, Typography class DialogFooter(QFrame): def __init__( - self, info_text: str = None, dialog: QDialog = None, margin: Tuple[int] = None + self, info_text: str = None, dialog: QDialog = None, margin: tuple[int] = None ): super().__init__() - from mosaic.icons import dialog_reject_icon, dialog_accept_icon, info_icon + from mosaic.icons import dialog_accept_icon, dialog_reject_icon, info_icon HelpLabel_style = f""" QLabel {{ diff --git a/src/mosaic/widgets/dock.py b/src/mosaic/widgets/dock.py index 54c3bff2..b414c529 100644 --- a/src/mosaic/widgets/dock.py +++ b/src/mosaic/widgets/dock.py @@ -1,15 +1,14 @@ -from qtpy.QtCore import Qt, QSize, QRectF -from qtpy.QtGui import QPainter, QColor +from qtpy.QtCore import QSize, Qt from qtpy.QtWidgets import ( - QDockWidget, QApplication, - QMainWindow, - QScrollArea, + QDockWidget, QFrame, - QWidget, QHBoxLayout, QLabel, + QMainWindow, + QScrollArea, QToolButton, + QWidget, ) diff --git a/src/mosaic/widgets/path_selector.py b/src/mosaic/widgets/path_selector.py index c6e36b68..d59a1bca 100644 --- a/src/mosaic/widgets/path_selector.py +++ b/src/mosaic/widgets/path_selector.py @@ -1,23 +1,23 @@ import os -from sys import platform from pathlib import Path -from typing import List, Union +from sys import platform -from qtpy.QtCore import Qt, QEvent, QObject, QStringListModel +from qtpy.QtCore import QEvent, QObject, QStringListModel, Qt from qtpy.QtGui import QColor, QPalette from qtpy.QtWidgets import ( - QVBoxLayout, + QCompleter, + QFileDialog, + QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QVBoxLayout, QWidget, - QFrame, - QFileDialog, - QCompleter, ) -from ..stylesheets import Colors, Typography + from ..icons import icon, icon_pixmap +from ..stylesheets import Colors, Typography class _TabCompleteFilter(QObject): @@ -98,7 +98,7 @@ def __init__( self.mode = mode self.file_filter = file_filter - self._paths: List[str] = [] + self._paths: list[str] = [] self._cached_dir = None main_layout = QVBoxLayout(self) @@ -144,9 +144,8 @@ def __init__( pal.setColor(QPalette.ColorRole.PlaceholderText, QColor(Colors.ICON_MUTED)) self.path_input.setPalette(pal) - self.path_input.focusInEvent = lambda *_: ( - self.container_frame.setStyleSheet( - f""" + self.path_input.focusInEvent = lambda *_: self.container_frame.setStyleSheet( + f""" #pathSelectorFrame {{ border: 1px solid {Colors.BORDER_HOVER}; border-radius: {Colors.RADIUS}px; @@ -155,11 +154,9 @@ def __init__( border: 1px solid {Colors.BORDER_HOVER}; }} """ - ) ) - self.path_input.focusOutEvent = lambda *_: ( - self.container_frame.setStyleSheet( - f""" + self.path_input.focusOutEvent = lambda *_: self.container_frame.setStyleSheet( + f""" #pathSelectorFrame {{ border: 1px solid {Colors.BORDER_DARK}; border-radius: {Colors.RADIUS}px; @@ -168,7 +165,6 @@ def __init__( border: 1px solid {Colors.BORDER_HOVER}; }} """ - ) ) self.browse_button = QPushButton() @@ -320,7 +316,7 @@ def on_accepted(): dialog.accepted.connect(on_accepted) dialog.open() - def get_path(self) -> Union[str, List[str]]: + def get_path(self) -> str | list[str]: """Return the selected path(s). Returns a single string for ``"file"``, ``"directory"``, and @@ -330,7 +326,7 @@ def get_path(self) -> Union[str, List[str]]: return list(self._paths) return self.path_input.text() - def set_path(self, path: Union[str, List[str]]): + def set_path(self, path: str | list[str]): """Set the path(s) in the input field.""" if self.mode == "files": if isinstance(path, str): diff --git a/src/mosaic/widgets/ribbon.py b/src/mosaic/widgets/ribbon.py index 205d4eb6..3d9bf4ac 100644 --- a/src/mosaic/widgets/ribbon.py +++ b/src/mosaic/widgets/ribbon.py @@ -1,19 +1,20 @@ -from qtpy.QtGui import QPainter, QPainterPath, QColor, QPen -from qtpy.QtCore import Qt, QSize, Signal, QPoint, QTimer, QRectF, QEvent +from qtpy.QtCore import QEvent, QPoint, QRectF, QSize, Qt, QTimer, Signal +from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen from qtpy.QtWidgets import ( - QWidget, - QVBoxLayout, - QLabel, - QHBoxLayout, - QPushButton, + QApplication, QFrame, QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, QSizePolicy, - QApplication, + QVBoxLayout, + QWidget, ) -from .settings import create_setting_widget -from ..stylesheets import Colors, Typography + from ..icons import icon +from ..stylesheets import Colors, Typography +from .settings import create_setting_widget class SettingsPanel(QFrame): diff --git a/src/mosaic/widgets/sculpt_palette_hud.py b/src/mosaic/widgets/sculpt_palette_hud.py index 44ea2e0f..50639e64 100644 --- a/src/mosaic/widgets/sculpt_palette_hud.py +++ b/src/mosaic/widgets/sculpt_palette_hud.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from qtpy.QtCore import Qt, QEvent, QPoint, QCoreApplication, Signal +from qtpy.QtCore import QCoreApplication, QEvent, QPoint, Qt, Signal from qtpy.QtGui import QColor, QPainter, QPen from qtpy.QtWidgets import ( QButtonGroup, diff --git a/src/mosaic/widgets/search_widget.py b/src/mosaic/widgets/search_widget.py index 17b4fa7b..5329b4ae 100644 --- a/src/mosaic/widgets/search_widget.py +++ b/src/mosaic/widgets/search_widget.py @@ -1,5 +1,6 @@ from qtpy.QtCore import Signal -from qtpy.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QLabel, QFrame +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QLineEdit, QWidget + from ..icons import icon_pixmap from ..stylesheets import Colors diff --git a/src/mosaic/widgets/segmented_control.py b/src/mosaic/widgets/segmented_control.py index a253c141..bca7e511 100644 --- a/src/mosaic/widgets/segmented_control.py +++ b/src/mosaic/widgets/segmented_control.py @@ -6,12 +6,12 @@ Author: Valentin Maurer """ -from qtpy.QtCore import Signal, QSize +from qtpy.QtCore import QSize, Signal from qtpy.QtGui import QIcon -from qtpy.QtWidgets import QWidget, QHBoxLayout, QPushButton +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget -from ..stylesheets import Colors from ..icons import icon as _icon +from ..stylesheets import Colors class SegmentedControl(QWidget): diff --git a/src/mosaic/widgets/settings.py b/src/mosaic/widgets/settings.py index 3e48dd18..dc762095 100644 --- a/src/mosaic/widgets/settings.py +++ b/src/mosaic/widgets/settings.py @@ -1,23 +1,21 @@ -from typing import Dict - from qtpy.QtGui import QDoubleValidator from qtpy.QtWidgets import ( - QSpinBox, - QDoubleSpinBox, - QComboBox, QCheckBox, + QComboBox, + QDoubleSpinBox, QFormLayout, QLineEdit, + QSpinBox, ) from ..stylesheets import Colors, Typography __all__ = [ - "format_tooltip", "create_setting_widget", + "format_tooltip", + "get_layout_widget_value", "get_widget_value", "set_widget_value", - "get_layout_widget_value", ] @@ -43,9 +41,8 @@ def format_tooltip(description=None, default=None, notes=None, **kwargs): return "".join(lines) -def create_setting_widget(setting: Dict): +def create_setting_widget(setting: dict): if setting["type"] in ("number", "float"): - widget = QSpinBox() wrange = int(setting.get("min", 0)), int(setting.get("max", 1 << 30)) if setting["type"] == "float": diff --git a/src/mosaic/widgets/sidebar.py b/src/mosaic/widgets/sidebar.py index 3b954f5b..ea1b1eee 100644 --- a/src/mosaic/widgets/sidebar.py +++ b/src/mosaic/widgets/sidebar.py @@ -8,19 +8,18 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import ( - QWidget, - QVBoxLayout, QLabel, QSizePolicy, QSplitter, + QVBoxLayout, + QWidget, ) -from .search_widget import SearchWidget from ..stylesheets import Colors, Typography +from .search_widget import SearchWidget class ObjectBrowserSidebar(QWidget): - def __init__(self, parent=None): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) diff --git a/src/mosaic/widgets/sliders.py b/src/mosaic/widgets/sliders.py index 135efea0..779ff85b 100644 --- a/src/mosaic/widgets/sliders.py +++ b/src/mosaic/widgets/sliders.py @@ -6,19 +6,19 @@ Author: Valentin Maurer """ -__all__ = ["DualHandleSlider", "SliderRow", "HistogramRangeSlider"] +__all__ = ["DualHandleSlider", "HistogramRangeSlider", "SliderRow"] import numpy as np -from qtpy.QtGui import QColor from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QColor from qtpy.QtWidgets import ( - QWidget, - QSlider, - QSizePolicy, + QGraphicsOpacityEffect, QHBoxLayout, - QVBoxLayout, QLabel, - QGraphicsOpacityEffect, + QSizePolicy, + QSlider, + QVBoxLayout, + QWidget, ) from ..stylesheets import Colors @@ -265,7 +265,7 @@ def _pixel_to_value(self, pixel): def paintEvent(self, event): """Draw the slider with range visualization.""" - from qtpy.QtGui import QPainter, QPen, QBrush + from qtpy.QtGui import QBrush, QPainter, QPen painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) @@ -422,7 +422,7 @@ def setSelection(self, lower, upper): def paintEvent(self, event): """Draw the histogram bars or placeholder.""" - from qtpy.QtGui import QPainter, QBrush, QPen + from qtpy.QtGui import QBrush, QPainter, QPen painter = QPainter(self) width = self.width() diff --git a/src/mosaic/widgets/status_indicator.py b/src/mosaic/widgets/status_indicator.py index 9b9983b7..9f8a4e54 100644 --- a/src/mosaic/widgets/status_indicator.py +++ b/src/mosaic/widgets/status_indicator.py @@ -9,24 +9,25 @@ import enum from collections import Counter -from qtpy.QtCore import Qt, QRectF, QTimer, Signal +from qtpy.QtCore import QRectF, Qt, QTimer, Signal +from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen, QTextCursor from qtpy.QtWidgets import ( - QWidget, - QLabel, - QVBoxLayout, - QHBoxLayout, - QScrollArea, - QPushButton, + QApplication, QFrame, QGroupBox, - QTextEdit, + QHBoxLayout, + QLabel, QProgressBar, - QApplication, + QPushButton, + QScrollArea, + QTextEdit, + QVBoxLayout, + QWidget, ) -from qtpy.QtGui import QTextCursor, QPainter, QColor, QPen, QPainterPath -from ..stylesheets import Colors, Typography + from ..icons import icon from ..parallel import BackgroundTaskManager +from ..stylesheets import Colors, Typography class ViewerModes(enum.Enum): diff --git a/src/mosaic/widgets/tabs.py b/src/mosaic/widgets/tabs.py index bbb07d5e..b6878b5a 100644 --- a/src/mosaic/widgets/tabs.py +++ b/src/mosaic/widgets/tabs.py @@ -7,19 +7,17 @@ """ import sys -from typing import Tuple -from qtpy.QtCore import Qt, Signal, QRect, QTimer -from qtpy.QtGui import QPainter, QColor +from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRect, Qt, QTimer, Signal +from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import ( - QWidget, + QButtonGroup, QHBoxLayout, QPushButton, - QButtonGroup, QStackedWidget, QVBoxLayout, + QWidget, ) -from qtpy.QtCore import QPropertyAnimation, QEasingCurve from ..stylesheets import Colors, Typography @@ -58,7 +56,7 @@ class TabBar(QWidget): def __init__( self, parent=None, - margins: Tuple[int, int, int, int] = (8, 0, 8, 0), + margins: tuple[int, int, int, int] = (8, 0, 8, 0), ): super().__init__(parent) self.setFixedHeight(38) @@ -225,7 +223,7 @@ class TabWidget(QWidget): def __init__( self, parent=None, - tab_bar_margins: Tuple[int, int, int, int] = (8, 0, 8, 0), + tab_bar_margins: tuple[int, int, int, int] = (8, 0, 8, 0), ): super().__init__(parent) layout = QVBoxLayout(self) diff --git a/src/mosaic/widgets/theme_toggle.py b/src/mosaic/widgets/theme_toggle.py index 53cf09b7..52c11362 100644 --- a/src/mosaic/widgets/theme_toggle.py +++ b/src/mosaic/widgets/theme_toggle.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from qtpy.QtCore import Qt, QPropertyAnimation, QEasingCurve, Property +from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, Qt from qtpy.QtGui import QColor, QFont, QPainter from qtpy.QtWidgets import QPushButton diff --git a/src/mosaic/widgets/trajectory_player.py b/src/mosaic/widgets/trajectory_player.py index cf3802d4..101adc98 100644 --- a/src/mosaic/widgets/trajectory_player.py +++ b/src/mosaic/widgets/trajectory_player.py @@ -8,13 +8,13 @@ from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtWidgets import ( - QWidget, - QVBoxLayout, + QFrame, QHBoxLayout, + QLabel, QPushButton, QSlider, - QLabel, - QFrame, + QVBoxLayout, + QWidget, ) from ..icons import icon diff --git a/src/mosaic/widgets/viewport_placeholder.py b/src/mosaic/widgets/viewport_placeholder.py index 61a0763e..b68b28af 100644 --- a/src/mosaic/widgets/viewport_placeholder.py +++ b/src/mosaic/widgets/viewport_placeholder.py @@ -7,9 +7,9 @@ """ import sys -from typing import Callable, Iterable, Optional, Tuple +from collections.abc import Callable, Iterable -from qtpy.QtCore import Qt, QEvent, Signal +from qtpy.QtCore import QEvent, Qt, Signal from qtpy.QtGui import QPainter from qtpy.QtWidgets import ( QFrame, @@ -26,7 +26,7 @@ __all__ = ["ViewportPlaceholder"] -ActionSpec = Tuple[Optional[str], str, Callable[[], None]] +ActionSpec = tuple[str | None, str, Callable[[], None]] def _modifier_glyph() -> str: @@ -38,7 +38,7 @@ class _ActionPill(QWidget): clicked = Signal() - def __init__(self, shortcut: Optional[str], label: str, parent=None): + def __init__(self, shortcut: str | None, label: str, parent=None): super().__init__(parent) self.setObjectName("actionPill") self.setCursor(Qt.CursorShape.PointingHandCursor) @@ -222,7 +222,7 @@ def _apply_theme(self): self._applying_theme = False -def default_actions(window) -> Tuple[ActionSpec, ...]: +def default_actions(window) -> tuple[ActionSpec, ...]: """Standard pill set bound to :class:`mosaic.gui.App` methods.""" mod = _modifier_glyph() return ( diff --git a/src/mosaic/widgets/volume_viewer.py b/src/mosaic/widgets/volume_viewer.py index db2f295b..7eaf19b6 100644 --- a/src/mosaic/widgets/volume_viewer.py +++ b/src/mosaic/widgets/volume_viewer.py @@ -9,25 +9,24 @@ from contextlib import contextmanager import vtk +from qtpy.QtCore import Signal from qtpy.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, - QWidget, QComboBox, - QPushButton, QFileDialog, + QHBoxLayout, QLabel, + QPushButton, + QVBoxLayout, + QWidget, ) -from ..icons import icon as _icon_factory -from .message_box import MosaicMessageBox - -from qtpy.QtCore import Signal from vtkmodules.util import numpy_support -from ..utils import Throttle +from ..icons import icon as _icon_factory from ..stylesheets import Colors +from ..utils import Throttle from .colors import ColorMapSelector -from .sliders import SliderRow, DualHandleSlider +from .message_box import MosaicMessageBox +from .sliders import DualHandleSlider, SliderRow _VTK_READERS = { ".mrc": "vtkMRCReader", diff --git a/src/mosaic/widgets/volume_viewer_hud.py b/src/mosaic/widgets/volume_viewer_hud.py index b83e1ddf..6b4ca346 100644 --- a/src/mosaic/widgets/volume_viewer_hud.py +++ b/src/mosaic/widgets/volume_viewer_hud.py @@ -6,20 +6,20 @@ Author: Valentin Maurer """ -from qtpy.QtGui import QColor, QPen, QCursor, QPainter -from qtpy.QtCore import Qt, QEvent, QTimer, QPoint, QCoreApplication +from qtpy.QtCore import QCoreApplication, QEvent, QPoint, Qt, QTimer +from qtpy.QtGui import QColor, QCursor, QPainter, QPen from qtpy.QtWidgets import ( - QVBoxLayout, + QApplication, QHBoxLayout, - QWidget, QPushButton, - QApplication, + QVBoxLayout, + QWidget, ) from ..icons import icon from ..stylesheets import Typography -from .volume_viewer import VolumeViewer from .segmented_control import SegmentedControl +from .volume_viewer import VolumeViewer _ICON = "#d4d4d8" _BTN = 28 diff --git a/src/mosaic/widgets/vtk_widgets.py b/src/mosaic/widgets/vtk_widgets.py index f20ee391..91c88985 100644 --- a/src/mosaic/widgets/vtk_widgets.py +++ b/src/mosaic/widgets/vtk_widgets.py @@ -8,20 +8,20 @@ __all__ = [ "AXIS_COLORS", - "LegendWidget", - "ScaleBarWidget", "AxesWidget", "BoundingBoxManager", + "LegendWidget", + "ScaleBarWidget", "create_bounding_box_actor", ] import vtk from vtk import ( - vtkCubeSource, - vtkPolyDataMapper, vtkActor, vtkAxesActor, + vtkCubeSource, vtkOrientationMarkerWidget, + vtkPolyDataMapper, ) from ..stylesheets import Colors diff --git a/tests/commands/test_dispatch.py b/tests/commands/test_dispatch.py index 759e99e2..a3a3e992 100644 --- a/tests/commands/test_dispatch.py +++ b/tests/commands/test_dispatch.py @@ -8,10 +8,10 @@ Author: Valentin Maurer """ -from mosaic.commands.repl import MosaicREPL -from mosaic.commands.session import Session from mosaic.commands.parser import parse_command from mosaic.commands.registry import CommandRegistry +from mosaic.commands.repl import MosaicREPL +from mosaic.commands.session import Session from mosaic.commands.theme import render_to_text as _render @@ -22,7 +22,6 @@ def _dispatch(session, text): class TestDispatch: - def test_list_empty(self): result = _dispatch(Session(), "list") assert "no geometries" in _render(result).lower() @@ -83,7 +82,6 @@ def test_help_smoke(self): class TestREPLExecute: - def test_execute_returns_output(self, make_session): repl = MosaicREPL(session=make_session(n=1)) output = repl.execute("info #0") @@ -113,7 +111,6 @@ def test_execute_script_text(self, make_session): class TestMeasureFilterDispatch: - def test_measure_n_points(self, make_session): session = make_session(n=2, n_points=50) result = _dispatch(session, "measure n_points *") @@ -127,9 +124,10 @@ def test_measure_store_array(self, make_session): def test_filter_population(self): session = Session(quiet=True) - from mosaic.geometry import Geometry import numpy as np + from mosaic.geometry import Geometry + for size in range(10, 110, 10): g = Geometry( points=np.random.RandomState(size).rand(size, 3).astype(np.float32) diff --git a/tests/commands/test_measure_filter.py b/tests/commands/test_measure_filter.py index 07d4dee7..bc031b6e 100644 --- a/tests/commands/test_measure_filter.py +++ b/tests/commands/test_measure_filter.py @@ -8,9 +8,9 @@ import numpy as np -from mosaic.commands.session import Session from mosaic.commands.parser import parse_command from mosaic.commands.registry import CommandRegistry +from mosaic.commands.session import Session from mosaic.commands.theme import render_to_text from mosaic.geometry import Geometry @@ -93,9 +93,9 @@ def test_store_scalar_skips_gracefully(self): geoms = session._all_geometries() for g in geoms: props = g.vertex_properties.properties - assert ( - "n_points" not in props - ), "Scalar result should not be stored as vertex property" + assert "n_points" not in props, ( + "Scalar result should not be stored as vertex property" + ) def test_store_per_vertex_array(self): """If measure returns per-vertex arrays, store=true should store them.""" diff --git a/tests/commands/test_parser.py b/tests/commands/test_parser.py index 38739db7..192c5bac 100644 --- a/tests/commands/test_parser.py +++ b/tests/commands/test_parser.py @@ -10,24 +10,23 @@ import shlex from types import SimpleNamespace -import pytest import numpy as np +import pytest from mosaic.commands import parser from mosaic.commands.parser import ( + _SPECIAL_TARGETS, + _TARGET_RE, ParsedCommand, - parse_command, - format_value, - format_kwargs, _coerce_value, - _TARGET_RE, - _SPECIAL_TARGETS, + format_kwargs, + format_value, + parse_command, ) from mosaic.registry import Param class TestParseCommand: - @pytest.mark.parametrize( "text, verb, targets, args, kwargs", [ @@ -83,7 +82,6 @@ def test_syntax_error(self): class TestParseCommandEdgeCases: - def test_empty_string(self): assert parse_command("") is None @@ -174,7 +172,6 @@ def test_tab_and_extra_whitespace(self): class TestCoerceValue: - @pytest.mark.parametrize( "raw, expected", [ @@ -199,7 +196,6 @@ def test_bracket_array(self): class TestCoerceValueEdgeCases: - @pytest.mark.parametrize("val", ["true", "True", "TRUE", "tRuE"]) def test_true_variants(self, val): assert _coerce_value(val) is True @@ -275,7 +271,6 @@ def test_large_int(self): class TestFormatValue: - @pytest.mark.parametrize( "value, expected", [ @@ -297,7 +292,6 @@ def test_list(self): class TestFormatKwargs: - @pytest.mark.parametrize( "kwargs", [ @@ -351,7 +345,6 @@ def test_special_chars_roundtrip(self, value): class TestTargetParsing: - def test_simple_target(self): assert _TARGET_RE.match("#0") assert _TARGET_RE.match("#999") @@ -421,7 +414,6 @@ def test_target_with_leading_zeros(self): class TestResolvePositional: - def test_maps_args_to_params(self): p = ParsedCommand(verb="test", args=["radius", "5"]) p.resolve_positional(["method", "voxel_size"]) diff --git a/tests/commands/test_registry.py b/tests/commands/test_registry.py index 142b939d..2b274032 100644 --- a/tests/commands/test_registry.py +++ b/tests/commands/test_registry.py @@ -45,7 +45,6 @@ def _dispatch(session, text): class TestExpandBraceRange: - def test_simple_range(self): assert _expand_brace_range("/data/file_{0..3}.star") == [ "/data/file_0.star", @@ -74,7 +73,6 @@ def test_brace_without_dots(self): class TestNatsortKey: - def test_numeric_ordering(self): paths = ["file_10.star", "file_2.star", "file_1.star"] assert sorted(paths, key=_natsort_key) == [ @@ -97,7 +95,6 @@ def test_mixed(self): class TestIsTargetRef: - @pytest.mark.parametrize("val", ["#0", "#0-5", "@last", "*", "#0,#1,#2"]) def test_positive(self, val): assert _is_target_ref(val) is True @@ -108,7 +105,6 @@ def test_negative(self, val): class TestResolveKwargs: - def test_geometry_ref_resolved(self, make_session): session = make_session(n=3) result = _resolve_kwargs(session, {"queries": "#0"}) @@ -132,7 +128,6 @@ def test_invalid_ref_raises(self): class TestDispatchEdgeCases: - def test_help_redirect(self, make_session): """'measure help' redirects to 'help measure'.""" session = make_session(n=1) @@ -156,7 +151,6 @@ def test_registry_defaults_injected(self, make_session): class TestCmdList: - def test_list_ids_format(self, make_session): session = make_session(n=3) result = _dispatch(session, "list format=ids") @@ -183,7 +177,6 @@ def test_list_no_match(self, make_session): class TestCmdInfo: - def test_info_multiple_geometries(self): session = _session_with(_geom(10, name="a"), _geom(20, name="b")) result = _dispatch(session, "info #0 #1") @@ -205,7 +198,6 @@ def test_info_shows_uuid(self): class TestCmdRename: - def test_rename_regex_global(self): g = _geom(10, name="aaa_bbb_aaa") session = _session_with(g) @@ -248,7 +240,6 @@ def test_rename_multiple_targets(self): class TestCmdVisibility: - def test_no_targets(self): session = _session_with(_geom(10)) result = _dispatch(session, "visibility") @@ -257,7 +248,6 @@ def test_no_targets(self): class TestCmdGroup: - def test_group_no_targets(self): session = _session_with(_geom(10)) result = _dispatch(session, "group") @@ -278,7 +268,6 @@ def test_ungroup_no_targets(self): class TestCmdRemove: - def test_remove_no_targets(self): session = _session_with(_geom(10)) result = _dispatch(session, "remove") @@ -287,7 +276,6 @@ def test_remove_no_targets(self): class TestCmdSave: - def test_save_no_filepath(self, make_session): session = make_session(n=1) result = _dispatch(session, "save") @@ -312,7 +300,6 @@ def test_save_session_file(self, make_session, tmp_path): class TestCmdOpen: - def test_open_no_filepath(self): result = _dispatch(Session(quiet=True), "open") text = _render(result) @@ -396,7 +383,6 @@ def test_open_persist_false(self, tmp_path): class TestCmdHelp: - def test_help_overview(self): result = _dispatch(Session(), "help") text = _render(result) @@ -436,7 +422,6 @@ def test_help_unknown_method(self): class TestCmdHistory: - def test_history_empty(self): result = _dispatch(Session(), "history") text = _render(result) @@ -453,7 +438,6 @@ def test_history_with_entries(self, make_session): class TestOperationHandler: - def test_operation_no_method(self, make_session): session = make_session(n=1) result = _dispatch(session, "cluster") @@ -474,7 +458,6 @@ def test_operation_no_geometry_targets(self): class TestCmdFilter: - def test_filter_no_targets(self): session = Session(quiet=True) result = _dispatch(session, "filter") @@ -501,7 +484,6 @@ def test_filter_upper_bound(self): class TestFilterMembership: - def test_filter_include_categorical(self, make_session): session = make_session(n=1, n_points=20) g = session._all_geometries()[0] @@ -538,7 +520,6 @@ def test_filter_range_on_string_errors(self, make_session): class TestCmdMerge: - def test_merge_too_few(self): session = _session_with(_geom(10)) result = _dispatch(session, "merge #0") @@ -547,7 +528,6 @@ def test_merge_too_few(self): class TestPropertiesCommand: - def test_no_targets_defaults_to_all(self, make_session): session = make_session(n=2, n_points=10) g0, g1 = session._all_geometries() @@ -683,7 +663,6 @@ def test_value_display_numeric_high_cardinality_int_uses_sparkline( class TestMeasureSparkline: - def test_measure_array_result_has_sparkline_column(self, make_session): # height returns a per-vertex array, so this hits the array branch session = make_session(n=1, n_points=200) @@ -714,7 +693,6 @@ def test_measure_scalar_result_unchanged(self, make_session): class TestCommandRegistryAPI: - def test_get_existing(self): cmd = CommandRegistry.get("list") assert cmd is not None @@ -733,7 +711,6 @@ def test_list_commands(self): class TestMeasureVertexPropertyBlocked: - def test_measure_help_does_not_list_vertex_property(self, make_session): session = make_session(n=1, n_points=10) result = _dispatch(session, "measure help") @@ -750,8 +727,8 @@ def test_measure_vertex_property_returns_redirect(self, make_session): def test_calculator_still_callable_from_python(self): """The GUI dialog path (GeometryProperties.compute) must keep working.""" - from mosaic.properties import GeometryProperties from mosaic.geometry import Geometry + from mosaic.properties import GeometryProperties g = Geometry(points=np.zeros((5, 3), dtype=np.float32)) g.vertex_properties.set_property("tag", np.array(["x"] * 5, dtype=object)) diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 239a963f..d6c1a099 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -19,7 +19,6 @@ def _geom(n_points=50, seed=None): class TestResolve: - @pytest.mark.parametrize( "spec, expected_len", [ @@ -53,7 +52,6 @@ def test_resolve_many_deduplicates(self, make_session): class TestApply: - def test_downsample_creates_geometry(self, make_session): session = make_session(n=1, n_points=200) geoms = session.resolve("*") @@ -90,7 +88,6 @@ def test_unknown_operation(self, make_session): class TestMeasure: - def test_scalar_property(self, make_session): session = make_session(n=1, n_points=77) results = session.measure("n_points", session._all_geometries()) @@ -118,7 +115,6 @@ def test_store_scalar_skips(self, make_session): class TestFilter: - def test_population_level(self): session = Session(quiet=True) for size in [50, 200, 300]: @@ -153,7 +149,6 @@ def test_updates_last_results(self): class TestMerge: - def test_merge_preserves_total_points(self, make_session): session = make_session(n=2, n_points=50) geoms = session._all_geometries() @@ -169,7 +164,6 @@ def test_merge_updates_last(self, make_session): class TestGrouping: - def test_create_group(self, make_session): session = make_session(n=3) geoms = session._all_geometries() @@ -201,7 +195,6 @@ def test_group_empty_raises(self): class TestRemove: - def test_remove_clears_order_and_last(self, make_session): session = make_session(n=2) geoms = session._all_geometries() @@ -220,7 +213,6 @@ def test_remove_cleans_tree(self, make_session): class TestListFiltered: - def test_visibility_filter(self, make_session): session = make_session(n=3) session._all_geometries()[0].set_visibility(False) @@ -242,7 +234,6 @@ def test_re_show(self, make_session): class TestLog: - def test_log_appends(self): s = Session() s.log_command("list") @@ -251,7 +242,6 @@ def test_log_appends(self): class TestHelpers: - def test_flatten_nested(self): assert Session._flatten([[1, 2], 3, [4]]) == [1, 2, 3, 4] @@ -303,7 +293,6 @@ def test_geometry_group_assigned(self, make_session): class TestMatchFilter: - def test_substring_match(self): assert Session._match_filter("cloud_data_123", "data") is True @@ -321,7 +310,6 @@ def test_no_match(self): class TestListFilteredExtended: - def test_group_filter(self, make_session): session = make_session(n=3) geoms = session._all_geometries() @@ -342,7 +330,6 @@ def test_combined_filters(self, make_session): class TestFilterExtended: - def test_upper_bound_population(self): session = Session(quiet=True) for size in [10, 50, 100]: @@ -485,7 +472,6 @@ def test_membership_population_scalar(self): class TestOpenFile: - def test_open_xyz(self, tmp_path): path = tmp_path / "test.xyz" pts = np.random.RandomState(0).rand(25, 3).astype(np.float32) * 100 @@ -519,7 +505,6 @@ def test_open_with_offset_scale(self, tmp_path): class TestSaveLoad: - def test_save_xyz_roundtrip(self, make_session, tmp_path): session = make_session(n=1, n_points=20) geoms = session._all_geometries() @@ -567,7 +552,6 @@ def test_save_infers_format(self, make_session, tmp_path): class TestMergeExtended: - def test_merge_default_name(self, make_session): session = make_session(n=2, n_points=10) geoms = session._all_geometries() @@ -585,7 +569,6 @@ def test_merge_single_geometry(self): class TestResolveRange: - def test_reversed_range(self, make_session): session = make_session(n=5) result = session.resolve("#3-1") diff --git a/tests/commands/test_theme.py b/tests/commands/test_theme.py index 257751e2..8e7ad2e4 100644 --- a/tests/commands/test_theme.py +++ b/tests/commands/test_theme.py @@ -8,7 +8,6 @@ from mosaic.commands.theme import MOSAIC_THEME - EXPECTED_MOSAIC_KEYS = { "mosaic.heading", "mosaic.accent", diff --git a/tests/core/test_container.py b/tests/core/test_container.py index c0374920..4791c551 100644 --- a/tests/core/test_container.py +++ b/tests/core/test_container.py @@ -6,7 +6,7 @@ Author: Valentin Maurer """ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import numpy as np import pytest @@ -31,7 +31,6 @@ def _patch_lod(): class TestDataContainer: - def test_init_defaults(self): c = DataContainer() assert c.base_color == (0.7, 0.7, 0.7) diff --git a/tests/core/test_geometry.py b/tests/core/test_geometry.py index 91b18980..62f523dc 100644 --- a/tests/core/test_geometry.py +++ b/tests/core/test_geometry.py @@ -1,6 +1,7 @@ -import numpy as np import warnings -from unittest.mock import Mock, patch, ANY +from unittest.mock import ANY, Mock, patch + +import numpy as np from mosaic.geometry import Geometry diff --git a/tests/core/test_properties.py b/tests/core/test_properties.py index 3787b26b..41874502 100644 --- a/tests/core/test_properties.py +++ b/tests/core/test_properties.py @@ -31,7 +31,6 @@ def _geom(n=50, seed=0): class TestAggregate: - def test_1d_passthrough(self): arr = np.array([1.0, 2.0, 3.0]) np.testing.assert_array_equal(_aggregate(arr, "mean"), arr) @@ -57,7 +56,6 @@ def test_case_insensitive(self): class TestCoordinateProperties: - def test_width(self): g = _geom(20) result = width(g) @@ -91,7 +89,6 @@ def test_box_size_single_axis(self): class TestVertexProperty: - def test_existing_property(self): g = _geom(10) g.vertex_properties.set_property("score", np.arange(10, dtype=np.float32)) @@ -112,7 +109,6 @@ def test_missing_property(self): class TestDistance: - def test_self_distance(self): g = _geom(30) result = distance(g, queries=[g], include_self=True, k=1) @@ -168,7 +164,6 @@ def test_single_query_not_list(self): class TestGetMeshDecorator: - def test_no_model_returns_none(self): g = _geom(10) result = GeometryProperties.compute( @@ -191,7 +186,6 @@ def test_mesh_statistics_no_model(self): class TestGeometryPropertiesDispatch: - def test_compute_known_property(self): g = _geom(25) result = GeometryProperties.compute("n_points", g) @@ -236,8 +230,8 @@ class TestMeshProperties: @pytest.fixture def mesh_geom(self): - from mosaic.operations import fit from mosaic.geometry import GeometryData + from mosaic.operations import fit pts = ( np.array( @@ -301,7 +295,6 @@ def test_mesh_curvature(self, mesh_geom): class TestExportPropertyCsv: - def test_scalar_export(self, tmp_path): path = str(tmp_path / "scalar.csv") g1, g2 = _geom(10), _geom(20) diff --git a/tests/core/test_registry.py b/tests/core/test_registry.py index d86d5619..3fd9bc7e 100644 --- a/tests/core/test_registry.py +++ b/tests/core/test_registry.py @@ -9,17 +9,16 @@ import pytest from mosaic.registry import ( - Param, - Method, - Operation, - MethodRegistry, _UNSET, _WIDGET_TYPE, + Method, + MethodRegistry, + Operation, + Param, ) class TestParam: - def test_defaults(self): p = Param(name="depth", type="float") assert p.name == "depth" @@ -113,7 +112,6 @@ def test_frozen(self): class TestMethod: - def test_defaults(self): m = Method(display_name="DBSCAN", internal_name="dbscan") assert m.display_name == "DBSCAN" @@ -134,7 +132,6 @@ def test_gui_false(self): class TestOperation: - @pytest.fixture def simple_op(self): return Operation( @@ -232,7 +229,6 @@ def test_to_settings_dict_excludes_gui_false(self): class TestMethodRegistry: - def test_get_registered_operation(self): op = MethodRegistry.get("cluster") assert op is not None diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py index 9754c5e8..ba8cf63c 100644 --- a/tests/core/test_settings.py +++ b/tests/core/test_settings.py @@ -1,8 +1,7 @@ -import pytest -from unittest.mock import Mock, patch from dataclasses import dataclass +from unittest.mock import Mock, patch -from mosaic.settings import SettingsProperty, SettingsCategory +from mosaic.settings import SettingsCategory, SettingsProperty class TestSettingsProperty: @@ -66,9 +65,8 @@ def test_set_saves_to_qsettings(self): def test_tuple_type_handling(self): """Test handling of tuple types in QSettings.""" - from typing import Tuple - prop = SettingsProperty("test_key", (1, 2, 3), Tuple[int, int, int]) + prop = SettingsProperty("test_key", (1, 2, 3), tuple[int, int, int]) mock_obj = Mock() mock_qsettings = Mock() diff --git a/tests/core/test_tree_state.py b/tests/core/test_tree_state.py index c8a0e239..0e84b792 100644 --- a/tests/core/test_tree_state.py +++ b/tests/core/test_tree_state.py @@ -10,7 +10,6 @@ class TestTreeStateData: - def test_empty_defaults(self): t = TreeStateData() assert t.groups == {} @@ -67,7 +66,6 @@ def test_remove_nonexistent_is_noop(self): class TestTreeState: - def test_get_all_uuids(self): t = TreeState() t.root_items = ["u1", "u2"] diff --git a/tests/formats/test_containers.py b/tests/formats/test_containers.py index dbd86d1f..74b38b1f 100644 --- a/tests/formats/test_containers.py +++ b/tests/formats/test_containers.py @@ -7,7 +7,6 @@ """ import numpy as np -import pytest from mosaic.formats.parser import GeometryDataContainer, VertexPropertyContainer diff --git a/tests/formats/test_roundtrip.py b/tests/formats/test_roundtrip.py index 66440dd2..b8db9424 100644 --- a/tests/formats/test_roundtrip.py +++ b/tests/formats/test_roundtrip.py @@ -12,7 +12,6 @@ import numpy as np import pytest -from mosaic.geometry import Geometry, GeometryData from mosaic.formats.parser import ( NotASegmentationError, points_from_flat_array, @@ -20,10 +19,11 @@ read_ndjson, read_star, read_txt, + resolve_parser, ) -from mosaic.formats.parser import resolve_parser -from mosaic.formats.reader import open_file, is_volume_file, is_likely_density_map +from mosaic.formats.reader import is_likely_density_map, is_volume_file, open_file from mosaic.formats.writer import write_geometries +from mosaic.geometry import Geometry, GeometryData class TestNdjsonRoundTrip: diff --git a/tests/formats/test_stream.py b/tests/formats/test_stream.py index 0725e61f..411070a3 100644 --- a/tests/formats/test_stream.py +++ b/tests/formats/test_stream.py @@ -88,7 +88,7 @@ def test_level_spacings(self): class TestZarrImageSource: def test_vtk_pipeline_produces_output(self): - from mosaic.formats.stream import ZarrPyramid, ZarrImageSource + from mosaic.formats.stream import ZarrImageSource, ZarrPyramid levels = _make_pyramid() pyramid = ZarrPyramid(levels) @@ -99,7 +99,7 @@ def test_vtk_pipeline_produces_output(self): assert output.GetNumberOfPoints() > 0 def test_level_tracks_independently(self): - from mosaic.formats.stream import ZarrPyramid, ZarrImageSource + from mosaic.formats.stream import ZarrImageSource, ZarrPyramid levels = _make_pyramid() pyramid = ZarrPyramid(levels) @@ -111,7 +111,7 @@ def test_level_tracks_independently(self): assert src.spacing == pyramid.level_spacing(2) def test_defaults_to_coarsest(self): - from mosaic.formats.stream import ZarrPyramid, ZarrImageSource + from mosaic.formats.stream import ZarrImageSource, ZarrPyramid levels = _make_pyramid() pyramid = ZarrPyramid(levels) diff --git a/tests/formats/test_writer.py b/tests/formats/test_writer.py index 67df4c4c..db7456e0 100644 --- a/tests/formats/test_writer.py +++ b/tests/formats/test_writer.py @@ -12,8 +12,8 @@ import pytest from scipy.spatial.transform import Rotation -from mosaic.geometry import Geometry from mosaic.formats.writer import write_geometries, write_topology_file +from mosaic.geometry import Geometry @pytest.fixture @@ -35,7 +35,6 @@ def cloud_no_quats(): class TestWriteGeometriesXyz: - def test_single_file(self, tmp_path, cloud_no_quats): path = str(tmp_path / "out.xyz") write_geometries([cloud_no_quats], path, format="xyz") @@ -60,7 +59,6 @@ def test_merged_single_path(self, tmp_path): class TestWriteGeometriesNdjson: - def test_single_file(self, tmp_path, cloud): path = str(tmp_path / "out.ndjson") write_geometries([cloud], path, format="ndjson") @@ -94,7 +92,6 @@ def test_roundtrip(self, tmp_path, cloud): class TestWriteGeometriesEdgeCases: - def test_empty_list_returns_none(self, tmp_path): result = write_geometries([], str(tmp_path / "empty.xyz"), format="xyz") assert result is None @@ -116,7 +113,6 @@ def test_unsupported_format_raises(self, tmp_path, cloud_no_quats): class TestWriteGeometriesSampling: - def test_relion_5_format(self, tmp_path, cloud_no_quats): # relion_5_format applies only to STAR; other formats absorb it. path = str(tmp_path / "relion5.xyz") @@ -133,7 +129,6 @@ def test_relion_5_format(self, tmp_path, cloud_no_quats): class TestCoerceRecords: - def test_pulls_point_data_and_sampling(self): from mosaic.formats.records import GeometryData from mosaic.formats.writer import _coerce_records @@ -179,7 +174,6 @@ def test_empty_input_returns_empty_list(self): class TestPrepareOrientedPoints: - def test_scales_by_sampling_and_concatenates(self): from mosaic.formats.writer import _coerce_records, _prepare_oriented_points @@ -220,7 +214,6 @@ def test_preserves_existing_quaternions(self, cloud): class TestWriteTopologyFile: - @pytest.fixture def topo_data(self): return { @@ -270,7 +263,6 @@ def test_tsi_with_inclusions(self, tmp_path, topo_data): class TestWriteStar: - def test_writes_star_file(self, tmp_path, cloud): from mosaic.formats.writer import _coerce_records, write_star @@ -310,8 +302,8 @@ def test_relion_5_writes_version_marker(self, tmp_path, cloud): def test_relion_5_writes_pixel_size_for_roundtrip(self, tmp_path, cloud): """RELION-5 needs the optics pixel size so import can undo the centering.""" - from mosaic.formats.writer import _coerce_records, write_star from mosaic.formats._utils import read_star_header + from mosaic.formats.writer import _coerce_records, write_star path = str(tmp_path / "r5.star") write_star( @@ -338,7 +330,6 @@ def test_relion_5_requires_shape(self, tmp_path, cloud): class TestWriteTsv: - def test_writes_tsv_with_orientation_columns(self, tmp_path, cloud): from mosaic.formats.writer import _coerce_records, write_tsv @@ -350,7 +341,6 @@ def test_writes_tsv_with_orientation_columns(self, tmp_path, cloud): class TestWriteXyz: - def test_writes_concatenated_points(self, tmp_path): from mosaic.formats.writer import _coerce_records, write_xyz @@ -363,7 +353,6 @@ def test_writes_concatenated_points(self, tmp_path): class TestWriteNdjson: - def test_writes_one_record_per_point(self, tmp_path, cloud): from mosaic.formats.writer import _coerce_records, write_ndjson @@ -392,7 +381,6 @@ def test_synthesizes_quaternions_from_normals(self, tmp_path): class TestWriteMeshes: - def test_no_mesh_raises(self, tmp_path, cloud_no_quats): from mosaic.formats.writer import _coerce_records, write_meshes @@ -401,7 +389,6 @@ def test_no_mesh_raises(self, tmp_path, cloud_no_quats): class TestWriteVolume: - def test_writes_volume_file(self, tmp_path): from mosaic.formats.writer import _coerce_records, write_volume @@ -412,7 +399,6 @@ def test_writes_volume_file(self, tmp_path): class TestWriteGeometriesMultiFile: - def test_one_file_per_geometry(self, tmp_path, cloud): g1 = Geometry(points=cloud.points[:10], quaternions=cloud.quaternions[:10]) g2 = Geometry(points=cloud.points[10:], quaternions=cloud.quaternions[10:]) @@ -433,7 +419,6 @@ def test_dispatch_reaches_each_format(self, tmp_path): class TestShapeInference: - def test_volume_shape_inferred_from_bounds(self, tmp_path): # No shape passed; orchestrator should infer it from point bounds. g = Geometry(points=np.array([[5.0, 6.0, 7.0]], dtype=np.float32)) @@ -443,7 +428,6 @@ def test_volume_shape_inferred_from_bounds(self, tmp_path): class TestRelion5Centering: - def test_origin_shifted_to_volume_center(self, tmp_path): # With sampling 1 and shape (100, 100, 100), the relion_5 center is # (50, 50, 50) Å. A point at (50, 50, 50) Å should be written as diff --git a/tests/gui/test_batch_navigator.py b/tests/gui/test_batch_navigator.py index d36eb27a..3cf68acc 100644 --- a/tests/gui/test_batch_navigator.py +++ b/tests/gui/test_batch_navigator.py @@ -1,12 +1,11 @@ -import pytest from unittest.mock import Mock + from qtpy.QtCore import Qt from mosaic.widgets.container_list import SessionListWidget class TestSessionListWidget: - def test_initial_state(self, qapp): widget = SessionListWidget(cdata=Mock()) assert widget.session_files == [] @@ -188,7 +187,6 @@ def test_context_menu_policy_set(self, qapp): class TestSessionHeader: - def test_header_widget_exists(self, qapp): widget = SessionListWidget(cdata=Mock()) assert widget._header is not None @@ -234,7 +232,6 @@ def test_clear_button_calls_clear_with_prompt(self, qapp, monkeypatch): class TestSidebarSessionIntegration: - def test_add_widget(self, qapp): sidebar = ObjectBrowserSidebar() session_widget = SessionListWidget(cdata=Mock()) diff --git a/tests/gui/test_icons.py b/tests/gui/test_icons.py index 92ffe12d..50b3ce65 100644 --- a/tests/gui/test_icons.py +++ b/tests/gui/test_icons.py @@ -4,8 +4,8 @@ Author: Valentin Maurer """ +from mosaic.icons import _resolve_colors, icon, icon_button, icon_pixmap from mosaic.stylesheets import Colors -from mosaic.icons import _resolve_colors, icon, icon_pixmap, icon_button def test_resolve_colors_defaults_to_muted_enabled_and_disabled(): diff --git a/tests/gui/test_import_dialog.py b/tests/gui/test_import_dialog.py index fa2d018a..3d3a0f16 100644 --- a/tests/gui/test_import_dialog.py +++ b/tests/gui/test_import_dialog.py @@ -4,7 +4,6 @@ from mosaic.formats._utils import read_star_header from mosaic.widgets.settings import get_widget_value - RELION4_PIXEL_STAR = textwrap.dedent( """\ data_optics diff --git a/tests/gui/test_io_tasks.py b/tests/gui/test_io_tasks.py index f47ba81d..f8c1d4c7 100644 --- a/tests/gui/test_io_tasks.py +++ b/tests/gui/test_io_tasks.py @@ -5,6 +5,7 @@ """ import time + import pytest from mosaic.parallel import BackgroundTaskManager @@ -67,9 +68,9 @@ def test_submit_task_process_backend_still_works(fresh_manager): args=(3,), ) - assert _wait_for( - lambda: results == [27], timeout=30.0 - ), f"process task never completed: {results}" + assert _wait_for(lambda: results == [27], timeout=30.0), ( + f"process task never completed: {results}" + ) def _boom(): @@ -135,7 +136,9 @@ def _slow_worker(barrier_event, n): def test_status_indicator_owner_handoff(fresh_manager, qtbot, qapp): import threading + from qtpy.QtWidgets import QMainWindow + from mosaic.widgets.status_indicator import StatusIndicator mw = QMainWindow() @@ -154,9 +157,9 @@ def test_status_indicator_owner_handoff(fresh_manager, qtbot, qapp): args=(release_a, 3), ) # Wait for A to become the active owner. - assert _wait_for( - lambda: indicator._active_io_task_id == a - ), f"A never became active owner; got {indicator._active_io_task_id}" + assert _wait_for(lambda: indicator._active_io_task_id == a), ( + f"A never became active owner; got {indicator._active_io_task_id}" + ) # Submit task B — should take over ownership (most-recent-wins). b = fresh_manager.submit_io_task( @@ -164,21 +167,21 @@ def test_status_indicator_owner_handoff(fresh_manager, qtbot, qapp): func=_slow_worker, args=(release_b, 3), ) - assert _wait_for( - lambda: indicator._active_io_task_id == b - ), f"B never took ownership; got {indicator._active_io_task_id}" + assert _wait_for(lambda: indicator._active_io_task_id == b), ( + f"B never took ownership; got {indicator._active_io_task_id}" + ) # Let B complete first. Ownership should hand back to A (still running). release_b.set() - assert _wait_for( - lambda: indicator._active_io_task_id == a - ), f"handoff to A failed; got {indicator._active_io_task_id}" + assert _wait_for(lambda: indicator._active_io_task_id == a), ( + f"handoff to A failed; got {indicator._active_io_task_id}" + ) # Let A finish. Ownership should clear. release_a.set() - assert _wait_for( - lambda: indicator._active_io_task_id is None - ), f"ownership never cleared; got {indicator._active_io_task_id}" + assert _wait_for(lambda: indicator._active_io_task_id is None), ( + f"ownership never cleared; got {indicator._active_io_task_id}" + ) finally: # Unblock worker threads regardless of assertion outcome so the # fresh_manager teardown doesn't race with parked threads. @@ -194,6 +197,7 @@ def test_status_indicator_owner_handoff(fresh_manager, qtbot, qapp): def test_status_indicator_ignores_process_tasks(fresh_manager, qtbot, qapp): from qtpy.QtWidgets import QMainWindow + from mosaic.widgets.status_indicator import StatusIndicator mw = QMainWindow() diff --git a/tests/gui/test_screenshot_manager.py b/tests/gui/test_screenshot_manager.py index 3a4b454d..ae146339 100644 --- a/tests/gui/test_screenshot_manager.py +++ b/tests/gui/test_screenshot_manager.py @@ -51,7 +51,9 @@ def recorded_capture(monkeypatch): calls = {} def fake_capture_frame(render_window, transparent_bg=False, magnification=1, **kw): - width = kw["width"] if kw.get("width") is not None else render_window.GetSize()[0] + width = ( + kw["width"] if kw.get("width") is not None else render_window.GetSize()[0] + ) height = ( kw["height"] if kw.get("height") is not None else render_window.GetSize()[1] ) @@ -76,7 +78,9 @@ def test_capture_size_is_independent_of_device_pixel_ratio( def test_capture_honors_explicit_dimensions(self, recorded_capture): widget = FakeVTKWidget((800, 600), 2.0) - image = ScreenshotManager(widget).capture(width=320, height=240, magnification=1) + image = ScreenshotManager(widget).capture( + width=320, height=240, magnification=1 + ) assert image.size == (320, 240) @@ -138,9 +142,7 @@ def _measure(frame): class TestRelativeScaling: """Screenshots must show the scene at the proportions the viewer shows.""" - def test_point_size_keeps_its_share_of_the_frame( - self, require_vtk_render_window - ): + def test_point_size_keeps_its_share_of_the_frame(self, require_vtk_render_window): render_window = _offscreen_window() base_diameter, base_separation = _measure( @@ -172,9 +174,7 @@ def test_point_size_follows_the_scene_when_aspect_changes( diameter / separation, rel=0.15 ) - def test_cropped_capture_keeps_point_proportions( - self, require_vtk_render_window - ): + def test_cropped_capture_keeps_point_proportions(self, require_vtk_render_window): render_window = _offscreen_window() diameter, separation = _measure( diff --git a/tests/gui/test_update_dialog.py b/tests/gui/test_update_dialog.py index be0deb6a..d8790fda 100644 --- a/tests/gui/test_update_dialog.py +++ b/tests/gui/test_update_dialog.py @@ -13,8 +13,10 @@ def _payload(*versions, info_version=None, yanked=(), files=True): releases[version] = [] continue releases[version] = [{"yanked": version in yanked}] - return {"info": {"version": info_version or (versions[-1] if versions else "")}, - "releases": releases} + return { + "info": {"version": info_version or (versions[-1] if versions else "")}, + "releases": releases, + } class TestNewerVersion: @@ -73,7 +75,9 @@ def test_returns_none_for_empty_payload(self): class TestReleaseExists: def test_true_when_tag_endpoint_resolves(self): - with patch("mosaic.dialogs.update._fetch_json", return_value={"tag_name": "v1.6.0"}): + with patch( + "mosaic.dialogs.update._fetch_json", return_value={"tag_name": "v1.6.0"} + ): assert release_exists("1.6.0") is True def test_false_when_tag_endpoint_is_missing(self): diff --git a/tests/operations/test_operations.py b/tests/operations/test_operations.py index 8d8c9774..fe992cea 100644 --- a/tests/operations/test_operations.py +++ b/tests/operations/test_operations.py @@ -1,6 +1,7 @@ +from unittest.mock import Mock + import numpy as np import pytest -from unittest.mock import Mock from mosaic.geometry import GeometryData from mosaic.operations import ( @@ -160,7 +161,6 @@ def test_hide(self): class TestSkeletonizeExtended: - @pytest.fixture def dense_cloud(self): """Dense cloud suitable for skeletonization (needs volume-filling points).""" diff --git a/tests/operations/test_parametrization.py b/tests/operations/test_parametrization.py index 2acea456..c4cfb819 100644 --- a/tests/operations/test_parametrization.py +++ b/tests/operations/test_parametrization.py @@ -6,10 +6,11 @@ """ import pickle -import pytest + import numpy as np +import pytest -from mosaic.parametrization import PARAMETRIZATION_TYPE, TriangularMesh, BallPivoting +from mosaic.parametrization import PARAMETRIZATION_TYPE, BallPivoting PARAMETRIZATION_CONFIG = [ ("Sphere", "sphere", {}), @@ -163,7 +164,6 @@ def test_points_per_sampling(self, fitted_param, normal_offset): class TestMeshSpecific: - @pytest.fixture(scope="class") def mesh(self): points = generate_random_points(100, "sphere") diff --git a/tests/operations/test_resolution_helpers.py b/tests/operations/test_resolution_helpers.py index 023e1141..c297e6d3 100644 --- a/tests/operations/test_resolution_helpers.py +++ b/tests/operations/test_resolution_helpers.py @@ -7,8 +7,8 @@ _MESH_CHORD_TOL, _MESH_MAX_SEG, _edge_length_from_curvature, - _n_segments, _grid_faces, + _n_segments, ) diff --git a/tests/pipeline/test_executor.py b/tests/pipeline/test_executor.py index c93c8398..1845f93a 100644 --- a/tests/pipeline/test_executor.py +++ b/tests/pipeline/test_executor.py @@ -8,11 +8,11 @@ import os import shlex -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import pytest -from mosaic.pipeline.executor import compile_run, generate_runs, execute_run +from mosaic.pipeline.executor import compile_run, execute_run, generate_runs def _run(operations, run_id="test", input_file="/data/input.star", input_params=None): @@ -45,7 +45,6 @@ def _op(op_id, settings=None, save_output=True, visible_output=True, group_name= class TestCompileImport: - def test_open(self): steps = compile_run(_run([_import_op()])) assert steps[0][1] == "open /data/input.star" @@ -80,7 +79,6 @@ def test_path_quoting_roundtrip(self, path): class TestCompileSaveExport: - def test_save_session_path(self, tmp_path): r = _run( [_import_op(), _op("save_session", {"output_dir": str(tmp_path)})], @@ -125,7 +123,6 @@ def test_export_filters_method_key(self, tmp_path): class TestCompileMeshAnalysis: - @patch("mosaic.pipeline.executor.MethodRegistry") def test_known_method(self, mock_reg): m = MagicMock() @@ -176,7 +173,6 @@ def test_extra_kwargs_appended(self, mock_reg): class TestCompileClusterSelect: - @pytest.mark.parametrize( "lower, upper, expected_parts", [ @@ -203,7 +199,6 @@ def test_thresholds(self, lower, upper, expected_parts): class TestCompileGeneric: - def test_persist_false(self): r = _run( [_import_op(), _op("cluster", {"method": "DBSCAN"}, save_output=False)] @@ -256,7 +251,6 @@ def test_method_with_spaces(self): class TestGenerateRuns: - @staticmethod def _pipeline(files, extra_nodes=None): import_node = { @@ -317,7 +311,6 @@ def test_no_input_files_raises(self): class TestExecuteRun: - def test_skip_complete(self, tmp_path): """When output already exists and skip_complete=True, execution is skipped.""" out_file = tmp_path / "test.pickle" diff --git a/tests/pipeline/test_pipeline.py b/tests/pipeline/test_pipeline.py index 452ff5ad..c116503d 100644 --- a/tests/pipeline/test_pipeline.py +++ b/tests/pipeline/test_pipeline.py @@ -7,13 +7,13 @@ """ import json -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import pytest from mosaic.pipeline._utils import ( - strip_filepath, natural_sort_key, + strip_filepath, topological_sort, ) from mosaic.pipeline.executor import generate_runs @@ -407,9 +407,10 @@ def valid_config(self, tmp_path, pipeline_config_data): def test_cli_config_not_found(self, tmp_path): """Test CLI with non-existent config file.""" - from mosaic.scripts.pipeline import main import sys + from mosaic.scripts.pipeline import main + nonexistent = tmp_path / "nonexistent.json" with patch.object(sys, "argv", ["mosaic-pipeline", str(nonexistent)]): @@ -419,9 +420,10 @@ def test_cli_config_not_found(self, tmp_path): def test_cli_dry_run(self, valid_config, pipeline_config_data, capsys): """Test CLI dry-run mode lists all runs from config.""" - from mosaic.scripts.pipeline import main import sys + from mosaic.scripts.pipeline import main + # Derive expected values from config import_node = next( n @@ -445,9 +447,10 @@ def test_cli_dry_run(self, valid_config, pipeline_config_data, capsys): def test_cli_index_out_of_range(self, valid_config, pipeline_config_data): """Test CLI with invalid index returns error.""" - from mosaic.scripts.pipeline import main import sys + from mosaic.scripts.pipeline import main + # Derive the valid range from config import_node = next( n @@ -468,9 +471,10 @@ def test_cli_index_out_of_range(self, valid_config, pipeline_config_data): def test_cli_index_valid(self, valid_config, pipeline_config_data, capsys): """Test CLI with valid index selects correct run.""" - from mosaic.scripts.pipeline import main import sys + from mosaic.scripts.pipeline import main + # Get first file's expected run_id import_node = next( n @@ -493,10 +497,11 @@ def test_cli_index_valid(self, valid_config, pipeline_config_data, capsys): def test_cli_invalid_config(self, tmp_path): """Test CLI with invalid JSON config raises JSONDecodeError.""" - from mosaic.scripts.pipeline import main import json import sys + from mosaic.scripts.pipeline import main + config_file = tmp_path / "invalid.json" config_file.write_text("not valid json {") @@ -506,9 +511,10 @@ def test_cli_invalid_config(self, tmp_path): def test_cli_empty_pipeline(self, tmp_path): """Test CLI with empty pipeline.""" - from mosaic.scripts.pipeline import main import sys + from mosaic.scripts.pipeline import main + config = {"version": "2.0", "nodes": []} config_file = tmp_path / "empty.json" config_file.write_text(json.dumps(config)) @@ -524,10 +530,11 @@ def test_cli_execution( self, mock_executor_class, mock_execute, valid_config, pipeline_config_data ): """Test CLI executes correct number of runs from config.""" - from mosaic.scripts.pipeline import main import sys from concurrent.futures import Future + from mosaic.scripts.pipeline import main + # Derive expected run count from config import_node = next( n @@ -626,6 +633,6 @@ def test_presets_reference_valid_operations(self): for preset_name, preset_ops in PIPELINE_PRESETS.items(): for op in preset_ops: - assert ( - op["name"] in valid_ops - ), f"Preset {preset_name} references invalid operation {op['name']}" + assert op["name"] in valid_ops, ( + f"Preset {preset_name} references invalid operation {op['name']}" + ) diff --git a/tests/screenshots/conftest.py b/tests/screenshots/conftest.py index 9241e04d..7a960921 100644 --- a/tests/screenshots/conftest.py +++ b/tests/screenshots/conftest.py @@ -1,11 +1,11 @@ import os import sys -import pytest -import numpy as np from pathlib import Path -from PIL import Image -from qtpy.QtGui import QFont, QFontInfo, QFontDatabase +import numpy as np +import pytest +from PIL import Image +from qtpy.QtGui import QFont, QFontDatabase, QFontInfo from qtpy.QtWidgets import QApplication from mosaic.stylesheets import ( @@ -15,7 +15,6 @@ build_qt_palette, ) - SCREENSHOT_DIR = Path(__file__).resolve().parents[2] / "docs" / "_static" / "tutorial" diff --git a/tests/screenshots/test_core_ui.py b/tests/screenshots/test_core_ui.py index f660c974..f62a5d5d 100644 --- a/tests/screenshots/test_core_ui.py +++ b/tests/screenshots/test_core_ui.py @@ -2,12 +2,11 @@ from mosaic.geometry import Geometry -from .conftest import save_widget_screenshot, make_sphere_points, make_two_blobs +from .conftest import make_sphere_points, make_two_blobs, save_widget_screenshot @pytest.mark.gui class TestCoreUI: - def test_empty_interface(self, mosaic_app, output_dir, qapp): mosaic_app.cdata.reset() mosaic_app.prime_viewport_placeholder() diff --git a/tests/screenshots/test_dialogs.py b/tests/screenshots/test_dialogs.py index 64c4b35d..220d2be7 100644 --- a/tests/screenshots/test_dialogs.py +++ b/tests/screenshots/test_dialogs.py @@ -1,7 +1,7 @@ import pytest -from mosaic.dialogs.import_data import ImportDataDialog from mosaic.dialogs.export import ExportDialog +from mosaic.dialogs.import_data import ImportDataDialog from mosaic.dialogs.properties import GeometryPropertiesDialog from mosaic.geometry import BASE_COLOR @@ -10,7 +10,6 @@ @pytest.mark.gui class TestDialogs: - def test_import_dialog(self, mosaic_app, output_dir, qapp): dialog = ImportDataDialog(mosaic_app) dialog.set_files(["membrane_segmentation.star"]) diff --git a/tests/screenshots/test_tabs.py b/tests/screenshots/test_tabs.py index a0a6c702..45c13f34 100644 --- a/tests/screenshots/test_tabs.py +++ b/tests/screenshots/test_tabs.py @@ -3,7 +3,6 @@ @pytest.mark.gui class TestTabs: - def test_segmentation_tab(self, mosaic_app, qapp): mosaic_app.tab_bar.setCurrentIndex(0) qapp.processEvents() diff --git a/tests/sculpt/test_adapter.py b/tests/sculpt/test_adapter.py index 5858c0f7..326fece7 100644 --- a/tests/sculpt/test_adapter.py +++ b/tests/sculpt/test_adapter.py @@ -81,9 +81,9 @@ def test_polydata_point_writer_is_zero_copy(flat_grid_mesh): view_before = numpy_support.vtk_to_numpy(geom._data.GetPoints().GetData()) writer.write(vs) view_after = numpy_support.vtk_to_numpy(geom._data.GetPoints().GetData()) - assert ( - view_before.ctypes.data == view_after.ctypes.data - ), "writer must not reallocate the polydata's point buffer" + assert view_before.ctypes.data == view_after.ctypes.data, ( + "writer must not reallocate the polydata's point buffer" + ) def test_sync_model_vertices_updates_open3d_mesh(flat_grid_mesh): @@ -141,6 +141,6 @@ def test_polydata_point_writer_rebinds_after_points_swap(flat_grid_mesh): writer.write(new_vs) live = numpy_support.vtk_to_numpy(geom._data.GetPoints().GetData()) - assert np.allclose( - live[:, 2], 1.3, atol=1e-5 - ), "writer must follow the polydata's current points buffer after a swap" + assert np.allclose(live[:, 2], 1.3, atol=1e-5), ( + "writer must follow the polydata's current points buffer after a swap" + ) diff --git a/tests/sculpt/test_hittest.py b/tests/sculpt/test_hittest.py index 89a46024..89a81372 100644 --- a/tests/sculpt/test_hittest.py +++ b/tests/sculpt/test_hittest.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock + import numpy as np from mosaic.sculpt.controller import MeshHit, pick_mesh diff --git a/tests/sculpt/test_shortcuts.py b/tests/sculpt/test_shortcuts.py index 13207485..5ccd8f03 100644 --- a/tests/sculpt/test_shortcuts.py +++ b/tests/sculpt/test_shortcuts.py @@ -3,9 +3,8 @@ from unittest.mock import MagicMock import pytest -from qtpy.QtCore import QEvent, Qt +from qtpy.QtCore import QEvent, QPoint, QPointF, Qt from qtpy.QtGui import QKeyEvent, QWheelEvent -from qtpy.QtCore import QPointF, QPoint from mosaic.sculpt.controller import Brush, SculptController from mosaic.sculpt.shortcuts import SculptShortcutFilter diff --git a/tests/sculpt/test_undo_integration.py b/tests/sculpt/test_undo_integration.py index 59a069b3..9f55937a 100644 --- a/tests/sculpt/test_undo_integration.py +++ b/tests/sculpt/test_undo_integration.py @@ -1,7 +1,6 @@ """Sculpt records carry both endpoints so STACK can both undo and redo.""" import numpy as np -import pytest from mosaic.sculpt.session import ( PositionRecord, diff --git a/tests/sculpt/test_wiring_smoke.py b/tests/sculpt/test_wiring_smoke.py index e0d28d62..312b50eb 100644 --- a/tests/sculpt/test_wiring_smoke.py +++ b/tests/sculpt/test_wiring_smoke.py @@ -5,10 +5,10 @@ SculptController -> SculptSession -> SculptTarget seam. """ -import pytest from unittest.mock import MagicMock import numpy as np +import pytest import vtk from vtkmodules.util import numpy_support @@ -136,16 +136,16 @@ def test_full_patch_stroke_lifecycle(punched_grid_mesh): c.handle_event(EventKind.LMB_UP, 10, 10) face_count_filled = len(c.session.target.fs) - assert ( - face_count_filled > face_count_before - ), "patch must have added faces to close the hole" + assert face_count_filled > face_count_before, ( + "patch must have added faces to close the hole" + ) assert STACK.undo() is not None - assert ( - len(c.session.target.fs) == face_count_before - ), "undo must revert to original face count" + assert len(c.session.target.fs) == face_count_before, ( + "undo must revert to original face count" + ) STACK.redo() - assert ( - len(c.session.target.fs) == face_count_filled - ), "redo must reapply the filled topology" + assert len(c.session.target.fs) == face_count_filled, ( + "redo must reapply the filled topology" + ) diff --git a/tests/test_normal_fabrication.py b/tests/test_normal_fabrication.py index 918aea2c..53d71ced 100644 --- a/tests/test_normal_fabrication.py +++ b/tests/test_normal_fabrication.py @@ -7,9 +7,9 @@ import numpy as np -from mosaic.geometry import Geometry, merge_geometries -from mosaic.utils import normals_to_rot, NORMAL_REFERENCE import mosaic.operations as ops +from mosaic.geometry import Geometry, merge_geometries +from mosaic.utils import NORMAL_REFERENCE, normals_to_rot def _pts(n=2000, seed=0): @@ -179,6 +179,7 @@ def test_swap_data_points_only_clears_stale_quaternions(): def _icosphere_mesh(radius=20.0, iterations=2): import open3d as o3d + from mosaic.parametrization import TriangularMesh mesh = o3d.geometry.TriangleMesh.create_icosahedron(radius=radius) diff --git a/tests/test_swaps.py b/tests/test_swaps.py index 4c033f68..8c28d2aa 100644 --- a/tests/test_swaps.py +++ b/tests/test_swaps.py @@ -37,7 +37,7 @@ def test_apply_changes_add_then_undo_removes(): assert target.container.get(g.uuid) is None -from mosaic.swaps import build_changes, place +from mosaic.swaps import place from mosaic.undo import STACK diff --git a/tests/widgets/test_volume_viewer_modes.py b/tests/widgets/test_volume_viewer_modes.py index 1f30b6e9..062a7419 100644 --- a/tests/widgets/test_volume_viewer_modes.py +++ b/tests/widgets/test_volume_viewer_modes.py @@ -1,7 +1,7 @@ """Tests for VolumeViewer mode behaviour and renderer strategy split.""" -import vtk import pytest +import vtk from mosaic.widgets.volume_viewer import VolumeViewer