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.
Files changed (47) hide show
  1. rowan/__init__.py +17 -0
  2. rowan/protein.py +10 -2
  3. rowan/rowan_rdkit/__init__.py +15 -0
  4. rowan/rowan_rdkit/chem_utils.py +29 -29
  5. rowan/types.py +1 -1
  6. rowan/user.py +66 -1
  7. rowan/workflows/__init__.py +1 -0
  8. rowan/workflows/admet.py +8 -2
  9. rowan/workflows/analogue_docking.py +8 -2
  10. rowan/workflows/base.py +88 -9
  11. rowan/workflows/basic_calculation.py +128 -29
  12. rowan/workflows/batch_docking.py +8 -2
  13. rowan/workflows/bde.py +8 -2
  14. rowan/workflows/conformer_search.py +8 -2
  15. rowan/workflows/descriptors.py +8 -2
  16. rowan/workflows/docking.py +8 -2
  17. rowan/workflows/double_ended_ts_search.py +20 -11
  18. rowan/workflows/electronic_properties.py +8 -2
  19. rowan/workflows/fukui.py +8 -2
  20. rowan/workflows/hydrogen_bond_donor_acceptor_strength.py +8 -2
  21. rowan/workflows/interaction_energy_decomposition.py +9 -3
  22. rowan/workflows/ion_mobility.py +8 -2
  23. rowan/workflows/irc.py +9 -3
  24. rowan/workflows/macropka.py +8 -2
  25. rowan/workflows/membrane_permeability.py +7 -1
  26. rowan/workflows/msa.py +8 -2
  27. rowan/workflows/multistage_optimization.py +9 -3
  28. rowan/workflows/nmr.py +8 -2
  29. rowan/workflows/pka.py +8 -2
  30. rowan/workflows/pose_analysis_md.py +8 -2
  31. rowan/workflows/protein_binder_design.py +8 -2
  32. rowan/workflows/protein_cofolding.py +8 -2
  33. rowan/workflows/protein_md.py +8 -2
  34. rowan/workflows/rbfe_graph.py +8 -2
  35. rowan/workflows/redox_potential.py +8 -2
  36. rowan/workflows/relative_binding_free_energy_perturbation.py +8 -2
  37. rowan/workflows/scan.py +8 -2
  38. rowan/workflows/solubility.py +8 -2
  39. rowan/workflows/solvent_dependent_conformers.py +8 -2
  40. rowan/workflows/spin_states.py +8 -2
  41. rowan/workflows/strain.py +8 -2
  42. rowan/workflows/tautomer_search.py +8 -2
  43. {rowan_python-3.0.0.dist-info → rowan_python-3.0.3.dist-info}/METADATA +2 -2
  44. rowan_python-3.0.3.dist-info/RECORD +55 -0
  45. rowan_python-3.0.0.dist-info/RECORD +0 -55
  46. {rowan_python-3.0.0.dist-info → rowan_python-3.0.3.dist-info}/WHEEL +0 -0
  47. {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(f"/protein/{self.uuid}/validate_forcefield")
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:
@@ -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
+ ]
@@ -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 [attr-defined]
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 [attr-defined]
110
+ rdkm = AllChem.AddHs(rdkm) # type: ignore[attr-defined]
111
111
  try:
112
- assert AllChem.EmbedMolecule(rdkm, maxAttempts=200) >= 0 # type: ignore [attr-defined]
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 [attr-defined]
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 [attr-defined]
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: MethodTooSlowError if the method is invalid
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: MethodTooSlowError if the method is invalid
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: MethodTooSlowError if the method is invalid
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: MethodTooSlowError if the method is invalid
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: MethodTooSlowError if the Method is invalid
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: MethodTooSlowError if the method is invalid
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 [attr-defined]
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: MethodTooSlowError if the method is invalid
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: MethodTooSlowError if the method is invalid
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: MethodTooSlowError if the method is invalid
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)
@@ -23,6 +23,7 @@ from .analogue_docking import (
23
23
  )
24
24
  from .base import (
25
25
  RESULT_REGISTRY,
26
+ DispatchInfo,
26
27
  Solvent,
27
28
  Workflow,
28
29
  WorkflowError,
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
- eager: bool = field(default=True, repr=False)
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
- obj = self._stjames_class.model_validate(self.workflow_data) # type: ignore[attr-defined]
87
- object.__setattr__(self, "_workflow", obj)
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, eager: bool = True
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 eager: If True (default), eagerly fetch related data (e.g. calculations)
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
- eager=eager,
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
- eager = self.status == stjames.Status.COMPLETED_OK
327
- return create_result(self.data, self.workflow_type, self.uuid, eager=eager)
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: