rowan-python 3.0.0__py3-none-any.whl → 3.0.3__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 +17 -0
- rowan/protein.py +10 -2
- rowan/rowan_rdkit/__init__.py +15 -0
- rowan/rowan_rdkit/chem_utils.py +29 -29
- rowan/types.py +1 -1
- rowan/user.py +66 -1
- rowan/workflows/__init__.py +1 -0
- rowan/workflows/admet.py +8 -2
- rowan/workflows/analogue_docking.py +8 -2
- rowan/workflows/base.py +88 -9
- rowan/workflows/basic_calculation.py +128 -29
- rowan/workflows/batch_docking.py +8 -2
- rowan/workflows/bde.py +8 -2
- rowan/workflows/conformer_search.py +8 -2
- rowan/workflows/descriptors.py +8 -2
- rowan/workflows/docking.py +8 -2
- rowan/workflows/double_ended_ts_search.py +20 -11
- rowan/workflows/electronic_properties.py +8 -2
- rowan/workflows/fukui.py +8 -2
- rowan/workflows/hydrogen_bond_donor_acceptor_strength.py +8 -2
- rowan/workflows/interaction_energy_decomposition.py +9 -3
- rowan/workflows/ion_mobility.py +8 -2
- rowan/workflows/irc.py +9 -3
- rowan/workflows/macropka.py +8 -2
- rowan/workflows/membrane_permeability.py +7 -1
- rowan/workflows/msa.py +8 -2
- rowan/workflows/multistage_optimization.py +9 -3
- rowan/workflows/nmr.py +8 -2
- rowan/workflows/pka.py +8 -2
- rowan/workflows/pose_analysis_md.py +8 -2
- rowan/workflows/protein_binder_design.py +8 -2
- rowan/workflows/protein_cofolding.py +8 -2
- rowan/workflows/protein_md.py +8 -2
- rowan/workflows/rbfe_graph.py +8 -2
- rowan/workflows/redox_potential.py +8 -2
- rowan/workflows/relative_binding_free_energy_perturbation.py +8 -2
- rowan/workflows/scan.py +8 -2
- rowan/workflows/solubility.py +8 -2
- rowan/workflows/solvent_dependent_conformers.py +8 -2
- rowan/workflows/spin_states.py +8 -2
- rowan/workflows/strain.py +8 -2
- rowan/workflows/tautomer_search.py +8 -2
- {rowan_python-3.0.0.dist-info → rowan_python-3.0.3.dist-info}/METADATA +2 -2
- rowan_python-3.0.3.dist-info/RECORD +55 -0
- rowan_python-3.0.0.dist-info/RECORD +0 -55
- {rowan_python-3.0.0.dist-info → rowan_python-3.0.3.dist-info}/WHEEL +0 -0
- {rowan_python-3.0.0.dist-info → rowan_python-3.0.3.dist-info}/licenses/LICENSE +0 -0
rowan/__init__.py
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# ruff: noqa
|
|
2
2
|
from . import constants
|
|
3
|
+
from stjames import (
|
|
4
|
+
Constraint,
|
|
5
|
+
ConstraintType,
|
|
6
|
+
Correction,
|
|
7
|
+
Engine,
|
|
8
|
+
Method,
|
|
9
|
+
Mode,
|
|
10
|
+
OptimizationSettings,
|
|
11
|
+
Settings,
|
|
12
|
+
SolventSettings,
|
|
13
|
+
Task,
|
|
14
|
+
)
|
|
15
|
+
from stjames.optimization.freezing_string_method import (
|
|
16
|
+
FSMInterpolation,
|
|
17
|
+
FSMOptimizationCoordinates,
|
|
18
|
+
FSMSettings,
|
|
19
|
+
)
|
|
3
20
|
|
|
4
21
|
api_key: str | None = None
|
|
5
22
|
project_uuid: str | None = None
|
rowan/protein.py
CHANGED
|
@@ -211,7 +211,7 @@ class Protein(BaseModel):
|
|
|
211
211
|
|
|
212
212
|
raise RuntimeError(f"Protein preparation timed out after {timeout:.0f}s for {self.uuid}.")
|
|
213
213
|
|
|
214
|
-
def validate_protein_forcefield(self) -> None:
|
|
214
|
+
def validate_protein_forcefield(self, exclude_residue_names: list[str] | None = None) -> None:
|
|
215
215
|
"""
|
|
216
216
|
Validate that this protein can be parameterized with the MD forcefield.
|
|
217
217
|
|
|
@@ -219,13 +219,21 @@ class Protein(BaseModel):
|
|
|
219
219
|
recognized by OpenMM and that there are no clashing atoms. Call this
|
|
220
220
|
before submitting any MD workflow to catch preparation issues early.
|
|
221
221
|
|
|
222
|
+
Ligand residues (``LIG``) are always excluded — they are parameterized
|
|
223
|
+
separately by the MD workflow from the provided SMILES.
|
|
224
|
+
|
|
222
225
|
If validation fails, try re-preparing with ``remove_invalid_hydrogens=True``:
|
|
223
226
|
``protein.prepare(remove_invalid_hydrogens=True)``
|
|
224
227
|
|
|
228
|
+
:param exclude_residue_names: Additional residue names to skip during validation.
|
|
225
229
|
:raises requests.HTTPError: if validation fails or the API request fails.
|
|
226
230
|
"""
|
|
231
|
+
excluded = list({"LIG"} | {name.upper() for name in (exclude_residue_names or [])})
|
|
227
232
|
with api_client() as client:
|
|
228
|
-
response = client.post(
|
|
233
|
+
response = client.post(
|
|
234
|
+
f"/protein/{self.uuid}/validate_forcefield",
|
|
235
|
+
json=excluded,
|
|
236
|
+
)
|
|
229
237
|
response.raise_for_status()
|
|
230
238
|
|
|
231
239
|
def download_pdb_file(self, path: Path | str | None = None, name: str | None = None) -> None:
|
rowan/rowan_rdkit/__init__.py
CHANGED
|
@@ -12,3 +12,18 @@ from .chem_utils import (
|
|
|
12
12
|
run_pka,
|
|
13
13
|
run_tautomers,
|
|
14
14
|
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"batch_charges",
|
|
18
|
+
"batch_conformers",
|
|
19
|
+
"batch_energy",
|
|
20
|
+
"batch_optimize",
|
|
21
|
+
"batch_pka",
|
|
22
|
+
"batch_tautomers",
|
|
23
|
+
"run_charges",
|
|
24
|
+
"run_conformers",
|
|
25
|
+
"run_energy",
|
|
26
|
+
"run_optimize",
|
|
27
|
+
"run_pka",
|
|
28
|
+
"run_tautomers",
|
|
29
|
+
]
|
rowan/rowan_rdkit/chem_utils.py
CHANGED
|
@@ -98,24 +98,24 @@ def _get_rdkit_mol_from_uuid(calculation_uuid: str) -> RdkitMol:
|
|
|
98
98
|
calc = rowan.retrieve_calculation(calculation_uuid)
|
|
99
99
|
if calc.molecule is None:
|
|
100
100
|
raise ValueError(f"Calculation {calculation_uuid} has no molecules")
|
|
101
|
-
return Chem.MolFromXYZBlock(calc.molecule.to_xyz())
|
|
101
|
+
return Chem.MolFromXYZBlock(calc.molecule.to_xyz()) # type: ignore[attr-defined]
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
def _embed_rdkit_mol(rdkm: RdkitMol) -> RdkitMol:
|
|
105
105
|
try:
|
|
106
|
-
AllChem.SanitizeMol(rdkm) # type: ignore
|
|
106
|
+
AllChem.SanitizeMol(rdkm) # type: ignore[attr-defined]
|
|
107
107
|
except Exception as e:
|
|
108
108
|
raise ValueError("Molecule could not be generated -- invalid chemistry!") from e
|
|
109
109
|
|
|
110
|
-
rdkm = AllChem.AddHs(rdkm) # type: ignore
|
|
110
|
+
rdkm = AllChem.AddHs(rdkm) # type: ignore[attr-defined]
|
|
111
111
|
try:
|
|
112
|
-
assert AllChem.EmbedMolecule(rdkm, maxAttempts=200) >= 0 # type: ignore
|
|
112
|
+
assert AllChem.EmbedMolecule(rdkm, maxAttempts=200) >= 0 # type: ignore[attr-defined]
|
|
113
113
|
except AssertionError as e:
|
|
114
|
-
status1 = AllChem.EmbedMolecule(rdkm, maxAttempts=200, useRandomCoords=True) # type: ignore
|
|
114
|
+
status1 = AllChem.EmbedMolecule(rdkm, maxAttempts=200, useRandomCoords=True) # type: ignore[attr-defined]
|
|
115
115
|
if status1 < 0:
|
|
116
116
|
raise ValueError("Cannot embed molecule!") from e
|
|
117
117
|
try:
|
|
118
|
-
assert AllChem.MMFFOptimizeMolecule(rdkm, maxIters=200) >= 0 # type: ignore
|
|
118
|
+
assert AllChem.MMFFOptimizeMolecule(rdkm, maxIters=200) >= 0 # type: ignore[attr-defined, call-arg]
|
|
119
119
|
except AssertionError:
|
|
120
120
|
pass
|
|
121
121
|
|
|
@@ -412,7 +412,7 @@ def run_energy(
|
|
|
412
412
|
:param timeout: time in seconds before the Workflow times out
|
|
413
413
|
:param name: name for the job
|
|
414
414
|
:param folder_uuid: folder UUID
|
|
415
|
-
:raises
|
|
415
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
416
416
|
:returns: dictionary with the energy in Hartree and the conformer index
|
|
417
417
|
"""
|
|
418
418
|
return asyncio.run(_single_energy(mol, method, engine, mode, timeout, name, folder_uuid))
|
|
@@ -439,7 +439,7 @@ def batch_energy(
|
|
|
439
439
|
:param timeout: time in seconds before the Workflow times out
|
|
440
440
|
:param name: name for the job
|
|
441
441
|
:param folder_uuid: folder UUID
|
|
442
|
-
:raises
|
|
442
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
443
443
|
:returns: list of dictionaries with the energy in Hartree and the conformer index
|
|
444
444
|
"""
|
|
445
445
|
|
|
@@ -472,15 +472,15 @@ async def _single_energy(
|
|
|
472
472
|
:param timeout: time in seconds before the Workflow times out
|
|
473
473
|
:param name: name for the job
|
|
474
474
|
:param folder_uuid: folder UUID
|
|
475
|
-
:raises
|
|
475
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
476
476
|
:returns: dictionary with the energy in Hartree and the conformer index
|
|
477
477
|
"""
|
|
478
478
|
get_api_key()
|
|
479
479
|
method = stjames.Method(method)
|
|
480
480
|
|
|
481
|
-
if mol.GetNumConformers() == 0:
|
|
481
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
482
482
|
mol = _embed_rdkit_mol(mol)
|
|
483
|
-
if mol.GetNumConformers() == 0:
|
|
483
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
484
484
|
raise NoConformersError("This molecule has no conformers")
|
|
485
485
|
|
|
486
486
|
if method not in FAST_METHODS:
|
|
@@ -490,7 +490,7 @@ async def _single_energy(
|
|
|
490
490
|
|
|
491
491
|
workflow_uuids = []
|
|
492
492
|
for conformer in mol.GetConformers():
|
|
493
|
-
cid = conformer.GetId()
|
|
493
|
+
cid = conformer.GetId() # type: ignore[call-arg]
|
|
494
494
|
stjames_mol = _rdkit_to_stjames(mol, cid)
|
|
495
495
|
post = rowan.submit_workflow(
|
|
496
496
|
name=name,
|
|
@@ -552,7 +552,7 @@ def run_optimize(
|
|
|
552
552
|
:param timeout: time in seconds before the Workflow times out
|
|
553
553
|
:param name: name for the job
|
|
554
554
|
:param folder_uuid: folder UUID
|
|
555
|
-
:raises
|
|
555
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
556
556
|
:returns: dictionary with the optimized conformer(s) and optional list of energies per conformer
|
|
557
557
|
"""
|
|
558
558
|
return asyncio.run(
|
|
@@ -583,7 +583,7 @@ def batch_optimize(
|
|
|
583
583
|
:param timeout: time in seconds before the Workflow times out
|
|
584
584
|
:param name: name for the job
|
|
585
585
|
:param folder_uuid: folder UUID
|
|
586
|
-
:raises
|
|
586
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
587
587
|
:returns: dictionaries with optimized conformer(s) and optional list of energies per conformer
|
|
588
588
|
"""
|
|
589
589
|
|
|
@@ -619,15 +619,15 @@ async def _single_optimize(
|
|
|
619
619
|
:param timeout: time in seconds before the Workflow times out
|
|
620
620
|
:param name: name for the job
|
|
621
621
|
:param folder_uuid: folder UUID
|
|
622
|
-
:raises
|
|
622
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
623
623
|
:returns: dictionary with the optimized conformer(s) and optional list of energies per conformer
|
|
624
624
|
"""
|
|
625
625
|
get_api_key()
|
|
626
626
|
method = stjames.Method(method)
|
|
627
627
|
|
|
628
|
-
if mol.GetNumConformers() == 0:
|
|
628
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
629
629
|
mol = _embed_rdkit_mol(mol)
|
|
630
|
-
if mol.GetNumConformers() == 0:
|
|
630
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
631
631
|
raise NoConformersError("This molecule has no conformers")
|
|
632
632
|
|
|
633
633
|
if method not in FAST_METHODS:
|
|
@@ -639,7 +639,7 @@ async def _single_optimize(
|
|
|
639
639
|
|
|
640
640
|
workflow_uuids = []
|
|
641
641
|
for conformer in mol.GetConformers():
|
|
642
|
-
cid = conformer.GetId()
|
|
642
|
+
cid = conformer.GetId() # type: ignore[call-arg]
|
|
643
643
|
stjames_mol = _rdkit_to_stjames(mol, cid)
|
|
644
644
|
|
|
645
645
|
post = rowan.submit_workflow(
|
|
@@ -680,7 +680,7 @@ async def _single_optimize(
|
|
|
680
680
|
energies.append(calc.energy)
|
|
681
681
|
|
|
682
682
|
for i, conformer in enumerate(optimized_mol.GetConformers()):
|
|
683
|
-
conformer.SetPositions(np.array(optimized_positions[i]))
|
|
683
|
+
conformer.SetPositions(np.array(optimized_positions[i])) # type: ignore[attr-defined]
|
|
684
684
|
|
|
685
685
|
return {
|
|
686
686
|
"molecule": mol,
|
|
@@ -799,9 +799,9 @@ async def _single_conformers(
|
|
|
799
799
|
get_api_key()
|
|
800
800
|
method = stjames.Method(method)
|
|
801
801
|
|
|
802
|
-
if mol.GetNumConformers() == 0:
|
|
802
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
803
803
|
mol = _embed_rdkit_mol(mol)
|
|
804
|
-
if mol.GetNumConformers() == 0:
|
|
804
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
805
805
|
raise NoConformersError("This molecule has no conformers")
|
|
806
806
|
|
|
807
807
|
if method not in FAST_METHODS:
|
|
@@ -861,14 +861,14 @@ async def _single_conformers(
|
|
|
861
861
|
lowest_n_uuids = [item[1][0] for item in sorted_data[:num_conformers]]
|
|
862
862
|
lowest_energies = [item[0] for item in sorted_data[:num_conformers]]
|
|
863
863
|
|
|
864
|
-
AllChem.EmbedMultipleConfs(mol, numConfs=num_conformers) # type: ignore
|
|
864
|
+
AllChem.EmbedMultipleConfs(mol, numConfs=num_conformers) # type: ignore[attr-defined]
|
|
865
865
|
|
|
866
866
|
for i, conformer in enumerate(mol.GetConformers()):
|
|
867
867
|
calc = rowan.retrieve_calculation(lowest_n_uuids[i])
|
|
868
868
|
if calc.molecule is None:
|
|
869
869
|
raise ValueError(f"Calculation {lowest_n_uuids[i]} has no molecules")
|
|
870
870
|
pos = [atom.position for atom in calc.molecule.atoms]
|
|
871
|
-
conformer.SetPositions(np.array(pos))
|
|
871
|
+
conformer.SetPositions(np.array(pos)) # type: ignore[attr-defined]
|
|
872
872
|
|
|
873
873
|
return {
|
|
874
874
|
"molecule": mol,
|
|
@@ -897,7 +897,7 @@ def run_charges(
|
|
|
897
897
|
:param timeout: timeout in seconds
|
|
898
898
|
:param name: name of the job
|
|
899
899
|
:param folder_uuid: folder UUID
|
|
900
|
-
:raises
|
|
900
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
901
901
|
:returns: dictionary with the charges and the conformer index
|
|
902
902
|
"""
|
|
903
903
|
return asyncio.run(_single_charges(mol, method, engine, mode, timeout, name, folder_uuid))
|
|
@@ -924,7 +924,7 @@ def batch_charges(
|
|
|
924
924
|
:param timeout: timeout in seconds
|
|
925
925
|
:param name: name of the job
|
|
926
926
|
:param folder_uuid: folder UUID
|
|
927
|
-
:raises
|
|
927
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
928
928
|
:returns: list of dictionaries with the charges and the conformer index
|
|
929
929
|
"""
|
|
930
930
|
|
|
@@ -958,15 +958,15 @@ async def _single_charges(
|
|
|
958
958
|
:param timeout: timeout in seconds
|
|
959
959
|
:param name: name of the job
|
|
960
960
|
:param folder_uuid: folder UUID
|
|
961
|
-
:raises
|
|
961
|
+
:raises MethodTooSlowError: if the method is invalid
|
|
962
962
|
:returns: dictionary with the charges and the conformer index
|
|
963
963
|
"""
|
|
964
964
|
get_api_key()
|
|
965
965
|
method = stjames.Method(method)
|
|
966
966
|
|
|
967
|
-
if mol.GetNumConformers() == 0:
|
|
967
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
968
968
|
mol = _embed_rdkit_mol(mol)
|
|
969
|
-
if mol.GetNumConformers() == 0:
|
|
969
|
+
if mol.GetNumConformers() == 0: # type: ignore[call-arg]
|
|
970
970
|
raise NoConformersError("This molecule has no conformers")
|
|
971
971
|
|
|
972
972
|
if method not in FAST_METHODS:
|
|
@@ -976,7 +976,7 @@ async def _single_charges(
|
|
|
976
976
|
|
|
977
977
|
workflow_uuids = []
|
|
978
978
|
for conformer in mol.GetConformers():
|
|
979
|
-
cid = conformer.GetId()
|
|
979
|
+
cid = conformer.GetId() # type: ignore[call-arg]
|
|
980
980
|
|
|
981
981
|
post = rowan.submit_workflow(
|
|
982
982
|
name=name,
|
rowan/types.py
CHANGED
|
@@ -9,7 +9,7 @@ from .molecule import Molecule as RowanMolecule
|
|
|
9
9
|
|
|
10
10
|
RdkitMol: TypeAlias = Chem.rdchem.Mol | Chem.rdchem.RWMol
|
|
11
11
|
StJamesMolecule: TypeAlias = stjames.Molecule
|
|
12
|
-
MoleculeInput: TypeAlias = dict[str, Any] | RowanMolecule | StJamesMolecule | RdkitMol
|
|
12
|
+
MoleculeInput: TypeAlias = dict[str, Any] | RowanMolecule | StJamesMolecule | RdkitMol | str
|
|
13
13
|
SolventInput: TypeAlias = stjames.Solvent | str | None
|
|
14
14
|
SMILES: TypeAlias = str
|
|
15
15
|
UUID: TypeAlias = str
|
rowan/user.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import time
|
|
4
|
+
|
|
1
5
|
from pydantic import BaseModel
|
|
2
6
|
|
|
3
7
|
from .utils import api_client
|
|
@@ -74,7 +78,7 @@ class User(BaseModel):
|
|
|
74
78
|
weekly_credits: float | None = None
|
|
75
79
|
credits: float | None = None
|
|
76
80
|
billing_name: str | None = None
|
|
77
|
-
billing_address: str | None = None
|
|
81
|
+
billing_address: str | dict | None = None
|
|
78
82
|
credit_balance_warning: float | None = None
|
|
79
83
|
organization: Organization | None = None
|
|
80
84
|
organization_role: OrganizationRole | None = None
|
|
@@ -135,3 +139,64 @@ def whoami() -> User:
|
|
|
135
139
|
response.raise_for_status()
|
|
136
140
|
|
|
137
141
|
return User(**response.json())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_webhook_secret() -> str | None:
|
|
145
|
+
"""Get current webhook secret, or None if not set."""
|
|
146
|
+
with api_client() as client:
|
|
147
|
+
response = client.get("/user/me/webhook_secret")
|
|
148
|
+
response.raise_for_status()
|
|
149
|
+
return response.json().get("webhook_secret")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_webhook_secret() -> str:
|
|
153
|
+
"""Create a webhook secret if one doesn't exist, return it."""
|
|
154
|
+
with api_client() as client:
|
|
155
|
+
response = client.post("/user/me/webhook_secret")
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
return response.json()["webhook_secret"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rotate_webhook_secret() -> str:
|
|
161
|
+
"""Generate a new webhook secret, invalidating the old one."""
|
|
162
|
+
with api_client() as client:
|
|
163
|
+
response = client.post("/user/me/webhook_secret/rotate")
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
return response.json()["webhook_secret"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def verify_webhook_secret(
|
|
169
|
+
raw_body: bytes,
|
|
170
|
+
signature_header: str,
|
|
171
|
+
secret: str,
|
|
172
|
+
max_age_seconds: int = 300,
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Verify an incoming webhook request from Rowan.
|
|
175
|
+
|
|
176
|
+
:param raw_body: The raw (unparsed) request body bytes.
|
|
177
|
+
:param signature_header: Value of the X-Rowan-Signature header.
|
|
178
|
+
:param secret: Your webhook secret (from :func:`create_webhook_secret` or
|
|
179
|
+
:func:`rotate_webhook_secret`).
|
|
180
|
+
:param max_age_seconds: Reject requests older than this many seconds (default 5 min).
|
|
181
|
+
:returns: True if the signature is valid and the request is fresh.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
parts = dict(part.split("=", 1) for part in signature_header.split(","))
|
|
185
|
+
except ValueError:
|
|
186
|
+
return False
|
|
187
|
+
timestamp = parts.get("t", "")
|
|
188
|
+
received_digest = parts.get("sha256", "")
|
|
189
|
+
|
|
190
|
+
if not timestamp or not received_digest:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
if abs(time.time() - int(timestamp)) > max_age_seconds:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
expected_digest = hmac.new(
|
|
197
|
+
secret.encode(),
|
|
198
|
+
f"{timestamp}.".encode() + raw_body,
|
|
199
|
+
hashlib.sha256,
|
|
200
|
+
).hexdigest()
|
|
201
|
+
|
|
202
|
+
return hmac.compare_digest(expected_digest, received_digest)
|
rowan/workflows/__init__.py
CHANGED
rowan/workflows/admet.py
CHANGED
|
@@ -31,6 +31,8 @@ def submit_admet_workflow(
|
|
|
31
31
|
folder_uuid: str | None = None,
|
|
32
32
|
folder: Folder | None = None,
|
|
33
33
|
max_credits: int | None = None,
|
|
34
|
+
webhook_url: str | None = None,
|
|
35
|
+
is_draft: bool = False,
|
|
34
36
|
) -> Workflow:
|
|
35
37
|
"""
|
|
36
38
|
Submits an ADMET workflow to predict drug-likeness properties.
|
|
@@ -43,6 +45,8 @@ def submit_admet_workflow(
|
|
|
43
45
|
:param folder_uuid: UUID of the folder to store the workflow in.
|
|
44
46
|
:param folder: Folder object to store the workflow in.
|
|
45
47
|
:param max_credits: Maximum number of credits to use for the workflow.
|
|
48
|
+
:param webhook_url: URL that Rowan will POST to when the workflow completes.
|
|
49
|
+
:param is_draft: If True, submit the workflow as a draft without starting execution.
|
|
46
50
|
:returns: Workflow object representing the submitted workflow.
|
|
47
51
|
:raises ValueError: If the molecule has no SMILES associated with it.
|
|
48
52
|
:raises requests.HTTPError: if the request to the API fails.
|
|
@@ -55,12 +59,14 @@ def submit_admet_workflow(
|
|
|
55
59
|
workflow = stjames.ADMETWorkflow(initial_smiles=initial_smiles)
|
|
56
60
|
|
|
57
61
|
data = {
|
|
58
|
-
"name": name,
|
|
59
|
-
"folder_uuid": folder_uuid,
|
|
60
62
|
"workflow_type": "admet",
|
|
61
63
|
"workflow_data": workflow.model_dump(mode="json"),
|
|
62
64
|
"initial_smiles": initial_smiles,
|
|
65
|
+
"name": name,
|
|
66
|
+
"folder_uuid": folder_uuid,
|
|
63
67
|
"max_credits": max_credits,
|
|
68
|
+
"webhook_url": webhook_url,
|
|
69
|
+
"is_draft": is_draft,
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
with api_client() as client:
|
|
@@ -166,6 +166,8 @@ def submit_analogue_docking_workflow(
|
|
|
166
166
|
folder_uuid: str | None = None,
|
|
167
167
|
folder: Folder | None = None,
|
|
168
168
|
max_credits: int | None = None,
|
|
169
|
+
webhook_url: str | None = None,
|
|
170
|
+
is_draft: bool = False,
|
|
169
171
|
) -> Workflow:
|
|
170
172
|
"""
|
|
171
173
|
Submits an analogue-docking workflow to the API.
|
|
@@ -180,6 +182,8 @@ def submit_analogue_docking_workflow(
|
|
|
180
182
|
:param folder_uuid: UUID of the folder to place the workflow in.
|
|
181
183
|
:param folder: Folder object to store the workflow in.
|
|
182
184
|
:param max_credits: Maximum number of credits to use for the workflow.
|
|
185
|
+
:param webhook_url: URL that Rowan will POST to when the workflow completes.
|
|
186
|
+
:param is_draft: If True, submit the workflow as a draft without starting execution.
|
|
183
187
|
:returns: Workflow object representing the submitted analogue-docking workflow.
|
|
184
188
|
:raises requests.HTTPError: if the request to the API fails.
|
|
185
189
|
"""
|
|
@@ -206,12 +210,14 @@ def submit_analogue_docking_workflow(
|
|
|
206
210
|
)
|
|
207
211
|
|
|
208
212
|
data = {
|
|
209
|
-
"name": name,
|
|
210
|
-
"folder_uuid": folder_uuid,
|
|
211
213
|
"initial_molecule": initial_molecule,
|
|
212
214
|
"workflow_type": "analogue_docking",
|
|
213
215
|
"workflow_data": workflow.model_dump(serialize_as_any=True, mode="json"),
|
|
216
|
+
"name": name,
|
|
217
|
+
"folder_uuid": folder_uuid,
|
|
214
218
|
"max_credits": max_credits,
|
|
219
|
+
"webhook_url": webhook_url,
|
|
220
|
+
"is_draft": is_draft,
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
with api_client() as client:
|
rowan/workflows/base.py
CHANGED
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from typing import Any, Callable, ClassVar, Self
|
|
11
11
|
|
|
12
12
|
import stjames
|
|
13
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
14
14
|
from rdkit import Chem
|
|
15
15
|
|
|
16
16
|
from ..folder import Folder
|
|
@@ -49,6 +49,20 @@ def parse_messages(raw_messages: list[stjames.Message] | None) -> list[Message]:
|
|
|
49
49
|
return [Message(title=m.title, body=m.body, type=m.type) for m in raw_messages]
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class DispatchInfo:
|
|
54
|
+
"""Estimated dispatch information for a workflow.
|
|
55
|
+
|
|
56
|
+
:param to_be_dispatched: whether workflow will be queued (vs starting immediately).
|
|
57
|
+
:param compute_hardware: hardware type (CPU, H200, A100, etc.).
|
|
58
|
+
:param estimated_runtime_minutes: estimated runtime in minutes, or None if unknown.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
to_be_dispatched: bool | None
|
|
62
|
+
compute_hardware: str | None
|
|
63
|
+
estimated_runtime_minutes: float | None
|
|
64
|
+
|
|
65
|
+
|
|
52
66
|
class WorkflowError(Exception):
|
|
53
67
|
"""Raised when a workflow fails or is stopped."""
|
|
54
68
|
|
|
@@ -71,7 +85,7 @@ class WorkflowResult:
|
|
|
71
85
|
workflow_data: dict[str, Any]
|
|
72
86
|
workflow_type: str
|
|
73
87
|
workflow_uuid: str
|
|
74
|
-
|
|
88
|
+
complete: bool = field(default=True, repr=False)
|
|
75
89
|
_workflow: Any = field(default=None, init=False)
|
|
76
90
|
_cache: dict[str, Any] = field(default_factory=dict, init=False)
|
|
77
91
|
|
|
@@ -83,8 +97,11 @@ class WorkflowResult:
|
|
|
83
97
|
def __post_init__(self) -> None:
|
|
84
98
|
"""Parse workflow data into stjames object for typed access."""
|
|
85
99
|
if self._stjames_class is not None:
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
try:
|
|
101
|
+
obj = self._stjames_class.model_validate(self.workflow_data) # type: ignore[attr-defined]
|
|
102
|
+
object.__setattr__(self, "_workflow", obj)
|
|
103
|
+
except ValidationError:
|
|
104
|
+
pass # incomplete or invalid data; _workflow stays None
|
|
88
105
|
|
|
89
106
|
@property
|
|
90
107
|
def data(self) -> dict[str, Any]:
|
|
@@ -115,7 +132,7 @@ def register_result(workflow_type: str) -> Callable[[type[WorkflowResult]], type
|
|
|
115
132
|
|
|
116
133
|
|
|
117
134
|
def create_result(
|
|
118
|
-
workflow_data: dict[str, Any], workflow_type: str, workflow_uuid: str,
|
|
135
|
+
workflow_data: dict[str, Any], workflow_type: str, workflow_uuid: str, complete: bool = True
|
|
119
136
|
) -> WorkflowResult:
|
|
120
137
|
"""
|
|
121
138
|
Factory function to create the appropriate result type for a workflow.
|
|
@@ -123,7 +140,7 @@ def create_result(
|
|
|
123
140
|
:param workflow_data: Raw data dict from the workflow.
|
|
124
141
|
:param workflow_type: Workflow type string.
|
|
125
142
|
:param workflow_uuid: UUID of the parent workflow.
|
|
126
|
-
:param
|
|
143
|
+
:param complete: If True (default), eagerly fetch related data (e.g. calculations)
|
|
127
144
|
in ``__post_init__``. Set to False when polling partial results with
|
|
128
145
|
``result(wait=False)`` to avoid unnecessary API calls.
|
|
129
146
|
:returns: Typed WorkflowResult subclass, or base WorkflowResult if unknown.
|
|
@@ -133,7 +150,7 @@ def create_result(
|
|
|
133
150
|
workflow_data=workflow_data,
|
|
134
151
|
workflow_type=workflow_type,
|
|
135
152
|
workflow_uuid=workflow_uuid,
|
|
136
|
-
|
|
153
|
+
complete=complete,
|
|
137
154
|
)
|
|
138
155
|
|
|
139
156
|
|
|
@@ -158,6 +175,7 @@ class Workflow(BaseModel):
|
|
|
158
175
|
:param data: Data of the workflow.
|
|
159
176
|
:param email_when_complete: Whether to send an email when the workflow completes.
|
|
160
177
|
:param max_credits: Maximum number of credits to use for the workflow.
|
|
178
|
+
:param webhook_url: URL that Rowan will POST to when the workflow completes.
|
|
161
179
|
:param elapsed: Elapsed time of the workflow.
|
|
162
180
|
:param credits_charged: Number of credits charged for the workflow.
|
|
163
181
|
:param logfile: Workflow logfile.
|
|
@@ -308,6 +326,11 @@ Workflow: {self.name}
|
|
|
308
326
|
:returns: WorkflowResult subclass with typed access to results.
|
|
309
327
|
:raises WorkflowError: If the workflow failed or was stopped.
|
|
310
328
|
"""
|
|
329
|
+
if self.status == stjames.Status.DRAFT:
|
|
330
|
+
raise WorkflowError(
|
|
331
|
+
f"Cannot get result of draft workflow '{self.name}'. Call submit_draft() first."
|
|
332
|
+
)
|
|
333
|
+
|
|
311
334
|
if wait:
|
|
312
335
|
while not self.done():
|
|
313
336
|
time.sleep(poll_interval)
|
|
@@ -323,8 +346,8 @@ Workflow: {self.name}
|
|
|
323
346
|
raise WorkflowError(
|
|
324
347
|
f"Workflow '{self.name}' has no results yet (status={status}, uuid={self.uuid})"
|
|
325
348
|
)
|
|
326
|
-
|
|
327
|
-
return create_result(self.data, self.workflow_type, self.uuid,
|
|
349
|
+
complete = self.status == stjames.Status.COMPLETED_OK
|
|
350
|
+
return create_result(self.data, self.workflow_type, self.uuid, complete=complete)
|
|
328
351
|
|
|
329
352
|
def stream_result(self, poll_interval: int = 5) -> Iterator["WorkflowResult"]:
|
|
330
353
|
"""
|
|
@@ -407,6 +430,50 @@ Workflow: {self.name}
|
|
|
407
430
|
response = client.delete(f"/workflow/{self.uuid}/delete_workflow_data")
|
|
408
431
|
response.raise_for_status()
|
|
409
432
|
|
|
433
|
+
def dispatch_info(self) -> DispatchInfo:
|
|
434
|
+
"""Fetch estimated dispatch information for this workflow.
|
|
435
|
+
|
|
436
|
+
:returns: estimated time, hardware, and queue info.
|
|
437
|
+
:raises HTTPError: if the API request fails.
|
|
438
|
+
"""
|
|
439
|
+
if self.data is None:
|
|
440
|
+
self.fetch_latest(in_place=True)
|
|
441
|
+
payload = {"workflow_type": self.workflow_type, "workflow_data": self.data}
|
|
442
|
+
with api_client() as client:
|
|
443
|
+
response = client.post("/workflow/dispatch_information", json=payload)
|
|
444
|
+
response.raise_for_status()
|
|
445
|
+
raw = response.json()
|
|
446
|
+
time_est = raw.get("time_estimate_min")
|
|
447
|
+
if isinstance(time_est, dict):
|
|
448
|
+
time_est = time_est.get("average")
|
|
449
|
+
info = DispatchInfo(
|
|
450
|
+
to_be_dispatched=raw.get("to_be_dispatched"),
|
|
451
|
+
compute_hardware=raw.get("compute_hardware"),
|
|
452
|
+
estimated_runtime_minutes=time_est,
|
|
453
|
+
)
|
|
454
|
+
if info.estimated_runtime_minutes is None:
|
|
455
|
+
logger.info(
|
|
456
|
+
"Runtime estimation not available for workflow type '%s'.",
|
|
457
|
+
self.workflow_type,
|
|
458
|
+
)
|
|
459
|
+
return info
|
|
460
|
+
|
|
461
|
+
def submit_draft(self) -> Self:
|
|
462
|
+
"""Submit a draft workflow for execution.
|
|
463
|
+
|
|
464
|
+
:returns: updated workflow instance.
|
|
465
|
+
:raises WorkflowError: if workflow is not in DRAFT status.
|
|
466
|
+
:raises HTTPError: if the API request fails.
|
|
467
|
+
"""
|
|
468
|
+
if self.status != stjames.Status.DRAFT:
|
|
469
|
+
raise WorkflowError(
|
|
470
|
+
f"Cannot submit draft: workflow status is {self.status.name}, not DRAFT"
|
|
471
|
+
)
|
|
472
|
+
with api_client() as client:
|
|
473
|
+
response = client.post(f"/workflow/{self.uuid}/submit_draft")
|
|
474
|
+
response.raise_for_status()
|
|
475
|
+
return self.fetch_latest(in_place=True)
|
|
476
|
+
|
|
410
477
|
def download_msa_files(
|
|
411
478
|
self, msa_format: stjames.MSAFormat, path: Path | str | None = None
|
|
412
479
|
) -> None:
|
|
@@ -525,6 +592,8 @@ def molecule_to_dict(mol: MoleculeInput) -> dict[str, Any]:
|
|
|
525
592
|
:returns: Dict representation suitable for API submission.
|
|
526
593
|
"""
|
|
527
594
|
match mol:
|
|
595
|
+
case str():
|
|
596
|
+
return RowanMolecule.from_smiles(mol).to_stjames().model_dump(mode="json")
|
|
528
597
|
case RowanMolecule():
|
|
529
598
|
return mol.to_stjames().model_dump(mode="json")
|
|
530
599
|
case stjames.Molecule():
|
|
@@ -545,6 +614,8 @@ def submit_workflow(
|
|
|
545
614
|
name: str | None = None,
|
|
546
615
|
folder_uuid: str | Folder | None = None,
|
|
547
616
|
max_credits: int | None = None,
|
|
617
|
+
webhook_url: str | None = None,
|
|
618
|
+
is_draft: bool = False,
|
|
548
619
|
) -> Workflow:
|
|
549
620
|
"""
|
|
550
621
|
Submits a workflow to the API.
|
|
@@ -556,6 +627,8 @@ def submit_workflow(
|
|
|
556
627
|
:param name: Name for the workflow.
|
|
557
628
|
:param folder_uuid: UUID of the folder to store the workflow in, or a Folder object.
|
|
558
629
|
:param max_credits: Maximum number of credits to use for the workflow.
|
|
630
|
+
:param webhook_url: URL that Rowan will POST to when the workflow completes.
|
|
631
|
+
:param is_draft: If True, submit the workflow as a draft without starting execution.
|
|
559
632
|
:returns: Workflow object representing the submitted workflow.
|
|
560
633
|
:raises ValueError: If neither `initial_smiles` nor a valid `initial_molecule` is provided.
|
|
561
634
|
:raises HTTPError: If the API request fails.
|
|
@@ -573,6 +646,8 @@ def submit_workflow(
|
|
|
573
646
|
"workflow_type": workflow_type,
|
|
574
647
|
"workflow_data": workflow_data,
|
|
575
648
|
"max_credits": max_credits,
|
|
649
|
+
"webhook_url": webhook_url,
|
|
650
|
+
"is_draft": is_draft,
|
|
576
651
|
}
|
|
577
652
|
|
|
578
653
|
if initial_smiles is not None:
|
|
@@ -709,6 +784,7 @@ def batch_submit_workflow(
|
|
|
709
784
|
names: list[str] | None = None,
|
|
710
785
|
folder_uuid: str | Folder | None = None,
|
|
711
786
|
max_credits: int | None = None,
|
|
787
|
+
webhook_url: str | None = None,
|
|
712
788
|
) -> list[Workflow]:
|
|
713
789
|
"""
|
|
714
790
|
Submits a batch of workflows to the API.
|
|
@@ -723,6 +799,7 @@ def batch_submit_workflow(
|
|
|
723
799
|
:param names: Names for the submitted workflows.
|
|
724
800
|
:param folder_uuid: UUID of the folder to store the workflows in.
|
|
725
801
|
:param max_credits: Maximum number of credits to use per workflow.
|
|
802
|
+
:param webhook_url: URL to call when each workflow completes.
|
|
726
803
|
:returns: List of Workflow objects representing the submitted workflows.
|
|
727
804
|
"""
|
|
728
805
|
if names is not None:
|
|
@@ -745,6 +822,7 @@ def batch_submit_workflow(
|
|
|
745
822
|
name=name,
|
|
746
823
|
folder_uuid=folder_uuid,
|
|
747
824
|
max_credits=max_credits,
|
|
825
|
+
webhook_url=webhook_url,
|
|
748
826
|
)
|
|
749
827
|
)
|
|
750
828
|
elif initial_molecules is not None:
|
|
@@ -758,6 +836,7 @@ def batch_submit_workflow(
|
|
|
758
836
|
name=name,
|
|
759
837
|
folder_uuid=folder_uuid,
|
|
760
838
|
max_credits=max_credits,
|
|
839
|
+
webhook_url=webhook_url,
|
|
761
840
|
)
|
|
762
841
|
)
|
|
763
842
|
else:
|