pyobs-gui 2.0.0.dev5__tar.gz → 2.0.0.dev7__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.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.claude/skills/verify/SKILL.md +1 -1
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.gitignore +5 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/PKG-INFO +1 -1
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/__init__.py +4 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/__main__.py +4 -4
- pyobs_gui-2.0.0.dev7/pyobs_gui/_nuitka_astropy_patch.py +453 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/mainwindow.py +3 -3
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/weatherwidget.py +51 -1
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyproject.toml +10 -1
- pyobs_gui-2.0.0.dev7/pysidedeploy.spec +97 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/specs/README.md +14 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/uv.lock +1 -1
- pyobs_gui-2.0.0.dev5/DEVELOPMENT.md +0 -155
- pyobs_gui-2.0.0.dev5/DEV_IAcquisition.md +0 -95
- pyobs_gui-2.0.0.dev5/DEV_IAutoFocus.md +0 -162
- pyobs_gui-2.0.0.dev5/DEV_IAutoGuiding.md +0 -156
- pyobs_gui-2.0.0.dev5/DEV_NavbarShortcuts.md +0 -286
- pyobs_gui-2.0.0.dev5/DEV_qml_evaluation.md +0 -218
- pyobs_gui-2.0.0.dev5/DEV_telescopewidget_layout.md +0 -199
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.github/dependabot.yml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.github/workflows/pypi.yml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.github/workflows/ruff.yml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.pre-commit-config.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/.readthedocs.yml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/CHANGELOG.rst +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/LICENSE +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/README.md +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/bin/mypyuic5 +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/docs/Makefile +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/docs/make.bat +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/docs/requirements.txt +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/docs/source/conf.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/docs/source/index.rst +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/mypy.ini +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/accounts.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/acquisitionwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/autofocuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/autoguidingwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/base.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/camerawidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/commandinputwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/compassmovewidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/coolingwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/datadisplaywidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/eventswidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/filterwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/fitsheaderswidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/focuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/gui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/login.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/loginwindow.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/logmodel.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/modewidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/modifiedmixin.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/modulegui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/autofocuswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/camerawidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/camerawidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/compassmoveplugin.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/compassmovewidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/compassmovewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/compile.sh +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/coolingwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/datadisplayplugin.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/designer.sh +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/eventswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/eventswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/filterwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/filterwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/focuswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/focuswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/mainwindow.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/mainwindow_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/modewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/modewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/search-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources.qrc +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/resources_rc.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/roofwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/roofwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/shellwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/shellwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/spectrographwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/telescopewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/temperatureswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/videowidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/videowidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/weatherwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/roofwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/shellwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/spectrographwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/statuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/telescopewidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/temperaturesplotwidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/temperatureswidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/utils.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/videowidget.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/pyobs_gui/watchedlabel.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/spike_standalone/main.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/spike_standalone/pysidedeploy.spec +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/acquisition.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/autofocus.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/camera.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/full.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/guiding.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/mode.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/roof.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/spectrograph.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/telescope.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/telescope_acl.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/telescope_acl_denied.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/test/video.yaml +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/tests/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/tests/conftest.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/tests/test_accounts.py +0 -0
- {pyobs_gui-2.0.0.dev5 → pyobs_gui-2.0.0.dev7}/tests/test_loginwindow.py +0 -0
|
@@ -6,7 +6,7 @@ description: Drive the real pyobs-gui app headlessly against a real pyobs module
|
|
|
6
6
|
# Verifying pyobs-gui changes
|
|
7
7
|
|
|
8
8
|
pyobs-gui is a PySide6 app that talks to pyobs modules over `Comm`. There's no
|
|
9
|
-
pytest suite
|
|
9
|
+
pytest suite — verification means running the real
|
|
10
10
|
`GUI` module against a real target module and observing widget state /
|
|
11
11
|
screenshots. This works headlessly, no Xvfb needed.
|
|
12
12
|
|
|
@@ -6,13 +6,13 @@ unless the user opts into "store password" in the login window.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from pyobs.application import Application
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
from .
|
|
10
|
+
def main() -> None:
|
|
11
|
+
from pyobs.application import Application
|
|
13
12
|
|
|
13
|
+
from .gui import GUI
|
|
14
|
+
from .login import login_and_build_gui
|
|
14
15
|
|
|
15
|
-
def main() -> None:
|
|
16
16
|
app = Application(module_factory=login_and_build_gui, loop_module_class=GUI)
|
|
17
17
|
app.run()
|
|
18
18
|
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Work around a Nuitka/astropy incompatibility that breaks unit-string parsing in the
|
|
2
|
+
compiled standalone binary (see specs/plans/gui-widget-plugins-and-packaging.md).
|
|
3
|
+
|
|
4
|
+
astropy.units.format.generic builds its PLY grammar by having ``astropy.extern.ply.yacc``/
|
|
5
|
+
``lex`` walk the call stack with ``sys._getframe()`` to auto-discover the ``tokens``/``p_*``/
|
|
6
|
+
``t_*`` local variables defined inside ``Generic._parser``/``Generic._lexer``. Nuitka-compiled
|
|
7
|
+
function frames don't populate ``f_locals`` the way CPython's interpreted frames do, so that
|
|
8
|
+
walk finds no grammar and the very first ``import astropy.units`` (pulled in unconditionally by
|
|
9
|
+
pyobs-core's ``pyobs.utils.enums``) crashes with ``YaccError: Unable to build parser``. This is a
|
|
10
|
+
real, unresolved upstream limitation as of 2026-07, not a pyobs bug:
|
|
11
|
+
https://github.com/astropy/astropy/issues/15069
|
|
12
|
+
https://github.com/Nuitka/Nuitka/issues/2313
|
|
13
|
+
|
|
14
|
+
Fix: patch ``astropy.extern.ply.yacc.get_caller_module_dict``/``...lex.get_caller_module_dict``
|
|
15
|
+
(the two functions actually doing the broken frame walk) so that whenever the walk comes back
|
|
16
|
+
without a ``tokens`` entry, they fall back to a namespace we rebuild here from the same grammar
|
|
17
|
+
(copied verbatim from astropy.units.format.generic), handed to PLY in a way that doesn't need
|
|
18
|
+
frame introspection at all.
|
|
19
|
+
|
|
20
|
+
This has to patch at the ``astropy.extern.ply`` level, not by importing
|
|
21
|
+
``astropy.units.format.generic`` and monkeypatching ``Generic`` directly, because of a
|
|
22
|
+
bootstrapping deadlock: merely importing ``astropy.units.core``/``astropy.units.format.generic``
|
|
23
|
+
runs ``astropy/units/__init__.py``, which (via ``astropy.units.astrophys``, defining physical
|
|
24
|
+
constants) *already* triggers the broken parser during that very import -- before a class-level
|
|
25
|
+
patch could ever be installed. Patching ``get_caller_module_dict`` needs only
|
|
26
|
+
``astropy.extern.ply``, which has no dependency on ``astropy.units`` at all, so it's safe to
|
|
27
|
+
install before anything touches ``astropy.units``. The vendored grammar's own reference to
|
|
28
|
+
``astropy.units.core.Unit``/``CompositeUnit``/``Generic`` is resolved lazily, only when the
|
|
29
|
+
fallback actually fires -- by then we're already being called from inside
|
|
30
|
+
``Generic._parser``/``_lexer``'s own call chain, so those names are guaranteed importable.
|
|
31
|
+
|
|
32
|
+
Scoped to the generic unit format only -- the one format pyobs_gui is known to hit
|
|
33
|
+
unconditionally at startup. astropy's cds/ogip/vounit formats and astropy.coordinates.angles use
|
|
34
|
+
the same PLY pattern and would need the identical treatment if/when they're actually exercised in
|
|
35
|
+
the compiled binary.
|
|
36
|
+
|
|
37
|
+
Must run before ``astropy.units`` is imported anywhere, i.e. as the first thing in
|
|
38
|
+
``pyobs_gui/__init__.py``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import re
|
|
44
|
+
import tempfile
|
|
45
|
+
import types
|
|
46
|
+
from fractions import Fraction
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
_generic_ns_cache: dict[str, types.ModuleType] = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_generic_grammar_namespace() -> types.ModuleType:
|
|
54
|
+
"""Rebuild astropy.units.format.generic's PLY grammar as a plain namespace object.
|
|
55
|
+
|
|
56
|
+
Only called lazily, from inside the ``get_caller_module_dict`` fallback below -- by that
|
|
57
|
+
point ``astropy.units.core``/``astropy.units.format.generic`` are already far enough along
|
|
58
|
+
to import from (see module docstring).
|
|
59
|
+
"""
|
|
60
|
+
from astropy.units.core import CompositeUnit, Unit
|
|
61
|
+
from astropy.units.format.generic import Generic
|
|
62
|
+
|
|
63
|
+
cls = Generic
|
|
64
|
+
tokens = cls._tokens
|
|
65
|
+
|
|
66
|
+
# astropy.utils.parsing wants to write a generated tab-file cache next to __file__'s
|
|
67
|
+
# directory. In a frozen binary, astropy/units/format/ isn't a real directory on disk (it's
|
|
68
|
+
# compiled into the binary), so point this at a writable scratch directory instead --
|
|
69
|
+
# writing there is harmless best-effort caching, not required for parsing to work.
|
|
70
|
+
stub_dir = Path(tempfile.gettempdir()) / "pyobs_gui_astropy_ply_stub"
|
|
71
|
+
stub_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
ns = types.ModuleType("_pyobs_gui_generic_grammar")
|
|
74
|
+
ns.__file__ = str(stub_dir / "generic.py")
|
|
75
|
+
ns.tokens = tokens
|
|
76
|
+
|
|
77
|
+
ns.t_COMMA = r"\,"
|
|
78
|
+
ns.t_PRODUCT = "[*.]"
|
|
79
|
+
ns.t_DIVISION = "/"
|
|
80
|
+
ns.t_POWER = r"\^|(\*\*)"
|
|
81
|
+
ns.t_OPEN_PAREN = r"\("
|
|
82
|
+
ns.t_CLOSE_PAREN = r"\)"
|
|
83
|
+
|
|
84
|
+
def t_UFLOAT(t):
|
|
85
|
+
r"((\d+\.?\d*)|(\.\d+))([eE][+-]?\d+)?"
|
|
86
|
+
if not re.search(r"[eE\.]", t.value):
|
|
87
|
+
t.type = "UINT"
|
|
88
|
+
t.value = int(t.value)
|
|
89
|
+
elif t.value.endswith("."):
|
|
90
|
+
t.type = "UINT"
|
|
91
|
+
t.value = int(t.value[:-1])
|
|
92
|
+
else:
|
|
93
|
+
t.value = float(t.value)
|
|
94
|
+
return t
|
|
95
|
+
|
|
96
|
+
def t_UINT(t):
|
|
97
|
+
r"\d+"
|
|
98
|
+
t.value = int(t.value)
|
|
99
|
+
return t
|
|
100
|
+
|
|
101
|
+
def t_SIGN(t):
|
|
102
|
+
r"[+-](?=\d)"
|
|
103
|
+
t.value = int(t.value + "1")
|
|
104
|
+
return t
|
|
105
|
+
|
|
106
|
+
def t_FUNCNAME(t):
|
|
107
|
+
r"((sqrt)|(ln)|(exp)|(log)|(mag)|(dB)|(dex))(?=\ *\()"
|
|
108
|
+
return t
|
|
109
|
+
|
|
110
|
+
def t_UNIT(t):
|
|
111
|
+
r"[^\s\d+\-\./\*\^\(\)\,]+"
|
|
112
|
+
t.value = cls._get_unit(t)
|
|
113
|
+
return t
|
|
114
|
+
|
|
115
|
+
ns.t_ignore = " "
|
|
116
|
+
|
|
117
|
+
def t_error(t):
|
|
118
|
+
raise ValueError(f"Invalid character at col {t.lexpos}")
|
|
119
|
+
|
|
120
|
+
ns.t_UFLOAT = t_UFLOAT
|
|
121
|
+
ns.t_UINT = t_UINT
|
|
122
|
+
ns.t_SIGN = t_SIGN
|
|
123
|
+
ns.t_FUNCNAME = t_FUNCNAME
|
|
124
|
+
ns.t_UNIT = t_UNIT
|
|
125
|
+
ns.t_error = t_error
|
|
126
|
+
|
|
127
|
+
def p_main(p):
|
|
128
|
+
"""
|
|
129
|
+
main : unit
|
|
130
|
+
| structured_unit
|
|
131
|
+
| structured_subunit
|
|
132
|
+
"""
|
|
133
|
+
if isinstance(p[1], tuple):
|
|
134
|
+
p[0] = p[1][0]
|
|
135
|
+
else:
|
|
136
|
+
p[0] = p[1]
|
|
137
|
+
|
|
138
|
+
def p_structured_subunit(p):
|
|
139
|
+
"""
|
|
140
|
+
structured_subunit : OPEN_PAREN structured_unit CLOSE_PAREN
|
|
141
|
+
"""
|
|
142
|
+
p[0] = (p[2],)
|
|
143
|
+
|
|
144
|
+
def p_structured_unit(p):
|
|
145
|
+
"""
|
|
146
|
+
structured_unit : subunit COMMA
|
|
147
|
+
| subunit COMMA subunit
|
|
148
|
+
"""
|
|
149
|
+
from astropy.units.structured import StructuredUnit
|
|
150
|
+
|
|
151
|
+
inputs = (p[1],) if len(p) == 3 else (p[1], p[3])
|
|
152
|
+
units = ()
|
|
153
|
+
for subunit in inputs:
|
|
154
|
+
if isinstance(subunit, tuple):
|
|
155
|
+
units += subunit
|
|
156
|
+
elif isinstance(subunit, StructuredUnit):
|
|
157
|
+
units += subunit.values()
|
|
158
|
+
else:
|
|
159
|
+
units += (subunit,)
|
|
160
|
+
|
|
161
|
+
p[0] = StructuredUnit(units)
|
|
162
|
+
|
|
163
|
+
def p_subunit(p):
|
|
164
|
+
"""
|
|
165
|
+
subunit : unit
|
|
166
|
+
| structured_unit
|
|
167
|
+
| structured_subunit
|
|
168
|
+
"""
|
|
169
|
+
p[0] = p[1]
|
|
170
|
+
|
|
171
|
+
def p_unit(p):
|
|
172
|
+
"""
|
|
173
|
+
unit : product_of_units
|
|
174
|
+
| factor product_of_units
|
|
175
|
+
| factor PRODUCT product_of_units
|
|
176
|
+
| division_product_of_units
|
|
177
|
+
| factor division_product_of_units
|
|
178
|
+
| factor PRODUCT division_product_of_units
|
|
179
|
+
| inverse_unit
|
|
180
|
+
| factor inverse_unit
|
|
181
|
+
| factor PRODUCT inverse_unit
|
|
182
|
+
| factor
|
|
183
|
+
"""
|
|
184
|
+
if len(p) == 2:
|
|
185
|
+
p[0] = Unit(p[1])
|
|
186
|
+
elif len(p) == 3:
|
|
187
|
+
p[0] = CompositeUnit(p[1] * p[2].scale, p[2].bases, p[2].powers)
|
|
188
|
+
elif len(p) == 4:
|
|
189
|
+
p[0] = CompositeUnit(p[1] * p[3].scale, p[3].bases, p[3].powers)
|
|
190
|
+
|
|
191
|
+
def p_division_product_of_units(p):
|
|
192
|
+
"""
|
|
193
|
+
division_product_of_units : division_product_of_units DIVISION product_of_units
|
|
194
|
+
| product_of_units
|
|
195
|
+
"""
|
|
196
|
+
if len(p) == 4:
|
|
197
|
+
p[0] = Unit(p[1] / p[3])
|
|
198
|
+
else:
|
|
199
|
+
p[0] = p[1]
|
|
200
|
+
|
|
201
|
+
def p_inverse_unit(p):
|
|
202
|
+
"""
|
|
203
|
+
inverse_unit : DIVISION unit_expression
|
|
204
|
+
"""
|
|
205
|
+
p[0] = p[2] ** -1
|
|
206
|
+
|
|
207
|
+
def p_factor(p):
|
|
208
|
+
"""
|
|
209
|
+
factor : factor_fits
|
|
210
|
+
| factor_float
|
|
211
|
+
| factor_int
|
|
212
|
+
"""
|
|
213
|
+
p[0] = p[1]
|
|
214
|
+
|
|
215
|
+
def p_factor_float(p):
|
|
216
|
+
"""
|
|
217
|
+
factor_float : signed_float
|
|
218
|
+
| signed_float UINT signed_int
|
|
219
|
+
| signed_float UINT POWER numeric_power
|
|
220
|
+
"""
|
|
221
|
+
if cls.name == "fits":
|
|
222
|
+
raise ValueError("Numeric factor not supported by FITS")
|
|
223
|
+
if len(p) == 4:
|
|
224
|
+
p[0] = p[1] * p[2] ** float(p[3])
|
|
225
|
+
elif len(p) == 5:
|
|
226
|
+
p[0] = p[1] * p[2] ** float(p[4])
|
|
227
|
+
elif len(p) == 2:
|
|
228
|
+
p[0] = p[1]
|
|
229
|
+
|
|
230
|
+
def p_factor_int(p):
|
|
231
|
+
"""
|
|
232
|
+
factor_int : UINT
|
|
233
|
+
| UINT signed_int
|
|
234
|
+
| UINT POWER numeric_power
|
|
235
|
+
| UINT UINT signed_int
|
|
236
|
+
| UINT UINT POWER numeric_power
|
|
237
|
+
"""
|
|
238
|
+
if cls.name == "fits":
|
|
239
|
+
raise ValueError("Numeric factor not supported by FITS")
|
|
240
|
+
if len(p) == 2:
|
|
241
|
+
p[0] = p[1]
|
|
242
|
+
elif len(p) == 3:
|
|
243
|
+
p[0] = p[1] ** float(p[2])
|
|
244
|
+
elif len(p) == 4:
|
|
245
|
+
if isinstance(p[2], int):
|
|
246
|
+
p[0] = p[1] * p[2] ** float(p[3])
|
|
247
|
+
else:
|
|
248
|
+
p[0] = p[1] ** float(p[3])
|
|
249
|
+
elif len(p) == 5:
|
|
250
|
+
p[0] = p[1] * p[2] ** p[4]
|
|
251
|
+
|
|
252
|
+
def p_factor_fits(p):
|
|
253
|
+
"""
|
|
254
|
+
factor_fits : UINT POWER OPEN_PAREN signed_int CLOSE_PAREN
|
|
255
|
+
| UINT POWER OPEN_PAREN UINT CLOSE_PAREN
|
|
256
|
+
| UINT POWER signed_int
|
|
257
|
+
| UINT POWER UINT
|
|
258
|
+
| UINT SIGN UINT
|
|
259
|
+
| UINT OPEN_PAREN signed_int CLOSE_PAREN
|
|
260
|
+
"""
|
|
261
|
+
if p[1] != 10:
|
|
262
|
+
if cls.name == "fits":
|
|
263
|
+
raise ValueError("Base must be 10")
|
|
264
|
+
else:
|
|
265
|
+
return
|
|
266
|
+
if len(p) == 4:
|
|
267
|
+
if p[2] in ("**", "^"):
|
|
268
|
+
p[0] = 10 ** p[3]
|
|
269
|
+
else:
|
|
270
|
+
p[0] = 10 ** (p[2] * p[3])
|
|
271
|
+
elif len(p) == 5:
|
|
272
|
+
p[0] = 10 ** p[3]
|
|
273
|
+
elif len(p) == 6:
|
|
274
|
+
p[0] = 10 ** p[4]
|
|
275
|
+
|
|
276
|
+
def p_product_of_units(p):
|
|
277
|
+
"""
|
|
278
|
+
product_of_units : unit_expression PRODUCT product_of_units
|
|
279
|
+
| unit_expression product_of_units
|
|
280
|
+
| unit_expression
|
|
281
|
+
"""
|
|
282
|
+
if len(p) == 2:
|
|
283
|
+
p[0] = p[1]
|
|
284
|
+
elif len(p) == 3:
|
|
285
|
+
p[0] = p[1] * p[2]
|
|
286
|
+
else:
|
|
287
|
+
p[0] = p[1] * p[3]
|
|
288
|
+
|
|
289
|
+
def p_unit_expression(p):
|
|
290
|
+
"""
|
|
291
|
+
unit_expression : function
|
|
292
|
+
| unit_with_power
|
|
293
|
+
| OPEN_PAREN product_of_units CLOSE_PAREN
|
|
294
|
+
"""
|
|
295
|
+
if len(p) == 2:
|
|
296
|
+
p[0] = p[1]
|
|
297
|
+
else:
|
|
298
|
+
p[0] = p[2]
|
|
299
|
+
|
|
300
|
+
def p_unit_with_power(p):
|
|
301
|
+
"""
|
|
302
|
+
unit_with_power : UNIT POWER numeric_power
|
|
303
|
+
| UNIT numeric_power
|
|
304
|
+
| UNIT
|
|
305
|
+
"""
|
|
306
|
+
if len(p) == 2:
|
|
307
|
+
p[0] = p[1]
|
|
308
|
+
elif len(p) == 3:
|
|
309
|
+
p[0] = p[1] ** p[2]
|
|
310
|
+
else:
|
|
311
|
+
p[0] = p[1] ** p[3]
|
|
312
|
+
|
|
313
|
+
def p_numeric_power(p):
|
|
314
|
+
"""
|
|
315
|
+
numeric_power : sign UINT
|
|
316
|
+
| OPEN_PAREN paren_expr CLOSE_PAREN
|
|
317
|
+
"""
|
|
318
|
+
if len(p) == 3:
|
|
319
|
+
p[0] = p[1] * p[2]
|
|
320
|
+
elif len(p) == 4:
|
|
321
|
+
p[0] = p[2]
|
|
322
|
+
|
|
323
|
+
def p_paren_expr(p):
|
|
324
|
+
"""
|
|
325
|
+
paren_expr : sign UINT
|
|
326
|
+
| signed_float
|
|
327
|
+
| frac
|
|
328
|
+
"""
|
|
329
|
+
if len(p) == 3:
|
|
330
|
+
p[0] = p[1] * p[2]
|
|
331
|
+
else:
|
|
332
|
+
p[0] = p[1]
|
|
333
|
+
|
|
334
|
+
def p_frac(p):
|
|
335
|
+
"""
|
|
336
|
+
frac : sign UINT DIVISION sign UINT
|
|
337
|
+
"""
|
|
338
|
+
p[0] = Fraction(p[1] * p[2], p[4] * p[5])
|
|
339
|
+
|
|
340
|
+
def p_sign(p):
|
|
341
|
+
"""
|
|
342
|
+
sign : SIGN
|
|
343
|
+
|
|
|
344
|
+
"""
|
|
345
|
+
if len(p) == 2:
|
|
346
|
+
p[0] = p[1]
|
|
347
|
+
else:
|
|
348
|
+
p[0] = 1
|
|
349
|
+
|
|
350
|
+
def p_signed_int(p):
|
|
351
|
+
"""
|
|
352
|
+
signed_int : SIGN UINT
|
|
353
|
+
"""
|
|
354
|
+
p[0] = p[1] * p[2]
|
|
355
|
+
|
|
356
|
+
def p_signed_float(p):
|
|
357
|
+
"""
|
|
358
|
+
signed_float : sign UINT
|
|
359
|
+
| sign UFLOAT
|
|
360
|
+
"""
|
|
361
|
+
p[0] = p[1] * p[2]
|
|
362
|
+
|
|
363
|
+
def p_function(p):
|
|
364
|
+
"""
|
|
365
|
+
function : FUNCNAME OPEN_PAREN main CLOSE_PAREN
|
|
366
|
+
"""
|
|
367
|
+
if p[1] == "sqrt":
|
|
368
|
+
p[0] = p[3] ** 0.5
|
|
369
|
+
return
|
|
370
|
+
elif p[1] in ("mag", "dB", "dex"):
|
|
371
|
+
function_unit = cls._validate_unit(p[1])
|
|
372
|
+
if callable(function_unit):
|
|
373
|
+
p[0] = function_unit(p[3])
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
raise ValueError(f"'{p[1]}' is not a recognized function")
|
|
377
|
+
|
|
378
|
+
def p_error(p):
|
|
379
|
+
raise ValueError()
|
|
380
|
+
|
|
381
|
+
ns.p_main = p_main
|
|
382
|
+
ns.p_structured_subunit = p_structured_subunit
|
|
383
|
+
ns.p_structured_unit = p_structured_unit
|
|
384
|
+
ns.p_subunit = p_subunit
|
|
385
|
+
ns.p_unit = p_unit
|
|
386
|
+
ns.p_division_product_of_units = p_division_product_of_units
|
|
387
|
+
ns.p_inverse_unit = p_inverse_unit
|
|
388
|
+
ns.p_factor = p_factor
|
|
389
|
+
ns.p_factor_float = p_factor_float
|
|
390
|
+
ns.p_factor_int = p_factor_int
|
|
391
|
+
ns.p_factor_fits = p_factor_fits
|
|
392
|
+
ns.p_product_of_units = p_product_of_units
|
|
393
|
+
ns.p_unit_expression = p_unit_expression
|
|
394
|
+
ns.p_unit_with_power = p_unit_with_power
|
|
395
|
+
ns.p_numeric_power = p_numeric_power
|
|
396
|
+
ns.p_paren_expr = p_paren_expr
|
|
397
|
+
ns.p_frac = p_frac
|
|
398
|
+
ns.p_sign = p_sign
|
|
399
|
+
ns.p_signed_int = p_signed_int
|
|
400
|
+
ns.p_signed_float = p_signed_float
|
|
401
|
+
ns.p_function = p_function
|
|
402
|
+
ns.p_error = p_error
|
|
403
|
+
|
|
404
|
+
return ns
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _namespace_pdict(ns: types.ModuleType) -> dict[str, Any]:
|
|
408
|
+
return {k: getattr(ns, k) for k in dir(ns)}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def patch_generic_unit_parser() -> None:
|
|
412
|
+
"""Patch astropy.extern.ply's frame-walking helpers to fall back to our vendored grammar.
|
|
413
|
+
|
|
414
|
+
Safe to call unconditionally (frozen or not) -- under a normal, unfrozen interpreter the
|
|
415
|
+
original frame walk always succeeds, so the fallback path never triggers.
|
|
416
|
+
"""
|
|
417
|
+
from astropy.extern.ply import lex as ply_lex
|
|
418
|
+
from astropy.extern.ply import yacc as ply_yacc
|
|
419
|
+
|
|
420
|
+
original_yacc_dict = ply_yacc.get_caller_module_dict
|
|
421
|
+
original_lex_dict = ply_lex.get_caller_module_dict
|
|
422
|
+
|
|
423
|
+
def _fallback_pdict(pdict: dict[str, Any] | None) -> dict[str, Any]:
|
|
424
|
+
# Keep whatever the real (possibly broken-locals) pdict already has -- __file__/
|
|
425
|
+
# __package__/__name__ are module globals and come through fine even on a compiled
|
|
426
|
+
# frame -- and only supplement the missing tokens/p_*/t_* grammar entries. Replacing
|
|
427
|
+
# __file__ wholesale would point astropy's tab-file bookkeeping at the wrong directory.
|
|
428
|
+
if "ns" not in _generic_ns_cache:
|
|
429
|
+
_generic_ns_cache["ns"] = _build_generic_grammar_namespace()
|
|
430
|
+
merged = dict(pdict) if pdict else {}
|
|
431
|
+
merged.update(_namespace_pdict(_generic_ns_cache["ns"]))
|
|
432
|
+
return merged
|
|
433
|
+
|
|
434
|
+
def patched_yacc_dict(levels: int) -> dict[str, Any]:
|
|
435
|
+
try:
|
|
436
|
+
pdict = original_yacc_dict(levels)
|
|
437
|
+
except Exception:
|
|
438
|
+
return _fallback_pdict(None)
|
|
439
|
+
if "tokens" not in pdict:
|
|
440
|
+
return _fallback_pdict(pdict)
|
|
441
|
+
return pdict
|
|
442
|
+
|
|
443
|
+
def patched_lex_dict(levels: int) -> dict[str, Any]:
|
|
444
|
+
try:
|
|
445
|
+
pdict = original_lex_dict(levels)
|
|
446
|
+
except Exception:
|
|
447
|
+
return _fallback_pdict(None)
|
|
448
|
+
if "tokens" not in pdict:
|
|
449
|
+
return _fallback_pdict(pdict)
|
|
450
|
+
return pdict
|
|
451
|
+
|
|
452
|
+
ply_yacc.get_caller_module_dict = patched_yacc_dict
|
|
453
|
+
ply_lex.get_caller_module_dict = patched_lex_dict
|
|
@@ -288,7 +288,7 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
|
|
|
288
288
|
self._modules_header_added = False
|
|
289
289
|
|
|
290
290
|
# navbar keyboard shortcuts: slot -> currently bound page name, session-only (see
|
|
291
|
-
#
|
|
291
|
+
# pyobs-core/specs/plans/gui-navbar-shortcuts.md)
|
|
292
292
|
self._slot_bindings: Dict[str, str] = {}
|
|
293
293
|
self.listPages.setItemDelegate(NavPageItemDelegate(self._slot_bindings, self))
|
|
294
294
|
self._setup_shortcuts()
|
|
@@ -469,8 +469,8 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
|
|
|
469
469
|
self._widgets / self._slot_bindings at press-time, since module pages come and go but the
|
|
470
470
|
shortcut objects themselves live for the app's lifetime. Every shortcut requires Ctrl (or
|
|
471
471
|
Ctrl+Alt) so no text/numeric-entry widget can ever mistake one for ordinary input -- see
|
|
472
|
-
|
|
473
|
-
per-widget-type verification.
|
|
472
|
+
pyobs-core/specs/plans/gui-navbar-shortcuts.md for why that's a structural guarantee
|
|
473
|
+
rather than one that needs per-widget-type verification.
|
|
474
474
|
"""
|
|
475
475
|
self._shortcuts: List[QtGui.QShortcut] = []
|
|
476
476
|
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import os
|
|
2
|
+
from collections import deque
|
|
3
|
+
from typing import Any, Deque, Dict, Tuple, TYPE_CHECKING
|
|
2
4
|
from PySide6 import QtWidgets, QtGui, QtCore # type: ignore
|
|
3
5
|
import logging
|
|
4
6
|
|
|
7
|
+
os.environ["QT_API"] = "PySide6"
|
|
8
|
+
from matplotlib import pyplot as plt
|
|
9
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
10
|
+
from matplotlib.dates import DateFormatter
|
|
11
|
+
|
|
5
12
|
from pyobs.interfaces import IWeather, WeatherState, WeatherSensorReading
|
|
6
13
|
from pyobs.utils.enums import WeatherSensors
|
|
7
14
|
from .qt.weatherwidget_ui import Ui_WeatherWidget
|
|
@@ -13,6 +20,9 @@ if TYPE_CHECKING:
|
|
|
13
20
|
|
|
14
21
|
log = logging.getLogger(__name__)
|
|
15
22
|
|
|
23
|
+
# how many samples to keep per sensor for the history plot
|
|
24
|
+
_HISTORY_LENGTH = 200
|
|
25
|
+
|
|
16
26
|
|
|
17
27
|
# label and display order for known sensors; anything else reported by the module is ignored
|
|
18
28
|
SENSOR_LABELS: Dict[WeatherSensors, str] = {
|
|
@@ -92,6 +102,14 @@ class WeatherWidget(BaseWidget, Ui_WeatherWidget):
|
|
|
92
102
|
self._good: bool | None = None
|
|
93
103
|
self._readings: Dict[WeatherSensors, WeatherSensorReading] = {}
|
|
94
104
|
self._current_widgets: Dict[str, WidgetCurrentSensor] = {}
|
|
105
|
+
self._history: Dict[WeatherSensors, "Deque[Tuple[Time, float]]"] = {}
|
|
106
|
+
|
|
107
|
+
# add plot
|
|
108
|
+
self.figure = plt.figure()
|
|
109
|
+
layout = QtWidgets.QVBoxLayout(self.framePlot)
|
|
110
|
+
self.canvas = FigureCanvas(self.figure)
|
|
111
|
+
layout.addWidget(self.canvas)
|
|
112
|
+
self.framePlot.setLayout(layout)
|
|
95
113
|
|
|
96
114
|
# before first update, disable myself
|
|
97
115
|
self.setEnabled(False)
|
|
@@ -106,6 +124,11 @@ class WeatherWidget(BaseWidget, Ui_WeatherWidget):
|
|
|
106
124
|
self._time = state.time
|
|
107
125
|
self._good = state.good
|
|
108
126
|
self._readings = {r.sensor: r for r in state.readings}
|
|
127
|
+
for reading in state.readings:
|
|
128
|
+
if reading.value is not None:
|
|
129
|
+
self._history.setdefault(reading.sensor, deque(maxlen=_HISTORY_LENGTH)).append(
|
|
130
|
+
(reading.time, reading.value)
|
|
131
|
+
)
|
|
109
132
|
self.signal_update_gui.emit()
|
|
110
133
|
|
|
111
134
|
def update_gui(self) -> None:
|
|
@@ -150,5 +173,32 @@ class WeatherWidget(BaseWidget, Ui_WeatherWidget):
|
|
|
150
173
|
widget.set_value(s)
|
|
151
174
|
widget.set_good(self._good)
|
|
152
175
|
|
|
176
|
+
self._plot_history()
|
|
177
|
+
|
|
178
|
+
def _plot_history(self) -> None:
|
|
179
|
+
# one stacked subplot per sensor that has history, in the same order as the current-value
|
|
180
|
+
# tiles; the sensor set (and therefore the subplot count) can change at runtime, so the
|
|
181
|
+
# figure is rebuilt from scratch rather than reusing fixed axes
|
|
182
|
+
sensors = [s for s in SENSOR_LABELS if self._history.get(s)]
|
|
183
|
+
|
|
184
|
+
self.figure.clear()
|
|
185
|
+
if not sensors:
|
|
186
|
+
self.canvas.draw()
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
axes = self.figure.subplots(len(sensors), 1, sharex=True)
|
|
190
|
+
if len(sensors) == 1:
|
|
191
|
+
axes = [axes]
|
|
192
|
+
for ax, sensor in zip(axes, sensors, strict=True):
|
|
193
|
+
times, values = zip(*self._history[sensor], strict=True)
|
|
194
|
+
ax.plot([t.to_datetime() for t in times], values, color="tab:blue")
|
|
195
|
+
ax.set_ylabel(SENSOR_LABELS[sensor], fontsize="small")
|
|
196
|
+
ax.grid(linestyle=":", alpha=0.5)
|
|
197
|
+
ax.set_axisbelow(True)
|
|
198
|
+
axes[-1].xaxis.set_major_formatter(DateFormatter("%H:%M:%S"))
|
|
199
|
+
self.figure.autofmt_xdate()
|
|
200
|
+
self.figure.tight_layout()
|
|
201
|
+
self.canvas.draw()
|
|
202
|
+
|
|
153
203
|
|
|
154
204
|
__all__ = ["WeatherWidget"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyobs-gui"
|
|
3
|
-
version = "2.0.0.
|
|
3
|
+
version = "2.0.0.dev7"
|
|
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,<3.14"
|
|
@@ -20,6 +20,9 @@ dependencies = [
|
|
|
20
20
|
"secretstorage>=3.5.0,<4",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
+
[project.scripts]
|
|
24
|
+
pyobs-gui = "pyobs_gui.__main__:main"
|
|
25
|
+
|
|
23
26
|
[dependency-groups]
|
|
24
27
|
dev = [
|
|
25
28
|
"black>=25.1.0,<27",
|
|
@@ -56,6 +59,12 @@ ignore = ["E203", "E266", "E501", "F403", "E722"]
|
|
|
56
59
|
|
|
57
60
|
[tool.ruff.lint.per-file-ignores]
|
|
58
61
|
"__init__.py" = ["F401"]
|
|
62
|
+
# The astropy/Nuitka patch must run before any other pyobs_gui import (see its own docstring).
|
|
63
|
+
"pyobs_gui/__init__.py" = ["E402"]
|
|
64
|
+
# _build_generic_grammar_namespace is a verbatim transcription of astropy's own PLY grammar,
|
|
65
|
+
# deliberately mirroring astropy's structure for easy diffing -- splitting it up to satisfy
|
|
66
|
+
# mccabe would work against that goal.
|
|
67
|
+
"pyobs_gui/_nuitka_astropy_patch.py" = ["C901"]
|
|
59
68
|
|
|
60
69
|
[tool.ruff.lint.mccabe]
|
|
61
70
|
max-complexity = 30
|