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/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))