webmo 1.4.2__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webmo-1.6.0/PKG-INFO ADDED
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: webmo
3
+ Version: 1.6.0
4
+ Summary: A Python-based interface to WebMO
5
+ Home-page: https://github.com/jrschmidt2/webmo-python
6
+ Download-URL: https://github.com/jrschmidt2/webmo-python/archive/refs/tags/1.6.0.tar.gz
7
+ Author: J.R. Schmidt
8
+ Author-email: schmidt@webmo.net
9
+ License: MIT
10
+ Keywords: WEBMO,REST,API
11
+ License-File: LICENSE
12
+ Requires-Dist: requests
13
+ Requires-Dist: websockets
14
+ Provides-Extra: spectrum
15
+ Requires-Dist: numpy; extra == "spectrum"
16
+ Provides-Extra: util
17
+ Requires-Dist: PubChemPy; extra == "util"
18
+ Provides-Extra: gui
19
+ Requires-Dist: questionary; extra == "gui"
20
+ Requires-Dist: ipywidgets; extra == "gui"
21
+ Provides-Extra: all
22
+ Requires-Dist: numpy; extra == "all"
23
+ Requires-Dist: PubChemPy; extra == "all"
24
+ Requires-Dist: questionary; extra == "all"
25
+ Requires-Dist: ipywidgets; extra == "all"
26
+ Dynamic: author
27
+ Dynamic: author-email
28
+ Dynamic: download-url
29
+ Dynamic: home-page
30
+ Dynamic: keywords
31
+ Dynamic: license
32
+ Dynamic: license-file
33
+ Dynamic: provides-extra
34
+ Dynamic: requires-dist
35
+ Dynamic: summary
@@ -1,4 +1,4 @@
1
- from distutils.core import setup
1
+ from setuptools import setup
2
2
  import codecs
3
3
  import os.path
4
4
 
@@ -1,2 +1,2 @@
1
1
  from .webmo_rest import WebMOREST
2
- __version__ = "1.4.2"
2
+ __version__ = "1.6.0"
@@ -1,10 +1,11 @@
1
1
  import numpy as np
2
2
 
3
- def xyz_from_name(name):
3
+ def xyz_from_name(name, include_atomic_number=False):
4
4
  """Obtains an XYZ-formatted geometry for the molecule name via PubChem lookup.
5
5
 
6
6
  Arguments:
7
7
  name(str): The name of the molecule.
8
+ include_atomic_number(bool): Whether to include the atomic number as well in the XYZ output (e.g. for GAMESS formatted Cartesian coordinates)
8
9
 
9
10
  Returns:
10
11
  A string containing the molecule's XYZ-formatted geometry.
@@ -19,10 +20,14 @@ def xyz_from_name(name):
19
20
  geom = ""
20
21
  for atom in record.atoms:
21
22
  element = atom.element
23
+ number = int(atom.number)
22
24
  x = float(atom.x)
23
25
  y = float(atom.y)
24
26
  z = float(atom.z)
25
- geom += f"{element}\t{x:f}\t{y:f}\t{z:f}\n"
27
+ if include_atomic_number:
28
+ geom += f"{element}\t{number:d}\t{x:f}\t{y:f}\t{z:f}\n"
29
+ else:
30
+ geom += f"{element}\t{x:f}\t{y:f}\t{z:f}\n"
26
31
 
27
32
  return geom
28
33
 
@@ -33,8 +38,8 @@ def get_energy(results, include_units=False):
33
38
  Requires WebMO 25 or higher.
34
39
 
35
40
  Arguments:
36
- dict(results): A dictionary of job results, obtained from get_job_results.
37
- bool(include_units): Whether to include the units with the returned energy (e.g. 1.5 vs. "1.5 Hartree")
41
+ results(dict): A dictionary of job results, obtained from get_job_results.
42
+ include_units(bool): Whether to include the units with the returned energy (e.g. 1.5 vs. "1.5 Hartree")
38
43
 
39
44
  Returns:
40
45
  The molecular energy.
@@ -65,8 +70,8 @@ def get_energies(results, include_units=False):
65
70
  """Returns a dictionary of available energies from the job results.
66
71
 
67
72
  Arguments:
68
- dict(results): A dictionary of job results, obtained from get_job_results.
69
- bool(include_units): Whether to include the units with the returned energy (e.g. 1.5 vs. "1.5 Hartree")
73
+ results(dict): A dictionary of job results, obtained from get_job_results.
74
+ include_units(bool): Whether to include the units with the returned energy (e.g. 1.5 vs. "1.5 Hartree")
70
75
 
71
76
  Returns:
72
77
  A dictionary of available energies.
@@ -92,7 +97,7 @@ def get_geometry(results):
92
97
  """Returns the XYZ-formatted final geometry from the job results.
93
98
 
94
99
  Arguments:
95
- dict(results): A dictionary of job results, obtained from get_job_results.
100
+ results(dict): A dictionary of job results, obtained from get_job_results.
96
101
 
97
102
  Returns:
98
103
  The XYZ-formatted final geometry as a string.
@@ -116,7 +121,7 @@ def get_stoichiometry(results):
116
121
  """Returns the stoichiometry from the job results.
117
122
 
118
123
  Arguments:
119
- dict(results): A dictionary of job results, obtained from get_job_results.
124
+ results(dict): A dictionary of job results, obtained from get_job_results.
120
125
 
121
126
  Returns:
122
127
  The stoichiometry as a string.
@@ -129,7 +134,7 @@ def get_dipole_moment(results):
129
134
  """Returns the dipole moment from the job results.
130
135
 
131
136
  Arguments:
132
- dict(results): A dictionary of job results, obtained from get_job_results.
137
+ results(dict): A dictionary of job results, obtained from get_job_results.
133
138
 
134
139
  Returns:
135
140
  The magnitude of the dipole moment.
@@ -147,8 +152,8 @@ def get_property(results, property_name):
147
152
  """Returns an arbitrary calculated property from the job results.
148
153
 
149
154
  Arguments:
150
- dict(results): A dictionary of job results, obtained from get_job_results.
151
- str(property_name): The name of the desired property
155
+ results(dict): A dictionary of job results, obtained from get_job_results.
156
+ property_name(str): The name of the desired property
152
157
 
153
158
  Returns:
154
159
  The value of the calculated property (scalar or dictionary).
@@ -266,6 +271,72 @@ def get_dihedral_angle(results, index1, index2, index3, index4):
266
271
 
267
272
  return np.arctan2(y, x)*180./np.pi
268
273
 
274
+ def get_option_matching(template, variable, desired_value_aliases, ignore_missing_variables=False):
275
+ """Returns the best matching template variable option for the specified variable.
276
+
277
+ This can be useful when an engine uses non-standard names for options and you want to use a
278
+ more descriptive name (like that shown in the dropdown) rather than the corresponding value. It attempts
279
+ to match either the corresponding option value (if it exists) OR the description.
280
+
281
+ For example:
282
+ get_option_matching(template, 'basisSet', '6-31G(d)')
283
+ will return the value:
284
+ "N31 NGAUSS=6 NDFUNC=1"
285
+ since it matches the corresponding option description "Routine: 6-31G(d)" in the dropdown.
286
+
287
+ Arguments:
288
+ template(dict): The desired job template (from get_templates)
289
+ variable(str): The template variable
290
+ desired_value_aliases(list): A list of potentially matching values, or aliases thereof; a single string is also acceptable.
291
+ ignore_missing_variables(bool, opt): Whether to raise an exception if the template variable does not exist in the template
292
+
293
+ Returns:
294
+ The best matching corresponding variable value
295
+ """
296
+
297
+ def ends_with_whole_word(text, target_word):
298
+ # Check if it ends with the word
299
+ if not text.endswith(target_word):
300
+ return False
301
+ # Get the start index of the target word in the text
302
+ idx = len(text) - len(target_word)
303
+ # If it's at the very beginning of the string, it's a whole word
304
+ if idx == 0:
305
+ return True
306
+ # Check if the character just before our word is a space or a colon
307
+ return text[idx - 1].isspace() or text[idx - 1] == ':'
308
+
309
+ #immediately return if the desired value is blank (for some reason)
310
+ if isinstance(desired_value_aliases, str) and not desired_value_aliases.strip():
311
+ return ""
312
+
313
+ #normalize any scalar arguement ot a list
314
+ if not isinstance(desired_value_aliases, list):
315
+ desired_value_aliases = [desired_value_aliases]
316
+ #immediately return if this variable does not exist in this particular template
317
+ if not variable in template['variables']:
318
+ if ignore_missing_variables:
319
+ return ""
320
+ else:
321
+ raise ValueError(f"Could not locate variable {variable} in template")
322
+ #ensure that the template variable is a 'dropdown' type
323
+ if template['variables'][variable]['type'] != 'dropdown':
324
+ raise ValueError(f"Template variable {variable} is not of type dropdown")
325
+
326
+ options = template['variables'][variable]['options']
327
+ for desired_value in desired_value_aliases:
328
+ for key, value in options.items():
329
+ #check if the specified value exists as value in the options dictionary
330
+ if value.casefold() == desired_value.casefold():
331
+ return value
332
+ #otherwise check if it matches the end of any key in the dictionary
333
+ if ends_with_whole_word(key.casefold(), desired_value.casefold()):
334
+ return options[key]
335
+
336
+ #not found!
337
+ raise ValueError(f"Could not locate option {desired_value_aliases} for template variable {variable}")
338
+
339
+
269
340
  def _xyz_to_array(xyz):
270
341
  xyz = xyz.strip()
271
342
 
@@ -284,4 +355,4 @@ def _xyz_to_array(xyz):
284
355
  del geom[0::4]
285
356
 
286
357
  #convert to an array of floats and return
287
- return np.array(geom,dtype=float)
358
+ return np.array(geom,dtype=float)
@@ -16,30 +16,43 @@ class WebMOREST:
16
16
  creation/destruction.
17
17
  """
18
18
 
19
- def __init__(self, base_url, username, password="", expiration=24):
19
+ def __init__(self, base_url, username, password="", expiration=24, totp="", recovery_code=""):
20
20
  """Constructor for WebMOREST
21
-
21
+
22
22
  This constructor generates a WebMOREST object and also generates and stores a newly
23
23
  session token.
24
-
24
+
25
25
  Args:
26
26
  base_url(str): The base URL (ending in rest.cgi) of the WeBMO rest endpoint
27
27
  username(str): The WebMO username
28
28
  password(str, optional): The WebMO password; if omitted, this is supplied interactively
29
29
  expiration(int, optional): The expiration time (in hours) of the REST session token
30
-
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
+
31
34
  Returns:
32
35
  object: The newly constructed WebMO object
33
36
  """
34
37
  from getpass import getpass
35
-
38
+
36
39
  #prompt for WebMO password if not specified
37
40
  if not password:
38
41
  password=getpass(prompt="Enter WebMO password for user %s:" % username)
39
42
  #obtain a REST token using the specified credentials
40
- login={'username' : username, 'password' : password, 'expiration' : expiration}
43
+ login={'username' : username, 'password' : password, 'expiration' : expiration,
44
+ 'totp' : totp, 'recoveryCode' : recovery_code}
41
45
  r = requests.post(base_url + '/sessions', data=login)
42
- r.raise_for_status() #raise an exception if there is a problem with the request
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
43
56
 
44
57
  self._base_url = base_url
45
58
  self._auth=r.json() #save an authorization token need to authenticate future REST requests
@@ -53,14 +66,15 @@ class WebMOREST:
53
66
 
54
67
  This destructor automatically deletes the session token using the REST interface
55
68
  """
56
- #End the REST sessions; check in case requests has already been released
57
- if requests.delete is not None:
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'):
58
72
  r = requests.delete(self._base_url + '/sessions', params=self._auth)
59
73
  #do not raise an exception for a failed request in this case due to issues
60
74
  #with object management in Jupyter (i.e. on code re-run, a new token is made
61
75
  #prior to deletion!)
62
76
 
63
- if self._callback_listener is not None:
77
+ if getattr(self, '_callback_listener', None) is not None:
64
78
  self._callback_listener.close()
65
79
 
66
80
  #
@@ -78,7 +92,7 @@ class WebMOREST:
78
92
  """
79
93
 
80
94
  r = requests.get(self._base_url + "/users", params=self._auth)
81
- r.raise_for_status()
95
+ WebMOREST._check_status(r)
82
96
  return r.json()["users"]
83
97
 
84
98
  def get_user_info(self, username):
@@ -95,7 +109,7 @@ class WebMOREST:
95
109
  """
96
110
 
97
111
  r = requests.get(self._base_url + "/users/%s" % username, params=self._auth)
98
- r.raise_for_status()
112
+ WebMOREST._check_status(r)
99
113
  return r.json()
100
114
 
101
115
  #
@@ -112,7 +126,7 @@ class WebMOREST:
112
126
  """
113
127
 
114
128
  r = requests.get(self._base_url + "/groups", params=self._auth)
115
- r.raise_for_status()
129
+ WebMOREST._check_status(r)
116
130
  return r.json()["groups"]
117
131
 
118
132
  def get_group_info(self, groupname):
@@ -129,7 +143,7 @@ class WebMOREST:
129
143
  """
130
144
 
131
145
  r = requests.get(self._base_url + "/groups/%s" % groupname, params=self._auth)
132
- r.raise_for_status()
146
+ WebMOREST._check_status(r)
133
147
  return r.json()
134
148
 
135
149
 
@@ -153,7 +167,7 @@ class WebMOREST:
153
167
  params = self._auth.copy()
154
168
  params.update({'user' : target_user})
155
169
  r = requests.get(self._base_url + "/folders", params=params)
156
- r.raise_for_status()
170
+ WebMOREST._check_status(r)
157
171
  return r.json()["folders"]
158
172
 
159
173
  #
@@ -179,7 +193,7 @@ class WebMOREST:
179
193
  params = self._auth.copy()
180
194
  params.update({'engine' : engine, 'status' : status, 'folderID' : folder_id, 'jobName' : job_name, 'user' : target_user})
181
195
  r = requests.get(self._base_url + '/jobs', params=params)
182
- r.raise_for_status()
196
+ WebMOREST._check_status(r)
183
197
  return r.json()["jobs"]
184
198
 
185
199
  def get_job_info(self, job_number):
@@ -195,7 +209,7 @@ class WebMOREST:
195
209
  """
196
210
 
197
211
  r = requests.get(self._base_url + "/jobs/%d" % job_number, params=self._auth)
198
- r.raise_for_status()
212
+ WebMOREST._check_status(r)
199
213
  return r.json()
200
214
 
201
215
  def _check_valid_job_list(self, job_numbers):
@@ -227,7 +241,7 @@ class WebMOREST:
227
241
  params.update({'jobNumber' : ",".join(str(job_number) for job_number in job_numbers)})
228
242
  r = requests.get(self._base_url + "/jobs/results", params=params)
229
243
 
230
- r.raise_for_status()
244
+ WebMOREST._check_status(r)
231
245
  return r.json()
232
246
 
233
247
  def get_job_geometry(self, job_number):
@@ -243,7 +257,7 @@ class WebMOREST:
243
257
  """
244
258
 
245
259
  r = requests.get(self._base_url + "/jobs/%d/geometry" % job_number, params=self._auth)
246
- r.raise_for_status()
260
+ WebMOREST._check_status(r)
247
261
  return r.json()["xyz"]
248
262
 
249
263
  def get_job_output(self, job_number):
@@ -259,7 +273,7 @@ class WebMOREST:
259
273
  """
260
274
 
261
275
  r = requests.get(self._base_url + "/jobs/%d/raw_output" % job_number, params=self._auth)
262
- r.raise_for_status()
276
+ WebMOREST._check_status(r)
263
277
  return r.text
264
278
 
265
279
  def get_job_archive(self, *job_numbers):
@@ -280,7 +294,7 @@ class WebMOREST:
280
294
  params = self._auth.copy()
281
295
  params.update({'jobNumber' : ",".join(str(job_number) for job_number in job_numbers)})
282
296
  r = requests.get(self._base_url + "/jobs/archive" % (), params=params)
283
- r.raise_for_status()
297
+ WebMOREST._check_status(r)
284
298
  return r.text
285
299
 
286
300
  def get_job_spreadsheet(self, *job_numbers):
@@ -301,7 +315,7 @@ class WebMOREST:
301
315
  params = self._auth.copy()
302
316
  params.update({'jobNumber' : ",".join(str(job_number) for job_number in job_numbers)})
303
317
  r = requests.get(self._base_url + "/jobs/spreadsheet" % (), params=params)
304
- r.raise_for_status()
318
+ WebMOREST._check_status(r)
305
319
  return r.text
306
320
 
307
321
  def delete_job(self, job_number):
@@ -314,7 +328,7 @@ class WebMOREST:
314
328
  """
315
329
 
316
330
  r = requests.delete(self._base_url + "/jobs/%d" % job_number, params=self._auth)
317
- r.raise_for_status()
331
+ WebMOREST._check_status(r)
318
332
 
319
333
  def import_job(self, job_name, output_filename, engine):
320
334
  """Imports an existing output file into WebMO
@@ -335,7 +349,7 @@ class WebMOREST:
335
349
  params.update({'jobName' : job_name, 'engine' : engine})
336
350
  output_file = {'outputFile' : ('output.log', open(output_filename, 'rb'), 'text/plain')}
337
351
  r = requests.post(self._base_url + '/jobs', data=params, files=output_file)
338
- r.raise_for_status()
352
+ WebMOREST._check_status(r)
339
353
  return r.json()["jobNumber"]
340
354
 
341
355
  def submit_job(self, job_name, input_contents, engine, queue=None, nodes=1, ppn=1, **kwargs):
@@ -362,7 +376,7 @@ class WebMOREST:
362
376
  params.update({'jobName' : job_name, 'engine' : engine, 'inputFile': input_contents,
363
377
  'queue': queue, 'nodes': nodes, 'ppn': ppn, **kwargs})
364
378
  r = requests.post(self._base_url + '/jobs', data=params)
365
- r.raise_for_status()
379
+ WebMOREST._check_status(r)
366
380
  return r.json()["jobNumber"]
367
381
 
368
382
  #
@@ -381,7 +395,7 @@ class WebMOREST:
381
395
  """
382
396
 
383
397
  r = requests.get(self._base_url + "/templates/%s" % engine, params=self._auth)
384
- r.raise_for_status()
398
+ WebMOREST._check_status(r)
385
399
  return r.json()["templates"]
386
400
 
387
401
 
@@ -399,6 +413,12 @@ class WebMOREST:
399
413
  A text-formatted input file
400
414
  """
401
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
402
422
  if auto_defaults:
403
423
  #update and amend 'variables' with default values for missing template parameters
404
424
  for var in template['variables']:
@@ -409,7 +429,7 @@ class WebMOREST:
409
429
  params = self._auth.copy()
410
430
  params.update({'variables' : json.dumps(amend_variables)})
411
431
  r = requests.get(self._base_url + "/templates/%s" % template['id'], params=params)
412
- r.raise_for_status()
432
+ WebMOREST._check_status(r)
413
433
  return r.text
414
434
 
415
435
  #
@@ -432,7 +452,7 @@ class WebMOREST:
432
452
  params = self._auth.copy()
433
453
  params.update({'user' : target_user})
434
454
  r = requests.get(self._base_url + "/engines", params=params)
435
- r.raise_for_status()
455
+ WebMOREST._check_status(r)
436
456
  return r.json()["engines"]
437
457
 
438
458
  #
@@ -479,7 +499,7 @@ class WebMOREST:
479
499
  await self._create_callback_listener()
480
500
 
481
501
  r = requests.get(self._base_url + "/jobs/%d/geometry" % job_number, params=self._auth)
482
- r.raise_for_status()
502
+ WebMOREST._check_status(r)
483
503
  geometryJSON = json.dumps(r.json())
484
504
  geometryJSON = geometryJSON.replace("\\", "\\\\")
485
505
 
@@ -614,7 +634,7 @@ class WebMOREST:
614
634
  """
615
635
 
616
636
  r = requests.get(self._base_url + "/status", params=self._auth)
617
- r.raise_for_status()
637
+ WebMOREST._check_status(r)
618
638
  return r.json()
619
639
 
620
640
 
@@ -651,7 +671,29 @@ class WebMOREST:
651
671
  #
652
672
  # Private helper methods
653
673
  #
654
-
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
+
655
697
  def _inject_javascript(self):
656
698
  if self._init_javascript:
657
699
  config = self.get_status_info()
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: webmo
3
+ Version: 1.6.0
4
+ Summary: A Python-based interface to WebMO
5
+ Home-page: https://github.com/jrschmidt2/webmo-python
6
+ Download-URL: https://github.com/jrschmidt2/webmo-python/archive/refs/tags/1.6.0.tar.gz
7
+ Author: J.R. Schmidt
8
+ Author-email: schmidt@webmo.net
9
+ License: MIT
10
+ Keywords: WEBMO,REST,API
11
+ License-File: LICENSE
12
+ Requires-Dist: requests
13
+ Requires-Dist: websockets
14
+ Provides-Extra: spectrum
15
+ Requires-Dist: numpy; extra == "spectrum"
16
+ Provides-Extra: util
17
+ Requires-Dist: PubChemPy; extra == "util"
18
+ Provides-Extra: gui
19
+ Requires-Dist: questionary; extra == "gui"
20
+ Requires-Dist: ipywidgets; extra == "gui"
21
+ Provides-Extra: all
22
+ Requires-Dist: numpy; extra == "all"
23
+ Requires-Dist: PubChemPy; extra == "all"
24
+ Requires-Dist: questionary; extra == "all"
25
+ Requires-Dist: ipywidgets; extra == "all"
26
+ Dynamic: author
27
+ Dynamic: author-email
28
+ Dynamic: download-url
29
+ Dynamic: home-page
30
+ Dynamic: keywords
31
+ Dynamic: license
32
+ Dynamic: license-file
33
+ Dynamic: provides-extra
34
+ Dynamic: requires-dist
35
+ Dynamic: summary
webmo-1.4.2/PKG-INFO DELETED
@@ -1,15 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: webmo
3
- Version: 1.4.2
4
- Summary: A Python-based interface to WebMO
5
- Home-page: https://github.com/jrschmidt2/webmo-python
6
- Download-URL: https://github.com/jrschmidt2/webmo-python/archive/refs/tags/1.4.2.tar.gz
7
- Author: J.R. Schmidt
8
- Author-email: schmidt@webmo.net
9
- License: MIT
10
- Keywords: WEBMO,REST,API
11
- Provides-Extra: spectrum
12
- Provides-Extra: util
13
- Provides-Extra: gui
14
- Provides-Extra: all
15
- License-File: LICENSE
@@ -1,15 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: webmo
3
- Version: 1.4.2
4
- Summary: A Python-based interface to WebMO
5
- Home-page: https://github.com/jrschmidt2/webmo-python
6
- Download-URL: https://github.com/jrschmidt2/webmo-python/archive/refs/tags/1.4.2.tar.gz
7
- Author: J.R. Schmidt
8
- Author-email: schmidt@webmo.net
9
- License: MIT
10
- Keywords: WEBMO,REST,API
11
- Provides-Extra: spectrum
12
- Provides-Extra: util
13
- Provides-Extra: gui
14
- Provides-Extra: all
15
- License-File: LICENSE
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes