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.
Files changed (109) hide show
  1. job_manager/__init__.py +795 -0
  2. job_manager/__main__.py +91 -0
  3. job_manager/about_dialog.py +119 -0
  4. job_manager/api_client.py +646 -0
  5. job_manager/api_core.py +751 -0
  6. job_manager/api_dialog.py +234 -0
  7. job_manager/api_inputs.py +209 -0
  8. job_manager/api_security.py +141 -0
  9. job_manager/api_server.py +419 -0
  10. job_manager/api_types.py +137 -0
  11. job_manager/beacon.py +73 -0
  12. job_manager/chat_webhook_dialog.py +139 -0
  13. job_manager/command_templates.py +174 -0
  14. job_manager/credentials.py +49 -0
  15. job_manager/details_dialog.py +191 -0
  16. job_manager/dialect.py +345 -0
  17. job_manager/download_dialog.py +240 -0
  18. job_manager/file_table.py +78 -0
  19. job_manager/file_tree.py +107 -0
  20. job_manager/folder_scan.py +198 -0
  21. job_manager/handoff.py +164 -0
  22. job_manager/host_monitor.py +490 -0
  23. job_manager/host_profile_form.py +413 -0
  24. job_manager/host_sampler.py +261 -0
  25. job_manager/host_stats.py +248 -0
  26. job_manager/host_widgets.py +885 -0
  27. job_manager/hosts_dialog.py +1023 -0
  28. job_manager/http_limits.py +35 -0
  29. job_manager/icon.py +80 -0
  30. job_manager/input_files_panel.py +127 -0
  31. job_manager/input_names.py +53 -0
  32. job_manager/input_scan.py +214 -0
  33. job_manager/instances.py +409 -0
  34. job_manager/job_actions.py +185 -0
  35. job_manager/job_database.py +229 -0
  36. job_manager/job_dependencies.py +148 -0
  37. job_manager/job_server_client/COPYING +674 -0
  38. job_manager/job_server_client/LICENSE +165 -0
  39. job_manager/job_server_client/LICENSING.md +49 -0
  40. job_manager/job_server_client/PROVENANCE.json +14 -0
  41. job_manager/job_server_client/__init__.py +1 -0
  42. job_manager/job_server_client/auth.py +17 -0
  43. job_manager/job_server_client/client.py +372 -0
  44. job_manager/job_server_client/credentials.py +75 -0
  45. job_manager/job_server_client/errors.py +8 -0
  46. job_manager/job_server_client/files.py +129 -0
  47. job_manager/job_table.py +304 -0
  48. job_manager/jobs_dialog.py +1629 -0
  49. job_manager/models.py +652 -0
  50. job_manager/notify.py +86 -0
  51. job_manager/output_file_dialog.py +681 -0
  52. job_manager/poller.py +352 -0
  53. job_manager/presence.py +311 -0
  54. job_manager/remote_paths.py +67 -0
  55. job_manager/remote_runner.py +965 -0
  56. job_manager/remote_runner_ps.py +709 -0
  57. job_manager/result_files.py +299 -0
  58. job_manager/result_view.py +207 -0
  59. job_manager/runner.py +1068 -0
  60. job_manager/schedulers/__init__.py +49 -0
  61. job_manager/schedulers/base.py +562 -0
  62. job_manager/schedulers/pbs.py +166 -0
  63. job_manager/schedulers/sge.py +136 -0
  64. job_manager/schedulers/shell.py +98 -0
  65. job_manager/schedulers/slurm.py +137 -0
  66. job_manager/schedulers/windows.py +328 -0
  67. job_manager/server_backend.py +258 -0
  68. job_manager/service.py +781 -0
  69. job_manager/settings_dialog.py +371 -0
  70. job_manager/ssh_terminal.py +101 -0
  71. job_manager/standalone.py +228 -0
  72. job_manager/status_widget.py +149 -0
  73. job_manager/store.py +1156 -0
  74. job_manager/store_io.py +64 -0
  75. job_manager/structure_relay.py +174 -0
  76. job_manager/submission_config.py +50 -0
  77. job_manager/submission_resources.py +239 -0
  78. job_manager/submission_templates.py +175 -0
  79. job_manager/submission_workflow.py +245 -0
  80. job_manager/submit_dialog.py +1595 -0
  81. job_manager/tail_file_dialog.py +157 -0
  82. job_manager/taskbar.py +56 -0
  83. job_manager/tasks.py +244 -0
  84. job_manager/template_editor_dialog.py +226 -0
  85. job_manager/text_dialog.py +532 -0
  86. job_manager/theme.py +359 -0
  87. job_manager/transport/__init__.py +58 -0
  88. job_manager/transport/base.py +85 -0
  89. job_manager/transport/local.py +229 -0
  90. job_manager/transport/openssh.py +237 -0
  91. job_manager/transport/paramiko_backend.py +352 -0
  92. job_manager/transport/wsl.py +394 -0
  93. job_manager/tray.py +406 -0
  94. job_manager/tree_utils.py +200 -0
  95. job_manager/ui_actions.py +58 -0
  96. job_manager/web_monitor.py +848 -0
  97. job_manager/web_monitor_dialog.py +315 -0
  98. job_manager/web_service.py +265 -0
  99. job_manager/webhook.py +118 -0
  100. job_manager/win_taskbar.py +337 -0
  101. job_manager/window_utils.py +87 -0
  102. moleditpy_job_manager-3.3.4.dist-info/METADATA +415 -0
  103. moleditpy_job_manager-3.3.4.dist-info/RECORD +109 -0
  104. moleditpy_job_manager-3.3.4.dist-info/WHEEL +5 -0
  105. moleditpy_job_manager-3.3.4.dist-info/entry_points.txt +3 -0
  106. moleditpy_job_manager-3.3.4.dist-info/licenses/COPYING +674 -0
  107. moleditpy_job_manager-3.3.4.dist-info/licenses/LICENSE +165 -0
  108. moleditpy_job_manager-3.3.4.dist-info/licenses/LICENSING.md +49 -0
  109. moleditpy_job_manager-3.3.4.dist-info/top_level.txt +1 -0
@@ -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)