moleditpy-job-manager 3.3.4__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.
- job_manager/__init__.py +795 -0
- job_manager/__main__.py +91 -0
- job_manager/about_dialog.py +119 -0
- job_manager/api_client.py +646 -0
- job_manager/api_core.py +751 -0
- job_manager/api_dialog.py +234 -0
- job_manager/api_inputs.py +209 -0
- job_manager/api_security.py +141 -0
- job_manager/api_server.py +419 -0
- job_manager/api_types.py +137 -0
- job_manager/beacon.py +73 -0
- job_manager/chat_webhook_dialog.py +139 -0
- job_manager/command_templates.py +174 -0
- job_manager/credentials.py +49 -0
- job_manager/details_dialog.py +191 -0
- job_manager/dialect.py +345 -0
- job_manager/download_dialog.py +240 -0
- job_manager/file_table.py +78 -0
- job_manager/file_tree.py +107 -0
- job_manager/folder_scan.py +198 -0
- job_manager/handoff.py +164 -0
- job_manager/host_monitor.py +490 -0
- job_manager/host_profile_form.py +413 -0
- job_manager/host_sampler.py +261 -0
- job_manager/host_stats.py +248 -0
- job_manager/host_widgets.py +885 -0
- job_manager/hosts_dialog.py +1023 -0
- job_manager/http_limits.py +35 -0
- job_manager/icon.py +80 -0
- job_manager/input_files_panel.py +127 -0
- job_manager/input_names.py +53 -0
- job_manager/input_scan.py +214 -0
- job_manager/instances.py +409 -0
- job_manager/job_actions.py +185 -0
- job_manager/job_database.py +229 -0
- job_manager/job_dependencies.py +148 -0
- job_manager/job_server_client/COPYING +674 -0
- job_manager/job_server_client/LICENSE +165 -0
- job_manager/job_server_client/LICENSING.md +49 -0
- job_manager/job_server_client/PROVENANCE.json +14 -0
- job_manager/job_server_client/__init__.py +1 -0
- job_manager/job_server_client/auth.py +17 -0
- job_manager/job_server_client/client.py +372 -0
- job_manager/job_server_client/credentials.py +75 -0
- job_manager/job_server_client/errors.py +8 -0
- job_manager/job_server_client/files.py +129 -0
- job_manager/job_table.py +304 -0
- job_manager/jobs_dialog.py +1629 -0
- job_manager/models.py +652 -0
- job_manager/notify.py +86 -0
- job_manager/output_file_dialog.py +681 -0
- job_manager/poller.py +352 -0
- job_manager/presence.py +311 -0
- job_manager/remote_paths.py +67 -0
- job_manager/remote_runner.py +965 -0
- job_manager/remote_runner_ps.py +709 -0
- job_manager/result_files.py +299 -0
- job_manager/result_view.py +207 -0
- job_manager/runner.py +1068 -0
- job_manager/schedulers/__init__.py +49 -0
- job_manager/schedulers/base.py +562 -0
- job_manager/schedulers/pbs.py +166 -0
- job_manager/schedulers/sge.py +136 -0
- job_manager/schedulers/shell.py +98 -0
- job_manager/schedulers/slurm.py +137 -0
- job_manager/schedulers/windows.py +328 -0
- job_manager/server_backend.py +258 -0
- job_manager/service.py +781 -0
- job_manager/settings_dialog.py +371 -0
- job_manager/ssh_terminal.py +101 -0
- job_manager/standalone.py +228 -0
- job_manager/status_widget.py +149 -0
- job_manager/store.py +1156 -0
- job_manager/store_io.py +64 -0
- job_manager/structure_relay.py +174 -0
- job_manager/submission_config.py +50 -0
- job_manager/submission_resources.py +239 -0
- job_manager/submission_templates.py +175 -0
- job_manager/submission_workflow.py +245 -0
- job_manager/submit_dialog.py +1595 -0
- job_manager/tail_file_dialog.py +157 -0
- job_manager/taskbar.py +56 -0
- job_manager/tasks.py +244 -0
- job_manager/template_editor_dialog.py +226 -0
- job_manager/text_dialog.py +532 -0
- job_manager/theme.py +359 -0
- job_manager/transport/__init__.py +58 -0
- job_manager/transport/base.py +85 -0
- job_manager/transport/local.py +229 -0
- job_manager/transport/openssh.py +237 -0
- job_manager/transport/paramiko_backend.py +352 -0
- job_manager/transport/wsl.py +394 -0
- job_manager/tray.py +406 -0
- job_manager/tree_utils.py +200 -0
- job_manager/ui_actions.py +58 -0
- job_manager/web_monitor.py +848 -0
- job_manager/web_monitor_dialog.py +315 -0
- job_manager/web_service.py +265 -0
- job_manager/webhook.py +118 -0
- job_manager/win_taskbar.py +337 -0
- job_manager/window_utils.py +87 -0
- moleditpy_job_manager-3.3.4.dist-info/METADATA +415 -0
- moleditpy_job_manager-3.3.4.dist-info/RECORD +109 -0
- moleditpy_job_manager-3.3.4.dist-info/WHEEL +5 -0
- moleditpy_job_manager-3.3.4.dist-info/entry_points.txt +3 -0
- moleditpy_job_manager-3.3.4.dist-info/licenses/COPYING +674 -0
- moleditpy_job_manager-3.3.4.dist-info/licenses/LICENSE +165 -0
- moleditpy_job_manager-3.3.4.dist-info/licenses/LICENSING.md +49 -0
- moleditpy_job_manager-3.3.4.dist-info/top_level.txt +1 -0
job_manager/__init__.py
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
"""Job Manager - submit calculations to remote clusters and track them.
|
|
2
|
+
|
|
3
|
+
Bridges the gap between the input generators and the result analyzers: upload
|
|
4
|
+
an input over SSH, submit it to SLURM/PBS/SGE (or plain nohup), poll the queue
|
|
5
|
+
on a deliberately slow timer, fetch the outputs when the job ends, and hand the
|
|
6
|
+
result to whichever plugin already claims that file type.
|
|
7
|
+
|
|
8
|
+
Job state lives in ``~/.moleditpy/job_manager/`` -- outside the plugin folder,
|
|
9
|
+
which the Plugin Installer replaces wholesale on update -- so tracked jobs
|
|
10
|
+
survive updates, restarts and "Reset All Settings".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
PLUGIN_NAME = "Job Manager"
|
|
20
|
+
PLUGIN_VERSION = "3.3.4"
|
|
21
|
+
PLUGIN_AUTHOR = "HiroYokoyama"
|
|
22
|
+
|
|
23
|
+
PLUGIN_DESCRIPTION = "Submit calculations to remote HPC clusters over SSH, track queue status, and fetch results back into MoleditPy. Ready-made command lines for ORCA, Gaussian, CP2K, GAMESS, MOPAC, NWChem, Psi4, PySCF, Quantum ESPRESSO, VASP and xTB; job lists save as SQLite .pmejdb or legacy .pmejbs and export to CSV and reopen by drag and drop. Runs on this machine too, with no SSH; chains jobs with each scheduler's own dependency flag; and can hold a job until a chosen time. Installing paramiko adds a backend that keeps one SSH session open and can log in with a password. Optional cryptography adds authenticated Job Server destinations on localhost, private LANs or Tailscale."
|
|
24
|
+
PLUGIN_CATEGORY = "Utility"
|
|
25
|
+
PLUGIN_TAGS = ["Utility"]
|
|
26
|
+
# The default OpenSSH backend needs nothing beyond the host app, so nothing
|
|
27
|
+
# here is required to submit a job.
|
|
28
|
+
PLUGIN_DEPENDENCIES = []
|
|
29
|
+
#: Unlocks the paramiko backend. It authenticates with a key, an agent or a
|
|
30
|
+
#: password -- not passwords only -- and it is the one backend that keeps a
|
|
31
|
+
#: single SSH session open rather than starting a new ssh process per command,
|
|
32
|
+
#: which is what makes the live host panel cheap to sample.
|
|
33
|
+
#: Optional rather than required because the default OpenSSH backend covers
|
|
34
|
+
#: key and agent authentication with nothing installed, and the Plugin
|
|
35
|
+
#: Installer lists an optional dependency without ever blocking an install.
|
|
36
|
+
PLUGIN_OPTIONAL_DEPENDENCIES = ["paramiko", "cryptography"]
|
|
37
|
+
PLUGIN_SUPPORTED_MOLEDITPY_VERSION = ">=5.0.0, <6.0.0"
|
|
38
|
+
PLUGIN_SUPPORTED_OS = ["Windows", "macOS", "Linux", "WSL"]
|
|
39
|
+
|
|
40
|
+
WINDOW_KEY = "job_monitor"
|
|
41
|
+
#: Registered separately from WINDOW_KEY so opening it standalone (Extensions >
|
|
42
|
+
#: Job Manager > Host Monitor) never has to build the job monitor first.
|
|
43
|
+
HOST_MONITOR_WINDOW_KEY = "job_manager_host_monitor"
|
|
44
|
+
|
|
45
|
+
#: Where a load keeps its `stop_api`; read by loads from before 2.0. See `_retire_previous_load`.
|
|
46
|
+
API_TEARDOWN_KEY = "api_teardown"
|
|
47
|
+
|
|
48
|
+
#: Where a load keeps `shutdown`, for the next load to call. See `_retire_previous_load`.
|
|
49
|
+
LOAD_TEARDOWN_KEY = "load_teardown"
|
|
50
|
+
|
|
51
|
+
_context: Optional[Any] = None
|
|
52
|
+
_service: Optional[Any] = None
|
|
53
|
+
_status_widget: Optional[Any] = None
|
|
54
|
+
_beacon: Optional[Any] = None
|
|
55
|
+
_api_server: Optional[Any] = None
|
|
56
|
+
_api_external = 0
|
|
57
|
+
_runtime_modules: dict[str, Any] = {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _runtime_module(name: str) -> Any:
|
|
61
|
+
# The host purges sys.modules on reload. Keep the modules that own this
|
|
62
|
+
# load's resources so its teardown cannot resolve to the new load instead.
|
|
63
|
+
if name not in _runtime_modules:
|
|
64
|
+
_runtime_modules[name] = importlib.import_module(f".{name}", __name__)
|
|
65
|
+
return _runtime_modules[name]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_context() -> Optional[Any]:
|
|
69
|
+
"""The PluginContext captured in :func:`initialize`."""
|
|
70
|
+
return _context
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_service(create: bool = True, store: Optional[Any] = None) -> Optional[Any]:
|
|
74
|
+
"""The session-scoped :class:`~job_manager.service.JobService`.
|
|
75
|
+
|
|
76
|
+
Created lazily on first use so merely loading the plugin costs nothing and
|
|
77
|
+
no network activity starts until there is a reason for it. ``store`` adopts
|
|
78
|
+
an already-loaded :class:`~job_manager.store.JobStore` rather than reading
|
|
79
|
+
the same files a second time.
|
|
80
|
+
"""
|
|
81
|
+
global _service
|
|
82
|
+
if _service is None and create:
|
|
83
|
+
from .service import JobService
|
|
84
|
+
|
|
85
|
+
for name in ("presence", "notify", "web_service", "taskbar"):
|
|
86
|
+
_runtime_module(name)
|
|
87
|
+
_service = JobService(store=store)
|
|
88
|
+
# Before the status widget: a host with no status bar still gets told
|
|
89
|
+
# when its jobs end.
|
|
90
|
+
_service.job_finished.connect(_notify_finished)
|
|
91
|
+
_install_status_widget(_service)
|
|
92
|
+
_install_presence(_service)
|
|
93
|
+
_install_beacon()
|
|
94
|
+
return _service
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_api_server(create: bool = True) -> Optional[Any]:
|
|
98
|
+
"""The local API server object, whether or not it is listening.
|
|
99
|
+
|
|
100
|
+
Building one starts nothing: it owns a socket only after :func:`start_api`.
|
|
101
|
+
"""
|
|
102
|
+
global _api_server
|
|
103
|
+
if _api_server is None and create:
|
|
104
|
+
from .api_server import JobApiServer
|
|
105
|
+
|
|
106
|
+
_api_server = JobApiServer(get_service())
|
|
107
|
+
return _api_server
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def start_api(port: int = 0) -> int:
|
|
111
|
+
"""Start listening on 127.0.0.1. Returns the port, or 0 if it failed.
|
|
112
|
+
|
|
113
|
+
Never called on its own initiative -- only from the preference being on at
|
|
114
|
+
load, or the user switching it on. See :func:`_resume_api`.
|
|
115
|
+
"""
|
|
116
|
+
global _api_external
|
|
117
|
+
server = get_api_server()
|
|
118
|
+
if server is None:
|
|
119
|
+
return 0
|
|
120
|
+
if server.running:
|
|
121
|
+
return server.port
|
|
122
|
+
# Another MoleditPy already serves this state directory: a second listener
|
|
123
|
+
# would overwrite its endpoint file and leave clients following whichever
|
|
124
|
+
# instance started last.
|
|
125
|
+
from .api_core import live_endpoint
|
|
126
|
+
|
|
127
|
+
other = live_endpoint(_service.store.directory)
|
|
128
|
+
if other is not None:
|
|
129
|
+
_api_external = int(other["port"])
|
|
130
|
+
logging.info(
|
|
131
|
+
"Job Manager: the local API is already served by another instance on port %s",
|
|
132
|
+
_api_external,
|
|
133
|
+
)
|
|
134
|
+
return _api_external
|
|
135
|
+
_api_external = 0
|
|
136
|
+
try:
|
|
137
|
+
return server.start(port or int(_service.store.get_pref("api_port", 0) or 0))
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
logging.exception("Job Manager: the local API could not start")
|
|
140
|
+
if _context is not None:
|
|
141
|
+
_context.show_status_message(f"Job Manager API: {exc}", 5000)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def stop_api() -> None:
|
|
146
|
+
"""Stop listening, and take the endpoint file with it."""
|
|
147
|
+
global _api_external
|
|
148
|
+
_api_external = 0
|
|
149
|
+
if _api_server is not None:
|
|
150
|
+
try:
|
|
151
|
+
_api_server.stop()
|
|
152
|
+
except Exception:
|
|
153
|
+
logging.debug("Job Manager: the local API did not stop cleanly", exc_info=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def api_external_port() -> int:
|
|
157
|
+
"""Port of the API another instance serves and this one deferred to, else 0."""
|
|
158
|
+
return _api_external
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def api_is_running() -> bool:
|
|
162
|
+
return bool(_api_server is not None and _api_server.running)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resume_api(store: Optional[Any] = None) -> None:
|
|
166
|
+
"""Start the API at load when the user has switched it on before.
|
|
167
|
+
|
|
168
|
+
Takes the store the startup peek already read rather than reading it
|
|
169
|
+
again: two JobStores at launch parse both files twice and leave two views
|
|
170
|
+
of the same jobs. A session with the API off still builds no service.
|
|
171
|
+
"""
|
|
172
|
+
store = store if store is not None else (_service.store if _service is not None else None)
|
|
173
|
+
if store is None or not store.get_pref("api_enabled", False):
|
|
174
|
+
return
|
|
175
|
+
get_service(store=store)
|
|
176
|
+
start_api()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _finished_words() -> dict:
|
|
180
|
+
"""What each terminal state is called in a notification.
|
|
181
|
+
|
|
182
|
+
Read at a glance with no monitor open to give it context, so "failed"
|
|
183
|
+
rather than "FAILED". Keyed by the canonical constants, not by literals: a
|
|
184
|
+
state that was renamed would otherwise silently fall back to its own name.
|
|
185
|
+
"""
|
|
186
|
+
from .models import STATE_CANCELLED, STATE_DONE, STATE_FAILED, STATE_LOST
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
STATE_DONE: "finished",
|
|
190
|
+
STATE_FAILED: "failed",
|
|
191
|
+
STATE_CANCELLED: "was cancelled",
|
|
192
|
+
STATE_LOST: "disappeared from the queue",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _notify_finished(job_id: str, state: str) -> None:
|
|
197
|
+
"""Say that a job ended: on this desktop, and in a chat room if asked."""
|
|
198
|
+
if _service is None or not _service.store.get_pref("notify_on_finish", True):
|
|
199
|
+
return
|
|
200
|
+
job = _service.store.jobs.get(job_id)
|
|
201
|
+
if job is None:
|
|
202
|
+
return
|
|
203
|
+
wording = _finished_words().get(state, state.lower())
|
|
204
|
+
message = f"{job.name} {wording} on {job.host_name}."
|
|
205
|
+
try:
|
|
206
|
+
from . import notify
|
|
207
|
+
|
|
208
|
+
notify.notify("MoleditPy job manager", message)
|
|
209
|
+
except Exception:
|
|
210
|
+
logging.debug("Job Manager: could not raise a notification", exc_info=True)
|
|
211
|
+
# Separately, and after: a chat room is the notification for the case where
|
|
212
|
+
# nobody is at this desktop to see the other one, so a tray that refuses the
|
|
213
|
+
# message must not take this with it.
|
|
214
|
+
try:
|
|
215
|
+
from . import webhook
|
|
216
|
+
|
|
217
|
+
url = str(_service.store.get_pref("notify_webhook", "") or "")
|
|
218
|
+
if _service.store.get_pref("notify_chat", False):
|
|
219
|
+
webhook.post_async(url, "MoleditPy job manager", message)
|
|
220
|
+
except Exception:
|
|
221
|
+
logging.debug("Job Manager: could not post to the chat webhook", exc_info=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _install_status_widget(service) -> None:
|
|
225
|
+
"""Put the job counter in the host's status bar, once."""
|
|
226
|
+
global _status_widget
|
|
227
|
+
if _status_widget is not None or _context is None:
|
|
228
|
+
return
|
|
229
|
+
try:
|
|
230
|
+
from .status_widget import install
|
|
231
|
+
|
|
232
|
+
_status_widget = install(
|
|
233
|
+
_context.get_main_window(), service, on_click=lambda: show_monitor(_context)
|
|
234
|
+
)
|
|
235
|
+
except Exception:
|
|
236
|
+
# A missing status bar, or a host that lays its own out differently, is
|
|
237
|
+
# not a reason to leave the user without a working plugin.
|
|
238
|
+
logging.debug("Job Manager: no status bar indicator", exc_info=True)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _install_presence(service) -> None:
|
|
242
|
+
"""The tray menu, task bar progress and title counts. See :mod:`.presence`.
|
|
243
|
+
|
|
244
|
+
Only inside MoleditPy: every action below asks the plugin context. The
|
|
245
|
+
standalone monitor and the tray process install their own, wired to their
|
|
246
|
+
own windows, and one built here first would only be torn down again.
|
|
247
|
+
"""
|
|
248
|
+
if _context is None:
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
from . import presence
|
|
252
|
+
|
|
253
|
+
main_window = _context.get_main_window()
|
|
254
|
+
presence.install(
|
|
255
|
+
service,
|
|
256
|
+
main_window,
|
|
257
|
+
{
|
|
258
|
+
"monitor": lambda: show_monitor(_context),
|
|
259
|
+
"submit": lambda: show_submit(_context),
|
|
260
|
+
"host_monitor": lambda: show_host_monitor_standalone(_context),
|
|
261
|
+
"select_job": lambda job_id: show_job(job_id, _context),
|
|
262
|
+
"settings": lambda: show_settings(_context),
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
except Exception:
|
|
266
|
+
logging.debug("Job Manager: no tray or task bar presence", exc_info=True)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _mark_opened() -> None:
|
|
270
|
+
"""A Job Manager window was opened here: see TrayController.keep_running."""
|
|
271
|
+
try:
|
|
272
|
+
from . import presence
|
|
273
|
+
|
|
274
|
+
current = presence.current()
|
|
275
|
+
if current is not None and current.tray is not None:
|
|
276
|
+
current.tray.mark_opened()
|
|
277
|
+
except Exception:
|
|
278
|
+
logging.debug("Job Manager: opening not recorded", exc_info=True)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _acknowledge_failures() -> None:
|
|
282
|
+
"""The monitor is in front of the user: stop flagging failures as unseen."""
|
|
283
|
+
try:
|
|
284
|
+
from . import presence
|
|
285
|
+
|
|
286
|
+
current = presence.current()
|
|
287
|
+
if current is not None:
|
|
288
|
+
current.acknowledge()
|
|
289
|
+
except Exception:
|
|
290
|
+
logging.debug("Job Manager: failures not acknowledged", exc_info=True)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _install_beacon() -> None:
|
|
294
|
+
"""Say a Job Manager runs in this MoleditPy, and answer a standalone launch.
|
|
295
|
+
|
|
296
|
+
Only once the plugin has a service: a MoleditPy where the Job Manager was
|
|
297
|
+
never opened tracks nothing, and a standalone monitor started beside it
|
|
298
|
+
has nothing to defer to. See :mod:`.instances`.
|
|
299
|
+
"""
|
|
300
|
+
global _beacon
|
|
301
|
+
if _beacon is not None or _context is None:
|
|
302
|
+
return
|
|
303
|
+
try:
|
|
304
|
+
from . import instances
|
|
305
|
+
from .beacon import InstanceBeacon
|
|
306
|
+
from .store import default_data_dir
|
|
307
|
+
|
|
308
|
+
_beacon = InstanceBeacon(
|
|
309
|
+
default_data_dir(),
|
|
310
|
+
instances.ROLE_MOLEDITPY,
|
|
311
|
+
{
|
|
312
|
+
instances.ACTION_SHOW_MONITOR: lambda: show_monitor(_context),
|
|
313
|
+
instances.ACTION_SHOW_HOST_MONITOR: lambda: show_host_monitor_standalone(_context),
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
_beacon.start()
|
|
317
|
+
except Exception:
|
|
318
|
+
logging.debug("Job Manager: not registered as a running instance", exc_info=True)
|
|
319
|
+
_beacon = None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _take_tracking_back() -> bool:
|
|
323
|
+
"""Stop the tray process a previous MoleditPy handed its jobs to.
|
|
324
|
+
|
|
325
|
+
Before the job list is read: that process may be part way through saving
|
|
326
|
+
it, and two trackers would query every host twice and announce every job
|
|
327
|
+
ending twice. See :mod:`.handoff` and :mod:`.instances`.
|
|
328
|
+
|
|
329
|
+
True when there was one to take over from: the Job Manager was already
|
|
330
|
+
running, so this session counts as having opened it.
|
|
331
|
+
"""
|
|
332
|
+
took_over = False
|
|
333
|
+
try:
|
|
334
|
+
from . import instances
|
|
335
|
+
from .store import default_data_dir
|
|
336
|
+
|
|
337
|
+
directory = default_data_dir()
|
|
338
|
+
for data in instances.live_instances(directory):
|
|
339
|
+
if data.get("role") == instances.ROLE_TRAY:
|
|
340
|
+
took_over = True
|
|
341
|
+
instances.send_request(directory, int(data["pid"]), instances.ACTION_STOP)
|
|
342
|
+
if not instances.wait_until_gone(directory, int(data["pid"])):
|
|
343
|
+
logging.warning("Job Manager: the background tray process did not stop")
|
|
344
|
+
except Exception:
|
|
345
|
+
logging.debug("Job Manager: the tray process was not stopped", exc_info=True)
|
|
346
|
+
return took_over
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _keep_what_was_taken_over(store: Optional[Any] = None) -> None:
|
|
350
|
+
"""A Job Manager that was running before this MoleditPy keeps running after it.
|
|
351
|
+
|
|
352
|
+
Taking it over must not turn "Keep tracking jobs after MoleditPy closes"
|
|
353
|
+
off for this session: the user had it running, so it is as good as opened
|
|
354
|
+
here -- and handed back to a tray process on the way out, jobs or not.
|
|
355
|
+
"""
|
|
356
|
+
get_service(store=store)
|
|
357
|
+
_mark_opened()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _startup_store() -> Optional[Any]:
|
|
361
|
+
"""The job list, read once at load for everything that has to peek at it.
|
|
362
|
+
|
|
363
|
+
Reading it costs nothing that would not be paid anyway, and an empty list
|
|
364
|
+
still means not a single byte of network traffic.
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
from .store import JobStore
|
|
368
|
+
|
|
369
|
+
return JobStore()
|
|
370
|
+
except Exception:
|
|
371
|
+
logging.debug("Job Manager: could not read the job list at startup", exc_info=True)
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _resume_tracking(store: Optional[Any] = None) -> None:
|
|
376
|
+
"""Start polling at launch when jobs from a previous session are running.
|
|
377
|
+
|
|
378
|
+
The service used to be built only by opening the monitor, so a restart with
|
|
379
|
+
three jobs on a cluster silently stopped tracking every one of them: no
|
|
380
|
+
polling, no auto-download, until the user happened to open the window. The
|
|
381
|
+
store is read either way, so peeking at it first costs nothing -- and an
|
|
382
|
+
empty job list still means not a single byte of network traffic.
|
|
383
|
+
"""
|
|
384
|
+
if _service is not None or store is None or not store.active_jobs():
|
|
385
|
+
return
|
|
386
|
+
# The store just read is the one the service adopts, rather than parsing
|
|
387
|
+
# both files again a line later.
|
|
388
|
+
get_service(store=store)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def forget_window() -> None:
|
|
392
|
+
"""Drop the registered window so the next open builds a live one.
|
|
393
|
+
|
|
394
|
+
A dialog that stays registered after being closed comes back as a stale
|
|
395
|
+
widget whose signals are already torn down.
|
|
396
|
+
"""
|
|
397
|
+
if _context is not None:
|
|
398
|
+
try:
|
|
399
|
+
_context.register_window(WINDOW_KEY, None)
|
|
400
|
+
except Exception:
|
|
401
|
+
logging.debug("Job Manager: could not deregister the window", exc_info=True)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def open_job_file(path: str) -> None:
|
|
405
|
+
"""Open a saved job list in the monitor. Registered with the host.
|
|
406
|
+
|
|
407
|
+
Makes ``.pmejdb`` and ``.pmejbs`` file types the application knows: File > Import, the
|
|
408
|
+
command line and a drop onto the main window all land here.
|
|
409
|
+
"""
|
|
410
|
+
show_monitor(_context)
|
|
411
|
+
window = _context.get_window(WINDOW_KEY) if _context is not None else None
|
|
412
|
+
if window is not None:
|
|
413
|
+
window.open_job_list(path)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def handle_dropped_file(path: str) -> bool:
|
|
417
|
+
"""Open a job list dropped onto the main window. True when it was ours.
|
|
418
|
+
|
|
419
|
+
Only ``.pmejdb`` and ``.pmejbs``. Input extensions are deliberately *not* claimed
|
|
420
|
+
application-wide: taking ``.inp`` and ``.xyz`` would stop a drop on the
|
|
421
|
+
main window doing the obvious thing, which is opening the molecule. The
|
|
422
|
+
monitor and the wizard accept those themselves, where the meaning of a drop
|
|
423
|
+
is unambiguous.
|
|
424
|
+
"""
|
|
425
|
+
from .store import JOB_EXTENSIONS
|
|
426
|
+
|
|
427
|
+
if not path or not path.lower().endswith(JOB_EXTENSIONS):
|
|
428
|
+
return False
|
|
429
|
+
try:
|
|
430
|
+
open_job_file(path)
|
|
431
|
+
except Exception:
|
|
432
|
+
logging.exception("Job Manager: could not open the dropped job list %s", path)
|
|
433
|
+
return False
|
|
434
|
+
return True
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _retire_previous_load(context) -> None:
|
|
438
|
+
"""Shut down everything a previous load of this plugin left running.
|
|
439
|
+
|
|
440
|
+
The host reloads a plugin by re-executing the package as new module objects
|
|
441
|
+
and calls no teardown hook. The old load's API server kept its port, and
|
|
442
|
+
its service kept polling: two pollers queried every host, the status bar
|
|
443
|
+
showed two counters, and every finished job was announced twice. Each load
|
|
444
|
+
therefore keeps its ``shutdown`` in the host's per-plugin registry, which
|
|
445
|
+
outlives the re-execution, and the next load calls it before doing anything
|
|
446
|
+
-- the old module's own globals being the only place its service is still
|
|
447
|
+
reachable.
|
|
448
|
+
|
|
449
|
+
A load from before 2.0 registered only its ``stop_api``; that function's
|
|
450
|
+
globals are its module's, so its ``shutdown`` is found through them.
|
|
451
|
+
"""
|
|
452
|
+
previous = context.get_window(LOAD_TEARDOWN_KEY)
|
|
453
|
+
if not callable(previous):
|
|
454
|
+
stop_previous_api = context.get_window(API_TEARDOWN_KEY)
|
|
455
|
+
if callable(stop_previous_api):
|
|
456
|
+
module_globals = getattr(stop_previous_api, "__globals__", None) or {}
|
|
457
|
+
previous = module_globals.get("shutdown") or stop_previous_api
|
|
458
|
+
# Calling initialize twice on the same module is not a reload, and must not
|
|
459
|
+
# take down what this very load is tracking.
|
|
460
|
+
if callable(previous) and previous is not shutdown:
|
|
461
|
+
# Its windows first: they hold the old service, and once it is shut
|
|
462
|
+
# down a monitor left open would show a list that never updates. Each
|
|
463
|
+
# deregisters itself on close, so the next open builds a live one.
|
|
464
|
+
for key in (WINDOW_KEY, HOST_MONITOR_WINDOW_KEY):
|
|
465
|
+
try:
|
|
466
|
+
window = context.get_window(key)
|
|
467
|
+
if window is not None:
|
|
468
|
+
window.close()
|
|
469
|
+
context.register_window(key, None)
|
|
470
|
+
except Exception:
|
|
471
|
+
logging.debug("Job Manager: the previous load's %s stayed", key, exc_info=True)
|
|
472
|
+
try:
|
|
473
|
+
previous()
|
|
474
|
+
except Exception:
|
|
475
|
+
logging.debug("Job Manager: the previous load did not shut down", exc_info=True)
|
|
476
|
+
# Both: a downgrade to a load that only knows the API key still finds it.
|
|
477
|
+
context.register_window(API_TEARDOWN_KEY, stop_api)
|
|
478
|
+
context.register_window(LOAD_TEARDOWN_KEY, shutdown)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def initialize(context) -> None:
|
|
482
|
+
"""Entry point called by the host at plugin load."""
|
|
483
|
+
global _context
|
|
484
|
+
_retire_previous_load(context)
|
|
485
|
+
_context = context
|
|
486
|
+
# Extensions rather than the Plugin menu. The host has no Extensions menu
|
|
487
|
+
# of its own and creates it on demand, so this is a top-level entry.
|
|
488
|
+
# add_menu_action, not add_plugin_menu: the latter is hard-wired to
|
|
489
|
+
# "Plugin/<path>". Both have existed since v3, so no fallback is needed.
|
|
490
|
+
context.add_menu_action("Extensions/Job Manager/Job Monitor", lambda: show_monitor(context))
|
|
491
|
+
# Standalone: opens only the host panel, not the job monitor behind it --
|
|
492
|
+
# for the case this window is built for, watching machines with nothing
|
|
493
|
+
# queued yet, where building the job monitor first was wasted work (and a
|
|
494
|
+
# second window to close).
|
|
495
|
+
context.add_menu_action(
|
|
496
|
+
"Extensions/Job Manager/Host Monitor", lambda: show_host_monitor_standalone(context)
|
|
497
|
+
)
|
|
498
|
+
context.add_menu_action("Extensions/Job Manager/Submit Job...", lambda: show_submit(context))
|
|
499
|
+
context.add_menu_action("Extensions/Job Manager/Settings...", lambda: show_settings(context))
|
|
500
|
+
# Its own entry rather than a tick in the monitor's preferences row: this
|
|
501
|
+
# is where the token is read from, and a user following the API
|
|
502
|
+
# documentation should not have to open a job window to find it.
|
|
503
|
+
context.add_menu_action("Extensions/Job Manager/Local API...", lambda: show_api_dialog(context))
|
|
504
|
+
# Last, and the only entry that reaches nothing on a host: which version is
|
|
505
|
+
# running is what a bug report needs, and once the plugin is installed the
|
|
506
|
+
# Installer's listing is no longer in front of anyone.
|
|
507
|
+
context.add_menu_action("Extensions/Job Manager/About...", lambda: show_about(context))
|
|
508
|
+
|
|
509
|
+
from .store import JOB_EXTENSIONS
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
for extension in JOB_EXTENSIONS:
|
|
513
|
+
context.register_file_opener(extension, open_job_file)
|
|
514
|
+
except AttributeError:
|
|
515
|
+
# Host older than the file-opener API; the menu entries still work.
|
|
516
|
+
logging.debug("Job Manager: this host has no register_file_opener")
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
# Priority 0: this handler answers for the two job-list extensions and declines
|
|
520
|
+
# everything else, so it has no reason to get in front of a plugin
|
|
521
|
+
# that wants a say in the file types it does claim.
|
|
522
|
+
context.register_drop_handler(handle_dropped_file, 0)
|
|
523
|
+
except AttributeError:
|
|
524
|
+
logging.debug("Job Manager: this host has no register_drop_handler")
|
|
525
|
+
|
|
526
|
+
# One store for both peeks: each used to build its own, which parses both
|
|
527
|
+
# files twice at every launch.
|
|
528
|
+
took_over = _take_tracking_back()
|
|
529
|
+
store = _startup_store()
|
|
530
|
+
_resume_tracking(store)
|
|
531
|
+
if took_over:
|
|
532
|
+
_keep_what_was_taken_over(store)
|
|
533
|
+
# After tracking, so an API that is on adopts the service that resume
|
|
534
|
+
# already built rather than making a second one.
|
|
535
|
+
_resume_api(store)
|
|
536
|
+
_resume_web(store)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _resume_web(store: Optional[Any] = None) -> None:
|
|
540
|
+
"""Serve the web view at load when the user left it on.
|
|
541
|
+
|
|
542
|
+
It used to start only with the Host Monitor window, so a saved link on a
|
|
543
|
+
phone loaded a blank page until someone opened that window at the desk.
|
|
544
|
+
A session with it off still builds no service.
|
|
545
|
+
"""
|
|
546
|
+
store = store if store is not None else (_service.store if _service is not None else None)
|
|
547
|
+
if store is None or not store.get_pref("host_monitor_web", False):
|
|
548
|
+
return
|
|
549
|
+
try:
|
|
550
|
+
from . import web_service
|
|
551
|
+
|
|
552
|
+
web_service.resume(get_service(store=store))
|
|
553
|
+
except Exception:
|
|
554
|
+
logging.warning("Job Manager: the web monitor did not start", exc_info=True)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def run(mw) -> None:
|
|
558
|
+
"""Legacy Plugins-menu entry."""
|
|
559
|
+
show_monitor(_context)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def show_monitor(context=None) -> None:
|
|
563
|
+
"""Open (or raise) the singleton job monitor."""
|
|
564
|
+
context = context or _context
|
|
565
|
+
if context is None:
|
|
566
|
+
return
|
|
567
|
+
window = context.get_window(WINDOW_KEY)
|
|
568
|
+
if window is not None:
|
|
569
|
+
from .window_utils import bring_to_front
|
|
570
|
+
|
|
571
|
+
bring_to_front(window)
|
|
572
|
+
_mark_opened()
|
|
573
|
+
_acknowledge_failures()
|
|
574
|
+
return
|
|
575
|
+
try:
|
|
576
|
+
from .jobs_dialog import JobsDialog
|
|
577
|
+
|
|
578
|
+
service = get_service()
|
|
579
|
+
window = JobsDialog(service, parent=None)
|
|
580
|
+
context.register_window(WINDOW_KEY, window)
|
|
581
|
+
window.show()
|
|
582
|
+
_mark_opened()
|
|
583
|
+
_acknowledge_failures()
|
|
584
|
+
except Exception as exc:
|
|
585
|
+
logging.exception("Job Manager: could not open the job monitor")
|
|
586
|
+
context.show_status_message(f"Job Manager: {exc}", 5000)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def show_job(job_id: str, context=None) -> None:
|
|
590
|
+
"""Open the monitor with one job selected -- the tray's job list lands here."""
|
|
591
|
+
context = context or _context
|
|
592
|
+
if context is None:
|
|
593
|
+
return
|
|
594
|
+
show_monitor(context)
|
|
595
|
+
window = context.get_window(WINDOW_KEY)
|
|
596
|
+
if window is not None and hasattr(window, "select_job"):
|
|
597
|
+
window.select_job(job_id)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def show_settings(context=None) -> None:
|
|
601
|
+
"""Every standing preference, in one window, from the menu or the tray."""
|
|
602
|
+
context = context or _context
|
|
603
|
+
try:
|
|
604
|
+
from .settings_dialog import SettingsDialog
|
|
605
|
+
from .window_utils import exec_once
|
|
606
|
+
|
|
607
|
+
exec_once("settings", lambda: SettingsDialog(get_service(), None))
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
logging.exception("Job Manager: could not open the settings")
|
|
610
|
+
if context is not None:
|
|
611
|
+
context.show_status_message(f"Job Manager: {exc}", 5000)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def show_submit(context=None) -> None:
|
|
615
|
+
"""Open the submit wizard, creating the monitor behind it."""
|
|
616
|
+
context = context or _context
|
|
617
|
+
if context is None:
|
|
618
|
+
return
|
|
619
|
+
show_monitor(context)
|
|
620
|
+
window = context.get_window(WINDOW_KEY)
|
|
621
|
+
if window is not None:
|
|
622
|
+
window.open_submit_dialog()
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def show_host_monitor_standalone(context=None) -> None:
|
|
626
|
+
"""Open (or raise) the host monitor on its own -- no job monitor window.
|
|
627
|
+
|
|
628
|
+
The job monitor is a QDialog that stays fully independent (see
|
|
629
|
+
window_utils.make_independent); the host monitor already was one too, but
|
|
630
|
+
every route to it went through show_monitor() first, so the Extensions
|
|
631
|
+
menu could not open one without also raising the other. This registers
|
|
632
|
+
the host monitor under its own window key so it is a standalone window
|
|
633
|
+
like the job monitor is, not a side effect of opening it.
|
|
634
|
+
"""
|
|
635
|
+
context = context or _context
|
|
636
|
+
if context is None:
|
|
637
|
+
return
|
|
638
|
+
window = context.get_window(HOST_MONITOR_WINDOW_KEY)
|
|
639
|
+
if window is not None:
|
|
640
|
+
from .window_utils import bring_to_front
|
|
641
|
+
|
|
642
|
+
# Minimised, it used to stay minimised: show() and raise_() leave it so.
|
|
643
|
+
bring_to_front(window)
|
|
644
|
+
_mark_opened()
|
|
645
|
+
return
|
|
646
|
+
try:
|
|
647
|
+
from .host_monitor import HostMonitorDialog
|
|
648
|
+
|
|
649
|
+
service = get_service()
|
|
650
|
+
window = HostMonitorDialog(service, parent=None)
|
|
651
|
+
context.register_window(HOST_MONITOR_WINDOW_KEY, window)
|
|
652
|
+
window.finished.connect(lambda *_: context.register_window(HOST_MONITOR_WINDOW_KEY, None))
|
|
653
|
+
window.show()
|
|
654
|
+
_mark_opened()
|
|
655
|
+
except Exception as exc:
|
|
656
|
+
logging.exception("Job Manager: could not open the host monitor")
|
|
657
|
+
context.show_status_message(f"Job Manager: {exc}", 5000)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def submit_file(paths, name: str = "") -> bool:
|
|
661
|
+
"""Open the submit wizard prefilled with an input file. **Public API.**
|
|
662
|
+
|
|
663
|
+
This is the handoff other plugins use: an input generator that has just
|
|
664
|
+
written a file calls it to offer "run this on the cluster" without knowing
|
|
665
|
+
anything about hosts, schedulers or transports. Callers find this plugin
|
|
666
|
+
through the host's plugin list and check for this attribute, so the name
|
|
667
|
+
and signature are a contract -- do not rename either.
|
|
668
|
+
|
|
669
|
+
``paths`` is one path or a list of them; the first is the file passed to
|
|
670
|
+
the command. Returns True if the wizard was opened.
|
|
671
|
+
|
|
672
|
+
The wizard opens on a host with an equal path (local mirror) configured
|
|
673
|
+
where there is one, rather than on whichever host was used last: work
|
|
674
|
+
arriving this way has no host in mind, and a mirrored host is the one
|
|
675
|
+
whose results need no downloading.
|
|
676
|
+
"""
|
|
677
|
+
if isinstance(paths, str):
|
|
678
|
+
paths = [paths]
|
|
679
|
+
files = [p for p in (paths or []) if p]
|
|
680
|
+
if not files or _context is None:
|
|
681
|
+
return False
|
|
682
|
+
show_monitor(_context)
|
|
683
|
+
window = _context.get_window(WINDOW_KEY)
|
|
684
|
+
if window is None:
|
|
685
|
+
return False
|
|
686
|
+
try:
|
|
687
|
+
window.open_submit_dialog(files=files, name=name, handoff=True)
|
|
688
|
+
except Exception:
|
|
689
|
+
logging.exception("Job Manager: could not open the submit wizard")
|
|
690
|
+
return False
|
|
691
|
+
return True
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def show_api_dialog(context=None) -> None:
|
|
695
|
+
"""Open the Local API window: the switch, the port, and the token."""
|
|
696
|
+
context = context or _context
|
|
697
|
+
if context is None:
|
|
698
|
+
return
|
|
699
|
+
try:
|
|
700
|
+
from .api_dialog import ApiDialog
|
|
701
|
+
from .window_utils import exec_once
|
|
702
|
+
|
|
703
|
+
exec_once("api", lambda: ApiDialog(get_service(), parent=None))
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
logging.exception("Job Manager: could not open the API window")
|
|
706
|
+
context.show_status_message(f"Job Manager: {exc}", 5000)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def show_about(context=None) -> None:
|
|
710
|
+
"""Open the About window: name, version, and where to report a problem."""
|
|
711
|
+
context = context or _context
|
|
712
|
+
try:
|
|
713
|
+
from .about_dialog import AboutDialog
|
|
714
|
+
from .window_utils import exec_once
|
|
715
|
+
|
|
716
|
+
exec_once("about", lambda: AboutDialog(parent=None))
|
|
717
|
+
except Exception as exc:
|
|
718
|
+
logging.exception("Job Manager: could not open the About window")
|
|
719
|
+
if context is not None:
|
|
720
|
+
context.show_status_message(f"Job Manager: {exc}", 5000)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def submit_job(request: dict) -> dict:
|
|
724
|
+
"""Submit a job from a dict, with no wizard and no user interaction.
|
|
725
|
+
|
|
726
|
+
**Public API**, and the same one the HTTP route serves -- a plugin running
|
|
727
|
+
inside MoleditPy calls this instead of going out over a socket to reach the
|
|
728
|
+
process it is already in. ``request`` takes the fields documented in
|
|
729
|
+
docs/API.md (``host`` plus ``command`` or ``preset``, ``files``, ...);
|
|
730
|
+
the job record comes back as a dict.
|
|
731
|
+
|
|
732
|
+
Raises :class:`~job_manager.api_core.ApiError` for a request that cannot be
|
|
733
|
+
served, whose ``message`` is written to be shown to a user as it is.
|
|
734
|
+
|
|
735
|
+
Must be called on the GUI thread, like every other writer of the job store.
|
|
736
|
+
"""
|
|
737
|
+
from .api_core import JobApi
|
|
738
|
+
|
|
739
|
+
return JobApi(get_service()).submit(request)["job"]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def shutdown() -> None:
|
|
743
|
+
"""Stop polling and release worker threads (called on plugin reload)."""
|
|
744
|
+
global _service, _status_widget, _api_server, _beacon
|
|
745
|
+
# First: the socket is the one thing that can still bring in work, and a
|
|
746
|
+
# request arriving while the service is being torn down would find half a
|
|
747
|
+
# plugin. Unconditional, since a listening server outlives a reload.
|
|
748
|
+
stop_api()
|
|
749
|
+
if _api_server is not None:
|
|
750
|
+
retire = getattr(_api_server, "deleteLater", None)
|
|
751
|
+
if callable(retire):
|
|
752
|
+
retire()
|
|
753
|
+
_api_server = None
|
|
754
|
+
if _beacon is not None:
|
|
755
|
+
try:
|
|
756
|
+
_beacon.stop()
|
|
757
|
+
except Exception:
|
|
758
|
+
logging.debug("Job Manager: the instance beacon did not stop", exc_info=True)
|
|
759
|
+
_beacon = None
|
|
760
|
+
# Before the service goes: the tray menu and the task bar progress read it,
|
|
761
|
+
# and keep-running must be undone while a main window can still be shown.
|
|
762
|
+
try:
|
|
763
|
+
_runtime_module("presence").uninstall()
|
|
764
|
+
except Exception:
|
|
765
|
+
logging.debug("Job Manager: presence teardown failed", exc_info=True)
|
|
766
|
+
if _status_widget is not None:
|
|
767
|
+
try:
|
|
768
|
+
_status_widget.detach()
|
|
769
|
+
except Exception:
|
|
770
|
+
logging.debug("Job Manager: status widget teardown failed", exc_info=True)
|
|
771
|
+
_status_widget = None
|
|
772
|
+
if _service is not None:
|
|
773
|
+
try:
|
|
774
|
+
# The socket and the sampler, before the service they read.
|
|
775
|
+
_runtime_module("web_service").shutdown_for(_service)
|
|
776
|
+
except Exception:
|
|
777
|
+
logging.debug("Job Manager: web monitor teardown failed", exc_info=True)
|
|
778
|
+
try:
|
|
779
|
+
_service.shutdown()
|
|
780
|
+
except Exception:
|
|
781
|
+
logging.debug("Job Manager: shutdown failed", exc_info=True)
|
|
782
|
+
_service = None
|
|
783
|
+
# Unconditionally, not only via the widget: a host with no status bar
|
|
784
|
+
# never gets one, and a badge that outlives the plugin would leave
|
|
785
|
+
# MoleditPy's icon claiming jobs are running for the rest of the run.
|
|
786
|
+
try:
|
|
787
|
+
_runtime_module("taskbar").clear_badge()
|
|
788
|
+
except Exception:
|
|
789
|
+
logging.debug("Job Manager: the badge was not cleared", exc_info=True)
|
|
790
|
+
# Same reasoning for the tray icon: one left behind outlives the plugin
|
|
791
|
+
# that put it there, and clicking it would reach nothing.
|
|
792
|
+
try:
|
|
793
|
+
_runtime_module("notify").shutdown()
|
|
794
|
+
except Exception:
|
|
795
|
+
logging.debug("Job Manager: the tray icon was not removed", exc_info=True)
|