pyobs-gui 2.6.3__tar.gz → 2.7.1__tar.gz

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.
Files changed (216) hide show
  1. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/CHANGELOG.md +22 -0
  2. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/PKG-INFO +2 -2
  3. pyobs_gui-2.7.1/pyobs_gui/appicon.py +19 -0
  4. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/gui.py +2 -0
  5. pyobs_gui-2.7.1/pyobs_gui/icons/README.md +13 -0
  6. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-128.png +0 -0
  7. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-16.png +0 -0
  8. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-24.png +0 -0
  9. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-256.png +0 -0
  10. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-32.png +0 -0
  11. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-48.png +0 -0
  12. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-512.png +0 -0
  13. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-64.png +0 -0
  14. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui.ico +0 -0
  15. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui.png +0 -0
  16. pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui.svg +13 -0
  17. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/loginwindow.py +2 -0
  18. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/mainwindow.py +2 -0
  19. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notifier_backend.py +3 -2
  20. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/settingsdialog.py +2 -0
  21. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/structuredconfigwidget.py +49 -14
  22. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/windows_identity.py +26 -1
  23. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyproject.toml +5 -2
  24. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pysidedeploy.spec +2 -2
  25. pyobs_gui-2.7.1/specs/2026-10-14-typed-config-choices.md +92 -0
  26. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/index.md +6 -0
  27. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_notifier_backend.py +11 -0
  28. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_structuredconfigwidget.py +117 -0
  29. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_windows_identity.py +53 -1
  30. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/uv.lock +512 -394
  31. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.claude/skills/verify/SKILL.md +0 -0
  32. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/dependabot.yml +0 -0
  33. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/dependabot-automerge.yml +0 -0
  34. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/pypi.yml +0 -0
  35. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/pyrefly.yml +0 -0
  36. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/ruff.yml +0 -0
  37. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.gitignore +0 -0
  38. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.pre-commit-config.yaml +0 -0
  39. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.readthedocs.yml +0 -0
  40. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/CLAUDE.md +0 -0
  41. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/CONTRIBUTING.md +0 -0
  42. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/LICENSE +0 -0
  43. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/README.md +0 -0
  44. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/bin/mypyuic5 +0 -0
  45. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/Makefile +0 -0
  46. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/make.bat +0 -0
  47. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/requirements.txt +0 -0
  48. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/source/_static/pyobs.gif +0 -0
  49. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/source/conf.py +0 -0
  50. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/source/index.rst +0 -0
  51. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/mypy.ini +0 -0
  52. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/__init__.py +0 -0
  53. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/__main__.py +0 -0
  54. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
  55. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/accounts.py +0 -0
  56. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/acquisitionwidget.py +0 -0
  57. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/autofocuswidget.py +0 -0
  58. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/autoguidingwidget.py +0 -0
  59. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/base.py +0 -0
  60. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/camerawidget.py +0 -0
  61. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/commandinputwidget.py +0 -0
  62. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/compassmovewidget.py +0 -0
  63. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/coolingwidget.py +0 -0
  64. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/datadisplaywidget.py +0 -0
  65. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/eventswidget.py +0 -0
  66. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/filterwidget.py +0 -0
  67. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/fitsheaderswidget.py +0 -0
  68. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/focuswidget.py +0 -0
  69. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/guisignals.py +0 -0
  70. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/livestream.py +0 -0
  71. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/login.py +0 -0
  72. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/logmodel.py +0 -0
  73. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/modewidget.py +0 -0
  74. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/modifiedmixin.py +0 -0
  75. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/modulegui.py +0 -0
  76. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/movestack.py +0 -0
  77. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notificationmanager.py +0 -0
  78. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notifications.py +0 -0
  79. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notifier.py +0 -0
  80. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/nowheelfilter.py +0 -0
  81. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/__init__.py +0 -0
  82. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
  83. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
  84. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autofocuswidget.ui +0 -0
  85. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
  86. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
  87. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
  88. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/camerawidget.ui +0 -0
  89. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/camerawidget_ui.py +0 -0
  90. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmoveplugin.py +0 -0
  91. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmovewidget.py +0 -0
  92. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmovewidget.ui +0 -0
  93. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
  94. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compile.sh +0 -0
  95. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/coolingwidget.ui +0 -0
  96. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
  97. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/datadisplayplugin.py +0 -0
  98. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
  99. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
  100. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/designer.sh +0 -0
  101. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/eventswidget.ui +0 -0
  102. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/eventswidget_ui.py +0 -0
  103. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/filterwidget.ui +0 -0
  104. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/filterwidget_ui.py +0 -0
  105. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
  106. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
  107. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/focuswidget.ui +0 -0
  108. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/focuswidget_ui.py +0 -0
  109. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/mainwindow.ui +0 -0
  110. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/mainwindow_ui.py +0 -0
  111. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/modewidget.ui +0 -0
  112. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/modewidget_ui.py +0 -0
  113. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
  114. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
  115. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
  116. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
  117. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
  118. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
  119. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
  120. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
  121. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
  122. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
  123. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/search-solid.svg +0 -0
  124. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
  125. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources.qrc +0 -0
  126. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources_rc.py +0 -0
  127. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roboticwidget.ui +0 -0
  128. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roboticwidget_ui.py +0 -0
  129. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roofwidget.ui +0 -0
  130. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roofwidget_ui.py +0 -0
  131. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/schedulewidget.ui +0 -0
  132. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/schedulewidget_ui.py +0 -0
  133. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/shellwidget.ui +0 -0
  134. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/shellwidget_ui.py +0 -0
  135. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/spectrographwidget.ui +0 -0
  136. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
  137. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/telescopewidget.ui +0 -0
  138. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
  139. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
  140. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
  141. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperatureswidget.ui +0 -0
  142. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
  143. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videograbwidget.ui +0 -0
  144. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videograbwidget_ui.py +0 -0
  145. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videowidget.ui +0 -0
  146. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videowidget_ui.py +0 -0
  147. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/weatherwidget.ui +0 -0
  148. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
  149. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/roboticwidget.py +0 -0
  150. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/roofwidget.py +0 -0
  151. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/schedulewidget.py +0 -0
  152. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/settings.py +0 -0
  153. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/shellwidget.py +0 -0
  154. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/spectrographwidget.py +0 -0
  155. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/statuswidget.py +0 -0
  156. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/telescopewidget.py +0 -0
  157. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/temperaturesplotwidget.py +0 -0
  158. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/temperatureswidget.py +0 -0
  159. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/utils.py +0 -0
  160. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/videograbwidget.py +0 -0
  161. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/videowidget.py +0 -0
  162. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/watchedlabel.py +0 -0
  163. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/weatherwidget.py +0 -0
  164. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
  165. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-21-gui-widget-startup-responsiveness.md +0 -0
  166. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-28-gui-main-vs-sidebar-widgets.md +0 -0
  167. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-28-structuredconfig-widget.md +0 -0
  168. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-31-irobotic-widgets.md +0 -0
  169. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-01-gui-video-widget-split.md +0 -0
  170. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-14-fitswidget-toolbar-overflow.md +0 -0
  171. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-14-stacked-widget-scroll-fallback.md +0 -0
  172. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-29-live-view-mjpeg-raw.md +0 -0
  173. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-10-04-desktop-notifications.md +0 -0
  174. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-10-04-settings-dialog.md +0 -0
  175. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/plans/2026-10-04-desktop-notifications.md +0 -0
  176. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/plans/2026-10-04-settings-dialog.md +0 -0
  177. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/plans/index.md +0 -0
  178. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/spike_standalone/main.py +0 -0
  179. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/spike_standalone/pysidedeploy.spec +0 -0
  180. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/acquisition.yaml +0 -0
  181. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/autofocus.yaml +0 -0
  182. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/camera.yaml +0 -0
  183. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/full.yaml +0 -0
  184. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/guiding.yaml +0 -0
  185. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/mode.yaml +0 -0
  186. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/robotic.yaml +0 -0
  187. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/roof.yaml +0 -0
  188. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/spectrograph.yaml +0 -0
  189. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/structuredconfig.yaml +0 -0
  190. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/telescope.yaml +0 -0
  191. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/telescope_acl.yaml +0 -0
  192. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/telescope_acl_denied.yaml +0 -0
  193. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/video.yaml +0 -0
  194. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/__init__.py +0 -0
  195. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/conftest.py +0 -0
  196. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_accounts.py +0 -0
  197. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_basewidget_discard.py +0 -0
  198. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_camerawidget.py +0 -0
  199. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_gui_notifications.py +0 -0
  200. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_import_order.py +0 -0
  201. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_livestream.py +0 -0
  202. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_loginwindow.py +0 -0
  203. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_mainwindow_startup.py +0 -0
  204. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_missing_vfs_root.py +0 -0
  205. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_modewidget.py +0 -0
  206. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_multiwidget_pages.py +0 -0
  207. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_notificationmanager.py +0 -0
  208. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_notifications.py +0 -0
  209. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_roboticwidget.py +0 -0
  210. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_schedulewidget.py +0 -0
  211. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_settings.py +0 -0
  212. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_settingsdialog.py +0 -0
  213. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_spectrographwidget.py +0 -0
  214. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_video_refresh.py +0 -0
  215. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_videograbwidget.py +0 -0
  216. {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_videowidget.py +0 -0
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  Entries for releases before this file existed were generated from commit subjects.
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ### Added
13
+
14
+ - Magenta observing-monitor app icon for Qt windows, desktop notifications and
15
+ Windows Start Menu shortcuts; update existing shortcuts without changing their targets (#197).
16
+
17
+ ## [2.7.0] - 2026-10-15
18
+
19
+ ### Added
20
+
21
+ - Display structured-configuration choice labels while retaining the original typed
22
+ values; preserve unknown stored choices for explicit correction (#211, #218).
23
+
24
+ ### Fixed
25
+
26
+ - Distinguish boolean and numeric choices when tracking configuration edits (#218).
27
+
28
+ ### Changed
29
+
30
+ - Require pyobs-core 2.18.0 or newer for typed configuration-choice capabilities.
31
+
10
32
  ## [2.6.1] - 2026-10-04
11
33
 
12
34
  - Require pyobs-core 2.14.0, which has the slimmed base dependencies
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-gui
3
- Version: 2.6.3
3
+ Version: 2.7.1
4
4
  Summary: A remote GUI for pyobs
5
5
  Project-URL: Homepage, https://www.pyobs.org
6
6
  Project-URL: Documentation, https://docs.pyobs.org
@@ -19,7 +19,7 @@ Requires-Dist: keyring<26,>=25.7.0
19
19
  Requires-Dist: matplotlib<4,>=3.10.1
20
20
  Requires-Dist: pandas>=2.2.3
21
21
  Requires-Dist: pydantic<3,>=2.12.5
22
- Requires-Dist: pyobs-core<3,>=2.14.0
22
+ Requires-Dist: pyobs-core<3,>=2.18.1
23
23
  Requires-Dist: pyside6-essentials>=6.10.1
24
24
  Requires-Dist: pywin32>=312; sys_platform == 'win32'
25
25
  Requires-Dist: pyyaml<7,>=6.0.2
@@ -0,0 +1,19 @@
1
+ """Shared app artwork as real files for Qt, notifications and Windows shortcuts."""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ if TYPE_CHECKING:
7
+ from PySide6.QtGui import QIcon
8
+
9
+
10
+ def icon_path(format: Literal["png", "ico"] = "png") -> Path:
11
+ """Installed package data, also placed beside this module by Nuitka."""
12
+ return Path(__file__).resolve().with_name("icons") / f"pyobs-gui.{format}"
13
+
14
+
15
+ def window_icon() -> "QIcon":
16
+ """Qt reads all resolutions in the ICO, including the small window-title icon."""
17
+ from PySide6.QtGui import QIcon
18
+
19
+ return QIcon(str(icon_path("ico")))
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
12
12
  from pyobs.interfaces import FitsHeaderEntry, IFitsHeaderBefore
13
13
  from pyobs.modules import Module
14
14
 
15
+ from .appicon import window_icon
15
16
  from .nowheelfilter import NoWheelWhenUnfocused
16
17
  from .guisignals import gui_signals
17
18
  from .notificationmanager import NotificationManager
@@ -147,6 +148,7 @@ class GUI(Module, IFitsHeaderBefore):
147
148
  @staticmethod
148
149
  def new_event_loop() -> asyncio.AbstractEventLoop:
149
150
  GUI.app = QtWidgets.QApplication(sys.argv)
151
+ GUI.app.setWindowIcon(window_icon())
150
152
  # standalone/login mode briefly has zero visible top-level windows while the login
151
153
  # dialog closes and the (old or new) MainWindow hasn't opened yet -- Qt's default
152
154
  # "quit when the last window closes" would tear down the whole event loop right there,
@@ -0,0 +1,13 @@
1
+ # pyobs GUI icon
2
+
3
+ White observing monitor and crosshair on magenta (`#d63384`), following the fleet's
4
+ white-symbol-on-colour app icons. Original artwork created for pyobs-gui and
5
+ approved on 2026-10-15; distributed under this repository's MIT licence.
6
+
7
+ `pyobs-gui.svg` is the editable source. PNG exports cover 16, 24, 32, 48, 64,
8
+ 128, 256 and 512 pixels. `pyobs-gui.png` is the 256-pixel notification icon;
9
+ `pyobs-gui.ico` contains 16 through 256-pixel renditions for Qt and Windows.
10
+
11
+ These files must remain ordinary package data: desktop-notifier and the Windows
12
+ shortcut require filesystem paths, rather than Qt resource URLs. Nuitka includes
13
+ them with `--include-package-data=pyobs_gui` in `pysidedeploy.spec`.
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
2
+ <title>pyobs GUI icon</title>
3
+ <desc>A white observing monitor with a crosshair on a magenta rounded square.</desc>
4
+ <rect width="256" height="256" rx="52" fill="#d63384"/>
5
+ <g fill="none" stroke="#fff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">
6
+ <rect x="42" y="53" width="172" height="119" rx="13"/>
7
+ <path d="M128 174v29M96 204h64"/>
8
+ </g>
9
+ <g fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round">
10
+ <circle cx="128" cy="112" r="20"/>
11
+ <path d="M128 78v16M128 130v16M94 112h16M146 112h16"/>
12
+ </g>
13
+ </svg>
@@ -14,6 +14,7 @@ from dataclasses import dataclass
14
14
  from PySide6 import QtCore, QtGui, QtWidgets
15
15
  from pyobs.comm.xmpp import is_valid_jid
16
16
 
17
+ from .appicon import window_icon
17
18
  from .accounts import SavedAccountsModel
18
19
 
19
20
  log = logging.getLogger(__name__)
@@ -86,6 +87,7 @@ def build_connection_request(
86
87
  class LoginWindow(QtWidgets.QDialog):
87
88
  def __init__(self, accounts: SavedAccountsModel | None = None, parent: QtWidgets.QWidget | None = None) -> None:
88
89
  super().__init__(parent)
90
+ self.setWindowIcon(window_icon())
89
91
  self.setWindowTitle("pyobs - Sign in")
90
92
  self.resize(640, 480)
91
93
 
@@ -37,6 +37,7 @@ from pyobs.interfaces import (
37
37
  IStructuredConfig,
38
38
  )
39
39
 
40
+ from .appicon import window_icon
40
41
  from .base import BaseWindow, BaseWidget, cancel_and_drain
41
42
  from .acquisitionwidget import AcquisitionWidget
42
43
  from .autofocuswidget import AutoFocusWidget
@@ -641,6 +642,7 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
641
642
  QtWidgets.QMainWindow.__init__(self)
642
643
  BaseWindow.__init__(self)
643
644
  self.setupUi(self) # type: ignore
645
+ self.setWindowIcon(window_icon())
644
646
  self.resize(1600, 900)
645
647
 
646
648
  # store stuff
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  from PySide6 import QtCore # type: ignore
13
13
 
14
+ from .appicon import icon_path
14
15
  from .notifier import NotifierError
15
16
 
16
17
  if TYPE_CHECKING:
@@ -77,9 +78,9 @@ class DesktopNotifierBackend:
77
78
  if self._factory is not None:
78
79
  self._notifier = self._factory()
79
80
  else:
80
- from desktop_notifier import DesktopNotifier
81
+ from desktop_notifier import DesktopNotifier, Icon
81
82
 
82
- self._notifier = DesktopNotifier(app_name=APP_NAME)
83
+ self._notifier = DesktopNotifier(app_name=APP_NAME, app_icon=Icon(path=icon_path()))
83
84
  return self._notifier
84
85
 
85
86
  async def ensure_authorisation(self) -> None:
@@ -18,6 +18,7 @@ from pydantic import ValidationError
18
18
  from pyobs.object import get_class_from_string
19
19
  from PySide6 import QtWidgets # type: ignore
20
20
 
21
+ from .appicon import window_icon
21
22
  from .notifier import NotifierError
22
23
  from .settings import GuiSettings, NotificationSettings, VfsSettings
23
24
 
@@ -83,6 +84,7 @@ class SettingsDialog(QtWidgets.QDialog):
83
84
  parent: QtWidgets.QWidget | None = None,
84
85
  ) -> None:
85
86
  super().__init__(parent)
87
+ self.setWindowIcon(window_icon())
86
88
  self.setWindowTitle("Settings")
87
89
  self.resize(640, 480)
88
90
  self._on_apply = on_apply
@@ -85,6 +85,53 @@ def _nested_set(children: Dict[str, _FieldEditor], value: ConfigValue) -> None:
85
85
  child.set_value(value[name])
86
86
 
87
87
 
88
+ def _choice_equal(left: ConfigValue, right: ConfigValue) -> bool:
89
+ if isinstance(left, dict) and isinstance(right, dict):
90
+ return left.keys() == right.keys() and all(_choice_equal(value, right[key]) for key, value in left.items())
91
+ if isinstance(left, list) and isinstance(right, list):
92
+ return len(left) == len(right) and all(_choice_equal(a, b) for a, b in zip(left, right, strict=True))
93
+ if type(left) in (int, float) and type(right) in (int, float):
94
+ return left == right
95
+ return type(left) is type(right) and left == right
96
+
97
+
98
+ def _build_enum_editor(schema: ConfigFieldSchema, on_change: Callable[[], None]) -> _FieldEditor:
99
+ combo = QtWidgets.QComboBox()
100
+ # Feature detection keeps older string-only core installations usable.
101
+ choices = getattr(schema, "choices", None)
102
+ values: list[ConfigValue] = (
103
+ [choice.value for choice in choices] if choices is not None else list(schema.options or [])
104
+ )
105
+ labels = [choice.label for choice in choices] if choices is not None else [str(value) for value in values]
106
+ combo.addItems(labels)
107
+ raw: list[ConfigValue] = [cast("ConfigValue", None)]
108
+
109
+ def set_value(value: ConfigValue) -> None:
110
+ raw[0] = value
111
+ if combo.count() > len(values):
112
+ combo.removeItem(len(values))
113
+ index = next((i for i, allowed in enumerate(values) if _choice_equal(value, allowed)), None)
114
+ if index is None:
115
+ index = len(values)
116
+ combo.addItem(f"(unknown value: {value!r})")
117
+ combo.setCurrentIndex(index)
118
+
119
+ def get_value() -> ConfigValue:
120
+ index = combo.currentIndex()
121
+ return values[index] if 0 <= index < len(values) else raw[0]
122
+
123
+ if schema.default is not None or not values:
124
+ set_value(cast("ConfigValue", schema.default))
125
+ combo.currentIndexChanged.connect(lambda _: on_change())
126
+ return _FieldEditor(
127
+ combo,
128
+ get_value,
129
+ set_value,
130
+ level=schema.level,
131
+ row_widget=_wrap_with_description(combo, schema.description),
132
+ )
133
+
134
+
88
135
  def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[], None]) -> _FieldEditor:
89
136
  """Build one field's editor from its schema (recursively, for a nested object)."""
90
137
 
@@ -146,19 +193,7 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
146
193
  )
147
194
 
148
195
  if schema.type == "enum":
149
- combo_box = QtWidgets.QComboBox()
150
- options = schema.options or []
151
- combo_box.addItems(options)
152
- if schema.default is not None and str(schema.default) in options:
153
- combo_box.setCurrentText(str(schema.default))
154
- combo_box.currentTextChanged.connect(lambda _: on_change())
155
- return _FieldEditor(
156
- combo_box,
157
- combo_box.currentText,
158
- lambda value: combo_box.setCurrentText(str(value)),
159
- level=schema.level,
160
- row_widget=_wrap_with_description(combo_box, schema.description),
161
- )
196
+ return _build_enum_editor(schema, on_change)
162
197
 
163
198
  if schema.type == "object" and schema.nested is not None:
164
199
  group_box = QtWidgets.QGroupBox(name)
@@ -325,7 +360,7 @@ class StructuredConfigWidget(BaseWidget):
325
360
  def _is_dirty(self) -> bool:
326
361
  if self._root is None or self._last_applied is None:
327
362
  return False
328
- return self._root.get_value() != self._last_applied
363
+ return not _choice_equal(self._root.get_value(), self._last_applied)
329
364
 
330
365
  def _update_buttons(self) -> None:
331
366
  dirty = self._is_dirty()
@@ -19,6 +19,8 @@ from typing import TYPE_CHECKING
19
19
  if TYPE_CHECKING:
20
20
  from collections.abc import Callable
21
21
 
22
+ from .appicon import icon_path
23
+
22
24
  log = logging.getLogger(__name__)
23
25
 
24
26
  SHORTCUT_NAME = "pyobs-gui.lnk"
@@ -52,6 +54,7 @@ def create_link_com(path: Path, target: str, arguments: str, app_id: str) -> Non
52
54
 
53
55
  link = pythoncom.CoCreateInstance(shell.CLSID_ShellLink, None, pythoncom.CLSCTX_INPROC_SERVER, shell.IID_IShellLink)
54
56
  link.SetPath(target)
57
+ link.SetIconLocation(str(icon_path("ico")), 0)
55
58
  if arguments:
56
59
  link.SetArguments(arguments)
57
60
  store = link.QueryInterface(propsys.IID_IPropertyStore)
@@ -60,15 +63,36 @@ def create_link_com(path: Path, target: str, arguments: str, app_id: str) -> Non
60
63
  link.QueryInterface(pythoncom.IID_IPersistFile).Save(str(path), 0)
61
64
 
62
65
 
66
+ def update_link_icon_com(path: Path) -> bool:
67
+ """Upgrade an existing shortcut's icon, preserving its target and app identity.
68
+
69
+ Like create_link_com(), this still needs verification on Windows (#192).
70
+ """
71
+ import pythoncom # type: ignore[import-not-found]
72
+ from win32com.shell import shell # type: ignore[import-not-found]
73
+
74
+ link = pythoncom.CoCreateInstance(shell.CLSID_ShellLink, None, pythoncom.CLSCTX_INPROC_SERVER, shell.IID_IShellLink)
75
+ persist = link.QueryInterface(pythoncom.IID_IPersistFile)
76
+ persist.Load(str(path))
77
+ location = (str(icon_path("ico")), 0)
78
+ if link.GetIconLocation() == location:
79
+ return False
80
+ link.SetIconLocation(*location)
81
+ persist.Save(str(path), 0)
82
+ return True
83
+
84
+
63
85
  def ensure_shortcut(
64
86
  app_id: str,
65
87
  path: Path | None = None,
66
88
  command: tuple[str, str] | None = None,
67
89
  create_link: Callable[[Path, str, str, str], None] = create_link_com,
90
+ update_icon: Callable[[Path], bool] = update_link_icon_com,
68
91
  ) -> bool:
69
92
  """Make sure the Start Menu shortcut with the AppUserModelID exists.
70
93
 
71
- An existing shortcut is left alone, whatever it points to.
94
+ An existing shortcut keeps its target and identity; only its icon is updated.
95
+ Repeated starts leave the file alone when the icon already matches.
72
96
 
73
97
  Returns:
74
98
  Whether a shortcut was created.
@@ -78,6 +102,7 @@ def ensure_shortcut(
78
102
  """
79
103
  path = shortcut_path() if path is None else path
80
104
  if path.exists():
105
+ update_icon(path)
81
106
  return False
82
107
  target, arguments = launch_command() if command is None else command
83
108
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyobs-gui"
3
- version = "2.6.3"
3
+ version = "2.7.1"
4
4
  description = "A remote GUI for pyobs"
5
5
  authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
6
6
  requires-python = ">=3.11"
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "astroquery>=0.4.10,<0.5",
16
16
  "QtAwesome>=1.4.0,<2",
17
17
  "qfitswidget>=1.2.0",
18
- "pyobs-core>=2.14.0,<3",
18
+ "pyobs-core>=2.18.1,<3",
19
19
  "pyside6-essentials>=6.10.1",
20
20
  "astroplan>=0.10.1",
21
21
  "astropy>=7.0.1,<8",
@@ -58,6 +58,9 @@ dev = [
58
58
  requires = ["hatchling"]
59
59
  build-backend = "hatchling.build"
60
60
 
61
+ [tool.hatch.build.targets.wheel]
62
+ packages = ["pyobs_gui"]
63
+
61
64
  [tool.black]
62
65
  line-length = 120
63
66
  target-version = ['py311']
@@ -16,7 +16,7 @@ exec_directory = dist
16
16
  project_file =
17
17
 
18
18
  # application icon
19
- icon = /home/husser/code/pyobs/pyobs-gui/.venv/lib/python3.13/site-packages/PySide6/scripts/deploy_lib/pyside_icon.jpg
19
+ icon = pyobs_gui/icons/pyobs-gui.png
20
20
 
21
21
  [python]
22
22
 
@@ -68,7 +68,7 @@ macos.permissions =
68
68
  mode = standalone
69
69
 
70
70
  # specify any extra nuitka arguments
71
- extra_args = --quiet --noinclude-qt-translations --python-flag=-m --include-package=astropy.units.format --include-package=astropy.units.format.generic_parsetab --include-package=astropy.units.format.generic_lextab --include-package=astropy.units.format.cds_parsetab --include-package=astropy.units.format.cds_lextab --include-package=astropy.units.format.ogip_parsetab --include-package=astropy.units.format.ogip_lextab --include-package=astropy.coordinates.angles --include-package=astropy.coordinates.angles.angle_parsetab --include-package=astropy.coordinates.angles.angle_lextab --include-package=astropy.extern.ply --include-package-data=astropy --include-package-data=sunpy --include-package-data=matplotlib --include-package-data=qfitswidget --include-package-data=astropy_iers_data --include-package=pyobs.vfs --include-package-data=astroquery --include-package=slixmpp --include-package=pyobs_gui --noinclude-dlls=*webengine* --noinclude-dlls=*WebEngine* --include-distribution-metadata=astropy --include-distribution-metadata=numpy --include-distribution-metadata=PyYAML --include-distribution-metadata=sunpy --include-distribution-metadata=matplotlib --include-distribution-metadata=qfitswidget --include-distribution-metadata=astroquery --include-distribution-metadata=pyobs-core --include-distribution-metadata=pyobs-gui
71
+ extra_args = --quiet --noinclude-qt-translations --python-flag=-m --include-package=astropy.units.format --include-package=astropy.units.format.generic_parsetab --include-package=astropy.units.format.generic_lextab --include-package=astropy.units.format.cds_parsetab --include-package=astropy.units.format.cds_lextab --include-package=astropy.units.format.ogip_parsetab --include-package=astropy.units.format.ogip_lextab --include-package=astropy.coordinates.angles --include-package=astropy.coordinates.angles.angle_parsetab --include-package=astropy.coordinates.angles.angle_lextab --include-package=astropy.extern.ply --include-package-data=astropy --include-package-data=sunpy --include-package-data=matplotlib --include-package-data=qfitswidget --include-package-data=astropy_iers_data --include-package=pyobs.vfs --include-package-data=astroquery --include-package=slixmpp --include-package=pyobs_gui --include-package-data=pyobs_gui --noinclude-dlls=*webengine* --noinclude-dlls=*WebEngine* --include-distribution-metadata=astropy --include-distribution-metadata=numpy --include-distribution-metadata=PyYAML --include-distribution-metadata=sunpy --include-distribution-metadata=matplotlib --include-distribution-metadata=qfitswidget --include-distribution-metadata=astroquery --include-distribution-metadata=pyobs-core --include-distribution-metadata=pyobs-gui
72
72
 
73
73
  [buildozer]
74
74
 
@@ -0,0 +1,92 @@
1
+ # Plan: preserve typed structured-config choices and show their labels
2
+
3
+ Status: implemented; release and deployment pending.
4
+ Repos: pyobs-gui, pyobs-core.
5
+ Issue: [#211](https://github.com/pyobs/pyobs-gui/issues/211).
6
+ Shared contract and rollout: [core plan](../../pyobs-core/specs/plans/2026-10-14-typed-config-choices.md).
7
+ Companion: [portal plan](../../pyobs-portal/specs/plans/2026-10-14-typed-config-choices.md).
8
+
9
+ ## Verified cause and intended behavior
10
+
11
+ `pyobs_gui/structuredconfigwidget.py::_build_editor` uses `QComboBox.addItems`,
12
+ `currentText`, and `setCurrentText` for enum choices. Its numeric spin boxes already
13
+ return numbers. Core currently stringifies Pydantic Literal values before the GUI
14
+ sees them, so a GUI-only cast is not sufficient.
15
+
16
+ With the coordinated core change, a synthetic choice `16416` can display
17
+ `Detector A` and submit integer `16416`. Labels come from capability metadata;
18
+ the widget remains generic and consumes no provider-specific label tables.
19
+
20
+ ## Implementation checklist
21
+
22
+ - [x] Feature-detect optional typed `choices` records on `ConfigFieldSchema`,
23
+ retaining compatibility with existing core versions and legacy string `options`.
24
+ - [x] Prefer `schema.choices` when present. Populate each combo entry with its display
25
+ label and keep its original scalar value in an authoritative Python-side list
26
+ indexed by combo index. Qt user data is also viable if tests establish exact
27
+ scalar preservation; do not rely on QVariant's implicit conversions.
28
+ - [x] Get the selected value from that typed collection, never `currentText`. Use
29
+ index-based change notifications so entries with identical labels remain distinct.
30
+ - [x] Set defaults/state/reset selection by scalar identity: bool differs from number,
31
+ strings differ from numbers, and numerical int/float equality follows the shared
32
+ JSON contract. Never match by display label or use `str(value)` as identity.
33
+ - [x] Preserve duplicate labels, `"1"` versus `1`, `True` versus `1`, and string codes
34
+ such as `"001"`. Do not globally parse numeric-looking strings.
35
+ - [x] Define invalid/missing selection behavior explicitly: if a received value is not
36
+ in the domain, preserve it with a visibly marked synthetic entry until the user
37
+ chooses a legal value. Never silently retain the previous selection or choose the
38
+ first entry. An empty domain needs a preserved raw value or a disabled editor;
39
+ getting index -1 must not accidentally return the last allowed option.
40
+ - [x] Keep default fallback only for genuinely absent values. A received `None` for a
41
+ non-null choice is a preserved invalid value, not a first-choice substitution.
42
+ General nullable editor support is outside this fix.
43
+ - [x] With `choices=None`, render legacy `options` as string-valued choices as before.
44
+ A numeric state value against a string-only legacy domain must remain visible
45
+ as unsupported/unknown rather than being guessed into a type. Document that
46
+ upgrading the producer is necessary for the numeric Literal fix.
47
+ - [x] Preserve descriptions, expert visibility, permissions, nested editors, apply/reset
48
+ flow and incoming-state dirty suppression. Test payloads through the existing
49
+ `_apply_config` proxy path. Avoid broad dirty-state or hidden-field refactoring.
50
+
51
+ ## Tests and acceptance
52
+
53
+ Extend `tests/test_structuredconfigwidget.py` (confirm existing test helpers before
54
+ adding fixtures), exercising real Qt widgets with the existing headless test setup:
55
+
56
+ - [x] Typed integer/float/bool/string choices retain exact Python types on initial
57
+ render, selection, state refresh, reset and apply. An unchanged numeric state
58
+ must not become dirty solely because a combo returns text.
59
+ - [x] Labels render correctly, including the Unicode choice label. Duplicate labels and
60
+ mixed typed values select by identity and trigger change handling by index.
61
+ - [x] Nested `main.detector` payload reaches the mocked `IStructuredConfig.set_config` as
62
+ an integer; rejection/server validation behavior remains intact.
63
+ - [x] Plain integer/float editors still return numbers; old string-only capabilities
64
+ still work. Partial/missing labels use raw display fallbacks from core.
65
+ - [x] Unknown value, None, empty options, invalid default, and invalid current index
66
+ do not silently overwrite configuration with the first or last option.
67
+ - [ ] Run the affected Qt suite and repository lint/type checks. Against candidate
68
+ installed core and model-provider versions, load a real representative configuration,
69
+ change a numeric choice and a numeric scalar, Apply, observe published state, then Reset.
70
+ Confirm no hardware operation beyond the explicitly authorized test environment.
71
+
72
+ ## Deployment constraints
73
+
74
+ For the full typed-choice fix, the new core must be installed in the GUI environment and in the module's environment;
75
+ update standalone bundles as well as normal Python installations. Old core decoders ignore
76
+ unknown `choices`, and old `options: list[str]` decoding cannot recover numerical types.
77
+ New GUI + old producer is therefore only legacy-compatible, not a complete fix. Old
78
+ GUI + new producer continues to render string options and retains its old bug.
79
+
80
+ Readable labels additionally require the model provider to publish the shared
81
+ `x-pyobs-choices` metadata and the module to republish capabilities after restart.
82
+ Test typed choices without labels too, so the numeric fix can ship independently of
83
+ label deployment. Implementation and automated regression checks are complete; release and deployment remain pending.
84
+
85
+ ## Implementation verification (2026-10-15)
86
+
87
+ - Full GUI suite: 311 passed. Structured-config regressions pass with both the
88
+ installed legacy core and the candidate typed-choice core.
89
+ - Ruff and Pyrefly pass; changed Python files pass Black. Repository-wide Black
90
+ reports existing differences in generated UI files and unrelated widgets.
91
+ - Candidate provider → core XML → Qt checks preserve numeric values and labels,
92
+ including unlabeled bitmask combinations. No hardware operation was performed.
@@ -74,6 +74,9 @@ ADRs that concern `pyobs-gui` live in `pyobs-core`'s `specs/` tree instead (`spe
74
74
  single compiled binary that works across sites with no rebuild. **rejected 2026-09-14** — the
75
75
  real build came out several GB, not viable as a one-file download. The two pieces below shipped
76
76
  independently and aren't affected.
77
+ - `pyobs-core/specs/design/gui-install-size-and-standalone-build.md` — slimmed install (1506 to
78
+ 716 MB) and the experimental Nuitka binary (764 MB) with its open astropy unit-format issue.
79
+ **dependency split implemented** (`pyobs-gui` 2.6.1, `pyobs-core` 2.14.0), binary experimental.
77
80
  - `pyobs-core/specs/plans/2026-07-26-gui-interactive-login.md` — interactive login/settings
78
81
  dialog to replace the current YAML-config-file requirement (pyobs-core side).
79
82
  **implemented, closed** (landed 2026-07-27)
@@ -107,3 +110,6 @@ ADRs that concern `pyobs-gui` live in `pyobs-core`'s `specs/` tree instead (`spe
107
110
  live view in `VideoWidget`: MJPEG with server-side stretch for slow links, or `/video.raw` with
108
111
  client-side stretch/cuts and crop on zoom. Plan: `pyobs-core/specs/plans/2026-09-29-basevideo-frame-buffer-redesign.md`
109
112
  (phase 6).
113
+
114
+ - [2026-10-14-typed-config-choices.md](2026-10-14-typed-config-choices.md) — **implemented; release pending** (#211). Typed structured-config combo values and
115
+ readable labels; coordinated core capability and provider metadata dependencies.
@@ -203,3 +203,14 @@ async def test_guard_passes_everything_on() -> None:
203
203
 
204
204
  def test_app_name_is_what_windows_uses_as_the_id() -> None:
205
205
  assert APP_NAME == "pyobs-gui"
206
+
207
+
208
+ def test_default_notifier_uses_packaged_app_icon(monkeypatch: pytest.MonkeyPatch) -> None:
209
+ from desktop_notifier import Icon
210
+ from pyobs_gui.appicon import icon_path
211
+
212
+ factory = MagicMock()
213
+ monkeypatch.setattr("desktop_notifier.DesktopNotifier", factory)
214
+ backend = DesktopNotifierBackend(ensure_identity=MagicMock())
215
+ backend._get()
216
+ factory.assert_called_once_with(app_name=APP_NAME, app_icon=Icon(path=icon_path()))
@@ -1,3 +1,4 @@
1
+ from types import SimpleNamespace
1
2
  from unittest.mock import AsyncMock, patch
2
3
 
3
4
  import pytest
@@ -376,3 +377,119 @@ async def test_apply_failure_surfaces_through_show_remote_error(qapp) -> None:
376
377
 
377
378
  mock_show_error.assert_awaited_once()
378
379
  widget.close()
380
+
381
+
382
+ @pytest.mark.parametrize("value", [16416, 1.5, True, False, "001"])
383
+ def test_typed_choice_preserves_value_and_label(value) -> None:
384
+ schema = _typed_schema(type="enum", choices=[SimpleNamespace(value=value, label="Detector α")], default=value)
385
+ editor = _build_editor("detector", schema, lambda: None)
386
+ assert isinstance(editor.widget, QtWidgets.QComboBox)
387
+ assert editor.widget.itemText(0) == "Detector α"
388
+ assert editor.get_value() == value
389
+ assert type(editor.get_value()) is type(value)
390
+ editor.set_value(value)
391
+ assert type(editor.get_value()) is type(value)
392
+
393
+
394
+ def test_typed_choice_identity_duplicate_labels_and_unknown_values() -> None:
395
+ values = [1, "1", True, "001"]
396
+ changes = []
397
+ editor = _build_editor(
398
+ "choice",
399
+ _typed_schema(
400
+ type="enum",
401
+ choices=[SimpleNamespace(value=value, label="same") for value in values],
402
+ default=1,
403
+ ),
404
+ lambda: changes.append(True),
405
+ )
406
+ combo = editor.widget
407
+ assert isinstance(combo, QtWidgets.QComboBox)
408
+ for index, value in enumerate(values):
409
+ editor.set_value(value)
410
+ assert combo.currentIndex() == index
411
+ assert type(editor.get_value()) is type(value)
412
+ assert changes # index changes fire even when every display label is the same
413
+ for unknown in [999, None, "missing"]:
414
+ editor.set_value(unknown) # pyrefly: ignore [bad-argument-type] -- intentionally invalid None
415
+ assert editor.get_value() == unknown
416
+ assert combo.count() == len(values) + 1
417
+ assert "unknown" in combo.currentText()
418
+ editor.set_value(1)
419
+ assert combo.count() == len(values)
420
+ combo.setCurrentIndex(-1)
421
+ assert editor.get_value() == 1
422
+
423
+
424
+ def test_empty_choice_domain_preserves_received_value() -> None:
425
+ editor = _build_editor("choice", _typed_schema(type="enum", options=["legacy"], choices=[]), lambda: None)
426
+ editor.set_value(999)
427
+ assert editor.get_value() == 999
428
+ assert isinstance(editor.widget, QtWidgets.QComboBox)
429
+ assert "legacy" not in editor.widget.currentText()
430
+
431
+
432
+ @pytest.mark.asyncio
433
+ async def test_typed_choice_state_reset_and_apply() -> None:
434
+ widget = StructuredConfigWidget()
435
+ widget.modules = ["module"]
436
+ widget._build_form(
437
+ ConfigSchema(
438
+ fields={
439
+ "main": _typed_schema(
440
+ type="object",
441
+ nested={
442
+ "detector": _typed_schema(
443
+ type="enum",
444
+ choices=[SimpleNamespace(value=1, label="A"), SimpleNamespace(value=2, label="B")],
445
+ )
446
+ },
447
+ )
448
+ }
449
+ )
450
+ )
451
+ widget._on_state(ConfigAppliedState(config={"main": {"detector": 1}}))
452
+ widget.update_gui()
453
+ assert not widget._is_dirty()
454
+ assert widget._root is not None and widget._root.children is not None
455
+ main = widget._root.children["main"]
456
+ assert main.children is not None
457
+ editor = main.children["detector"]
458
+ editor.set_value(2)
459
+ assert widget._is_dirty()
460
+ widget.reset_clicked()
461
+ assert editor.get_value() == 1
462
+ assert not widget._is_dirty()
463
+ editor.set_value(2)
464
+ proxy = AsyncMock()
465
+ widget._comm = FakeComm(proxy) # pyrefly: ignore [bad-assignment]
466
+ await widget._apply_config(_nested_get(widget._root.children))
467
+ proxy.set_config.assert_awaited_once_with({"main": {"detector": 2}})
468
+ assert type(proxy.set_config.call_args.args[0]["main"]["detector"]) is int
469
+
470
+
471
+ def _typed_schema(*, choices=None, **kwargs):
472
+ # Build the additive capability shape without requiring an unreleased core in CI.
473
+ schema = ConfigFieldSchema(**kwargs)
474
+ vars(schema)["choices"] = choices
475
+ return schema
476
+
477
+
478
+ def test_numeric_and_boolean_choices_have_distinct_dirty_state() -> None:
479
+ widget = StructuredConfigWidget()
480
+ widget.modules = ["module"]
481
+ widget._build_form(
482
+ ConfigSchema(
483
+ fields={
484
+ "choice": _typed_schema(
485
+ type="enum",
486
+ choices=[SimpleNamespace(value=1, label="Number"), SimpleNamespace(value=True, label="Boolean")],
487
+ )
488
+ }
489
+ )
490
+ )
491
+ widget._on_state(ConfigAppliedState(config={"choice": 1}))
492
+ widget.update_gui()
493
+ assert widget._root is not None and widget._root.children is not None
494
+ widget._root.children["choice"].set_value(True)
495
+ assert widget._is_dirty()