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.
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/CHANGELOG.md +22 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/PKG-INFO +2 -2
- pyobs_gui-2.7.1/pyobs_gui/appicon.py +19 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/gui.py +2 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/README.md +13 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-128.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-16.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-24.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-256.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-32.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-48.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-512.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui-64.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui.ico +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui.png +0 -0
- pyobs_gui-2.7.1/pyobs_gui/icons/pyobs-gui.svg +13 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/loginwindow.py +2 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/mainwindow.py +2 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notifier_backend.py +3 -2
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/settingsdialog.py +2 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/structuredconfigwidget.py +49 -14
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/windows_identity.py +26 -1
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyproject.toml +5 -2
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pysidedeploy.spec +2 -2
- pyobs_gui-2.7.1/specs/2026-10-14-typed-config-choices.md +92 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/index.md +6 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_notifier_backend.py +11 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_structuredconfigwidget.py +117 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_windows_identity.py +53 -1
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/uv.lock +512 -394
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.claude/skills/verify/SKILL.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/dependabot.yml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/dependabot-automerge.yml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/pypi.yml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.github/workflows/ruff.yml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.gitignore +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.pre-commit-config.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/.readthedocs.yml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/CLAUDE.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/CONTRIBUTING.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/LICENSE +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/README.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/bin/mypyuic5 +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/Makefile +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/make.bat +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/requirements.txt +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/source/conf.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/docs/source/index.rst +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/mypy.ini +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/__init__.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/__main__.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/accounts.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/acquisitionwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/autofocuswidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/autoguidingwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/base.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/camerawidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/commandinputwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/compassmovewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/coolingwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/datadisplaywidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/eventswidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/filterwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/fitsheaderswidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/focuswidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/guisignals.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/livestream.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/login.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/logmodel.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/modewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/modifiedmixin.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/modulegui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/movestack.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notificationmanager.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notifications.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/notifier.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/nowheelfilter.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/__init__.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autofocuswidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/camerawidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/camerawidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmoveplugin.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmovewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmovewidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/compile.sh +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/coolingwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/datadisplayplugin.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/designer.sh +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/eventswidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/eventswidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/filterwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/filterwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/focuswidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/focuswidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/mainwindow.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/mainwindow_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/modewidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/modewidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/search-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources.qrc +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/resources_rc.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roboticwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roboticwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roofwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/roofwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/schedulewidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/schedulewidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/shellwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/shellwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/spectrographwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/telescopewidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperatureswidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videograbwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videograbwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videowidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/videowidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/weatherwidget.ui +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/roboticwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/roofwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/schedulewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/settings.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/shellwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/spectrographwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/statuswidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/telescopewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/temperaturesplotwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/temperatureswidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/utils.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/videograbwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/videowidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/watchedlabel.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/pyobs_gui/weatherwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-21-gui-widget-startup-responsiveness.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-28-gui-main-vs-sidebar-widgets.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-28-structuredconfig-widget.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-08-31-irobotic-widgets.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-01-gui-video-widget-split.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-14-fitswidget-toolbar-overflow.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-14-stacked-widget-scroll-fallback.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-09-29-live-view-mjpeg-raw.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-10-04-desktop-notifications.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/2026-10-04-settings-dialog.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/plans/2026-10-04-desktop-notifications.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/plans/2026-10-04-settings-dialog.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/specs/plans/index.md +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/spike_standalone/main.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/spike_standalone/pysidedeploy.spec +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/acquisition.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/autofocus.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/camera.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/full.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/guiding.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/mode.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/robotic.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/roof.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/spectrograph.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/structuredconfig.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/telescope.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/telescope_acl.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/telescope_acl_denied.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/test/video.yaml +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/__init__.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/conftest.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_accounts.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_basewidget_discard.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_camerawidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_gui_notifications.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_import_order.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_livestream.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_loginwindow.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_mainwindow_startup.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_missing_vfs_root.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_modewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_multiwidget_pages.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_notificationmanager.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_notifications.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_roboticwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_schedulewidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_settings.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_settingsdialog.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_spectrographwidget.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_video_refresh.py +0 -0
- {pyobs_gui-2.6.3 → pyobs_gui-2.7.1}/tests/test_videograbwidget.py +0 -0
- {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.
|
|
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.
|
|
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`.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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()
|
|
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
|
|
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.
|
|
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.
|
|
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 = /
|
|
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()
|