setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QMenu, QToolButton
12
12
 
13
13
  from PyQt6.QtCore import QElapsedTimer
14
14
 
15
+ import sys
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  pass
@@ -30,10 +31,10 @@ from setiastro.saspro.resources import (
30
31
  stacking_path, pedestal_icon_path, starspike_path, astrospike_path,
31
32
  signature_icon_path, livestacking_path, convoicon_path, spcc_icon_path,
32
33
  exoicon_path, peeker_icon, dse_icon_path, isophote_path, statstretch_path,
33
- starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path,
34
+ starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path, narrowbandnormalization_path,
34
35
  nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
35
36
  satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
36
- debayer_path, aberration_path, functionbundles_path, viewbundles_path,
37
+ debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
37
38
  selectivecolor_path, rgbalign_path,
38
39
  )
39
40
 
@@ -255,6 +256,7 @@ class ToolbarMixin:
255
256
  tb_tl.addAction(self.act_blink) # Tools start here; Blink shows with QIcon(blink_path)
256
257
  tb_tl.addAction(self.act_ppp) # Perfect Palette Picker
257
258
  tb_tl.addAction(self.act_nbtorgb)
259
+ tb_tl.addAction(self.act_narrowband_normalization)
258
260
  tb_tl.addAction(self.act_selective_color)
259
261
  tb_tl.addAction(self.act_freqsep)
260
262
  tb_tl.addAction(self.act_multiscale_decomp)
@@ -301,6 +303,7 @@ class ToolbarMixin:
301
303
  tb_star.addAction(self.act_psf_viewer)
302
304
  tb_star.addAction(self.act_stacking_suite)
303
305
  tb_star.addAction(self.act_live_stacking)
306
+ tb_star.addAction(self.act_planetary_stacker)
304
307
  tb_star.addAction(self.act_plate_solve)
305
308
  tb_star.addAction(self.act_star_align)
306
309
  tb_star.addAction(self.act_star_register)
@@ -366,6 +369,7 @@ class ToolbarMixin:
366
369
  tb_hidden.setObjectName("Hidden")
367
370
  tb_hidden.setSettingsKey("Toolbar/Hidden")
368
371
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_hidden)
372
+ #tb_hidden.addAction(self.act_narrowband_normalization)
369
373
  tb_hidden.setVisible(False) # <- always hidden
370
374
 
371
375
  # This can move actions between toolbars, so do it after each toolbar has its base order restored.
@@ -594,7 +598,16 @@ class ToolbarMixin:
594
598
  fit_menu.addAction(self.act_auto_fit_resize) # use the real action
595
599
  btn_fit.setMenu(fit_menu)
596
600
  btn_fit.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
601
+ tb = self._toolbar_containing_action(self.act_autostretch)
602
+ if tb:
603
+ btn = tb.widgetForAction(self.act_autostretch)
604
+ if isinstance(btn, QToolButton):
605
+ # ... build menu ...
606
+ btn.setMenu(menu)
607
+ btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
597
608
 
609
+ # IMPORTANT: re-apply style after action moves / rebind
610
+ self._style_toggle_toolbutton(btn)
598
611
 
599
612
  def _bind_view_toolbar_menus(self, tb: DraggableToolBar):
600
613
  # --- Display-Stretch menu ---
@@ -650,6 +663,12 @@ class ToolbarMixin:
650
663
  btn_fit.setMenu(fit_menu)
651
664
  btn_fit.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
652
665
 
666
+ def _linux_force_text_action(self, act: QAction, text: str) -> None:
667
+ """On Linux, show text-only for this action (no theme icon)."""
668
+ if not sys.platform.startswith("linux"):
669
+ return
670
+ act.setIcon(QIcon()) # remove whatever theme icon got assigned
671
+ act.setText(text) # show the glyph/text
653
672
 
654
673
  def _create_actions(self):
655
674
  # File actions
@@ -771,30 +790,30 @@ class ToolbarMixin:
771
790
  self.act_bake_display_stretch.triggered.connect(self._bake_display_stretch)
772
791
 
773
792
  # --- Zoom controls ---
774
- # --- Zoom controls (themed icons) ---
775
793
  self.act_zoom_out = QAction(QIcon.fromTheme("zoom-out"), self.tr("Zoom Out"), self)
776
794
  self.act_zoom_out.setStatusTip(self.tr("Zoom out"))
777
795
  self.act_zoom_out.setShortcuts([QKeySequence("Ctrl+-")])
778
796
  self.act_zoom_out.triggered.connect(lambda: self._zoom_step_active(-1))
797
+ self._linux_force_text_action(self.act_zoom_out, "−") # true minus
779
798
 
780
799
  self.act_zoom_in = QAction(QIcon.fromTheme("zoom-in"), self.tr("Zoom In"), self)
781
800
  self.act_zoom_in.setStatusTip(self.tr("Zoom in"))
782
- self.act_zoom_in.setShortcuts([
783
- QKeySequence("Ctrl++"), # Ctrl + (Shift + = on many keyboards)
784
- QKeySequence("Ctrl+="), # fallback
785
- ])
801
+ self.act_zoom_in.setShortcuts([QKeySequence("Ctrl++"), QKeySequence("Ctrl+=")])
786
802
  self.act_zoom_in.triggered.connect(lambda: self._zoom_step_active(+1))
803
+ self._linux_force_text_action(self.act_zoom_in, "+")
787
804
 
788
805
  self.act_zoom_1_1 = QAction(QIcon.fromTheme("zoom-original"), self.tr("1:1"), self)
789
806
  self.act_zoom_1_1.setStatusTip(self.tr("Zoom to 100% (pixel-for-pixel)"))
790
807
  self.act_zoom_1_1.setShortcut(QKeySequence("Ctrl+1"))
791
808
  self.act_zoom_1_1.triggered.connect(self._zoom_active_1_1)
809
+ self._linux_force_text_action(self.act_zoom_1_1, "1:1")
792
810
 
793
811
  self.act_zoom_fit = QAction(QIcon.fromTheme("zoom-fit-best"), self.tr("Fit"), self)
794
812
  self.act_zoom_fit.setStatusTip(self.tr("Fit image to current window"))
795
813
  self.act_zoom_fit.setShortcut(QKeySequence("Ctrl+0"))
796
814
  self.act_zoom_fit.triggered.connect(self._zoom_active_fit)
797
815
  self.act_zoom_fit.setCheckable(True)
816
+ self._linux_force_text_action(self.act_zoom_fit, "Fit")
798
817
 
799
818
  self.act_auto_fit_resize = QAction(self.tr("Auto-fit on Resize"), self)
800
819
  self.act_auto_fit_resize.setCheckable(True)
@@ -813,6 +832,10 @@ class ToolbarMixin:
813
832
  self.act_paste_view.setShortcut("Ctrl+Shift+V")
814
833
  self.act_copy_view.triggered.connect(self._copy_active_view)
815
834
  self.act_paste_view.triggered.connect(self._paste_active_view)
835
+ # --- Edit: Mono -> RGB (triplicate channels) ---
836
+ self.act_mono_to_rgb = QAction(self.tr("Convert Mono to RGB"), self)
837
+ self.act_mono_to_rgb.setStatusTip(self.tr("Convert a mono image to RGB by duplicating the channel"))
838
+ self.act_mono_to_rgb.triggered.connect(self._convert_mono_to_rgb_active)
816
839
 
817
840
  # Functions
818
841
  self.act_crop = QAction(QIcon(cropicon_path), self.tr("Crop..."), self)
@@ -877,7 +900,7 @@ class ToolbarMixin:
877
900
  self.act_linear_fit.setShortcut("Ctrl+L")
878
901
  self.act_linear_fit.triggered.connect(self._open_linear_fit)
879
902
 
880
- self.act_remove_green = QAction(QIcon(green_path), self.tr("Remove Green..."), self)
903
+ self.act_remove_green = QAction(QIcon(green_path), self.tr("Remove Green (SCNR)..."), self)
881
904
  self.act_remove_green.setToolTip(self.tr("SCNR-style green channel removal."))
882
905
  self.act_remove_green.setIconVisibleInMenu(True)
883
906
  self.act_remove_green.triggered.connect(self._open_remove_green)
@@ -1102,6 +1125,16 @@ class ToolbarMixin:
1102
1125
  self.act_ppp.setStatusTip(self.tr("Pick the perfect palette for your image"))
1103
1126
  self.act_ppp.triggered.connect(self._open_ppp_tool)
1104
1127
 
1128
+ self.act_narrowband_normalization = QAction(QIcon(narrowbandnormalization_path), self.tr("Narrowband Normalization..."), self)
1129
+ self.act_narrowband_normalization.setStatusTip(
1130
+ self.tr("Normalize HOO/SHO/HSO/HOS (PixelMath port by Bill Blanshan and Mike Cranfield)")
1131
+ )
1132
+ self.act_narrowband_normalization.setIconVisibleInMenu(False)
1133
+ self.act_narrowband_normalization.setShortcut(QKeySequence("Ctrl+Alt+Shift+N"))
1134
+ self.act_narrowband_normalization.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
1135
+ self.addAction(self.act_narrowband_normalization)
1136
+ self.act_narrowband_normalization.triggered.connect(self._open_narrowband_normalization_tool)
1137
+
1105
1138
  self.act_nbtorgb = QAction(QIcon(nbtorgb_path), self.tr("NB->RGB Stars..."), self)
1106
1139
  self.act_nbtorgb.setStatusTip(self.tr("Combine narrowband to RGB with optional OSC stars"))
1107
1140
  self.act_nbtorgb.setIconVisibleInMenu(True)
@@ -1149,6 +1182,11 @@ class ToolbarMixin:
1149
1182
  self.act_live_stacking.setStatusTip(self.tr("Live monitor and stack incoming frames"))
1150
1183
  self.act_live_stacking.triggered.connect(self._open_live_stacking)
1151
1184
 
1185
+ self.act_planetary_stacker = QAction(QIcon(planetarystacker_path), self.tr("Planetary Stacker..."), self)
1186
+ self.act_planetary_stacker.setIconVisibleInMenu(True)
1187
+ self.act_planetary_stacker.setStatusTip(self.tr("Stack SER videos (planetary/solar/lunar)"))
1188
+ self.act_planetary_stacker.triggered.connect(self._open_planetary_stacker)
1189
+
1152
1190
  self.act_plate_solve = QAction(QIcon(platesolve_path), self.tr("Plate Solver..."), self)
1153
1191
  self.act_plate_solve.setIconVisibleInMenu(True)
1154
1192
  self.act_plate_solve.setStatusTip(self.tr("Solve WCS/SIP for the active image or a file"))
@@ -1345,6 +1383,7 @@ class ToolbarMixin:
1345
1383
  reg("nbtorgb", self.act_nbtorgb)
1346
1384
  reg("freqsep", self.act_freqsep)
1347
1385
  reg("selective_color", self.act_selective_color)
1386
+ reg("narrowband_normalization", self.act_narrowband_normalization)
1348
1387
  reg("contsub", self.act_contsub)
1349
1388
  reg("abe", self.act_abe)
1350
1389
  reg("create_mask", self.act_create_mask)
@@ -1369,7 +1408,7 @@ class ToolbarMixin:
1369
1408
  reg("pixel_math", self.act_pixelmath)
1370
1409
  reg("signature_insert", self.act_signature)
1371
1410
  reg("halo_b_gon", self.act_halobgon)
1372
-
1411
+ reg("planetary_stacker", self.act_planetary_stacker)
1373
1412
  reg("multiscale_decomp", self.act_multiscale_decomp)
1374
1413
  reg("geom_invert", self.act_geom_invert)
1375
1414
  reg("geom_flip_horizontal", self.act_geom_flip_h)
@@ -1407,6 +1446,83 @@ class ToolbarMixin:
1407
1446
  reg("view_bundles", self.act_view_bundles)
1408
1447
  reg("function_bundles", self.act_function_bundles)
1409
1448
 
1449
+ def _reset_all_toolbars_to_factory(self):
1450
+ """
1451
+ Clears all persisted toolbar membership/order/hidden state so the UI
1452
+ returns to the factory layout defined in code.
1453
+ """
1454
+ if not hasattr(self, "settings"):
1455
+ return
1456
+
1457
+ # 1) Clear global toolbar persistence
1458
+ keys_to_clear = [
1459
+ "Toolbar/Assignments",
1460
+ "Toolbar/HiddenPrev",
1461
+ "Toolbar/HiddenMigrationDone",
1462
+
1463
+ # Per-toolbar order lists
1464
+ "Toolbar/View",
1465
+ "Toolbar/Functions",
1466
+ "Toolbar/Cosmic",
1467
+ "Toolbar/Tools",
1468
+ "Toolbar/Geometry",
1469
+ "Toolbar/StarStuff",
1470
+ "Toolbar/Masks",
1471
+ "Toolbar/WhatsInMy",
1472
+ "Toolbar/Bundles",
1473
+ "Toolbar/Hidden",
1474
+ ]
1475
+
1476
+ for k in keys_to_clear:
1477
+ try:
1478
+ self.settings.remove(k)
1479
+ except Exception:
1480
+ pass
1481
+
1482
+ # 2) Clear legacy hidden lists (old system) if they exist
1483
+ # (These are the ones like "Toolbar/Tools/Hidden")
1484
+ try:
1485
+ # brute-force remove known ones
1486
+ legacy_hidden = [
1487
+ "Toolbar/View/Hidden",
1488
+ "Toolbar/Functions/Hidden",
1489
+ "Toolbar/Cosmic/Hidden",
1490
+ "Toolbar/Tools/Hidden",
1491
+ "Toolbar/Geometry/Hidden",
1492
+ "Toolbar/StarStuff/Hidden",
1493
+ "Toolbar/Masks/Hidden",
1494
+ "Toolbar/WhatsInMy/Hidden",
1495
+ "Toolbar/Bundles/Hidden",
1496
+ ]
1497
+ for k in legacy_hidden:
1498
+ self.settings.remove(k)
1499
+ except Exception:
1500
+ pass
1501
+
1502
+ try:
1503
+ self.settings.sync()
1504
+ except Exception:
1505
+ pass
1506
+
1507
+ # 3) Rebuild toolbars from code defaults
1508
+ # Safest approach: remove existing toolbars and rebuild.
1509
+ from setiastro.saspro.shortcuts import DraggableToolBar
1510
+ for tb in self.findChildren(DraggableToolBar):
1511
+ try:
1512
+ self.removeToolBar(tb)
1513
+ tb.deleteLater()
1514
+ except Exception:
1515
+ pass
1516
+
1517
+ # Build fresh
1518
+ self._init_toolbar()
1519
+
1520
+ # Optional: ensure Hidden stays hidden
1521
+ tb_hidden = self._hidden_toolbar()
1522
+ if tb_hidden:
1523
+ tb_hidden.setVisible(False)
1524
+
1525
+
1410
1526
  def _restore_toolbar_order(self, tb, settings_key: str):
1411
1527
  """
1412
1528
  Restore toolbar action order from QSettings, using command_id/objectName.
@@ -1821,4 +1937,10 @@ class ToolbarMixin:
1821
1937
  if hasattr(self, "act_hide_mask"):
1822
1938
  self.act_hide_mask.setEnabled(has_mask and overlay_on)
1823
1939
 
1824
-
1940
+ def _style_toggle_toolbutton(self, btn: QToolButton):
1941
+ # Make sure the action visually shows "on" state
1942
+ btn.setCheckable(True) # safe even if already
1943
+ btn.setStyleSheet("""
1944
+ QToolButton { color: #dcdcdc; }
1945
+ QToolButton:checked { color: #DAA520; font-weight: 600; }
1946
+ """)
@@ -10,7 +10,7 @@ import json
10
10
  import sys
11
11
  import webbrowser
12
12
  from typing import TYPE_CHECKING
13
-
13
+ import re
14
14
  from PyQt6.QtCore import QUrl
15
15
  from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply
16
16
  from PyQt6.QtWidgets import QMessageBox, QApplication
@@ -58,21 +58,20 @@ class UpdateMixin:
58
58
  self._ensure_network_manager()
59
59
  url_str = self.settings.value("updates/url", self._updates_url, type=str) or self._updates_url
60
60
 
61
- # ---- TLS availability guard (prevents crash on OpenSSL mismatch) ----
62
61
  if url_str.lower().startswith("https://"):
63
62
  try:
64
63
  if not QSslSocket.supportsSsl():
65
- msg = "TLS unavailable in Qt (QSslSocket.supportsSsl()=False). Skipping update check."
66
64
  if self.statusBar():
67
- self.statusBar().showMessage(self.tr("Update check unavailable (TLS missing)."), 5000)
65
+ self.statusBar().showMessage(self.tr("Update check unavailable (TLS missing)."), 8000)
68
66
  if interactive:
69
- QMessageBox.information(self, self.tr("Update Check"),
70
- self.tr("Update check is unavailable because TLS is not available on this system."))
67
+ QMessageBox.information(
68
+ self, self.tr("Update Check"),
69
+ self.tr("Update check is unavailable because TLS is not available on this system.")
70
+ )
71
71
  else:
72
- print(f"[updates] {msg}")
72
+ print("[updates] TLS unavailable in Qt; skipping update check.")
73
73
  return
74
74
  except Exception as e:
75
- # If QtNetwork is in a weird state, fail safe (do not attempt TLS)
76
75
  print(f"[updates] TLS probe failed ({e}); skipping update check.")
77
76
  return
78
77
 
@@ -111,20 +110,22 @@ class UpdateMixin:
111
110
  def _on_update_reply(self, reply: QNetworkReply):
112
111
  """Handle network reply from update check or download."""
113
112
  interactive = bool(reply.property("interactive"))
114
-
113
+
115
114
  # Was this the second request (the actual installer download)?
116
115
  if bool(reply.property("is_update_download")):
117
116
  self._on_windows_update_download_finished(reply)
118
117
  return
119
-
118
+
120
119
  try:
121
120
  if reply.error() != QNetworkReply.NetworkError.NoError:
122
121
  err = reply.errorString()
123
122
  if self.statusBar():
124
- self.statusBar().showMessage("Update check failed.", 5000)
123
+ self.statusBar().showMessage(self.tr("Update check failed."), 5000)
125
124
  if interactive:
126
- QMessageBox.warning(self, self.tr("Update Check Failed"),
127
- self.tr("Unable to check for updates.\n\n{err}").replace("{err}", err))
125
+ QMessageBox.warning(
126
+ self, self.tr("Update Check Failed"),
127
+ self.tr("Unable to check for updates.\n\n{0}").format(err)
128
+ )
128
129
  else:
129
130
  print(f"[updates] check failed: {err}")
130
131
  return
@@ -134,12 +135,14 @@ class UpdateMixin:
134
135
  data = json.loads(raw.decode("utf-8"))
135
136
  except Exception as je:
136
137
  if self.statusBar():
137
- self.statusBar().showMessage("Update check failed (bad JSON).", 5000)
138
+ self.statusBar().showMessage(self.tr("Update check failed (bad JSON)."), 5000)
138
139
  if interactive:
139
- QMessageBox.warning(self, self.tr("Update Check Failed"),
140
- self.tr("Update JSON is invalid.\n\n{je}").replace("{je}", str(je)))
140
+ QMessageBox.warning(
141
+ self, self.tr("Update Check Failed"),
142
+ self.tr("Update JSON is invalid.\n\n{0}").format(str(je))
143
+ )
141
144
  else:
142
- print(f"[updates] bad JSON: {je}")
145
+ print(f"[updates] bad JSON: {je!r}")
143
146
  return
144
147
 
145
148
  latest_str = str(data.get("version", "")).strip()
@@ -148,25 +151,53 @@ class UpdateMixin:
148
151
 
149
152
  if not latest_str:
150
153
  if self.statusBar():
151
- self.statusBar().showMessage("Update check failed (no 'version').", 5000)
154
+ self.statusBar().showMessage(self.tr("Update check failed (no version)."), 5000)
152
155
  if interactive:
153
- QMessageBox.warning(self, self.tr("Update Check Failed"),
154
- self.tr("Update JSON missing the 'version' field."))
156
+ QMessageBox.warning(
157
+ self, self.tr("Update Check Failed"),
158
+ self.tr("Update JSON missing the 'version' field.")
159
+ )
155
160
  else:
156
161
  print("[updates] JSON missing 'version'")
157
162
  return
158
163
 
159
- cur_tuple = self._parse_version_tuple(self._current_version_str)
160
- latest_tuple = self._parse_version_tuple(latest_str)
161
- available = bool(latest_tuple and cur_tuple and latest_tuple > cur_tuple)
164
+ # ---- PEP 440 version compare ----
165
+ try:
166
+ from packaging.version import Version
167
+ cur_v = Version(str(self._current_version_str).strip())
168
+ latest_v = Version(latest_str)
169
+ except Exception as e:
170
+ if self.statusBar():
171
+ self.statusBar().showMessage(self.tr("Update check failed (version parse)."), 5000)
172
+ if interactive:
173
+ QMessageBox.warning(
174
+ self, self.tr("Update Check Failed"),
175
+ self.tr("Could not compare versions.\n\nCurrent: {0}\nLatest: {1}\n\n{2}")
176
+ .format(self._current_version_str, latest_str, str(e))
177
+ )
178
+ else:
179
+ print(f"[updates] version parse failed: cur={self._current_version_str!r} latest={latest_str!r} err={e!r}")
180
+ return
181
+
182
+ available = latest_v > cur_v
162
183
 
163
184
  if available:
164
185
  if self.statusBar():
165
186
  self.statusBar().showMessage(self.tr("Update available: {0}").format(latest_str), 5000)
187
+
166
188
  msg_box = QMessageBox(self)
167
189
  msg_box.setIcon(QMessageBox.Icon.Information)
168
190
  msg_box.setWindowTitle(self.tr("Update Available"))
169
- msg_box.setText(self.tr("A new version ({0}) is available!").format(latest_str))
191
+ installed_norm = str(cur_v)
192
+ reported_norm = str(latest_v)
193
+
194
+ msg_box.setText(
195
+ self.tr(
196
+ "An update is available!\n\n"
197
+ "Installed version: {0}\n"
198
+ "Available version: {1}"
199
+ ).format(installed_norm, reported_norm)
200
+ )
170
201
  if notes:
171
202
  msg_box.setInformativeText(self.tr("Release Notes:\n{0}").format(notes))
172
203
  msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
@@ -178,27 +209,48 @@ class UpdateMixin:
178
209
 
179
210
  if msg_box.exec() == QMessageBox.StandardButton.Yes:
180
211
  plat = sys.platform
181
- link = downloads.get(
212
+ key = (
182
213
  "Windows" if plat.startswith("win") else
183
- "macOS" if plat.startswith("darwin") else
184
- "Linux" if plat.startswith("linux") else "", ""
214
+ "macOS" if plat.startswith("darwin") else
215
+ "Linux" if plat.startswith("linux") else
216
+ ""
185
217
  )
218
+ link = downloads.get(key, "")
186
219
  if not link:
187
- QMessageBox.warning(self, self.tr("Download"), self.tr("No download link available for this platform."))
220
+ QMessageBox.warning(self, self.tr("Download"),
221
+ self.tr("No download link available for this platform."))
188
222
  return
189
223
 
190
224
  if plat.startswith("win"):
191
- # Use in-app updater for Windows
192
225
  self._start_windows_update_download(link)
193
226
  else:
194
- # Open browser for other platforms
195
227
  webbrowser.open(link)
196
228
  else:
197
229
  if self.statusBar():
198
- self.statusBar().showMessage("You're up to date.", 3000)
230
+ self.statusBar().showMessage(self.tr("You're up to date."), 3000)
231
+
199
232
  if interactive:
200
- QMessageBox.information(self, self.tr("Up to Date"),
201
- self.tr("You're already running the latest version."))
233
+ # Use the same parsed versions you already computed
234
+ installed_str = str(self._current_version_str).strip()
235
+ reported_str = str(latest_str).strip()
236
+
237
+ # If you have cur_v/latest_v (packaging.Version), use their string forms too
238
+ try:
239
+ installed_norm = str(cur_v) # normalized PEP440 (e.g. 1.6.6.post3)
240
+ reported_norm = str(latest_v)
241
+ except Exception:
242
+ installed_norm = installed_str
243
+ reported_norm = reported_str
244
+
245
+ QMessageBox.information(
246
+ self,
247
+ self.tr("Up to Date"),
248
+ self.tr(
249
+ "You're already running the latest version.\n\n"
250
+ "Installed version: {0}\n"
251
+ "Update source reports: {1}"
252
+ ).format(installed_norm, reported_norm)
253
+ )
202
254
  finally:
203
255
  reply.deleteLater()
204
256
 
@@ -321,3 +373,39 @@ class UpdateMixin:
321
373
 
322
374
  # Close app so the installer can overwrite files
323
375
  QApplication.instance().quit()
376
+
377
+
378
+ def _normalize_version_str(self, v: str) -> str:
379
+ v = (v or "").strip()
380
+ # common cases: "v1.2.3", "Version 1.2.3", "1.2.3 (build xyz)"
381
+ v = re.sub(r'^[^\d]*', '', v) # strip leading non-digits
382
+ v = re.split(r'[\s\(\[]', v, 1)[0].strip() # stop at whitespace/( or [
383
+ return v
384
+
385
+ def _parse_version(self, v: str):
386
+ v = self._normalize_version_str(v)
387
+ if not v:
388
+ return None
389
+ # Prefer packaging if present
390
+ try:
391
+ from packaging.version import Version
392
+ return Version(v)
393
+ except Exception:
394
+ # Fallback: compare numeric dot parts only
395
+ parts = re.findall(r'\d+', v)
396
+ if not parts:
397
+ return None
398
+ # normalize length to 3+ (so 1.2 == 1.2.0)
399
+ nums = [int(x) for x in parts[:6]]
400
+ while len(nums) < 3:
401
+ nums.append(0)
402
+ return tuple(nums)
403
+
404
+ def _is_update_available(self, latest_str: str) -> bool:
405
+ cur = self._parse_version(self._current_version_str)
406
+ latest = self._parse_version(latest_str)
407
+ if cur is None or latest is None:
408
+ # If we cannot compare, do NOT claim "up to date".
409
+ # Treat as "unknown" and show a failure message in interactive mode.
410
+ return False
411
+ return latest > cur