rowan-python 0.0.5__py3-none-any.whl → 1.1.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.
rowan/__init__.py CHANGED
@@ -1,4 +1,10 @@
1
1
  # ruff: noqa
2
2
 
3
3
  from . import utils
4
- from .client import Client
4
+ from .client import compute
5
+
6
+ from . import constants
7
+
8
+ from .folder import Folder
9
+ from .workflow import Workflow
10
+ from .calculation import Calculation
rowan/calculation.py ADDED
@@ -0,0 +1,11 @@
1
+ import stjames
2
+
3
+ from .utils import api_client
4
+
5
+ class Calculation:
6
+ @classmethod
7
+ def retrieve(cls, uuid: stjames.UUID) -> dict:
8
+ with api_client() as client:
9
+ response = client.get(f"/calculation/{uuid}/stjames")
10
+ response.raise_for_status()
11
+ return response.json()
rowan/client.py CHANGED
@@ -1,155 +1,50 @@
1
- from __future__ import annotations
2
-
3
1
  import cctk
4
- import httpx
5
- from typing import Optional
6
2
  import stjames
7
- from dataclasses import dataclass, field
8
3
  import time
4
+ from typing import Optional
9
5
 
10
- import rowan
11
-
12
- API_URL = "https://api.rowansci.com"
13
-
14
-
15
- @dataclass
16
- class Client:
17
- blocking: bool = True
18
- print: bool = True
19
- ping_interval: int = 5
20
- delete_when_finished: bool = False
21
-
22
- headers: dict = field(init=False)
23
-
24
- def __post_init__(self):
25
- self.headers = {"X-API-Key": rowan.utils.get_api_key()}
26
-
27
- if not self.blocking and self.delete_when_finished:
28
- print("Warning: ``delete_when_finished`` has no effect when ``blocking`` is False. To delete calculations, call ``Client.delete()`` manually.")
29
-
30
- def compute(
31
- self,
32
- type: Optional[str] = "calculation",
33
- input_mol: Optional[cctk.Molecule] = None,
34
- input_smiles: Optional[str] = None,
35
- name: Optional[str] = None,
36
- folder_uuid: Optional[str] = None,
37
- engine: str = "peregrine",
38
- **options,
39
- ) -> dict | str:
40
- if (input_mol is None) == (input_smiles is None):
41
- raise ValueError("Must specify exactly one of ``input_smiles`` and ``input_mol``!")
42
-
43
- if input_mol is None:
44
- input_mol = cctk.Molecule.new_from_smiles(input_smiles)
45
-
46
- molecule = rowan.utils.cctk_to_stjames(input_mol)
47
-
48
- with httpx.Client() as client:
49
- if type == "calculation":
50
- settings = stjames.Settings(**options)
51
- calc = stjames.Calculation(molecules=[molecule], name=name, engine=engine, settings=settings)
52
-
53
- response = client.post(
54
- f"{API_URL}/calculation",
55
- headers=self.headers,
56
- json={
57
- "json_data": calc.model_dump(mode="json"),
58
- "folder_uuid": folder_uuid,
59
- },
60
- )
61
-
62
- else:
63
- response = client.post(
64
- f"{API_URL}/workflow",
65
- headers=self.headers,
66
- json={
67
- "initial_molecule": molecule.model_dump(mode="json"),
68
- "workflow_type": type,
69
- "name": name,
70
- "folder_uuid": folder_uuid,
71
- "workflow_data": options,
72
- },
73
- )
74
-
75
- response.raise_for_status()
76
- response_dict = response.json()
77
- calc_uuid = response_dict["uuid"]
78
-
79
- if self.blocking:
80
- while not self.is_finished(calc_uuid, type):
81
- time.sleep(self.ping_interval)
82
- result = self.get(calc_uuid, type)
83
-
84
- if self.delete_when_finished:
85
- self.delete(calc_uuid, type)
86
-
87
- return result
88
-
89
- else:
90
- return calc_uuid
91
-
92
- def is_finished(self, calc_uuid: str, type: str = "calculation") -> bool:
93
- with httpx.Client() as client:
94
- if type == "calculation":
95
- response = client.get(f"{API_URL}/calculation/{calc_uuid}", headers=self.headers)
96
- response.raise_for_status()
97
- response_dict = response.json()
98
- status = response_dict["status"]
99
-
100
- else:
101
- response = client.get(f"{API_URL}/workflow/{calc_uuid}", headers=self.headers)
102
- response.raise_for_status()
103
- response_dict = response.json()
104
- status = response_dict["object_status"]
105
-
106
- return status in [2, 3, 4]
107
-
108
- def get(self, calc_uuid: str, type: str = "calculation") -> dict:
109
- with httpx.Client() as client:
110
- if type == "calculation":
111
- stj_response = client.get(f"{API_URL}/calculation/{calc_uuid}/stjames", headers=self.headers)
112
- stj_response.raise_for_status()
113
- stj_dict = stj_response.json()
114
-
115
- response = client.get(f"{API_URL}/calculation/{calc_uuid}", headers=self.headers)
116
- response.raise_for_status()
117
- response_dict = response.json()
118
-
119
- # reformat
120
- del response_dict["settings"]
121
- response_dict["data"] = stj_dict
122
-
123
- return response_dict
124
-
125
- else:
126
- response = client.get(f"{API_URL}/workflow/{calc_uuid}", headers=self.headers)
127
- response.raise_for_status()
128
- response_dict = response.json()
129
-
130
- # reformat
131
- response_dict["data"] = response_dict["object_data"]
132
- del response_dict["object_data"]
133
- del response_dict["status"]
134
-
135
- return response_dict
136
-
137
- def stop(self, calc_uuid: str, type: str = "calculation") -> None:
138
- with httpx.Client() as client:
139
- if type == "calculation":
140
- response = client.post(f"{API_URL}/calculation/{calc_uuid}/stop", headers=self.headers)
141
- response.raise_for_status()
142
-
143
- else:
144
- response = client.post(f"{API_URL}/workflow/{calc_uuid}/stop", headers=self.headers)
145
- response.raise_for_status()
146
-
147
- def delete(self, calc_uuid: str, type: str = "calculation") -> None:
148
- with httpx.Client() as client:
149
- if type == "calculation":
150
- response = client.delete(f"{API_URL}/calculation/{calc_uuid}", headers=self.headers)
151
- response.raise_for_status()
152
-
153
- else:
154
- response = client.delete(f"{API_URL}/folder/{calc_uuid}", headers=self.headers)
155
- response.raise_for_status()
6
+ from .utils import cctk_to_stjames, smiles_to_stjames
7
+ from .workflow import Workflow
8
+
9
+ """ A high-level interface to submitting a calculation. """
10
+
11
+
12
+ def compute(
13
+ molecule: str | cctk.Molecule | stjames.Molecule,
14
+ workflow_type: str,
15
+ name: str = "",
16
+ folder_uuid: Optional[stjames.UUID] = None,
17
+ blocking: bool = True,
18
+ ping_interval: int = 5,
19
+ **workflow_data,
20
+ ) -> dict:
21
+ """High-level function to compute and return workflows."""
22
+
23
+ if isinstance(molecule, str):
24
+ stjmol = smiles_to_stjames(molecule)
25
+ elif isinstance(molecule, cctk.Molecule):
26
+ stjmol = cctk_to_stjames(molecule)
27
+ elif isinstance(molecule, stjames.Molecule):
28
+ stjmol = molecule
29
+ else:
30
+ raise ValueError("Invalid type for `molecule`!")
31
+
32
+ result = Workflow.submit(
33
+ initial_molecule=stjmol,
34
+ workflow_type=workflow_type,
35
+ name=name,
36
+ folder_uuid=folder_uuid,
37
+ workflow_data=workflow_data,
38
+ )
39
+
40
+ if blocking:
41
+ uuid = result["uuid"]
42
+
43
+ while not Workflow.is_finished(uuid):
44
+ time.sleep(ping_interval)
45
+
46
+ completed_result = Workflow.retrieve(uuid)
47
+ return completed_result
48
+
49
+ else:
50
+ return result
rowan/constants.py ADDED
@@ -0,0 +1 @@
1
+ API_URL = "https://api.rowansci.com"
rowan/folder.py ADDED
@@ -0,0 +1,97 @@
1
+ import stjames
2
+ from typing import Optional
3
+
4
+ from .utils import api_client
5
+
6
+
7
+ class Folder:
8
+ @classmethod
9
+ def create(
10
+ cls,
11
+ name: str,
12
+ parent_uuid: Optional[stjames.UUID] = None,
13
+ notes: str = "",
14
+ starred: bool = False,
15
+ public: bool = False,
16
+ ) -> dict:
17
+ with api_client() as client:
18
+ response = client.post(
19
+ "/folder",
20
+ json={
21
+ "name": name,
22
+ "parent_uuid": parent_uuid,
23
+ "notes": notes,
24
+ "starred": starred,
25
+ "public": public,
26
+ },
27
+ )
28
+ response.raise_for_status()
29
+ return response.json()
30
+
31
+ @classmethod
32
+ def retrieve(cls, uuid: stjames.UUID) -> dict:
33
+ with api_client() as client:
34
+ response = client.get(f"/folder/{uuid}")
35
+ response.raise_for_status()
36
+ return response.json()
37
+
38
+ @classmethod
39
+ def update(
40
+ cls,
41
+ uuid: stjames.UUID,
42
+ name: Optional[str] = None,
43
+ parent_uuid: Optional[stjames.UUID] = None,
44
+ notes: Optional[str] = None,
45
+ starred: Optional[bool] = None,
46
+ public: Optional[bool] = None,
47
+ ) -> None:
48
+ old_data = cls.retrieve(uuid)
49
+
50
+ new_data = {}
51
+ new_data["name"] = name if name is not None else old_data["name"]
52
+ new_data["parent_uuid"] = (
53
+ parent_uuid if parent_uuid is not None else old_data["parent_uuid"]
54
+ )
55
+ new_data["notes"] = notes if notes is not None else old_data["notes"]
56
+ new_data["starred"] = starred if starred is not None else old_data["starred"]
57
+ new_data["public"] = public if public is not None else old_data["public"]
58
+
59
+ with api_client() as client:
60
+ response = client.post(f"/folder/{uuid}", json=new_data)
61
+ response.raise_for_status()
62
+ return response.json()
63
+
64
+ @classmethod
65
+ def delete(cls, uuid: stjames.UUID) -> None:
66
+ with api_client() as client:
67
+ response = client.delete(f"/folder/{uuid}")
68
+ response.raise_for_status()
69
+
70
+ @classmethod
71
+ def list(
72
+ cls,
73
+ parent_uuid: Optional[stjames.UUID] = None,
74
+ name_contains: Optional[str] = None,
75
+ public: Optional[bool] = None,
76
+ starred: Optional[bool] = None,
77
+ page: int = 0,
78
+ size: int = 10,
79
+ ):
80
+ params = {"page": page, "size": size}
81
+
82
+ if parent_uuid is not None:
83
+ params["parent_uuid"] = parent_uuid
84
+
85
+ if name_contains is not None:
86
+ params["name_contains"] = name_contains
87
+
88
+ if public is not None:
89
+ params["public"] = public
90
+
91
+ if starred is not None:
92
+ params["starred"] = starred
93
+
94
+ with api_client() as client:
95
+ response = client.get("/folder", params=params)
96
+ response.raise_for_status()
97
+ return response.json()
@@ -0,0 +1,3 @@
1
+ from .chem_utils import pka, tautomers, conformers, energy, optimize, batch_pka, batch_tautomers, batch_energy, batch_optimize, batch_conformers
2
+
3
+ __all__ = ["pka", "tautomers", "energy", "conformers", "optimize", "batch_pka", "batch_tautomers", "batch_energy", "batch_optimize", "batch_conformers"]
@@ -0,0 +1,610 @@
1
+ from rdkit import Chem
2
+ from rdkit.Chem import AllChem
3
+ from typing import Literal, TypeAlias, Optional, List
4
+ import rowan
5
+ import copy
6
+ import asyncio
7
+ import logging
8
+ import math
9
+ from rowan.utils import get_api_key
10
+ import stjames
11
+ import time
12
+ import numpy as np
13
+ import cctk
14
+
15
+ RdkitMol: TypeAlias = Chem.rdchem.Mol | Chem.rdchem.RWMol
16
+ pKaMode = Literal["reckless", "rapid", "careful"]
17
+ TautomerMode = Literal["reckless", "rapid", "careful"]
18
+ ConformerMode = Literal["reckless", "rapid"]
19
+ FAST_METHODS: list[stjames.Method] = [
20
+ *stjames.method.XTB_METHODS,
21
+ *stjames.method.NNP_METHODS,
22
+ ]
23
+
24
+ class ConversionError(ValueError):
25
+ pass
26
+
27
+ class NoConformersError(Exception):
28
+ pass
29
+
30
+ class MethodTooSlowError(Exception):
31
+ pass
32
+
33
+ def _get_rdkit_mol_from_uuid(calculation_uuid: str) -> RdkitMol:
34
+ stjames_mol_dict = rowan.Calculation.retrieve(calculation_uuid)["molecules"][-1]
35
+ stjames_mol = stjames.Molecule(**stjames_mol_dict)
36
+ rdkm = Chem.MolFromXYZBlock(stjames_mol.to_xyz())
37
+ return rdkm
38
+
39
+ def embed_rdkit_mol(rdkm: RdkitMol):
40
+ try:
41
+ AllChem.SanitizeMol(rdkm)
42
+ except Exception as e:
43
+ raise ValueError(f"Molecule could not be generated -- invalid chemistry!\n{e}")
44
+
45
+ rdkm = AllChem.AddHs(rdkm)
46
+ try:
47
+ status1 = AllChem.EmbedMolecule(rdkm, maxAttempts=200)
48
+ assert status1 >= 0
49
+ except Exception as e:
50
+ status1 = AllChem.EmbedMolecule(rdkm, maxAttempts=200, useRandomCoords=True)
51
+ if status1 < 0:
52
+ raise ValueError(f"Cannot embed molecule! Error: {e}")
53
+ try:
54
+ status2 = AllChem.MMFFOptimizeMolecule(rdkm, maxIters=200)
55
+ assert status2 >= 0
56
+ except AssertionError:
57
+ pass
58
+
59
+ return rdkm
60
+
61
+ def rdkit_to_cctk(rdkm: RdkitMol, cid: int = 0) -> cctk.Molecule:
62
+ if len(rdkm.GetConformers()) == 0:
63
+ rdkm = embed_rdkit_mol(rdkm)
64
+ try:
65
+ nums = [atom.GetAtomicNum() for atom in rdkm.GetAtoms()]
66
+ geom = rdkm.GetConformers()[cid].GetPositions()
67
+ return cctk.Molecule(nums, geom, charge=Chem.GetFormalCharge(rdkm))
68
+ except IndexError as e:
69
+ raise ConversionError("RDKit molecule does not have a conformer with the given ID") from e
70
+
71
+ def cctk_to_stjames(cmol: cctk.Molecule) -> stjames.Molecule:
72
+ atomic_numbers = cmol.atomic_numbers.view(np.ndarray)
73
+ geometry = cmol.geometry.view(np.ndarray)
74
+ atoms = []
75
+ for i in range(cmol.num_atoms()):
76
+ atoms.append(stjames.Atom(atomic_number=atomic_numbers[i], position=geometry[i]))
77
+
78
+ return stjames.Molecule(atoms=atoms, charge=cmol.charge, multiplicity=cmol.multiplicity)
79
+
80
+ def rdkit_to_stjames(rdkm: RdkitMol, cid: int = 0) -> stjames.Molecule:
81
+ cmol = rdkit_to_cctk(rdkm, cid)
82
+ return cctk_to_stjames(cmol)
83
+
84
+ def pka(mol: RdkitMol,
85
+ mode: pKaMode = "rapid",
86
+ timeout: int = 600,
87
+ name: str = "pKa API Workflow",
88
+ pka_range: tuple[int, int] = (2, 12),
89
+ deprotonate_elements: list[int] = [7, 8, 16],
90
+ protonate_elements: list[int] = [7],
91
+ folder_uuid: Optional[stjames.UUID] = None)-> tuple[dict[int, float], dict[int, float]]:
92
+ return asyncio.run(_single_pka(mol, mode, timeout, name, pka_range, deprotonate_elements, protonate_elements, folder_uuid))
93
+
94
+ def batch_pka(mols: List[RdkitMol],
95
+ mode: pKaMode = "rapid",
96
+ timeout: int = 600,
97
+ name: str = "pKa API Workflow",
98
+ pka_range: tuple[int, int] = (2, 12),
99
+ deprotonate_elements: list[int] = [7, 8, 16],
100
+ protonate_elements: list[int] = [7],
101
+ folder_uuid: Optional[stjames.UUID] = None)-> tuple[dict[int, float], dict[int, float]]:
102
+ loop = asyncio.new_event_loop()
103
+ asyncio.set_event_loop(loop)
104
+ tasks = [
105
+ _single_pka(mol, mode, timeout, name, pka_range, deprotonate_elements, protonate_elements, folder_uuid)
106
+ for mol in mols
107
+ ]
108
+ results = loop.run_until_complete(asyncio.gather(*tasks))
109
+ return results
110
+
111
+
112
+ async def _single_pka(mol: RdkitMol,
113
+ mode: pKaMode = "rapid",
114
+ timeout: int = 600,
115
+ name: str = "pKa API Workflow",
116
+ pka_range: tuple[int, int] = (2, 12),
117
+ deprotonate_elements: list[int] = [7, 8, 16],
118
+ protonate_elements: list[int] = [7],
119
+ folder_uuid: Optional[stjames.UUID] = None) -> tuple[dict[int, float], dict[int, float]]:
120
+ """
121
+ Calculate the pKa of a molecule.
122
+ :param mol: RDKit molecule object
123
+ :return: dictionary of pKa values
124
+ """
125
+ get_api_key()
126
+ post = rowan.Workflow.submit(
127
+ name=name,
128
+ workflow_type="pka",
129
+ initial_molecule=rdkit_to_stjames(mol),
130
+ workflow_data={
131
+ "pka_range": pka_range,
132
+ "deprotonate_elements": deprotonate_elements,
133
+ "deprotonate_atoms": [],
134
+ "protonate_elements": protonate_elements,
135
+ "protonate_atoms": [],
136
+ "mode": mode,
137
+ },
138
+ folder_uuid=folder_uuid
139
+ )
140
+
141
+ start = time.time()
142
+ while not rowan.Workflow.is_finished(post["uuid"]):
143
+ await asyncio.sleep(5)
144
+ if time.time() - start > timeout:
145
+ raise TimeoutError("Workflow timed out")
146
+
147
+ result = rowan.Workflow.retrieve(post["uuid"])
148
+
149
+ acidic_pkas = []
150
+ for microstate in result["object_data"]["conjugate_bases"]:
151
+ symbol = cctk.helper_functions.get_symbol(result["object_data"]["initial_molecule"]["atoms"][microstate["atom_index"]-1]["atomic_number"])
152
+ acidic_pkas.append({
153
+ "element": symbol,
154
+ "index": microstate["atom_index"],
155
+ "pKa": round(microstate["pka"], 2)
156
+ })
157
+
158
+
159
+ basic_pkas = []
160
+ for microstate in result["object_data"]["conjugate_acids"]:
161
+ symbol = cctk.helper_functions.get_symbol(result["object_data"]["initial_molecule"]["atoms"][microstate["atom_index"]-1]["atomic_number"])
162
+ basic_pkas.append({
163
+ "element": symbol,
164
+ "index": microstate["atom_index"],
165
+ "pKa": round(microstate["pka"], 2)
166
+ })
167
+
168
+ return {"acidic_pkas": acidic_pkas, "basic_pkas": basic_pkas}
169
+
170
+
171
+ def tautomers(mol: RdkitMol,
172
+ mode: TautomerMode = "reckless",
173
+ timeout: int = 600,
174
+ name: str = "Tautomers API Workflow",
175
+ folder_uuid: Optional[stjames.UUID] = None) -> list[tuple[RdkitMol, float]]:
176
+ """
177
+ Generate possible tautomers of a molecule.
178
+ :param mol: RDKit molecule object
179
+ :return: A list of tautomer dictionaries which include the RDKit molecule object, the relative energy, and the weight
180
+ """
181
+ return asyncio.run(_single_tautomers(mol, mode, timeout, name, folder_uuid))
182
+
183
+ def batch_tautomers(mols: List[RdkitMol],
184
+ mode: TautomerMode = "reckless",
185
+ timeout: int = 600,
186
+ name: str = "Tautomers API Workflow",
187
+ folder_uuid: Optional[stjames.UUID] = None) -> list[tuple[RdkitMol, float]]:
188
+ """
189
+ Generate possible tautomers of a molecule.
190
+ :param mol: RDKit molecule object
191
+ :return: A list of lists of tautomer dictionaries which include the RDKit molecule object, the relative energy, and the weight
192
+ """
193
+ loop = asyncio.new_event_loop()
194
+ asyncio.set_event_loop(loop)
195
+ tasks = [_single_tautomers(mol, mode, timeout, name, folder_uuid) for mol in mols]
196
+ results = loop.run_until_complete(asyncio.gather(*tasks))
197
+ return results
198
+
199
+
200
+ async def _single_tautomers(mol: RdkitMol,
201
+ mode: TautomerMode = "reckless",
202
+ timeout: int = 600,
203
+ name: str = "Tautomers API Workflow",
204
+ folder_uuid: Optional[stjames.UUID] = None) -> list[tuple[RdkitMol, float]]:
205
+ """
206
+ Generate possible tautomers of a molecule.
207
+ :param mol: RDKit molecule object
208
+ :return: A list of tautomer dictionaries which include the RDKit molecule object, the relative energy, and the weight
209
+ """
210
+ get_api_key()
211
+ post = rowan.Workflow.submit(
212
+ name=name,
213
+ workflow_type="tautomers",
214
+ initial_molecule=rdkit_to_stjames(mol),
215
+ workflow_data={
216
+ "mode": mode,
217
+ },
218
+ folder_uuid=folder_uuid
219
+ )
220
+
221
+ start = time.time()
222
+ while not rowan.Workflow.is_finished(post["uuid"]):
223
+ await asyncio.sleep(5)
224
+ if time.time() - start > timeout:
225
+ raise TimeoutError("Workflow timed out")
226
+
227
+ result = rowan.Workflow.retrieve(post["uuid"])
228
+
229
+ tautomers = []
230
+ for tautomer in result["object_data"]["tautomers"]:
231
+ rdkit_mol = _get_rdkit_mol_from_uuid(tautomer["structures"][0]["uuid"])
232
+ tautomers.append({
233
+ "molecule": rdkit_mol,
234
+ "predicted_relative_energy": round(tautomer["predicted_relative_energy"], 2),
235
+ "weight": round(tautomer["weight"], 5)
236
+ })
237
+
238
+ #return relative weights too
239
+ return tautomers
240
+
241
+ def energy(
242
+ mol: RdkitMol,
243
+ method: str = "aimnet2_wb97md3",
244
+ engine: str = "aimnet2",
245
+ mode: str = "auto",
246
+ timeout: int = 600,
247
+ name: str = "Energy API Workflow",
248
+ folder_uuid: Optional[stjames.UUID] = None
249
+ ):
250
+ """
251
+ Computes the energy for the given molecule.
252
+
253
+ :param mol: the input molecule
254
+ :param method: the method with which to compute the molecule's energy
255
+ :raises: MethodTooSlowError if the method is invalid
256
+ :returns: a dictionary with the energy in Hartree and the conformer index
257
+ """
258
+ return asyncio.run(_single_energy(mol, method, engine, mode, timeout, name, folder_uuid))
259
+
260
+ def batch_energy(
261
+ mols: List[RdkitMol],
262
+ method: str = "aimnet2_wb97md3",
263
+ engine: str = "aimnet2",
264
+ mode: str = "auto",
265
+ timeout: int = 600,
266
+ name: str = "Energy API Workflow",
267
+ folder_uuid: Optional[stjames.UUID] = None
268
+ ):
269
+ """
270
+ Computes the energy for the given molecule.
271
+
272
+ :param mol: the input molecule
273
+ :param method: the method with which to compute the molecule's energy
274
+ :raises: MethodTooSlowError if the method is invalid
275
+ :returns: a list of dictionaries with the energy in Hartree and the conformer index
276
+ """
277
+ loop = asyncio.new_event_loop()
278
+ asyncio.set_event_loop(loop)
279
+ tasks = [_single_energy(mol, method, engine, mode, timeout, name, folder_uuid) for mol in mols]
280
+ results = loop.run_until_complete(asyncio.gather(*tasks))
281
+ return results
282
+
283
+ async def _single_energy(
284
+ mol: RdkitMol,
285
+ method: str = "aimnet2_wb97md3",
286
+ engine: str = "aimnet2",
287
+ mode: str = "auto",
288
+ timeout: int = 600,
289
+ name: str = "Energy API Workflow",
290
+ folder_uuid: Optional[stjames.UUID] = None
291
+ ):
292
+ """
293
+ Computes the energy for the given molecule.
294
+
295
+ :param mol: the input molecule
296
+ :param method: the method with which to compute the molecule's energy
297
+ :raises: MethodTooSlowError if the method is invalid
298
+ :returns: a dictionary with the energy in Hartree and the conformer index
299
+ """
300
+ get_api_key()
301
+
302
+ method = stjames.Method(method)
303
+
304
+ if mol.GetNumConformers() == 0:
305
+ mol = embed_rdkit_mol(mol)
306
+ if mol.GetNumConformers() == 0:
307
+ raise NoConformersError("This molecule has no conformers")
308
+
309
+ if method not in FAST_METHODS:
310
+ raise MethodTooSlowError(
311
+ "This method is too slow; try running this through our web interface."
312
+ )
313
+
314
+ workflow_uuids = []
315
+ for conformer in mol.GetConformers():
316
+ cid = conformer.GetId()
317
+ stjames_mol = rdkit_to_stjames(mol, cid)
318
+ get_api_key()
319
+ post = rowan.Workflow.submit(
320
+ name=name,
321
+ workflow_type="basic_calculation",
322
+ initial_molecule=stjames_mol,
323
+ workflow_data={
324
+ "settings": {
325
+ "method": method.value,
326
+ "corrections": [],
327
+ "tasks": [
328
+ "energy"
329
+ ],
330
+ "mode": mode,
331
+ "opt_settings": {
332
+ "constraints": []
333
+ }
334
+ },
335
+ "engine": engine
336
+ },
337
+ folder_uuid=folder_uuid
338
+ )
339
+
340
+ workflow_uuids.append(post["uuid"])
341
+
342
+ start = time.time()
343
+ while not all(rowan.Workflow.is_finished(uuid) for uuid in workflow_uuids):
344
+ await asyncio.sleep(5)
345
+ if time.time() - start > timeout:
346
+ raise TimeoutError("Workflow timed out")
347
+
348
+ workflow_results = [rowan.Workflow.retrieve(uuid) for uuid in workflow_uuids]
349
+ energies = [rowan.Calculation.retrieve(workflow["object_data"]["calculation_uuid"])["molecules"][-1]["energy"] for workflow in workflow_results]
350
+
351
+ return [{"conformer_index": index, "energy": energy} for index, energy in enumerate(energies)]
352
+
353
+
354
+ def optimize(
355
+ mol: RdkitMol,
356
+ method: str = "aimnet2_wb97md3",
357
+ engine: str = "aimnet2",
358
+ mode: str = "auto",
359
+ return_energies: bool = False,
360
+ timeout: int = 600,
361
+ name: str = "Optimize API Workflow",
362
+ folder_uuid: Optional[stjames.UUID] = None
363
+ ) -> RdkitMol | tuple[RdkitMol, dict[int, float]]:
364
+ """
365
+ Optimize each of a molecule's conformers and then return the molecule.
366
+
367
+ :param mol: the input molecule
368
+ :param method: the method with which to compute the molecule's energy
369
+ :param return_energies: whether to return energies in Hartree too
370
+ :raises: MethodTooSlowError if the method is invalid
371
+ :returns: a dictionary with the molecule, with optimized conformers, and optionally a list of energies per conformer too
372
+
373
+ """
374
+ return asyncio.run(_single_optimize(mol, method, engine, mode, return_energies, timeout, name, folder_uuid))
375
+
376
+ def batch_optimize(
377
+ mols: List[RdkitMol],
378
+ method: str = "aimnet2_wb97md3",
379
+ engine: str = "aimnet2",
380
+ mode: str = "auto",
381
+ return_energies: bool = False,
382
+ timeout: int = 600,
383
+ name: str = "Optimize API Workflow",
384
+ folder_uuid: Optional[stjames.UUID] = None
385
+ ) -> RdkitMol | tuple[RdkitMol, dict[int, float]]:
386
+ """
387
+ Optimize each of a molecule's conformers and then return the molecule.
388
+
389
+ :param mol: the input molecule
390
+ :param method: the method with which to compute the molecule's energy
391
+ :param return_energies: whether to return energies in Hartree too
392
+ :raises: MethodTooSlowError if the method is invalid
393
+ :returns: a list of dictionaries with the molecule, with optimized conformers, and optionally a list of energies per conformer too
394
+
395
+ """
396
+ loop = asyncio.new_event_loop()
397
+ asyncio.set_event_loop(loop)
398
+ tasks = [_single_optimize(mol, method, engine, mode, return_energies, timeout, name, folder_uuid) for mol in mols]
399
+ results = loop.run_until_complete(asyncio.gather(*tasks))
400
+ return results
401
+
402
+ async def _single_optimize(
403
+ mol: RdkitMol,
404
+ method: str = "aimnet2_wb97md3",
405
+ engine: str = "aimnet2",
406
+ mode: str = "auto",
407
+ return_energies: bool = False,
408
+ timeout: int = 600,
409
+ name: str = "Optimize API Workflow",
410
+ folder_uuid: Optional[stjames.UUID] = None
411
+ ) -> RdkitMol | tuple[RdkitMol, dict[int, float]]:
412
+ """
413
+ Optimize each of a molecule's conformers and then return the molecule.
414
+
415
+ :param mol: the input molecule
416
+ :param method: the method with which to compute the molecule's energy
417
+ :param return_energies: whether to return energies in Hartree too
418
+ :raises: MethodTooSlowError if the method is invalid
419
+ :returns: a dictionary with the molecule, with optimized conformers, and optionally a list of energies per conformer too
420
+
421
+ """
422
+ get_api_key()
423
+
424
+ method = stjames.Method(method)
425
+
426
+ if mol.GetNumConformers() == 0:
427
+ mol = embed_rdkit_mol(mol)
428
+ if mol.GetNumConformers() == 0:
429
+ raise NoConformersError("This molecule has no conformers")
430
+
431
+ if method not in FAST_METHODS:
432
+ raise MethodTooSlowError(
433
+ "This method is too slow; try running this through our web interface."
434
+ )
435
+
436
+ optimized_mol = copy.deepcopy(mol)
437
+
438
+ workflow_uuids = []
439
+ for conformer in mol.GetConformers():
440
+ cid = conformer.GetId()
441
+ stjames_mol = rdkit_to_stjames(mol, cid)
442
+ get_api_key()
443
+ post = rowan.Workflow.submit(
444
+ name=name,
445
+ workflow_type="basic_calculation",
446
+ initial_molecule=stjames_mol,
447
+ workflow_data={
448
+ "settings": {
449
+ "method": method.value,
450
+ "corrections": [],
451
+ "tasks": [
452
+ "optimize"
453
+ ],
454
+ "mode": mode,
455
+ "opt_settings": {
456
+ "constraints": []
457
+ }
458
+ },
459
+ "engine": engine
460
+ },
461
+ folder_uuid=folder_uuid
462
+ )
463
+
464
+ workflow_uuids.append(post["uuid"])
465
+
466
+ start = time.time()
467
+ while not all(rowan.Workflow.is_finished(uuid) for uuid in workflow_uuids):
468
+ await asyncio.sleep(5)
469
+ if time.time() - start > timeout:
470
+ raise TimeoutError("Workflow timed out")
471
+
472
+ workflow_results = [rowan.Workflow.retrieve(uuid) for uuid in workflow_uuids]
473
+ calculations = [rowan.Calculation.retrieve(workflow["object_data"]["calculation_uuid"]) for workflow in workflow_results]
474
+ optimization_atoms = [cacluation["molecules"][-1]["atoms"] for cacluation in calculations]
475
+ optimized_positions = [[atom["position"] for atom in atoms] for atoms in optimization_atoms]
476
+
477
+ energies = [cacluation["molecules"][-1]["energy"] for cacluation in calculations]
478
+
479
+ for i, conformer in enumerate(optimized_mol.GetConformers()):
480
+ conformer.SetPositions(np.array(optimized_positions[i]))
481
+
482
+ return_dict = {"rdkit_molecule": mol}
483
+
484
+ if return_energies:
485
+ return_dict["energies"] = energies
486
+
487
+ return return_dict
488
+
489
+ def conformers(mol: RdkitMol, num_conformers=10,
490
+ method: str = "aimnet2_wb97md3",
491
+ mode: str = "rapid",
492
+ return_energies: bool = False,
493
+ timeout: int = 600,
494
+ name: str = "Conformer API Workflow",
495
+ folder_uuid: Optional[stjames.UUID] = None):
496
+ """
497
+ Generate conformers for a molecule.
498
+ :param mol: RDKit molecule object
499
+ :param num_conformers: Number of conformers to generate
500
+ :return: A dictonary with the RDKit molecule with num_conformers lowest energy conformers and optional energies
501
+ """
502
+ return asyncio.run(_single_conformers(mol, num_conformers, method, mode, return_energies, timeout, name, folder_uuid))
503
+
504
+ def batch_conformers(mols: List[RdkitMol], num_conformers=10,
505
+ method: str = "aimnet2_wb97md3",
506
+ mode: str = "rapid",
507
+ return_energies: bool = False,
508
+ timeout: int = 600,
509
+ name: str = "Conformer API Workflow",
510
+ folder_uuid: Optional[stjames.UUID] = None):
511
+ """
512
+ Generate conformers for a molecule.
513
+ :param mol: RDKit molecule object
514
+ :param num_conformers: Number of conformers to generate
515
+ :return: A list of dictonaries with the RDKit molecule with num_conformers lowest energy conformers and optional energies
516
+ """
517
+ loop = asyncio.new_event_loop()
518
+ asyncio.set_event_loop(loop)
519
+ tasks = [_single_conformers(mol, num_conformers, method, mode, return_energies, timeout, name, folder_uuid) for mol in mols]
520
+ results = loop.run_until_complete(asyncio.gather(*tasks))
521
+ return results
522
+
523
+
524
+ async def _single_conformers(mol: RdkitMol, num_conformers=10,
525
+ method: str = "aimnet2_wb97md3",
526
+ mode: str = "rapid",
527
+ return_energies: bool = False,
528
+ timeout: int = 600,
529
+ name: str = "Conformer API Workflow",
530
+ folder_uuid: Optional[stjames.UUID] = None):
531
+ """
532
+ Generate conformers for a molecule.
533
+ :param mol: RDKit molecule object
534
+ :param num_conformers: Number of conformers to generate
535
+ :return: A dictonary with the RDKit molecule with num_conformers lowest energy conformers and optional energies
536
+ """
537
+
538
+ get_api_key()
539
+
540
+ method = stjames.Method(method)
541
+
542
+ if mol.GetNumConformers() == 0:
543
+ mol = embed_rdkit_mol(mol)
544
+ if mol.GetNumConformers() == 0:
545
+ raise NoConformersError("This molecule has no conformers")
546
+
547
+ if method not in FAST_METHODS:
548
+ raise MethodTooSlowError(
549
+ "This method is too slow; try running this through our web interface."
550
+ )
551
+
552
+ post = rowan.Workflow.submit(
553
+ name=name,
554
+ workflow_type="conformer_search",
555
+ initial_molecule=rdkit_to_stjames(mol),
556
+ workflow_data={
557
+ "conf_gen_mode": "rapid",
558
+ "mode": mode,
559
+ "mso_mode": "manual",
560
+ "multistage_opt_settings": {
561
+ "mode": "manual",
562
+ "optimization_settings": [
563
+ {
564
+ "method": method.value,
565
+ "tasks": [
566
+ "optimize"
567
+ ],
568
+ "corrections": [],
569
+ "mode": "auto"
570
+ }
571
+ ],
572
+ "solvent": None,
573
+ "transition_state": False,
574
+ "constraints": []
575
+ }
576
+ },
577
+ folder_uuid=folder_uuid
578
+ )
579
+
580
+ start = time.time()
581
+ while not rowan.Workflow.is_finished(post["uuid"]):
582
+ await asyncio.sleep(5)
583
+ if time.time() - start > timeout:
584
+ raise TimeoutError("Workflow timed out")
585
+
586
+ result = rowan.Workflow.retrieve(post["uuid"])
587
+
588
+ sorted_data = sorted(zip(result["object_data"]["energies"], result["object_data"]["conformer_uuids"]), key=lambda x: x[0])
589
+
590
+ if len(sorted_data) < num_conformers:
591
+ logging.warning("Number of conformers requested is greater than number of conformers available")
592
+ num_conformers = min(num_conformers, len(sorted_data))
593
+
594
+ # Extract the UUIDs of the lowest n energies
595
+ lowest_n_uuids = [item[1][0] for item in sorted_data[:num_conformers]]
596
+ lowest_energies = [item[0] for item in sorted_data[:num_conformers]]
597
+
598
+ AllChem.EmbedMultipleConfs(mol, numConfs=num_conformers)
599
+
600
+ for i, conformer in enumerate(mol.GetConformers()):
601
+ atoms = rowan.Calculation.retrieve(lowest_n_uuids[i])["molecules"][-1]["atoms"]
602
+ pos = [atom["position"] for atom in atoms]
603
+ conformer.SetPositions(np.array(pos))
604
+
605
+ return_dict = {"rdkit_molecule": mol}
606
+
607
+ if return_energies:
608
+ return_dict["energies"] = lowest_energies
609
+
610
+ return return_dict
rowan/utils.py CHANGED
@@ -2,9 +2,13 @@ import os
2
2
  import cctk
3
3
  import stjames
4
4
  import numpy as np
5
+ from contextlib import contextmanager
6
+ import httpx
5
7
 
6
8
  import rowan
7
9
 
10
+ from .constants import API_URL
11
+
8
12
 
9
13
  def get_api_key() -> str:
10
14
  api_key = os.environ.get("ROWAN_API_KEY")
@@ -24,10 +28,27 @@ def cctk_to_stjames(molecule: cctk.Molecule) -> stjames.Molecule:
24
28
 
25
29
  atoms = list()
26
30
  for i in range(molecule.num_atoms()):
27
- atoms.append(stjames.Atom(atomic_number=atomic_numbers[i], position=geometry[i]))
31
+ atoms.append(
32
+ stjames.Atom(atomic_number=atomic_numbers[i], position=geometry[i])
33
+ )
28
34
 
29
35
  return stjames.Molecule(
30
36
  atoms=atoms,
31
37
  charge=molecule.charge,
32
38
  multiplicity=molecule.multiplicity,
33
39
  )
40
+
41
+
42
+ def smiles_to_stjames(smiles: str) -> stjames.Molecule:
43
+ cmol = cctk.Molecule.new_from_smiles(smiles)
44
+ return cctk_to_stjames(cmol)
45
+
46
+
47
+ @contextmanager
48
+ def api_client():
49
+ """Wraps `httpx.Client` with Rowan-specific kwargs."""
50
+ with httpx.Client(
51
+ base_url=API_URL,
52
+ headers={"X-API-Key": get_api_key()},
53
+ ) as client:
54
+ yield client
rowan/workflow.py ADDED
@@ -0,0 +1,139 @@
1
+ import stjames
2
+ from typing import Optional
3
+
4
+
5
+ from .utils import api_client
6
+
7
+
8
+ class Workflow:
9
+ @classmethod
10
+ def submit(
11
+ cls,
12
+ workflow_type: str,
13
+ initial_molecule: dict | stjames.Molecule,
14
+ workflow_data: dict,
15
+ name: Optional[str] = None,
16
+ folder_uuid: Optional[stjames.UUID] = None,
17
+ ) -> dict:
18
+ if isinstance(initial_molecule, stjames.Molecule):
19
+ molecule_dict = initial_molecule.model_dump()
20
+ elif isinstance(initial_molecule, dict):
21
+ molecule_dict = initial_molecule
22
+ else:
23
+ raise ValueError("Invalid type for `initial_molecule`")
24
+
25
+ with api_client() as client:
26
+ response = client.post(
27
+ "/workflow",
28
+ json={
29
+ "name": name,
30
+ "folder_uuid": folder_uuid,
31
+ "initial_molecule": molecule_dict,
32
+ "workflow_type": workflow_type,
33
+ "workflow_data": workflow_data,
34
+ },
35
+ )
36
+ response.raise_for_status()
37
+ return response.json()
38
+
39
+ @classmethod
40
+ def retrieve(cls, uuid: stjames.UUID) -> dict:
41
+ with api_client() as client:
42
+ response = client.get(f"/workflow/{uuid}")
43
+ response.raise_for_status()
44
+ return response.json()
45
+
46
+ @classmethod
47
+ def update(
48
+ cls,
49
+ uuid: stjames.UUID,
50
+ name: Optional[str] = None,
51
+ parent_uuid: Optional[stjames.UUID] = None,
52
+ notes: Optional[str] = None,
53
+ starred: Optional[bool] = None,
54
+ email_when_complete: Optional[bool] = None,
55
+ public: Optional[bool] = None,
56
+ ) -> None:
57
+ old_data = cls.retrieve(uuid)
58
+
59
+ new_data = {}
60
+ new_data["name"] = name if name is not None else old_data["name"]
61
+ new_data["parent_uuid"] = (
62
+ parent_uuid if parent_uuid is not None else old_data["parent_uuid"]
63
+ )
64
+ new_data["notes"] = notes if notes is not None else old_data["notes"]
65
+ new_data["starred"] = starred if starred is not None else old_data["starred"]
66
+ new_data["email_when_complete"] = (
67
+ email_when_complete
68
+ if email_when_complete is not None
69
+ else old_data["email_when_complete"]
70
+ )
71
+ new_data["public"] = public if public is not None else old_data["public"]
72
+
73
+ with api_client() as client:
74
+ response = client.post(f"/workflow/{uuid}", json=new_data)
75
+ response.raise_for_status()
76
+ return response.json()
77
+
78
+ @classmethod
79
+ def status(cls, uuid: stjames.UUID) -> int:
80
+ data = cls.retrieve(uuid)
81
+ return data["object_status"]
82
+
83
+ @classmethod
84
+ def is_finished(cls, uuid: stjames.UUID) -> bool:
85
+ status = cls.status(uuid)
86
+ return status in {
87
+ stjames.Status.COMPLETED_OK.value,
88
+ stjames.Status.FAILED.value,
89
+ stjames.Status.STOPPED.value,
90
+ }
91
+
92
+ @classmethod
93
+ def stop(cls, uuid: stjames.UUID) -> None:
94
+ with api_client() as client:
95
+ response = client.post(f"/workflow/{uuid}/stop")
96
+ response.raise_for_status()
97
+
98
+ @classmethod
99
+ def delete(cls, uuid: stjames.UUID) -> None:
100
+ with api_client() as client:
101
+ response = client.delete(f"/workflow/{uuid}")
102
+ response.raise_for_status()
103
+
104
+ @classmethod
105
+ def list(
106
+ cls,
107
+ parent_uuid: Optional[stjames.UUID] = None,
108
+ name_contains: Optional[str] = None,
109
+ public: Optional[bool] = None,
110
+ starred: Optional[bool] = None,
111
+ object_status: Optional[int] = None,
112
+ object_type: Optional[str] = None,
113
+ page: int = 0,
114
+ size: int = 10,
115
+ ):
116
+ params = {"page": page, "size": size}
117
+
118
+ if parent_uuid is not None:
119
+ params["parent_uuid"] = parent_uuid
120
+
121
+ if name_contains is not None:
122
+ params["name_contains"] = name_contains
123
+
124
+ if public is not None:
125
+ params["public"] = public
126
+
127
+ if starred is not None:
128
+ params["starred"] = starred
129
+
130
+ if object_status is not None:
131
+ params["object_status"] = object_status
132
+
133
+ if object_type is not None:
134
+ params["object_type"] = object_type
135
+
136
+ with api_client() as client:
137
+ response = client.get("/workflow", params=params)
138
+ response.raise_for_status()
139
+ return response.json()
@@ -1,22 +1,19 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: rowan-python
3
- Version: 0.0.5
3
+ Version: 1.1.0
4
4
  Summary: Rowan Python Library
5
- Author-email: Corin Wagen <corin@rowansci.com>
6
5
  Project-URL: Homepage, https://github.com/rowansci/rowan-client
7
6
  Project-URL: Bug Tracker, https://github.com/rowansci/rowan-client/issues
7
+ Author-email: Corin Wagen <corin@rowansci.com>
8
+ License-File: LICENSE
8
9
  Requires-Python: >=3.8
9
10
  Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: cctk >=0.2.18
12
- Requires-Dist: httpx >=0.25
13
- Requires-Dist: numpy >=1.24
14
- Requires-Dist: stjames >=0.0.23
15
11
 
16
12
  # Rowan Python Library
17
13
 
18
14
  [![pypi](https://img.shields.io/pypi/v/rowan-python.svg)](https://pypi.python.org/pypi/rowan-python)
19
- [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json)](https://github.com/charliermarsh/ruff)
15
+ [![pixi](https://img.shields.io/badge/Powered_by-Pixi-facc15)](https://pixi.sh)
16
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json)](https://github.com/charliermarsh/ruff)
20
17
 
21
18
  The Rowan Python library provides convenient access to the Rowan API from applications written in the Python language.
22
19
 
@@ -0,0 +1,13 @@
1
+ rowan/__init__.py,sha256=9JmdQXDkbru5WDq4Slt-XWH2OP4888rYyhIoLQfKqGw,183
2
+ rowan/calculation.py,sha256=fdWbVF97bZ2BQOBEBERLmU8DpeFUrwkZttWDSMYqwk8,312
3
+ rowan/client.py,sha256=GQYwqZ3pZv7vH3QvfHKEWqLPklbr1QHbZtjKRpRmiIs,1282
4
+ rowan/constants.py,sha256=ZZvv3L0b2y3dMGlWGeaRmx40J5tBrpxNxvJgjP1TNjg,37
5
+ rowan/folder.py,sha256=W7-YnPxugqzIdw-t1sr-WjeSQa-x4IjZ2mV2DwIq3II,2965
6
+ rowan/utils.py,sha256=IMACnRJpjFns_DF-FZQDu8p8fbgu4C2dbDaxdGcSZQs,1405
7
+ rowan/workflow.py,sha256=An3CW9LlHxYByE4mRl1iYThYcIGry8TwTi5rgbAsEBc,4467
8
+ rowan/rowan_rdkit/__init__.py,sha256=31bFO_UzagQKXQlybrPc2wdFJ6lUGMmDzDqT-D6b0Fk,300
9
+ rowan/rowan_rdkit/chem_utils.py,sha256=3MRMuwi6PIr_xkKmswfMlB7P7qdJgSQs-Q3YVhGh4LY,22438
10
+ rowan_python-1.1.0.dist-info/METADATA,sha256=CKyKYywsk9M48GAZ6Rjk_Z7qjb6YpDkSti9AuOzOXl8,1030
11
+ rowan_python-1.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ rowan_python-1.1.0.dist-info/licenses/LICENSE,sha256=i7ehYBS-6gGmbTcgU4mgk28pyOx2kScJ0kcx8n7bWLM,1084
13
+ rowan_python-1.1.0.dist-info/RECORD,,
@@ -1,5 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
-
@@ -1,8 +0,0 @@
1
- rowan/__init__.py,sha256=0O7Cmo2Io01Uqqx9iL8xcWW0t0YfgwBX7RKxDPXnbVU,61
2
- rowan/client.py,sha256=HoJ7DWxpeHyMdnokKK9M3jTCqEGcwIwHmpa5EFkHg0g,5591
3
- rowan/utils.py,sha256=AVldYWowm7g1Ffs7JhJY7X85goNuFHvMUel0zXguDrk,932
4
- rowan_python-0.0.5.dist-info/LICENSE,sha256=i7ehYBS-6gGmbTcgU4mgk28pyOx2kScJ0kcx8n7bWLM,1084
5
- rowan_python-0.0.5.dist-info/METADATA,sha256=Adimw59X6Bd1-Kp_B9qM-hEWwHtxame5-epgi1MG0ec,1067
6
- rowan_python-0.0.5.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
7
- rowan_python-0.0.5.dist-info/top_level.txt,sha256=0B0BJ1GvTwD5rxpsHctrermP7PVk4SFaQb2syJpGQl8,6
8
- rowan_python-0.0.5.dist-info/RECORD,,
@@ -1 +0,0 @@
1
- rowan