ms-components 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. {ms_components-0.4.2 → ms_components-0.5.0}/PKG-INFO +3 -3
  2. {ms_components-0.4.2 → ms_components-0.5.0}/pyproject.toml +2 -2
  3. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_executors.py +200 -10
  4. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/__init__.py +3 -1
  5. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/bridge.py +6 -4
  6. ms_components-0.5.0/src/ms_components/ms_monitor/detail.py +671 -0
  7. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/formatting.py +8 -3
  8. ms_components-0.5.0/src/ms_components/ms_monitor/jobs.py +294 -0
  9. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/models.py +21 -3
  10. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/polling.py +42 -6
  11. ms_components-0.5.0/src/ms_components/ms_monitor/resources.py +448 -0
  12. ms_components-0.5.0/src/ms_components/ms_monitor/widget.py +345 -0
  13. ms_components-0.5.0/src/ms_components/ms_pymol/command_line.py +136 -0
  14. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/widget.py +43 -3
  15. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_tutorial/controller.py +21 -3
  16. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_tutorial/overlay.py +8 -2
  17. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components.egg-info/PKG-INFO +3 -3
  18. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components.egg-info/SOURCES.txt +6 -3
  19. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components.egg-info/requires.txt +2 -2
  20. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components.egg-info/scm_file_list.json +6 -3
  21. ms_components-0.5.0/src/ms_components.egg-info/scm_version.json +8 -0
  22. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_executors_ui.py +65 -1
  23. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_molsuite_monitor_bridge.py +9 -7
  24. ms_components-0.5.0/tests/test_molsuite_monitor_widgets.py +178 -0
  25. ms_components-0.5.0/tests/test_optional_integrations.py +103 -0
  26. ms_components-0.5.0/tests/test_pymol_command_line.py +48 -0
  27. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_pymol_toolbar.py +16 -0
  28. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_tutorial.py +64 -0
  29. ms_components-0.4.2/src/ms_components/ms_monitor/cards.py +0 -75
  30. ms_components-0.4.2/src/ms_components/ms_monitor/detail_panel.py +0 -121
  31. ms_components-0.4.2/src/ms_components/ms_monitor/job_cards.py +0 -512
  32. ms_components-0.4.2/src/ms_components/ms_monitor/widget.py +0 -344
  33. ms_components-0.4.2/src/ms_components.egg-info/scm_version.json +0 -8
  34. ms_components-0.4.2/tests/test_molsuite_monitor_widgets.py +0 -75
  35. {ms_components-0.4.2 → ms_components-0.5.0}/.github/workflows/docs.yml +0 -0
  36. {ms_components-0.4.2 → ms_components-0.5.0}/.github/workflows/publish.yml +0 -0
  37. {ms_components-0.4.2 → ms_components-0.5.0}/.gitignore +0 -0
  38. {ms_components-0.4.2 → ms_components-0.5.0}/LICENSE +0 -0
  39. {ms_components-0.4.2 → ms_components-0.5.0}/MANIFEST.in +0 -0
  40. {ms_components-0.4.2 → ms_components-0.5.0}/README.md +0 -0
  41. {ms_components-0.4.2 → ms_components-0.5.0}/examples/tutorial_demo.py +0 -0
  42. {ms_components-0.4.2 → ms_components-0.5.0}/examples/tutorial_demo.toml +0 -0
  43. {ms_components-0.4.2 → ms_components-0.5.0}/licenses/qt-themes-LICENSE.txt +0 -0
  44. {ms_components-0.4.2 → ms_components-0.5.0}/setup.cfg +0 -0
  45. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/__init__.py +0 -0
  46. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/backdrop.py +0 -0
  47. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/base.qss +0 -0
  48. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/chips.py +0 -0
  49. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ketcher.py +0 -0
  50. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/listview.py +0 -0
  51. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  52. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  53. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  54. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  55. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_dockwidget/widget.py +0 -0
  56. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/config.py +0 -0
  57. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_monitor/palette.py +0 -0
  58. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_projects/__init__.py +0 -0
  59. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_projects/app.py +0 -0
  60. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_projects/backend.py +0 -0
  61. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_projects/browser.py +0 -0
  62. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_projects/scratch.py +0 -0
  63. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_projects/widget.py +0 -0
  64. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/__init__.py +0 -0
  65. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  66. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  67. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  68. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  69. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  70. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +0 -0
  71. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/hydrogens.svg +0 -0
  72. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  73. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  74. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  75. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  76. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  77. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  78. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/style.svg +0 -0
  79. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  80. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/plugins.py +0 -0
  81. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_pymol/scene.py +0 -0
  82. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_settings/__init__.py +0 -0
  83. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_settings/panel.py +0 -0
  84. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_stepper.py +0 -0
  85. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/__init__.py +0 -0
  86. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/action_header.py +0 -0
  87. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/cell_delegate.py +0 -0
  88. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/column_panel.py +0 -0
  89. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/example_usage.py +0 -0
  90. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/filter_panel.py +0 -0
  91. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/columns.svg +0 -0
  92. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/export.svg +0 -0
  93. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/filter.svg +0 -0
  94. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/menu.svg +0 -0
  95. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/reload.svg +0 -0
  96. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/search.svg +0 -0
  97. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/settings.svg +0 -0
  98. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  99. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  100. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/icons/sort.svg +0 -0
  101. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  102. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/pagination_control.py +0 -0
  103. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/query_builder.py +0 -0
  104. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/session_provider.py +0 -0
  105. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/smart_table_view.py +0 -0
  106. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/sort_panel.py +0 -0
  107. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/table_config.py +0 -0
  108. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/table_interaction.py +0 -0
  109. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/table_model.py +0 -0
  110. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/test_external_filters.py +0 -0
  111. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_table/test_toolbar.py +0 -0
  112. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_tutorial/__init__.py +0 -0
  113. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_tutorial/bindings.py +0 -0
  114. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/ms_tutorial/models.py +0 -0
  115. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/opengl.py +0 -0
  116. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/pagination.py +0 -0
  117. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/palettes.py +0 -0
  118. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/step_dialog.py +0 -0
  119. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/theme.py +0 -0
  120. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/tool_panel.py +0 -0
  121. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components/wheel.py +0 -0
  122. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components.egg-info/dependency_links.txt +0 -0
  123. {ms_components-0.4.2 → ms_components-0.5.0}/src/ms_components.egg-info/top_level.txt +0 -0
  124. {ms_components-0.4.2 → ms_components-0.5.0}/tests/conftest.py +0 -0
  125. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_app_settings.py +0 -0
  126. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_chips.py +0 -0
  127. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_dock_window.py +0 -0
  128. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_ketcher.py +0 -0
  129. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_monitor_config.py +0 -0
  130. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_ms_projects_widget.py +0 -0
  131. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_opengl.py +0 -0
  132. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_progress_column.py +0 -0
  133. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_project_browser.py +0 -0
  134. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_pymol_grid.py +0 -0
  135. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_pymol_scene.py +0 -0
  136. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_query_builder_keyset.py +0 -0
  137. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_shift_hscroll.py +0 -0
  138. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_split_button_style.py +0 -0
  139. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_step_dialog.py +0 -0
  140. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_table_interaction.py +0 -0
  141. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_table_toolbar.py +0 -0
  142. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_table_view_state.py +0 -0
  143. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_theme_contrast.py +0 -0
  144. {ms_components-0.4.2 → ms_components-0.5.0}/tests/test_tool_panel.py +0 -0
  145. {ms_components-0.4.2 → ms_components-0.5.0}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: PySide6 UI components for MolSuite: smart table, project browser, job monitor, PyMOL dock, steppers.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -21,11 +21,11 @@ Requires-Dist: PySide6>=6.5
21
21
  Provides-Extra: monitor
22
22
  Requires-Dist: pydantic>=2.0; extra == "monitor"
23
23
  Provides-Extra: projects
24
- Requires-Dist: ms_flow>=0.2.0; extra == "projects"
24
+ Requires-Dist: ms_flow>=0.7.1; extra == "projects"
25
25
  Provides-Extra: pymol
26
26
  Requires-Dist: pymol-open-source>=3.2.0a0; extra == "pymol"
27
27
  Provides-Extra: steps
28
- Requires-Dist: ms_env>=0.2.0; extra == "steps"
28
+ Requires-Dist: ms_env>=0.3.3; extra == "steps"
29
29
  Provides-Extra: table
30
30
  Requires-Dist: sqlmodel>=0.0.14; extra == "table"
31
31
  Provides-Extra: all
@@ -31,9 +31,9 @@ dependencies = [
31
31
 
32
32
  [project.optional-dependencies]
33
33
  monitor = ["pydantic>=2.0"]
34
- projects = ["ms_flow>=0.2.0"]
34
+ projects = ["ms_flow>=0.7.1"]
35
35
  pymol = ["pymol-open-source>=3.2.0a0"]
36
- steps = ["ms_env>=0.2.0"]
36
+ steps = ["ms_env>=0.3.3"]
37
37
  table = ["sqlmodel>=0.0.14"]
38
38
  all = ["ms_components[monitor,projects,pymol,steps,table]"]
39
39
  # Test + development tooling.
@@ -8,7 +8,11 @@ be done now, and says why.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import re
12
+ import shlex
13
+ import sys
11
14
  import threading
15
+ import time
12
16
  import types
13
17
  from typing import Any, Literal, Union, get_args, get_origin
14
18
 
@@ -27,6 +31,7 @@ from PySide6.QtWidgets import (
27
31
  QListWidgetItem,
28
32
  QMessageBox,
29
33
  QPlainTextEdit,
34
+ QProgressBar,
30
35
  QPushButton,
31
36
  QRadioButton,
32
37
  QSpinBox,
@@ -35,7 +40,7 @@ from PySide6.QtWidgets import (
35
40
  )
36
41
 
37
42
 
38
- def _editor(field, value: Any, parent: QWidget, hosts=()) -> QWidget:
43
+ def _editor(field, value: Any, parent: QWidget, names=()) -> QWidget:
39
44
  annotation, options = field.annotation, get_args(field.annotation)
40
45
  if get_origin(annotation) in (Union, types.UnionType) and options == (bool, type(None)):
41
46
  editor = QComboBox(parent)
@@ -45,13 +50,14 @@ def _editor(field, value: Any, parent: QWidget, hosts=()) -> QWidget:
45
50
  editor = QComboBox(parent)
46
51
  for option in options:
47
52
  editor.addItem(str(option), option)
48
- elif get_origin(annotation) is list: # names of hosts: the ones kept, to tick
53
+ elif list in (get_origin(annotation), *map(get_origin, options)):
54
+ # `names` to tick: the hosts kept, or the programs of the app; None ticks them all
49
55
  editor = QListWidget(parent)
50
56
  editor.setMaximumHeight(110)
51
- for host in hosts:
52
- item = QListWidgetItem("local (this computer)" if host == "local" else host, editor)
53
- item.setData(Qt.ItemDataRole.UserRole, host)
54
- item.setCheckState(Qt.CheckState.Checked if host in (value or ()) else Qt.CheckState.Unchecked)
57
+ for name in names:
58
+ item = QListWidgetItem("local (this computer)" if name == "local" else name, editor)
59
+ item.setData(Qt.ItemDataRole.UserRole, name)
60
+ item.setCheckState(Qt.CheckState.Checked if value is None or name in value else Qt.CheckState.Unchecked)
55
61
  return editor
56
62
  elif get_origin(annotation) is dict:
57
63
  editor = QPlainTextEdit("\n".join(f"{key} = {path}" for key, path in (value or {}).items()), parent)
@@ -90,6 +96,93 @@ def _value(editor: QWidget) -> Any:
90
96
  return editor.text().strip()
91
97
 
92
98
 
99
+ class _Steps(QWidget):
100
+ """How far the preparation of a host got, what it is doing and for how long: moved by the
101
+ lines `[done/total] what` it says; `[0/0] what` is a wait with no steps to count."""
102
+
103
+ _LINE = re.compile(r"\[(\d+)/(\d+)\] (.*)")
104
+
105
+ def __init__(self, parent: QWidget) -> None:
106
+ super().__init__(parent)
107
+ layout = QVBoxLayout(self)
108
+ layout.setContentsMargins(0, 0, 0, 0)
109
+ self.label = QLabel(self)
110
+ self.bar = QProgressBar(self)
111
+ self.bar.setTextVisible(False)
112
+ layout.addWidget(self.label)
113
+ layout.addWidget(self.bar)
114
+ self._what, self._since = "", 0.0
115
+ self._clock = QTimer(self) # a step of minutes moves nothing else: the time spent says it is alive
116
+ self._clock.setInterval(1000)
117
+ self._clock.timeout.connect(self._tell)
118
+ self.setVisible(False)
119
+
120
+ def take(self, line: str) -> bool:
121
+ """Whether `line` was one of those, shown here then, not in the log."""
122
+ found = self._LINE.fullmatch(line)
123
+ if found:
124
+ done, total, what = int(found[1]), int(found[2]), found[3]
125
+ if what != self._what:
126
+ self._what, self._since = what, time.monotonic()
127
+ self.bar.setRange(0, total) # 0 of 0: Qt shows it busy
128
+ self.bar.setValue(done)
129
+ self._tell()
130
+ self.setVisible(True)
131
+ self._clock.start()
132
+ return bool(found)
133
+
134
+ def full(self) -> bool:
135
+ return 0 < self.bar.maximum() == self.bar.value()
136
+
137
+ def _tell(self) -> None:
138
+ spent = int(time.monotonic() - self._since)
139
+ done, total = self.bar.value(), self.bar.maximum()
140
+ counted = f" · step {done + 1} of {total}" if done < total else ""
141
+ self.label.setText(f"{self._what} · {spent // 60}:{spent % 60:02d}{counted}")
142
+
143
+ def hideEvent(self, event) -> None:
144
+ self._clock.stop()
145
+ super().hideEvent(event)
146
+
147
+
148
+ _VERSIONS = """import sys, importlib.metadata as m
149
+ print("Python", sys.version.split()[0], "at", sys.prefix)
150
+ for name in sys.argv[1:]:
151
+ try:
152
+ print(f" {name} {m.version(name)}")
153
+ except m.PackageNotFoundError:
154
+ print(f" {name}: not installed")
155
+ """
156
+
157
+
158
+ def describe_host(address: str, env: str = "", packages=(), tools=None) -> str:
159
+ """What the machine `address` (empty: this one) is and has, in a few lines: its system, CPUs,
160
+ memory and GPUs, the Python of `env` there with the version of each of `packages`, and whether
161
+ each of `tools` (name -> path) can be run. One `ssh` call, which raises `HostError` when it does
162
+ not get in. Blocks: not on the GUI thread."""
163
+ from ms_env import Host
164
+
165
+ tools = tools or {}
166
+ python = f"{env.rstrip('/')}/bin/python" if env else ("" if address else sys.executable)
167
+ script = "; ".join([
168
+ "export LC_ALL=C", "uname -srm", '(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
169
+ 'echo "CPUs: $(nproc 2>/dev/null || sysctl -n hw.ncpu)"',
170
+ "awk '/MemTotal/ {printf \"Memory: %.1f GB\\n\", $2/1048576}' /proc/meminfo 2>/dev/null",
171
+ "(nvidia-smi -L 2>/dev/null || echo 'GPUs: none seen by nvidia-smi')",
172
+ "echo",
173
+ *([f"if [ -x {shlex.quote(python)} ]; then {shlex.quote(python)} -c {shlex.quote(_VERSIONS)} "
174
+ f"{' '.join(map(shlex.quote, packages))}; else echo 'Environment: none at '{shlex.quote(python)}; fi"]
175
+ if python else ["echo 'Environment: none named; the app prepares one before the first job'"]),
176
+ *(f"([ -x {shlex.quote(path)} ] && echo ' ✓ {name} '{shlex.quote(path)} || echo ' ✗ {name} not found at '{shlex.quote(path)})"
177
+ for name, path in tools.items()),
178
+ ])
179
+ if tools:
180
+ script = script.replace("; ([ -x", "; echo 'Tools:'; ([ -x", 1)
181
+ began = time.monotonic()
182
+ said = Host(address or None).run(script, timeout=60).decode(errors="replace").strip()
183
+ return f"Connected to {address or 'this machine'} in {time.monotonic() - began:.1f} s\n\n{said}"
184
+
185
+
93
186
  class KeptPage(QWidget):
94
187
  """The entries kept in `table` (`hosts` or `executors`) and the form of the chosen one.
95
188
 
@@ -98,10 +191,17 @@ class KeptPage(QWidget):
98
191
  """
99
192
 
100
193
  changed = Signal()
101
-
102
- def __init__(self, workspace, table: str, parent: QWidget | None = None):
194
+ _checked = Signal(str)
195
+ _synced = Signal(str, bool) # a line said there, and whether it is the last
196
+
197
+ def __init__(self, workspace, table: str, parent: QWidget | None = None, *, packages=("ms_flow", "ms_env"),
198
+ default_env: str = "", programs=()):
199
+ """`packages`: whose version "Check connection" reads on a host; `default_env`: the
200
+ environment the app prepares on a host that names none, under the home there;
201
+ `programs`: the ones it can install there, to tick for each host (none: no choice shown)."""
103
202
  super().__init__(parent)
104
203
  self._workspace, self._table, self._one = workspace, table, table[:-1]
204
+ self._packages, self._default_env, self._programs = tuple(packages), default_env, tuple(programs)
105
205
  settings = type(workspace.settings_manager.settings)
106
206
  self._model = get_args(settings.model_fields[table].annotation)[1]
107
207
  self._editors: dict[str, QWidget] = {}
@@ -130,6 +230,25 @@ class KeptPage(QWidget):
130
230
  self.save_button.clicked.connect(self._save)
131
231
  foot = QHBoxLayout()
132
232
  foot.addWidget(self.said, 1)
233
+ if table == "hosts":
234
+ self.check_button = QPushButton("Check connection", self)
235
+ self.check_button.setToolTip("Log in to it and say what it is and what it has: system, CPUs, GPUs, "
236
+ "the environment of the app and its packages, the tools named.")
237
+ self.check_button.clicked.connect(self._check)
238
+ foot.addWidget(self.check_button)
239
+ self.sync_button = QPushButton("Sync", self)
240
+ self.sync_button.setToolTip("Install there, or bring up to date, the environment of the app and the "
241
+ "programs ticked, as saved. What is already as it is here is left alone.")
242
+ self.sync_button.clicked.connect(self._sync)
243
+ foot.addWidget(self.sync_button)
244
+ self.steps = _Steps(self)
245
+ self._right.addWidget(self.steps)
246
+ self.report = QPlainTextEdit(self)
247
+ self.report.setReadOnly(True)
248
+ self.report.setVisible(False)
249
+ self._right.addWidget(self.report, 8) # the room the stretch below has while nothing is said
250
+ self._checked.connect(self._show_report)
251
+ self._synced.connect(self._sync_said)
133
252
  foot.addWidget(self.save_button)
134
253
  self._right.addStretch(1)
135
254
  self._right.addLayout(foot)
@@ -171,9 +290,12 @@ class KeptPage(QWidget):
171
290
  hosts = self._workspace.settings_manager.settings.hosts
172
291
  self._editors = {}
173
292
  for key, field in self._model.model_fields.items():
174
- editor = self._editors[key] = _editor(field, getattr(entry, key, None), body, hosts)
293
+ names = self._programs if key == "install" else hosts
294
+ editor = self._editors[key] = _editor(field, getattr(entry, key, None), body, names)
175
295
  editor.setToolTip(field.description or "")
176
296
  form.addRow(field.title or key.replace("_", " ").capitalize(), editor)
297
+ if "install" in self._editors: # this machine has its programs from the app's own settings
298
+ form.setRowVisible(self._editors["install"], bool(self._programs) and name != "local")
177
299
  kind = self._editors.get("kind")
178
300
  if name == "local": # this machine and its pool: what they are is not a choice
179
301
  for fixed in ("address", "kind", "hosts"):
@@ -187,6 +309,10 @@ class KeptPage(QWidget):
187
309
  self._only_of_kind()
188
310
  self.remove_button.setEnabled(bool(name) and name != "local")
189
311
  self.said.setText("")
312
+ if self._table == "hosts":
313
+ self.report.setVisible(False)
314
+ self.steps.setVisible(False)
315
+ self.sync_button.setVisible(bool(name) and name != "local" and getattr(self._workspace, "host_env", None) is not None)
190
316
  self._right.insertWidget(0, body)
191
317
 
192
318
  def _only_of_kind(self) -> None:
@@ -226,6 +352,60 @@ class KeptPage(QWidget):
226
352
  self.said.setText(f"`{name}` is kept.")
227
353
  self.changed.emit()
228
354
 
355
+ def _check(self) -> None:
356
+ """What `describe_host` says of the host as the form has it now, saved or not."""
357
+ fields = {key: _value(editor) for key, editor in self._editors.items()}
358
+ address = fields.get("address") or ""
359
+ env = fields.get("env") or (self._default_env if address else "")
360
+ self.check_button.setEnabled(False)
361
+ self._show_report(f"Connecting to {address or 'this machine'}…")
362
+
363
+ def work() -> None:
364
+ try:
365
+ said = describe_host(address, env, self._packages, fields.get("tools") or {})
366
+ except Exception as error: # noqa: BLE001 - said in the page, where it was asked for
367
+ said = f"Could not check {address or 'this machine'}:\n{str(error).split('` on ', 1)[-1]}"
368
+ self._checked.emit(said)
369
+
370
+ threading.Thread(target=work, name="host-check", daemon=True).start()
371
+
372
+ def _sync(self) -> None:
373
+ """`Workspace.host_env` for the host as it is saved: what the app prepares there, made as it is here."""
374
+ name = self.names.currentItem().text()
375
+ host = self._kept()[name]
376
+ if host.env:
377
+ self._show_report(f"`{name}` names its own environment, {host.env}: the app prepares nothing there.")
378
+ return
379
+ users = [row["name"] for row in self._workspace.executors()
380
+ if row["state"] in ("on", "starting") and name in row["hosts"]]
381
+ if users: # what it runs would change under it
382
+ self._show_report(f"`{name}` is in use by `{users[0]}`, which is active: deactivate it first.")
383
+ return
384
+ self.sync_button.setEnabled(False)
385
+ self._show_report(f"Preparing {host.address}…")
386
+
387
+ def work() -> None:
388
+ try:
389
+ self._workspace.host_env(host.address, None, lambda line: self._synced.emit(line, False))
390
+ said = f"\n{name} is ready."
391
+ except Exception as error: # noqa: BLE001 - said in the page, where it was asked for
392
+ said = f"\nCould not prepare {name}:\n{error}"
393
+ self._synced.emit(said, True)
394
+
395
+ threading.Thread(target=work, name="host-sync", daemon=True).start()
396
+
397
+ def _sync_said(self, line: str, last: bool) -> None:
398
+ if not self.steps.take(line):
399
+ self.report.appendPlainText(line)
400
+ self.sync_button.setEnabled(last)
401
+ if last:
402
+ self.steps.setVisible(False)
403
+
404
+ def _show_report(self, text: str) -> None:
405
+ self.report.setPlainText(text)
406
+ self.report.setVisible(True)
407
+ self.check_button.setEnabled(not text.endswith("…"))
408
+
229
409
  def _remove(self) -> None:
230
410
  name = self.name.text().strip()
231
411
  asked = QMessageBox.question(self, "Remove", f"Remove `{name}` from the {self._table} kept?")
@@ -259,6 +439,7 @@ class ExecutorsPanel(QDialog):
259
439
  self.resize(640, 260)
260
440
  self._workspace = workspace
261
441
  self._busy: set[str] = set() # the ones a thread of this panel is starting or stopping
442
+ self._waiting = "" # the line the bar shows while they have no steps to count
262
443
  self._shown: Any = None
263
444
  self._body: QWidget | None = None
264
445
  self.buttons: dict[str, QPushButton] = {}
@@ -266,6 +447,8 @@ class ExecutorsPanel(QDialog):
266
447
  self.defaults: dict[str, QRadioButton] = {}
267
448
 
268
449
  self._layout = QVBoxLayout(self)
450
+ self.steps = _Steps(self)
451
+ self._layout.addWidget(self.steps)
269
452
  self.said = QPlainTextEdit(self)
270
453
  self.said.setReadOnly(True)
271
454
  self.said.setVisible(False)
@@ -345,18 +528,25 @@ class ExecutorsPanel(QDialog):
345
528
  self.ended.emit(name, "")
346
529
 
347
530
  self._busy.add(name)
531
+ self._waiting = f"[0/0] {'stopping' if stop else 'starting'} {name}"
532
+ self.steps.take(self._waiting)
348
533
  self.refresh()
349
534
  threading.Thread(target=work, name=f"executor-{name}", daemon=True).start()
350
535
 
351
536
  def _say(self, text: str) -> None:
537
+ if self.steps.take(text):
538
+ if self.steps.full() and self._busy: # its hosts are ready: it is still starting on them
539
+ self.steps.take(self._waiting)
540
+ return
352
541
  self.said.setVisible(True)
353
542
  self.said.appendPlainText(text)
354
543
 
355
544
  def _ended(self, name: str, error: str) -> None:
356
545
  self._busy.discard(name)
546
+ self.steps.setVisible(bool(self._busy))
357
547
  if error:
358
548
  self._say(f"{name}: {error}")
359
549
  self.refresh()
360
550
 
361
551
 
362
- __all__ = ["ExecutorsPanel", "KeptPage"]
552
+ __all__ = ["ExecutorsPanel", "KeptPage", "describe_host"]
@@ -4,8 +4,10 @@ from importlib import import_module
4
4
 
5
5
  _LAZY = {
6
6
  "FailureState": "models",
7
- "JobDetailPanel": "detail_panel",
7
+ "HostsStrip": "resources",
8
+ "JobDetail": "detail",
8
9
  "JobDetailState": "models",
10
+ "JobList": "jobs",
9
11
  "JobMonitorState": "models",
10
12
  "MonitorConfig": "config",
11
13
  "MonitorSyncState": "models",
@@ -105,10 +105,12 @@ class MolSuiteMonitorBridge(QObject):
105
105
  self._set_sync_state(in_progress=False, last_refresh_at=datetime.now())
106
106
  return self._poller.current_snapshot
107
107
 
108
- def set_unfolded_jobs(self, job_ids) -> None:
109
- """The jobs whose tasks in flight the next polls bring (`JobMonitorState.tasks`)."""
110
- self._poller.unfolded = frozenset(job_ids)
111
- self.request_refresh()
108
+ def select_job(self, job_id: str | None) -> None:
109
+ """The job whose tasks in flight and chunk states the next polls bring
110
+ (`JobMonitorState.tasks`, `JobMonitorState.chunk_states`): one at a time."""
111
+ if self._poller.selected != job_id:
112
+ self._poller.selected = job_id
113
+ self.request_refresh()
112
114
 
113
115
  def get_job_detail(self, job_id: str | None, *, failure_limit: int = 200) -> JobDetailState | None:
114
116
  """The job as last polled plus its failed chunks, read from the project's journal."""