pycontrol-gui 3.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. pycontrol_gui/__init__.py +15 -0
  2. pycontrol_gui/__main__.py +5 -0
  3. pycontrol_gui/app.py +102 -0
  4. pycontrol_gui/bus_adapter.py +54 -0
  5. pycontrol_gui/error_logging.py +134 -0
  6. pycontrol_gui/experiment_extensions.py +39 -0
  7. pycontrol_gui/main_window.py +894 -0
  8. pycontrol_gui/persistent_variables.py +55 -0
  9. pycontrol_gui/plotting/__init__.py +17 -0
  10. pycontrol_gui/plotting/base.py +38 -0
  11. pycontrol_gui/plotting/default_plotter.py +123 -0
  12. pycontrol_gui/plotting/host.py +364 -0
  13. pycontrol_gui/plotting/loader.py +48 -0
  14. pycontrol_gui/plotting/profiling.py +81 -0
  15. pycontrol_gui/plotting/widgets/__init__.py +7 -0
  16. pycontrol_gui/plotting/widgets/analog_plot.py +167 -0
  17. pycontrol_gui/plotting/widgets/events_plot.py +164 -0
  18. pycontrol_gui/plotting/widgets/states_plot.py +132 -0
  19. pycontrol_gui/resources/__init__.py +38 -0
  20. pycontrol_gui/resources/icons/add.svg +3 -0
  21. pycontrol_gui/resources/icons/bar-graph.svg +1 -0
  22. pycontrol_gui/resources/icons/book.svg +3 -0
  23. pycontrol_gui/resources/icons/circle-arrow-up.svg +3 -0
  24. pycontrol_gui/resources/icons/circle-arrow-up_white.svg +3 -0
  25. pycontrol_gui/resources/icons/close.svg +3 -0
  26. pycontrol_gui/resources/icons/close_white.svg +3 -0
  27. pycontrol_gui/resources/icons/collapse.svg +1 -0
  28. pycontrol_gui/resources/icons/comment.svg +3 -0
  29. pycontrol_gui/resources/icons/connect.svg +12 -0
  30. pycontrol_gui/resources/icons/copy.svg +1 -0
  31. pycontrol_gui/resources/icons/copy_evil.svg +1 -0
  32. pycontrol_gui/resources/icons/delete.svg +3 -0
  33. pycontrol_gui/resources/icons/detach.svg +1 -0
  34. pycontrol_gui/resources/icons/disable.svg +5 -0
  35. pycontrol_gui/resources/icons/disconnect.svg +13 -0
  36. pycontrol_gui/resources/icons/down.svg +3 -0
  37. pycontrol_gui/resources/icons/edit.svg +3 -0
  38. pycontrol_gui/resources/icons/enable.svg +4 -0
  39. pycontrol_gui/resources/icons/expand.svg +1 -0
  40. pycontrol_gui/resources/icons/filter.svg +3 -0
  41. pycontrol_gui/resources/icons/folder.svg +3 -0
  42. pycontrol_gui/resources/icons/funnel.svg +1 -0
  43. pycontrol_gui/resources/icons/github.svg +1 -0
  44. pycontrol_gui/resources/icons/help.svg +8 -0
  45. pycontrol_gui/resources/icons/info.svg +1 -0
  46. pycontrol_gui/resources/icons/keyboard.svg +1 -0
  47. pycontrol_gui/resources/icons/left.svg +3 -0
  48. pycontrol_gui/resources/icons/logo.svg +68 -0
  49. pycontrol_gui/resources/icons/mount.svg +1 -0
  50. pycontrol_gui/resources/icons/notion.svg +1 -0
  51. pycontrol_gui/resources/icons/pause.svg +3 -0
  52. pycontrol_gui/resources/icons/play.svg +3 -0
  53. pycontrol_gui/resources/icons/play_white.svg +3 -0
  54. pycontrol_gui/resources/icons/record.svg +10 -0
  55. pycontrol_gui/resources/icons/record_white.svg +8 -0
  56. pycontrol_gui/resources/icons/refresh.svg +3 -0
  57. pycontrol_gui/resources/icons/remove.svg +3 -0
  58. pycontrol_gui/resources/icons/right.svg +3 -0
  59. pycontrol_gui/resources/icons/run.svg +3 -0
  60. pycontrol_gui/resources/icons/run_white.svg +3 -0
  61. pycontrol_gui/resources/icons/save.svg +3 -0
  62. pycontrol_gui/resources/icons/save_as.svg +6 -0
  63. pycontrol_gui/resources/icons/settings.svg +4 -0
  64. pycontrol_gui/resources/icons/share.svg +1 -0
  65. pycontrol_gui/resources/icons/stop.svg +3 -0
  66. pycontrol_gui/resources/icons/stop_white.svg +3 -0
  67. pycontrol_gui/resources/icons/telegram.svg +1 -0
  68. pycontrol_gui/resources/icons/trash.svg +3 -0
  69. pycontrol_gui/resources/icons/up.svg +3 -0
  70. pycontrol_gui/resources/icons/upload.svg +6 -0
  71. pycontrol_gui/resources/icons/wrench.svg +4 -0
  72. pycontrol_gui/sandbox_session.py +83 -0
  73. pycontrol_gui/session_controller.py +605 -0
  74. pycontrol_gui/session_helpers.py +183 -0
  75. pycontrol_gui/session_runtime.py +194 -0
  76. pycontrol_gui/setup_maintenance.py +139 -0
  77. pycontrol_gui/task_variables.py +121 -0
  78. pycontrol_gui/update_check.py +283 -0
  79. pycontrol_gui/widgets/__init__.py +34 -0
  80. pycontrol_gui/widgets/about_dialog.py +103 -0
  81. pycontrol_gui/widgets/controls_dialog.py +828 -0
  82. pycontrol_gui/widgets/controls_dialog_editor.py +885 -0
  83. pycontrol_gui/widgets/controls_spec_io.py +36 -0
  84. pycontrol_gui/widgets/error_log_dialog.py +115 -0
  85. pycontrol_gui/widgets/experiment_editor_rows.py +70 -0
  86. pycontrol_gui/widgets/experiment_editor_table.py +137 -0
  87. pycontrol_gui/widgets/experiment_run_control.py +144 -0
  88. pycontrol_gui/widgets/experiment_run_models.py +48 -0
  89. pycontrol_gui/widgets/experiment_run_plots.py +306 -0
  90. pycontrol_gui/widgets/experiment_run_summary.py +113 -0
  91. pycontrol_gui/widgets/experiment_run_view.py +1288 -0
  92. pycontrol_gui/widgets/experiments_tab.py +1859 -0
  93. pycontrol_gui/widgets/file_tree.py +46 -0
  94. pycontrol_gui/widgets/hardware_variables_dialog.py +206 -0
  95. pycontrol_gui/widgets/keyboard_shortcuts_dialog.py +68 -0
  96. pycontrol_gui/widgets/log_filter_controller.py +218 -0
  97. pycontrol_gui/widgets/log_view.py +221 -0
  98. pycontrol_gui/widgets/run_task_tab.py +1598 -0
  99. pycontrol_gui/widgets/sandbox_dialog.py +422 -0
  100. pycontrol_gui/widgets/session_controls_shell.py +42 -0
  101. pycontrol_gui/widgets/session_event_ui.py +177 -0
  102. pycontrol_gui/widgets/session_panel.py +218 -0
  103. pycontrol_gui/widgets/session_shutdown_ui.py +42 -0
  104. pycontrol_gui/widgets/settings_dialog.py +728 -0
  105. pycontrol_gui/widgets/setups_tab.py +837 -0
  106. pycontrol_gui/widgets/share_dialog.py +162 -0
  107. pycontrol_gui/widgets/style.py +177 -0
  108. pycontrol_gui/widgets/subject_run_panel.py +1282 -0
  109. pycontrol_gui/widgets/task_controls_host.py +144 -0
  110. pycontrol_gui/widgets/task_selector_widgets.py +161 -0
  111. pycontrol_gui/widgets/task_status.py +177 -0
  112. pycontrol_gui/workspace_model.py +699 -0
  113. pycontrol_gui-3.0.0a1.dist-info/METADATA +228 -0
  114. pycontrol_gui-3.0.0a1.dist-info/RECORD +116 -0
  115. pycontrol_gui-3.0.0a1.dist-info/WHEEL +4 -0
  116. pycontrol_gui-3.0.0a1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,15 @@
1
+ """Qt frontend package for pycontrol-core.
2
+
3
+ The stable package-level surface is intentionally small. GUI widgets,
4
+ controllers, and workspace models are application internals unless documented
5
+ in the public integration guides.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__ = version("pycontrol-gui")
12
+ except PackageNotFoundError: # pragma: no cover - editable tree before install.
13
+ __version__ = "0.0.0"
14
+
15
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ from pycontrol_gui.app import launch_gui
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(launch_gui())
pycontrol_gui/app.py ADDED
@@ -0,0 +1,102 @@
1
+ """QApplication bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import threading
8
+ import traceback
9
+ from pathlib import Path
10
+ from types import TracebackType
11
+
12
+ from PySide6.QtCore import QObject, Qt, Signal
13
+ from PySide6.QtGui import QFont
14
+ from PySide6.QtWidgets import QApplication
15
+
16
+ from pycontrol.workspace import Workspace
17
+ from pycontrol_gui.error_logging import configure_error_logging
18
+ from pycontrol_gui.main_window import MainWindow
19
+ from pycontrol_gui.resources import icon
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class _ExceptionNotifier(QObject):
25
+ message = Signal(str)
26
+
27
+
28
+ def launch_gui(start_path: str | Path | None = None) -> int:
29
+ resolved_start_path = _resolve_start_path(start_path)
30
+ configure_error_logging(resolved_start_path)
31
+ app = QApplication.instance()
32
+ created_here = app is None
33
+ if app is None:
34
+ app = QApplication(sys.argv)
35
+
36
+ app.setStyle("Fusion")
37
+ app.setWindowIcon(icon("logo"))
38
+ app.setApplicationDisplayName("pyControl GUI")
39
+ app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus, False)
40
+
41
+ font = QFont()
42
+ font.setPixelSize(13)
43
+ app.setFont(font)
44
+
45
+ window = MainWindow(start_path=resolved_start_path)
46
+ _install_excepthook(window)
47
+ window.show()
48
+ if created_here:
49
+ return app.exec()
50
+ return 0
51
+
52
+
53
+ def _resolve_start_path(start_path: str | Path | None = None) -> Path | None:
54
+ if start_path is not None:
55
+ return Path(start_path).expanduser()
56
+ cli_path = _workspace_path_from_argv(sys.argv[1:])
57
+ if cli_path is not None:
58
+ return cli_path
59
+ try:
60
+ if Workspace.discover(Path.cwd()) is not None:
61
+ return Path.cwd()
62
+ except OSError:
63
+ return None
64
+ return None
65
+
66
+
67
+ def _workspace_path_from_argv(args: list[str]) -> Path | None:
68
+ for arg in args:
69
+ if not arg or arg.startswith("-"):
70
+ continue
71
+ return Path(arg).expanduser()
72
+ return None
73
+
74
+
75
+ def _install_excepthook(window: MainWindow) -> None:
76
+ notifier = _ExceptionNotifier(window)
77
+ notifier.message.connect(window.run_task_tab.on_error_message, Qt.ConnectionType.QueuedConnection)
78
+ window._exception_notifier = notifier # type: ignore[attr-defined]
79
+
80
+ def _forward_exception_to_ui(prefix: str, message: str) -> None:
81
+ try:
82
+ notifier.message.emit(f"{prefix}\n{message}")
83
+ except Exception:
84
+ logger.exception("Failed to forward exception text to Run task tab.")
85
+
86
+ def excepthook(ex_type: type[BaseException], ex_value: BaseException, ex_traceback: TracebackType | None) -> None:
87
+ logger.error("Uncaught exception in GUI thread.", exc_info=(ex_type, ex_value, ex_traceback))
88
+ message = "".join(traceback.format_exception(ex_type, ex_value, ex_traceback))
89
+ _forward_exception_to_ui("Uncaught exception:", message)
90
+
91
+ def thread_excepthook(args: threading.ExceptHookArgs) -> None:
92
+ thread_name = args.thread.name if args.thread is not None else "<unknown>"
93
+ logger.error(
94
+ "Uncaught exception in thread %s.",
95
+ thread_name,
96
+ exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
97
+ )
98
+ message = "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback))
99
+ _forward_exception_to_ui(f"Uncaught thread exception ({thread_name}):", message)
100
+
101
+ sys.excepthook = excepthook
102
+ threading.excepthook = thread_excepthook
@@ -0,0 +1,54 @@
1
+ """Qt signal bridge for pycontrol bus events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QObject, Signal
6
+
7
+ from pycontrol.bus.events import (
8
+ Error,
9
+ EventsBatch,
10
+ SessionEnded,
11
+ SessionStarted,
12
+ TaskStatusUpdated,
13
+ VariableChanged,
14
+ Warning,
15
+ )
16
+
17
+
18
+ class QtBusAdapter(QObject):
19
+ """Subscriber implementation that re-emits typed events as Qt signals.
20
+
21
+ Core calls these methods on the board pump thread. The signal emissions also
22
+ originate from that thread; GUI safety depends on connecting them to
23
+ GUI-thread ``QObject`` receivers so Qt delivers the calls as queued
24
+ cross-thread signals.
25
+ """
26
+
27
+ session_started = Signal(object) # SessionStarted
28
+ session_ended = Signal(object) # SessionEnded
29
+ events_batch = Signal(object) # EventsBatch
30
+ variable_changed = Signal(object) # VariableChanged
31
+ task_status_updated = Signal(object) # TaskStatusUpdated
32
+ warning = Signal(object) # Warning
33
+ error = Signal(object) # Error
34
+
35
+ def on_session_start(self, ev: SessionStarted) -> None:
36
+ self.session_started.emit(ev)
37
+
38
+ def on_data(self, batch: EventsBatch) -> None:
39
+ self.events_batch.emit(batch)
40
+
41
+ def on_variable_changed(self, ev: VariableChanged) -> None:
42
+ self.variable_changed.emit(ev)
43
+
44
+ def on_task_status_updated(self, ev: TaskStatusUpdated) -> None:
45
+ self.task_status_updated.emit(ev)
46
+
47
+ def on_warning(self, ev: Warning) -> None:
48
+ self.warning.emit(ev)
49
+
50
+ def on_error(self, ev: Error) -> None:
51
+ self.error.emit(ev)
52
+
53
+ def on_session_end(self, ev: SessionEnded) -> None:
54
+ self.session_ended.emit(ev)
@@ -0,0 +1,134 @@
1
+ """Diagnostic logging bootstrap for the Qt frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from logging.handlers import RotatingFileHandler
7
+ from pathlib import Path
8
+
9
+ from pycontrol.workspace import Workspace
10
+
11
+ ERROR_LOG_FILENAME = "ErrorLog.txt"
12
+ _FILE_HANDLER_NAME = "pycontrol_gui.error_file"
13
+ _STREAM_HANDLER_NAME = "pycontrol_gui.stderr"
14
+ _MAX_BYTES = 1_000_000
15
+ _BACKUP_COUNT = 5
16
+
17
+ _ACTIVE_ERROR_LOG_PATH: Path | None = None
18
+
19
+
20
+ def _workspace_root(start_path: str | Path | None) -> Path | None:
21
+ try:
22
+ workspace = Workspace.discover(start_path)
23
+ except Exception:
24
+ return None
25
+ if workspace is None:
26
+ return None
27
+ return workspace.root
28
+
29
+
30
+ def _candidate_log_dirs(start_path: str | Path | None) -> tuple[Path, ...]:
31
+ candidates: list[Path] = []
32
+ workspace_root = _workspace_root(start_path)
33
+ if workspace_root is not None:
34
+ candidates.append(workspace_root)
35
+ candidates.append(Path.home() / ".pycontrol-gui" / "logs")
36
+ candidates.append(Path.cwd())
37
+ return tuple(candidates)
38
+
39
+
40
+ def _choose_log_path(start_path: str | Path | None) -> Path:
41
+ for directory in _candidate_log_dirs(start_path):
42
+ try:
43
+ directory.mkdir(parents=True, exist_ok=True)
44
+ return directory / ERROR_LOG_FILENAME
45
+ except OSError:
46
+ continue
47
+ return Path.cwd() / ERROR_LOG_FILENAME
48
+
49
+
50
+ def _upsert_file_handler(root_logger: logging.Logger, target_path: Path, formatter: logging.Formatter) -> None:
51
+ existing = next((h for h in root_logger.handlers if getattr(h, "name", "") == _FILE_HANDLER_NAME), None)
52
+ if isinstance(existing, RotatingFileHandler):
53
+ if Path(existing.baseFilename) == target_path:
54
+ existing.setLevel(logging.ERROR)
55
+ existing.setFormatter(formatter)
56
+ return
57
+ root_logger.removeHandler(existing)
58
+ existing.close()
59
+ elif existing is not None:
60
+ root_logger.removeHandler(existing)
61
+ existing.close()
62
+
63
+ file_handler = RotatingFileHandler(
64
+ target_path, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, encoding="utf-8", delay=True
65
+ )
66
+ file_handler.set_name(_FILE_HANDLER_NAME)
67
+ file_handler.setLevel(logging.ERROR)
68
+ file_handler.setFormatter(formatter)
69
+ root_logger.addHandler(file_handler)
70
+
71
+
72
+ def _upsert_stream_handler(root_logger: logging.Logger, formatter: logging.Formatter) -> None:
73
+ existing = next((h for h in root_logger.handlers if getattr(h, "name", "") == _STREAM_HANDLER_NAME), None)
74
+ if isinstance(existing, logging.StreamHandler):
75
+ existing.setLevel(logging.WARNING)
76
+ existing.setFormatter(formatter)
77
+ return
78
+ if existing is not None:
79
+ root_logger.removeHandler(existing)
80
+ existing.close()
81
+
82
+ stream_handler = logging.StreamHandler()
83
+ stream_handler.set_name(_STREAM_HANDLER_NAME)
84
+ stream_handler.setLevel(logging.WARNING)
85
+ stream_handler.setFormatter(formatter)
86
+ root_logger.addHandler(stream_handler)
87
+
88
+
89
+ def configure_error_logging(start_path: str | Path | None = None) -> Path:
90
+ """Configure process-wide GUI diagnostics and return active log path.
91
+
92
+ This mutates the root logger so uncaught errors from worker threads, plugin
93
+ imports, and Qt-facing callbacks land in the same rotating diagnostics file.
94
+ Repeated calls update the handlers installed by pycontrol-gui rather than
95
+ stacking duplicate handlers.
96
+ """
97
+ global _ACTIVE_ERROR_LOG_PATH
98
+
99
+ target_path = _choose_log_path(start_path)
100
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
101
+ root_logger = logging.getLogger()
102
+ # Keep INFO+ records available to stderr while persisting only ERROR+ to file.
103
+ root_logger.setLevel(logging.INFO)
104
+ _upsert_file_handler(root_logger, target_path, formatter)
105
+ _upsert_stream_handler(root_logger, formatter)
106
+ _ACTIVE_ERROR_LOG_PATH = target_path
107
+ return target_path
108
+
109
+
110
+ def get_error_log_path() -> Path:
111
+ """Return the currently configured error-log path."""
112
+ global _ACTIVE_ERROR_LOG_PATH
113
+ if _ACTIVE_ERROR_LOG_PATH is None:
114
+ _ACTIVE_ERROR_LOG_PATH = _choose_log_path(None)
115
+ return _ACTIVE_ERROR_LOG_PATH
116
+
117
+
118
+ def clear_error_log(path: Path | None = None) -> Path:
119
+ """Truncate the active diagnostics file and keep handlers alive."""
120
+ target = path if path is not None else get_error_log_path()
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ for handler in logging.getLogger().handlers:
123
+ if not isinstance(handler, logging.FileHandler):
124
+ continue
125
+ base = getattr(handler, "baseFilename", None)
126
+ if base is None:
127
+ continue
128
+ if Path(base) == target:
129
+ handler.flush()
130
+ target.write_text("", encoding="utf-8")
131
+ return target
132
+
133
+
134
+ __all__ = ["ERROR_LOG_FILENAME", "clear_error_log", "configure_error_logging", "get_error_log_path"]
@@ -0,0 +1,39 @@
1
+ """Shared helpers for GUI experiment extension loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pycontrol.config import Settings
8
+ from pycontrol.extension import ExperimentExtension
9
+ from pycontrol.extension.loader import instantiate_experiment_extension
10
+
11
+
12
+ def resolve_experiment_extension_path(directory: str | Path | None, stem: str) -> Path | None:
13
+ if directory in (None, ""):
14
+ return None
15
+ candidate = Path(directory) / f"{stem}.py"
16
+ return candidate if candidate.exists() else None
17
+
18
+
19
+ def load_experiment_extension(
20
+ path: Path, *, settings: Settings | None = None, run_control: object | None = None
21
+ ) -> ExperimentExtension:
22
+ extension = instantiate_experiment_extension(path)
23
+ if settings is not None:
24
+ extension_settings = dict(settings.extensions.experiment_extensions.get(path.stem, {}))
25
+ extension.configure_settings(extension_settings, settings)
26
+ if run_control is not None:
27
+ extension.attach_run_control(run_control)
28
+ return extension
29
+
30
+
31
+ def validate_experiment_extension(path: Path) -> str | None:
32
+ try:
33
+ load_experiment_extension(path)
34
+ except Exception as exc: # noqa: BLE001 - user plugin diagnostics.
35
+ return str(exc)
36
+ return None
37
+
38
+
39
+ __all__ = ["load_experiment_extension", "resolve_experiment_extension_path", "validate_experiment_extension"]