webmo 1.6.0__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.
- webmo/__init__.py +2 -0
- webmo/gui.py +193 -0
- webmo/spectrum.py +451 -0
- webmo/util.py +358 -0
- webmo/webmo_rest.py +903 -0
- webmo-1.6.0.dist-info/METADATA +35 -0
- webmo-1.6.0.dist-info/RECORD +10 -0
- webmo-1.6.0.dist-info/WHEEL +5 -0
- webmo-1.6.0.dist-info/licenses/LICENSE +21 -0
- webmo-1.6.0.dist-info/top_level.txt +1 -0
webmo/webmo_rest.py
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from IPython.display import display, clear_output, Javascript, Image
|
|
7
|
+
has_ipython = True
|
|
8
|
+
except ImportError:
|
|
9
|
+
has_ipython = False
|
|
10
|
+
|
|
11
|
+
class WebMOREST:
|
|
12
|
+
"""The WebMOREST class provides an object-oriented Python API for the WebMO REST interface.
|
|
13
|
+
|
|
14
|
+
The WebMOREST class provides a simple Python API to access the WebMO REST interface. As of now
|
|
15
|
+
there are no public methods. Session tokens are handled automatically handled during object
|
|
16
|
+
creation/destruction.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_url, username, password="", expiration=24, totp="", recovery_code=""):
|
|
20
|
+
"""Constructor for WebMOREST
|
|
21
|
+
|
|
22
|
+
This constructor generates a WebMOREST object and also generates and stores a newly
|
|
23
|
+
session token.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url(str): The base URL (ending in rest.cgi) of the WeBMO rest endpoint
|
|
27
|
+
username(str): The WebMO username
|
|
28
|
+
password(str, optional): The WebMO password; if omitted, this is supplied interactively
|
|
29
|
+
expiration(int, optional): The expiration time (in hours) of the REST session token
|
|
30
|
+
totp(str, optional): The current TOTP code from an authenticator app, for users with MFA enabled;
|
|
31
|
+
if omitted and MFA is required, a TOTP or recovery code is supplied interactively
|
|
32
|
+
recovery_code(str, optional): A single-use MFA recovery code, as an alternative to totp
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
object: The newly constructed WebMO object
|
|
36
|
+
"""
|
|
37
|
+
from getpass import getpass
|
|
38
|
+
|
|
39
|
+
#prompt for WebMO password if not specified
|
|
40
|
+
if not password:
|
|
41
|
+
password=getpass(prompt="Enter WebMO password for user %s:" % username)
|
|
42
|
+
#obtain a REST token using the specified credentials
|
|
43
|
+
login={'username' : username, 'password' : password, 'expiration' : expiration,
|
|
44
|
+
'totp' : totp, 'recoveryCode' : recovery_code}
|
|
45
|
+
r = requests.post(base_url + '/sessions', data=login)
|
|
46
|
+
#prompt for an MFA code if required but not specified; a request without a code
|
|
47
|
+
#does not count as a failed MFA attempt, and a lockout is reported as an error below
|
|
48
|
+
if not totp and not recovery_code and WebMOREST._is_mfa_required(r):
|
|
49
|
+
code = input("Enter WebMO MFA code (or recovery code) for user %s:" % username).strip()
|
|
50
|
+
if code.isdigit():
|
|
51
|
+
login['totp'] = code
|
|
52
|
+
else:
|
|
53
|
+
login['recoveryCode'] = code
|
|
54
|
+
r = requests.post(base_url + '/sessions', data=login)
|
|
55
|
+
WebMOREST._check_status(r) #raise an exception if there is a problem with the request
|
|
56
|
+
|
|
57
|
+
self._base_url = base_url
|
|
58
|
+
self._auth=r.json() #save an authorization token need to authenticate future REST requests
|
|
59
|
+
|
|
60
|
+
if has_ipython:
|
|
61
|
+
self._init_javascript = True
|
|
62
|
+
self._callback_listener = None
|
|
63
|
+
|
|
64
|
+
def __del__(self):
|
|
65
|
+
"""Destructor for WebMOREST
|
|
66
|
+
|
|
67
|
+
This destructor automatically deletes the session token using the REST interface
|
|
68
|
+
"""
|
|
69
|
+
#End the REST sessions; check in case requests has already been released,
|
|
70
|
+
#or the constructor failed before a session token was obtained
|
|
71
|
+
if requests.delete is not None and hasattr(self, '_auth'):
|
|
72
|
+
r = requests.delete(self._base_url + '/sessions', params=self._auth)
|
|
73
|
+
#do not raise an exception for a failed request in this case due to issues
|
|
74
|
+
#with object management in Jupyter (i.e. on code re-run, a new token is made
|
|
75
|
+
#prior to deletion!)
|
|
76
|
+
|
|
77
|
+
if getattr(self, '_callback_listener', None) is not None:
|
|
78
|
+
self._callback_listener.close()
|
|
79
|
+
|
|
80
|
+
#
|
|
81
|
+
# Users resource
|
|
82
|
+
#
|
|
83
|
+
def get_users(self):
|
|
84
|
+
"""Fetches a list of available WebMO users
|
|
85
|
+
|
|
86
|
+
This call returns a list of available WebMO users. For non-administrative users, this will be
|
|
87
|
+
only the current authenticated user. For group administrators, this call will return all users
|
|
88
|
+
in the group. For system administrators, this call will return all system users.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A list of users
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
r = requests.get(self._base_url + "/users", params=self._auth)
|
|
95
|
+
WebMOREST._check_status(r)
|
|
96
|
+
return r.json()["users"]
|
|
97
|
+
|
|
98
|
+
def get_user_info(self, username):
|
|
99
|
+
"""Returns information about the specified user
|
|
100
|
+
|
|
101
|
+
This call returns a JSON formatted string summarizing information about the requested user. For non-
|
|
102
|
+
administrative users, only requests for the authenticated user will be accepted.
|
|
103
|
+
|
|
104
|
+
Arguments:
|
|
105
|
+
username(str): The username about whom to return information
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A JSON formatted string summarizing the user information
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
r = requests.get(self._base_url + "/users/%s" % username, params=self._auth)
|
|
112
|
+
WebMOREST._check_status(r)
|
|
113
|
+
return r.json()
|
|
114
|
+
|
|
115
|
+
#
|
|
116
|
+
# Groups resource
|
|
117
|
+
#
|
|
118
|
+
def get_groups(self):
|
|
119
|
+
"""Fetches a list of available WebMO groups
|
|
120
|
+
|
|
121
|
+
This call returns a list of available WebMO groups. For non-administrative users, this will be
|
|
122
|
+
only the current authenticated group. For system administrators, this call will return all system groups.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A list of groups
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
r = requests.get(self._base_url + "/groups", params=self._auth)
|
|
129
|
+
WebMOREST._check_status(r)
|
|
130
|
+
return r.json()["groups"]
|
|
131
|
+
|
|
132
|
+
def get_group_info(self, groupname):
|
|
133
|
+
"""Returns information about the specified group
|
|
134
|
+
|
|
135
|
+
This call returns a JSON formatted string summarizing information about the requested group. For non-
|
|
136
|
+
administrative users, only requests for the authenticated group will be accepted.
|
|
137
|
+
|
|
138
|
+
Arguments:
|
|
139
|
+
groupname(str): The groupname about whom to return information
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A JSON formatted string summarizing the group information
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
r = requests.get(self._base_url + "/groups/%s" % groupname, params=self._auth)
|
|
146
|
+
WebMOREST._check_status(r)
|
|
147
|
+
return r.json()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
#
|
|
151
|
+
# Folders resource
|
|
152
|
+
#
|
|
153
|
+
def get_folders(self, target_user=""):
|
|
154
|
+
"""Fetches a list of folders owned by the current user or the specified target user
|
|
155
|
+
|
|
156
|
+
This call returns a list of available folders. Administrative users must specify the target user,
|
|
157
|
+
otherwise the folders owned by the current user are returned.
|
|
158
|
+
|
|
159
|
+
Arguments:
|
|
160
|
+
target_user(str, optional): The target username whose folders are retrieved. Otherwise, uses the authenticated user.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
A list of folders
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
#append other relevant parameters
|
|
167
|
+
params = self._auth.copy()
|
|
168
|
+
params.update({'user' : target_user})
|
|
169
|
+
r = requests.get(self._base_url + "/folders", params=params)
|
|
170
|
+
WebMOREST._check_status(r)
|
|
171
|
+
return r.json()["folders"]
|
|
172
|
+
|
|
173
|
+
#
|
|
174
|
+
# Jobs resource
|
|
175
|
+
#
|
|
176
|
+
def get_jobs(self, engine="", status="", folder_id="", job_name="", target_user=""):
|
|
177
|
+
"""Fetches a list of jobs satisfying the specified filter criteria
|
|
178
|
+
|
|
179
|
+
This call returns a list of available jobs owned by the current user or (for administrative users)
|
|
180
|
+
the specified target user AND the specified filter criteria.
|
|
181
|
+
|
|
182
|
+
Arguements:
|
|
183
|
+
engine(str, optional): Filter by specified computational engine
|
|
184
|
+
status(str, optional): Filter by job status
|
|
185
|
+
folder_id(str, optional): Filter by folder ID (not name!)
|
|
186
|
+
target_user(str, optional): The target username whose jobs are retrieved. Otherwise, uses the authenticated user.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A list of jobs meeting the specified criteria
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
#append other relevant paramters
|
|
193
|
+
params = self._auth.copy()
|
|
194
|
+
params.update({'engine' : engine, 'status' : status, 'folderID' : folder_id, 'jobName' : job_name, 'user' : target_user})
|
|
195
|
+
r = requests.get(self._base_url + '/jobs', params=params)
|
|
196
|
+
WebMOREST._check_status(r)
|
|
197
|
+
return r.json()["jobs"]
|
|
198
|
+
|
|
199
|
+
def get_job_info(self, job_number):
|
|
200
|
+
"""Returns information about the specified job
|
|
201
|
+
|
|
202
|
+
This call returns a JSON formatted string summarizing basic information about the requested job.
|
|
203
|
+
|
|
204
|
+
Arguments:
|
|
205
|
+
job_number(int): The job about whom to return information
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
A JSON formatted string summarizing the job information
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
r = requests.get(self._base_url + "/jobs/%d" % job_number, params=self._auth)
|
|
212
|
+
WebMOREST._check_status(r)
|
|
213
|
+
return r.json()
|
|
214
|
+
|
|
215
|
+
def _check_valid_job_list(self, job_numbers):
|
|
216
|
+
if len(job_numbers) == 0:
|
|
217
|
+
raise ValueError("One or more job number(s) must be specified")
|
|
218
|
+
|
|
219
|
+
def get_job_results(self, *job_numbers):
|
|
220
|
+
"""Returns detailed results of the calculation (e.g. energy, properties) from the specified job(s).
|
|
221
|
+
|
|
222
|
+
This call returns a JSON formatted string summarize all of the calculated and parsed properties
|
|
223
|
+
from the specified job. This information is normally summarized on the View Job page.
|
|
224
|
+
|
|
225
|
+
Arguments:
|
|
226
|
+
*job_numbers(int): The job(s) for which to generate the results
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
A JSON formatted string summarizing the calculated properties
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
self._check_valid_job_list(job_numbers)
|
|
233
|
+
|
|
234
|
+
if (len(job_numbers) == 1):
|
|
235
|
+
#handle case of single job, return JSON dictionary for single job
|
|
236
|
+
r = requests.get(self._base_url + "/jobs/%d/results" % job_numbers[0], params=self._auth)
|
|
237
|
+
else:
|
|
238
|
+
#handle case of multiple jobs, return JSON array of dictionaries
|
|
239
|
+
#append other relevant parameters
|
|
240
|
+
params = self._auth.copy()
|
|
241
|
+
params.update({'jobNumber' : ",".join(str(job_number) for job_number in job_numbers)})
|
|
242
|
+
r = requests.get(self._base_url + "/jobs/results", params=params)
|
|
243
|
+
|
|
244
|
+
WebMOREST._check_status(r)
|
|
245
|
+
return r.json()
|
|
246
|
+
|
|
247
|
+
def get_job_geometry(self, job_number):
|
|
248
|
+
"""Returns the final optimized geometry from the specified job.
|
|
249
|
+
|
|
250
|
+
This call returns an XYZ formatted file of the final optimized geometry from the specified job.
|
|
251
|
+
|
|
252
|
+
Arguments:
|
|
253
|
+
job_number(int): The job about whom to return information
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
A string containing XYZ formatted optimized geometry
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
r = requests.get(self._base_url + "/jobs/%d/geometry" % job_number, params=self._auth)
|
|
260
|
+
WebMOREST._check_status(r)
|
|
261
|
+
return r.json()["xyz"]
|
|
262
|
+
|
|
263
|
+
def get_job_output(self, job_number):
|
|
264
|
+
"""Returns the raw text output from the specified job.
|
|
265
|
+
|
|
266
|
+
This call returns the textual raw output file from the specified job.
|
|
267
|
+
|
|
268
|
+
Arguments:
|
|
269
|
+
job_number(int): The job about whom to return information
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
A string containing the contents of the raw output file
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
r = requests.get(self._base_url + "/jobs/%d/raw_output" % job_number, params=self._auth)
|
|
276
|
+
WebMOREST._check_status(r)
|
|
277
|
+
return r.text
|
|
278
|
+
|
|
279
|
+
def get_job_archive(self, *job_numbers):
|
|
280
|
+
"""Returns a WebMO archive from the specified job(s).
|
|
281
|
+
|
|
282
|
+
This call generates and returns a binary WebMO archive (tar/zip) file from the specified job(s).
|
|
283
|
+
|
|
284
|
+
Arguments:
|
|
285
|
+
*job_numbers(int): The job(s) for which to generate the archive
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
The raw data (as a string) of the WebMO archive, appropriate for saving to disk
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
self._check_valid_job_list(job_numbers)
|
|
292
|
+
|
|
293
|
+
#append other relevant parameters
|
|
294
|
+
params = self._auth.copy()
|
|
295
|
+
params.update({'jobNumber' : ",".join(str(job_number) for job_number in job_numbers)})
|
|
296
|
+
r = requests.get(self._base_url + "/jobs/archive" % (), params=params)
|
|
297
|
+
WebMOREST._check_status(r)
|
|
298
|
+
return r.text
|
|
299
|
+
|
|
300
|
+
def get_job_spreadsheet(self, *job_numbers):
|
|
301
|
+
"""Returns a spreadsheet summary of the specified job(s).
|
|
302
|
+
|
|
303
|
+
This call generates and returns a CSV-formatted spreadsheet summary of the specified job(s).
|
|
304
|
+
|
|
305
|
+
Arguments:
|
|
306
|
+
*job_numbers(int): The job(s) for which to generate the spreadsheet
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
The raw data (as a string) of the spreadsheet, appropriate for saving to disk
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
self._check_valid_job_list(job_numbers)
|
|
313
|
+
|
|
314
|
+
#append other relevant parameters
|
|
315
|
+
params = self._auth.copy()
|
|
316
|
+
params.update({'jobNumber' : ",".join(str(job_number) for job_number in job_numbers)})
|
|
317
|
+
r = requests.get(self._base_url + "/jobs/spreadsheet" % (), params=params)
|
|
318
|
+
WebMOREST._check_status(r)
|
|
319
|
+
return r.text
|
|
320
|
+
|
|
321
|
+
def delete_job(self, job_number):
|
|
322
|
+
"""Permanently deletes a WebMO job
|
|
323
|
+
|
|
324
|
+
This call deletes the specified job.
|
|
325
|
+
|
|
326
|
+
Arguments:
|
|
327
|
+
job_number(int): The job to delete
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
r = requests.delete(self._base_url + "/jobs/%d" % job_number, params=self._auth)
|
|
331
|
+
WebMOREST._check_status(r)
|
|
332
|
+
|
|
333
|
+
def import_job(self, job_name, output_filename, engine):
|
|
334
|
+
"""Imports an existing output file into WebMO
|
|
335
|
+
|
|
336
|
+
This call imports an existing computational output file, parsing the output and creating a newly
|
|
337
|
+
WebMO job.
|
|
338
|
+
|
|
339
|
+
Arguments:
|
|
340
|
+
job_name(str): The name of the new WebMO job
|
|
341
|
+
output_filename(str): The filename (full path) of an existing output file to import
|
|
342
|
+
engine(str): The name of the computational engine
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
The the job number of the new job, upon success
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
params = self._auth.copy()
|
|
349
|
+
params.update({'jobName' : job_name, 'engine' : engine})
|
|
350
|
+
output_file = {'outputFile' : ('output.log', open(output_filename, 'rb'), 'text/plain')}
|
|
351
|
+
r = requests.post(self._base_url + '/jobs', data=params, files=output_file)
|
|
352
|
+
WebMOREST._check_status(r)
|
|
353
|
+
return r.json()["jobNumber"]
|
|
354
|
+
|
|
355
|
+
def submit_job(self, job_name, input_contents, engine, queue=None, nodes=1, ppn=1, **kwargs):
|
|
356
|
+
"""Submits and executes a new WebMO job
|
|
357
|
+
|
|
358
|
+
This call submits and executes a new job to a computational engine, generating a new WebMO job,
|
|
359
|
+
using nodes*ppn total cores.
|
|
360
|
+
|
|
361
|
+
Arguments:
|
|
362
|
+
job_name(str): The name of the new WebMO job
|
|
363
|
+
input_contents(str): The valid input for submission to the computational engine as its input file
|
|
364
|
+
engine(str): The name of the computational engine
|
|
365
|
+
queue(str, optional): The name of the queue in which to submit the job_name (required for external queues)
|
|
366
|
+
nodes(int, optional): The number of nodes to use; defaults to 1
|
|
367
|
+
ppn(int, optional): The number of cores per node to use; defaults to 1
|
|
368
|
+
**kwargs(optional): Additional optional fields such as nodeType [PBS],
|
|
369
|
+
resourceList (aka constraints) [SGE/SLURM], timeLimit [SLURM], gpus [SLURM]
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The the job number of the new job, upon success.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
params = self._auth.copy()
|
|
376
|
+
params.update({'jobName' : job_name, 'engine' : engine, 'inputFile': input_contents,
|
|
377
|
+
'queue': queue, 'nodes': nodes, 'ppn': ppn, **kwargs})
|
|
378
|
+
r = requests.post(self._base_url + '/jobs', data=params)
|
|
379
|
+
WebMOREST._check_status(r)
|
|
380
|
+
return r.json()["jobNumber"]
|
|
381
|
+
|
|
382
|
+
#
|
|
383
|
+
# Template resource
|
|
384
|
+
#
|
|
385
|
+
def get_templates(self, engine):
|
|
386
|
+
"""Fetches a list of job templates available to the current user
|
|
387
|
+
|
|
388
|
+
This call returns a dictionary of available templates and associated job variables.
|
|
389
|
+
|
|
390
|
+
Arguments:
|
|
391
|
+
engine(str): The name of the computational engine for which to fetch templates.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
A dictionary of available templates
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
r = requests.get(self._base_url + "/templates/%s" % engine, params=self._auth)
|
|
398
|
+
WebMOREST._check_status(r)
|
|
399
|
+
return r.json()["templates"]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def generate_input(self, template, variables, auto_defaults=True):
|
|
403
|
+
"""Generates an input file from the specified template and dictionary of template job variables.
|
|
404
|
+
|
|
405
|
+
This call returns a text-formatted input file appropriate for submission.
|
|
406
|
+
|
|
407
|
+
Arguments:
|
|
408
|
+
template(dict): The desired job tempate (from get_templates)
|
|
409
|
+
variables(dict): A dictionary of variables to be used to generate the input file from the template.
|
|
410
|
+
auto_defaults(bool): Automatically use WebMO default values for omitted template variables
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
A text-formatted input file
|
|
414
|
+
"""
|
|
415
|
+
amend_variables = variables.copy()
|
|
416
|
+
#handle booleans, converting False to '' if the template variable is a checkbox
|
|
417
|
+
for var in amend_variables:
|
|
418
|
+
if isinstance(amend_variables[var], bool) and (var in template['variables']) and (template['variables'][var]['type'] == 'checkbox'):
|
|
419
|
+
if amend_variables[var] == False:
|
|
420
|
+
amend_variables[var] = ''
|
|
421
|
+
#add in defaults, if appropriate
|
|
422
|
+
if auto_defaults:
|
|
423
|
+
#update and amend 'variables' with default values for missing template parameters
|
|
424
|
+
for var in template['variables']:
|
|
425
|
+
if not var in variables:
|
|
426
|
+
amend_variables[var] = template['variables'][var]['default']
|
|
427
|
+
|
|
428
|
+
#append other relevant paramters
|
|
429
|
+
params = self._auth.copy()
|
|
430
|
+
params.update({'variables' : json.dumps(amend_variables)})
|
|
431
|
+
r = requests.get(self._base_url + "/templates/%s" % template['id'], params=params)
|
|
432
|
+
WebMOREST._check_status(r)
|
|
433
|
+
return r.text
|
|
434
|
+
|
|
435
|
+
#
|
|
436
|
+
# Engines resource
|
|
437
|
+
#
|
|
438
|
+
def get_engines(self, target_user=""):
|
|
439
|
+
"""Fetches a list of computational engines available to the current user or specified target user
|
|
440
|
+
|
|
441
|
+
This call returns a JSON-formatted list of available engines and associated queues.
|
|
442
|
+
|
|
443
|
+
Arguments:
|
|
444
|
+
target_user(str, optional): The target username whose folders are retrieved. Otherwise, uses the authenticated user.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
A JSON-formatted list of available engines, associated queues/servers, and
|
|
448
|
+
node and processor limits.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
#append other relevant parameters
|
|
452
|
+
params = self._auth.copy()
|
|
453
|
+
params.update({'user' : target_user})
|
|
454
|
+
r = requests.get(self._base_url + "/engines", params=params)
|
|
455
|
+
WebMOREST._check_status(r)
|
|
456
|
+
return r.json()["engines"]
|
|
457
|
+
|
|
458
|
+
#
|
|
459
|
+
# Jupyter-related
|
|
460
|
+
#
|
|
461
|
+
async def display_job_property(self, job_number, property_name, property_index=1, peak_width=0.0, tms_shift=0.0, proton_coupling=0.0, nmr_field=400.0, x_range=None, y_range=None, width=400, height=400, background_color=(255,255,255), transparent_background=False, rotate=(0.,0.,0.)):
|
|
462
|
+
"""Uses Javascript and IPython to display an image of the specified molecule and property,
|
|
463
|
+
calculated from a previous WebMO job.
|
|
464
|
+
|
|
465
|
+
This call outputs (via IPython) a PNG-formatted image of the molecule and property into the
|
|
466
|
+
Jupyter notebook cell. Requires IPython and WebMO 24 or higher. This is an asynchronous
|
|
467
|
+
method that must be 'await'ed, e.g. image = await rest.display_job_property(...)
|
|
468
|
+
|
|
469
|
+
Arguments:
|
|
470
|
+
job_number(int): The job about whom to return information
|
|
471
|
+
property_name(str): The name of the property to display. Must be one of 'geometry', 'dipole_moment', 'partial_charges', 'vibrational_mode', 'mo' (molecular orbital), 'esp' (electrostatic potential), 'nucleophilic', 'electrophilic', 'radical', 'nbo' (natural bonding orbital), 'nho' (natural hybrid orbital), 'nao' (natural atomic orbital), or various spectra ('ir_spectrum', 'raman_spectrum', 'vcd_spectrum', 'uvvis_spectrum', 'hnmr_spectrum', 'cnmr_spectrum')
|
|
472
|
+
property_index(int, optional): The 1-based index of the property to display, e.g. which vibrational or orbital
|
|
473
|
+
|
|
474
|
+
x_range(float,float,optional): A tuple specifying the min/max x-range for spectra; can be reversed to invert axis
|
|
475
|
+
y_range(float,float,optional): A tuple specifying the min/max y-range for spectra; can be reversed to invert axis
|
|
476
|
+
peak_width(float, optional): The peak width to use for spectra; default is spectrum-type specific
|
|
477
|
+
tms_shift(float, optional): The chemical shift of TMS (in ppm, at same level of theory), which can be used for H1 NMR spectra
|
|
478
|
+
proton_coupling(float, optional): The proton-proton coupling (in Hz), which can be used for H1 NMR spectra
|
|
479
|
+
nmr_field(float, optional): The NMR field strength (in MHz), which can be used for H1 NMR spectra
|
|
480
|
+
|
|
481
|
+
width(int, optional): The approximate width (in pixels) of the image to display (defaults to 400px)
|
|
482
|
+
height(int, optional): The approximate height (in pixels) of the image to display (defaults to 300px)
|
|
483
|
+
background_color(int,int,int,optional): A tuple specifying the (r,g,b) color of the background, where each color intensity is [0,255]
|
|
484
|
+
transparent_background(boolean,optional): Whether the background is transparent or opaque
|
|
485
|
+
rotate(int,int,int,optional): A tuple specifying the desired rotation (in degrees) of the molecule about the x,y,z axes about the "default" orientation
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
An EmbeddedImage object, which can be displayed and further manipulated.
|
|
489
|
+
"""
|
|
490
|
+
from math import sqrt
|
|
491
|
+
|
|
492
|
+
self._check_ipython()
|
|
493
|
+
|
|
494
|
+
if self._init_javascript:
|
|
495
|
+
print("Loading required Javascript...")
|
|
496
|
+
self._inject_javascript()
|
|
497
|
+
|
|
498
|
+
if self._callback_listener is None:
|
|
499
|
+
await self._create_callback_listener()
|
|
500
|
+
|
|
501
|
+
r = requests.get(self._base_url + "/jobs/%d/geometry" % job_number, params=self._auth)
|
|
502
|
+
WebMOREST._check_status(r)
|
|
503
|
+
geometryJSON = json.dumps(r.json())
|
|
504
|
+
geometryJSON = geometryJSON.replace("\\", "\\\\")
|
|
505
|
+
|
|
506
|
+
results = self.get_job_results(job_number)
|
|
507
|
+
|
|
508
|
+
javascript_string = self._set_moledit_size(width,height)
|
|
509
|
+
javascript_string += self._set_moledit_background(background_color[0],background_color[1],background_color[2])
|
|
510
|
+
javascript_string += self._set_moledit_geometry(geometryJSON)
|
|
511
|
+
|
|
512
|
+
if property_index <= 0:
|
|
513
|
+
raise ValueError("Invalid property_index specified")
|
|
514
|
+
|
|
515
|
+
property_string = ""
|
|
516
|
+
WAVEFUNCTION_PROPERTIES = ["mo", "nao", "nho", "nbo", "esp", "nucleophilic", "electrophilic", "radical"]
|
|
517
|
+
SURFACE_PROPERTIES = ["esp", "nucleophilic", "electrophilic", "radical"]
|
|
518
|
+
if property_name == "geometry":
|
|
519
|
+
#do nothing special
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
elif property_name == "dipole_moment":
|
|
523
|
+
dipole_moment = results['properties']['dipole_moment']
|
|
524
|
+
total_dipole = sqrt(dipole_moment[0]**2 + dipole_moment[1]**2 + dipole_moment[2]**2)
|
|
525
|
+
property_string = "%f:%f:%f:%f" % (*dipole_moment,total_dipole)
|
|
526
|
+
javascript_string += self._set_moledit_dipole_moment(property_string)
|
|
527
|
+
|
|
528
|
+
elif property_name == "partial_charges":
|
|
529
|
+
partial_charges = results['properties']['partial_charges']['mulliken']
|
|
530
|
+
for i in range(len(partial_charges)):
|
|
531
|
+
property_string += "%d,X,%f:" % (i+1,partial_charges[i])
|
|
532
|
+
property_string = property_string.rstrip(":")
|
|
533
|
+
javascript_string += self._set_moledit_partial_charge(property_string)
|
|
534
|
+
|
|
535
|
+
elif property_name == "vibrational_mode":
|
|
536
|
+
frequency = results['properties']['vibrations']['frequencies'][property_index-1]
|
|
537
|
+
displacements = results['properties']['vibrations']['displacement'][property_index-1]
|
|
538
|
+
for i in range(len(displacements)//3):
|
|
539
|
+
property_string += "%d,%f,%f,%f:" % (i+1,displacements[i*3+0],displacements[i*3+1],displacements[i*3+2])
|
|
540
|
+
property_string = property_string.rstrip(":")
|
|
541
|
+
javascript_string += self._set_moledit_vibrational_mode(property_string, property_index, frequency, 1.0)
|
|
542
|
+
|
|
543
|
+
elif property_name in WAVEFUNCTION_PROPERTIES:
|
|
544
|
+
if property_name in SURFACE_PROPERTIES:
|
|
545
|
+
property_index = 0 #this is required
|
|
546
|
+
javascript_string += self._rotate_moledit_view(rotate[0],rotate[1],rotate[2])
|
|
547
|
+
javascript_string += self._set_moledit_wavefunction(job_number, property_name, property_index, transparent_background) #handles screenshot in callback
|
|
548
|
+
|
|
549
|
+
elif property_name in ["ir_spectrum", "raman_spectrum", "vcd_spectrum"]:
|
|
550
|
+
frequencies = results['properties']['vibrations']['frequencies']
|
|
551
|
+
if property_name == "ir_spectrum":
|
|
552
|
+
intensities = results['properties']['vibrations']['intensities']['IR']
|
|
553
|
+
elif property_name == "raman_spectrum":
|
|
554
|
+
intensities = results['properties']['vibrations']['intensities']['raman']
|
|
555
|
+
else:
|
|
556
|
+
intensities = results['properties']['vibrations']['intensities']['VCD']
|
|
557
|
+
|
|
558
|
+
for i in range(len(frequencies)):
|
|
559
|
+
property_string += "%d,-,%f,%f:" % (i+1,frequencies[i],intensities[i])
|
|
560
|
+
property_string = property_string.rstrip(":")
|
|
561
|
+
|
|
562
|
+
if property_name == "ir_spectrum":
|
|
563
|
+
javascript_string += self._set_datagrapher_ir_spectrum(property_string, peak_width if peak_width > 0 else 40.0)
|
|
564
|
+
elif property_name == "raman_spectrum":
|
|
565
|
+
javascript_string += self._set_datagrapher_raman_spectrum(property_string, peak_width if peak_width > 0 else 40.0)
|
|
566
|
+
else:
|
|
567
|
+
javascript_string += self._set_datagrapher_vcd_spectrum(property_string, peak_width if peak_width > 0 else 40.0)
|
|
568
|
+
|
|
569
|
+
elif property_name == "uvvis_spectrum":
|
|
570
|
+
transition_energies = results['properties']['excited_states']['transition_energies']
|
|
571
|
+
intensities = results['properties']['excited_states']['intensities']
|
|
572
|
+
units = results['properties']['excited_states']['units']
|
|
573
|
+
|
|
574
|
+
for i in range(len(transition_energies)):
|
|
575
|
+
property_string += "%d,-,%f,%f:" % (i+1,transition_energies[i],intensities[i])
|
|
576
|
+
property_string = property_string.rstrip(":")
|
|
577
|
+
|
|
578
|
+
javascript_string += self._set_datagrapher_uvvis_spectrum(property_string, units, peak_width if peak_width > 0 else 20.0)
|
|
579
|
+
|
|
580
|
+
elif property_name == "hnmr_spectrum" or property_name == "cnmr_spectrum":
|
|
581
|
+
symbols = results['symbols']
|
|
582
|
+
isotropic = results['properties']['nmr_shifts']['isotropic']
|
|
583
|
+
anisotropy = results['properties']['nmr_shifts']['anisotropy']
|
|
584
|
+
|
|
585
|
+
for i in range(len(isotropic)):
|
|
586
|
+
if tms_shift > 0 and symbols[i] == "H":
|
|
587
|
+
isotropic[i] = tms_shift - isotropic[i] #apply the TMS shift, if provided
|
|
588
|
+
property_string += "%d,%s,%f,%f:" % (i+1,symbols[i],isotropic[i],anisotropy[i])
|
|
589
|
+
property_string = property_string.rstrip(":")
|
|
590
|
+
|
|
591
|
+
atom_type = "C" if property_name == "cnmr_spectrum" else "H"
|
|
592
|
+
peak_width = peak_width if peak_width > 0 else 0.001
|
|
593
|
+
relative_spectrum = 1 if tms_shift > 0 and atom_type == "H" else 0
|
|
594
|
+
|
|
595
|
+
if atom_type == "H" and proton_coupling > 0:
|
|
596
|
+
javascript_string += self._set_datagrapher_h1nmr_spectrum(property_string, peak_width, proton_coupling, nmr_field, relative_spectrum)
|
|
597
|
+
else:
|
|
598
|
+
javascript_string += self._set_datagrapher_nmr_spectrum(property_string, atom_type, peak_width, relative_spectrum)
|
|
599
|
+
|
|
600
|
+
else:
|
|
601
|
+
raise ValueError("Invalid property_name specified")
|
|
602
|
+
|
|
603
|
+
if not property_name in WAVEFUNCTION_PROPERTIES: #these are already done in the setWavefunction callback
|
|
604
|
+
if property_name.endswith("spectrum"):
|
|
605
|
+
if x_range is not None:
|
|
606
|
+
javascript_string += self._set_x_range(x_range[0], x_range[1])
|
|
607
|
+
if y_range is not None:
|
|
608
|
+
javascript_string += self._set_y_range(y_range[0], y_range[1])
|
|
609
|
+
javascript_string += self._display_datagrapher_screenshot()
|
|
610
|
+
else:
|
|
611
|
+
javascript_string += self._rotate_moledit_view(rotate[0],rotate[1],rotate[2])
|
|
612
|
+
javascript_string += self._display_moledit_screenshot(transparent_background)
|
|
613
|
+
|
|
614
|
+
#display the Javascript for execution
|
|
615
|
+
display(Javascript("_call_when_ready(function(){%s})" % javascript_string))
|
|
616
|
+
clear_output()
|
|
617
|
+
#wait for the Javascript callback and process / display the result
|
|
618
|
+
return await self._process_callback_response()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
#
|
|
622
|
+
# Status resource
|
|
623
|
+
#
|
|
624
|
+
def get_status_info(self):
|
|
625
|
+
"""Returns information about the specified WebMO instance
|
|
626
|
+
|
|
627
|
+
This call returns a JSON formatted string summarizing basic status information about the specified WebMO instance.
|
|
628
|
+
|
|
629
|
+
Arguments:
|
|
630
|
+
None
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
A JSON formatted string summarizing the status information
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
r = requests.get(self._base_url + "/status", params=self._auth)
|
|
637
|
+
WebMOREST._check_status(r)
|
|
638
|
+
return r.json()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
#
|
|
642
|
+
# Helper functions
|
|
643
|
+
#
|
|
644
|
+
def wait_for_job(self, *job_numbers, poll_frequency=5):
|
|
645
|
+
"""Waits for completion of the specified WebMO jobs(s)
|
|
646
|
+
|
|
647
|
+
This call blocks until the specified WebMO job(s) have all finished executing (successfully or not)
|
|
648
|
+
|
|
649
|
+
Arguments:
|
|
650
|
+
job_numbers(list): A list of job numbers which will be waited upon
|
|
651
|
+
poll_frequency(int, optional): The frequency at which to check the job status (default is 5s)
|
|
652
|
+
"""
|
|
653
|
+
from time import sleep
|
|
654
|
+
|
|
655
|
+
status = {}
|
|
656
|
+
done = False
|
|
657
|
+
|
|
658
|
+
for job_number in job_numbers:
|
|
659
|
+
status[job_number] = ''
|
|
660
|
+
|
|
661
|
+
while not done:
|
|
662
|
+
done = True
|
|
663
|
+
for job_number in job_numbers:
|
|
664
|
+
if status[job_number] != 'complete' and status[job_number] != 'failed':
|
|
665
|
+
status[job_number] = self.get_job_info(job_number)['properties']['jobStatus']
|
|
666
|
+
if status[job_number] != 'complete' and status[job_number] != 'failed':
|
|
667
|
+
done = False
|
|
668
|
+
if not done:
|
|
669
|
+
sleep(poll_frequency)
|
|
670
|
+
|
|
671
|
+
#
|
|
672
|
+
# Private helper methods
|
|
673
|
+
#
|
|
674
|
+
@staticmethod
|
|
675
|
+
def _check_status(response):
|
|
676
|
+
try:
|
|
677
|
+
response.raise_for_status()
|
|
678
|
+
except requests.exceptions.HTTPError as e:
|
|
679
|
+
# Grab the original message and append the body
|
|
680
|
+
new_message = f"{e.args[0]}\nResponse Body: {response.text[:500]}"
|
|
681
|
+
# Overwrite the exception arguments with your new message
|
|
682
|
+
e.args = (new_message,)
|
|
683
|
+
raise e
|
|
684
|
+
|
|
685
|
+
@staticmethod
|
|
686
|
+
def _is_mfa_required(response):
|
|
687
|
+
#MFA-enabled users without a valid code receive a 401 flagged with mfaRequired;
|
|
688
|
+
#an MFA lockout is flagged the same way, but prompting for a code would not help
|
|
689
|
+
if response.status_code != 401:
|
|
690
|
+
return False
|
|
691
|
+
try:
|
|
692
|
+
body = response.json()
|
|
693
|
+
except ValueError:
|
|
694
|
+
return False
|
|
695
|
+
return body.get('mfaRequired', False) and body.get('error') == 'MFA code required'
|
|
696
|
+
|
|
697
|
+
def _inject_javascript(self):
|
|
698
|
+
if self._init_javascript:
|
|
699
|
+
config = self.get_status_info()
|
|
700
|
+
major_version = int(config["version"].split(".")[0])
|
|
701
|
+
|
|
702
|
+
#ensure WebMO support as well, otherwise disable IPython features
|
|
703
|
+
if major_version < 24:
|
|
704
|
+
global has_ipython
|
|
705
|
+
has_ipython = False
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
ipython = get_ipython()
|
|
710
|
+
ipython.run_cell_magic("html", "", "\
|
|
711
|
+
<script src='%s/javascript/jquery.js'></script>\
|
|
712
|
+
<script src='%s/javascript/moledit_js/moledit_js.nocache.js'></script>\
|
|
713
|
+
<script src='%s/javascript/jupyter_moledit.js'></script>\
|
|
714
|
+
<script>\
|
|
715
|
+
if(document.getElementById('moledit-panel'))\
|
|
716
|
+
document.getElementById('moledit-panel').remove();\
|
|
717
|
+
if(document.getElementById('datagrapher-panel'))\
|
|
718
|
+
document.getElementById('datagrapher-panel').remove();\
|
|
719
|
+
moledit_div = document.createElement('div');\
|
|
720
|
+
moledit_div.innerHTML = \"<DIV ID='moledit-panel' CLASS='gwt-app' STYLE='width:300px; height:300px; visibility: hidden; position: absolute' orbitalSrc='%s/get_orbital.cgi' viewOnly='true' isJupyter='true'></DIV><DIV ID='datagrapher-panel' CLASS='gwt-app' STYLE='width: 300px; height: 300px; visibility: hidden; position: absolute'></DIV>\";\
|
|
721
|
+
document.body.prepend(moledit_div);\
|
|
722
|
+
var exception_count = 0;\
|
|
723
|
+
function _call_when_ready(func) {\
|
|
724
|
+
const ready = document.getElementById('moledit-panel').children.length > 0 && document.getElementById('datagrapher-panel').children.length > 0 && _render_lock();\
|
|
725
|
+
if (!ready) {\
|
|
726
|
+
setTimeout(function() {_call_when_ready(func)}, 100);\
|
|
727
|
+
return;\
|
|
728
|
+
}\
|
|
729
|
+
try {\
|
|
730
|
+
func();\
|
|
731
|
+
exception_count=0;\
|
|
732
|
+
}\
|
|
733
|
+
catch(e) {\
|
|
734
|
+
console.log(e);\
|
|
735
|
+
_clear_lock();\
|
|
736
|
+
if (exception_count++ < 3)\
|
|
737
|
+
setTimeout(function() {_call_when_ready(func)}, 1000);\
|
|
738
|
+
}\
|
|
739
|
+
}\
|
|
740
|
+
function _render_lock() {\
|
|
741
|
+
if (window.render_lock > 0 && Date.now() - window.render_lock < 5000) {\
|
|
742
|
+
return false;\
|
|
743
|
+
}\
|
|
744
|
+
else {\
|
|
745
|
+
window.render_lock = Date.now();\
|
|
746
|
+
return true;\
|
|
747
|
+
}\
|
|
748
|
+
}\
|
|
749
|
+
function _clear_lock() {\
|
|
750
|
+
window.render_lock = 0;\
|
|
751
|
+
}\
|
|
752
|
+
</script>" % (config['url_html'], config['url_html'], config['url_html'], config['url_cgi']))
|
|
753
|
+
self._init_javascript = False
|
|
754
|
+
except:
|
|
755
|
+
has_ipython = False
|
|
756
|
+
|
|
757
|
+
def _check_ipython(self):
|
|
758
|
+
if not has_ipython:
|
|
759
|
+
raise NotImplementedError("IPython and WebMO 24+ are required for this feature")
|
|
760
|
+
|
|
761
|
+
def _set_moledit_geometry(self,geometryJSON):
|
|
762
|
+
return "_set_moledit_geometry('%s');" % geometryJSON
|
|
763
|
+
|
|
764
|
+
def _set_moledit_dipole_moment(self, value):
|
|
765
|
+
return "_set_moledit_dipole_moment('%s');" % value
|
|
766
|
+
|
|
767
|
+
def _set_moledit_partial_charge(self, value):
|
|
768
|
+
return "_set_moledit_partial_charge('%s');" % value
|
|
769
|
+
|
|
770
|
+
def _set_moledit_vibrational_mode(self, value, mode, freq, scale):
|
|
771
|
+
return "_set_moledit_vibrational_mode('%s', %d, %f, %f);" % (value, mode, freq, scale)
|
|
772
|
+
|
|
773
|
+
def _set_moledit_wavefunction(self, job_number, wavefunction_type, mo_index, transparent_background):
|
|
774
|
+
return "_set_moledit_wavefunction(%d,'%s', %d, %d, %s);" % (job_number, wavefunction_type, mo_index, self._callback_port, 'true' if transparent_background else 'false')
|
|
775
|
+
|
|
776
|
+
def _set_datagrapher_ir_spectrum(self, value, peak_width):
|
|
777
|
+
return "_set_datagrapher_ir_spectrum('%s', %f);" % (value, peak_width)
|
|
778
|
+
|
|
779
|
+
def _set_datagrapher_raman_spectrum(self, value, peak_width):
|
|
780
|
+
return "_set_datagrapher_raman_spectrum('%s', %f);" % (value, peak_width)
|
|
781
|
+
|
|
782
|
+
def _set_datagrapher_vcd_spectrum(self, value, peak_width):
|
|
783
|
+
return "_set_datagrapher_vcd_spectrum('%s', %f);" % (value, peak_width)
|
|
784
|
+
|
|
785
|
+
def _set_datagrapher_uvvis_spectrum(self, value, units, peak_width):
|
|
786
|
+
return "_set_datagrapher_uvvis_spectrum('%s', '%s', %f);" % (value, units, peak_width)
|
|
787
|
+
|
|
788
|
+
def _set_datagrapher_nmr_spectrum(self, value, atom_type, peak_width, relative_spectrum):
|
|
789
|
+
return "_set_datagrapher_nmr_spectrum('%s', '%s', %f, %d);" % (value, atom_type, peak_width, relative_spectrum)
|
|
790
|
+
|
|
791
|
+
def _set_datagrapher_h1nmr_spectrum(self, value, peak_width, proton_coupling, nmr_field, relative_spectrum):
|
|
792
|
+
return "_set_datagrapher_h1nmr_spectrum('%s', %f, %f, %f, %d);" % (value, peak_width, proton_coupling, nmr_field, relative_spectrum)
|
|
793
|
+
|
|
794
|
+
def _set_x_range(self, min, max):
|
|
795
|
+
return "_set_x_range(%f, %f);" % (min, max)
|
|
796
|
+
|
|
797
|
+
def _set_y_range(self, min, max):
|
|
798
|
+
return "_set_y_range(%f, %f);" % (min, max)
|
|
799
|
+
|
|
800
|
+
def _set_moledit_size(self,width,height):
|
|
801
|
+
return "_set_moledit_size(%d,%d);" % (width, height)
|
|
802
|
+
|
|
803
|
+
def _set_moledit_background(self,r,g,b):
|
|
804
|
+
return "_set_moledit_background(%d,%d,%d);" % (r, g, b)
|
|
805
|
+
|
|
806
|
+
def _rotate_moledit_view(self,rx,ry,rz):
|
|
807
|
+
return "_rotate_moledit_view(%f,%f,%f);" % (rx, ry, rz)
|
|
808
|
+
|
|
809
|
+
def _display_moledit_screenshot(self, transparent_background):
|
|
810
|
+
return "_display_moledit_screenshot(%d,%s);" % (self._callback_port, 'true' if transparent_background else 'false')
|
|
811
|
+
|
|
812
|
+
def _display_datagrapher_screenshot(self):
|
|
813
|
+
return "_display_datagrapher_screenshot(%d);" % self._callback_port
|
|
814
|
+
|
|
815
|
+
#
|
|
816
|
+
# Methods for handling WebSocket data connections and callbacks
|
|
817
|
+
#
|
|
818
|
+
async def _create_callback_listener(self):
|
|
819
|
+
from weakref import ref
|
|
820
|
+
from random import randint
|
|
821
|
+
from websockets.server import serve
|
|
822
|
+
|
|
823
|
+
#create a weak reference to self, otherwise the cyclic reference
|
|
824
|
+
#between the server and WebMOREST will prevent destruction due to
|
|
825
|
+
#custom __dell__ method
|
|
826
|
+
self_weakref = ref(self)
|
|
827
|
+
async def _websocket_callback(websocket):
|
|
828
|
+
async for message in websocket:
|
|
829
|
+
self_weakref()._websocket_response_queue.append(message)
|
|
830
|
+
|
|
831
|
+
#generate a random port between 50-60k, in the unreserved range
|
|
832
|
+
self._callback_port = 50000 + randint(1,10000)
|
|
833
|
+
self._callback_listener = await serve(_websocket_callback, port=self._callback_port)
|
|
834
|
+
|
|
835
|
+
self._websocket_response_queue = []
|
|
836
|
+
|
|
837
|
+
async def _process_callback_response(self):
|
|
838
|
+
from base64 import b64decode
|
|
839
|
+
TIMEOUT = 60
|
|
840
|
+
|
|
841
|
+
json_msg = None
|
|
842
|
+
for i in range(TIMEOUT):
|
|
843
|
+
await asyncio.sleep(1)
|
|
844
|
+
|
|
845
|
+
if len(self._websocket_response_queue) > 0:
|
|
846
|
+
json_msg = self._websocket_response_queue.pop(0)
|
|
847
|
+
break
|
|
848
|
+
|
|
849
|
+
#Handle timeout waiting for response
|
|
850
|
+
if json_msg is None:
|
|
851
|
+
print("Timeout waiting for response from Javascript")
|
|
852
|
+
return
|
|
853
|
+
|
|
854
|
+
result = json.loads(json_msg)
|
|
855
|
+
|
|
856
|
+
#create and return the image
|
|
857
|
+
base64_image_data = result['imageURI'].split(",",2)[1]
|
|
858
|
+
decoded_image_data = b64decode(base64_image_data);
|
|
859
|
+
|
|
860
|
+
return EmbeddedImage(data=decoded_image_data)
|
|
861
|
+
|
|
862
|
+
if has_ipython:
|
|
863
|
+
class EmbeddedImage(Image):
|
|
864
|
+
"""The EmbeddedImage class is a IPython-displayable image object with additional functionality.
|
|
865
|
+
|
|
866
|
+
The EmbeddedImage class is a Jupyter-renderable image object, derived from IPython.display.image.
|
|
867
|
+
However, the EmbeddedImage also adds additional utility conversion methods.
|
|
868
|
+
"""
|
|
869
|
+
def __init__(self, data):
|
|
870
|
+
super().__init__(data)
|
|
871
|
+
|
|
872
|
+
def save(self, filename):
|
|
873
|
+
"""Saves the EmbeddedImage to disk as a PNG-formatted image.
|
|
874
|
+
|
|
875
|
+
This call saves the EmbeddedImage object to disk as PNG-formatted image.
|
|
876
|
+
|
|
877
|
+
Arguments:
|
|
878
|
+
filename(str): The path to the target PNG file. A "png" extension is added, if not provided.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
None
|
|
882
|
+
"""
|
|
883
|
+
if not filename.endswith(".png"):
|
|
884
|
+
filename += ".png"
|
|
885
|
+
with open(filename, 'wb') as fp:
|
|
886
|
+
fp.write(self.data)
|
|
887
|
+
|
|
888
|
+
def to_pil_image(self):
|
|
889
|
+
"""Returns an equivalent Pillow (PIL) Image object.
|
|
890
|
+
|
|
891
|
+
This call converts and returns and equivalent Pillow (PIL) Image representation of the
|
|
892
|
+
current EmbeddedImage object, for further manipulation.
|
|
893
|
+
|
|
894
|
+
Arguments:
|
|
895
|
+
None
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
A Pillow PIL.Image.Image object.
|
|
899
|
+
"""
|
|
900
|
+
from PIL import Image
|
|
901
|
+
from io import BytesIO
|
|
902
|
+
|
|
903
|
+
return Image.open(BytesIO(self.data))
|