rowan-python 3.0.0__py3-none-any.whl → 3.0.1__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 (45) hide show
  1. rowan/__init__.py +17 -0
  2. rowan/protein.py +10 -2
  3. rowan/rowan_rdkit/chem_utils.py +20 -20
  4. rowan/types.py +1 -1
  5. rowan/user.py +65 -0
  6. rowan/workflows/admet.py +5 -2
  7. rowan/workflows/analogue_docking.py +5 -2
  8. rowan/workflows/base.py +22 -9
  9. rowan/workflows/basic_calculation.py +125 -29
  10. rowan/workflows/batch_docking.py +5 -2
  11. rowan/workflows/bde.py +5 -2
  12. rowan/workflows/conformer_search.py +5 -2
  13. rowan/workflows/descriptors.py +5 -2
  14. rowan/workflows/docking.py +5 -2
  15. rowan/workflows/double_ended_ts_search.py +17 -11
  16. rowan/workflows/electronic_properties.py +5 -2
  17. rowan/workflows/fukui.py +5 -2
  18. rowan/workflows/hydrogen_bond_donor_acceptor_strength.py +5 -2
  19. rowan/workflows/interaction_energy_decomposition.py +6 -3
  20. rowan/workflows/ion_mobility.py +5 -2
  21. rowan/workflows/irc.py +6 -3
  22. rowan/workflows/macropka.py +5 -2
  23. rowan/workflows/membrane_permeability.py +4 -1
  24. rowan/workflows/msa.py +5 -2
  25. rowan/workflows/multistage_optimization.py +6 -3
  26. rowan/workflows/nmr.py +5 -2
  27. rowan/workflows/pka.py +5 -2
  28. rowan/workflows/pose_analysis_md.py +5 -2
  29. rowan/workflows/protein_binder_design.py +5 -2
  30. rowan/workflows/protein_cofolding.py +5 -2
  31. rowan/workflows/protein_md.py +5 -2
  32. rowan/workflows/rbfe_graph.py +5 -2
  33. rowan/workflows/redox_potential.py +5 -2
  34. rowan/workflows/relative_binding_free_energy_perturbation.py +5 -2
  35. rowan/workflows/scan.py +5 -2
  36. rowan/workflows/solubility.py +5 -2
  37. rowan/workflows/solvent_dependent_conformers.py +5 -2
  38. rowan/workflows/spin_states.py +5 -2
  39. rowan/workflows/strain.py +5 -2
  40. rowan/workflows/tautomer_search.py +5 -2
  41. {rowan_python-3.0.0.dist-info → rowan_python-3.0.1.dist-info}/METADATA +2 -2
  42. rowan_python-3.0.1.dist-info/RECORD +55 -0
  43. rowan_python-3.0.0.dist-info/RECORD +0 -55
  44. {rowan_python-3.0.0.dist-info → rowan_python-3.0.1.dist-info}/WHEEL +0 -0
  45. {rowan_python-3.0.0.dist-info → rowan_python-3.0.1.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:
@@ -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
 
@@ -478,9 +478,9 @@ async def _single_energy(
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,
@@ -625,9 +625,9 @@ async def _single_optimize(
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,
@@ -964,9 +964,9 @@ async def _single_charges(
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
@@ -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/admet.py CHANGED
@@ -31,6 +31,7 @@ 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,
34
35
  ) -> Workflow:
35
36
  """
36
37
  Submits an ADMET workflow to predict drug-likeness properties.
@@ -43,6 +44,7 @@ def submit_admet_workflow(
43
44
  :param folder_uuid: UUID of the folder to store the workflow in.
44
45
  :param folder: Folder object to store the workflow in.
45
46
  :param max_credits: Maximum number of credits to use for the workflow.
47
+ :param webhook_url: URL that Rowan will POST to when the workflow completes.
46
48
  :returns: Workflow object representing the submitted workflow.
47
49
  :raises ValueError: If the molecule has no SMILES associated with it.
48
50
  :raises requests.HTTPError: if the request to the API fails.
@@ -55,12 +57,13 @@ def submit_admet_workflow(
55
57
  workflow = stjames.ADMETWorkflow(initial_smiles=initial_smiles)
56
58
 
57
59
  data = {
58
- "name": name,
59
- "folder_uuid": folder_uuid,
60
60
  "workflow_type": "admet",
61
61
  "workflow_data": workflow.model_dump(mode="json"),
62
62
  "initial_smiles": initial_smiles,
63
+ "name": name,
64
+ "folder_uuid": folder_uuid,
63
65
  "max_credits": max_credits,
66
+ "webhook_url": webhook_url,
64
67
  }
65
68
 
66
69
  with api_client() as client:
@@ -166,6 +166,7 @@ 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,
169
170
  ) -> Workflow:
170
171
  """
171
172
  Submits an analogue-docking workflow to the API.
@@ -180,6 +181,7 @@ def submit_analogue_docking_workflow(
180
181
  :param folder_uuid: UUID of the folder to place the workflow in.
181
182
  :param folder: Folder object to store the workflow in.
182
183
  :param max_credits: Maximum number of credits to use for the workflow.
184
+ :param webhook_url: URL that Rowan will POST to when the workflow completes.
183
185
  :returns: Workflow object representing the submitted analogue-docking workflow.
184
186
  :raises requests.HTTPError: if the request to the API fails.
185
187
  """
@@ -206,12 +208,13 @@ def submit_analogue_docking_workflow(
206
208
  )
207
209
 
208
210
  data = {
209
- "name": name,
210
- "folder_uuid": folder_uuid,
211
211
  "initial_molecule": initial_molecule,
212
212
  "workflow_type": "analogue_docking",
213
213
  "workflow_data": workflow.model_dump(serialize_as_any=True, mode="json"),
214
+ "name": name,
215
+ "folder_uuid": folder_uuid,
214
216
  "max_credits": max_credits,
217
+ "webhook_url": webhook_url,
215
218
  }
216
219
 
217
220
  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
@@ -71,7 +71,7 @@ class WorkflowResult:
71
71
  workflow_data: dict[str, Any]
72
72
  workflow_type: str
73
73
  workflow_uuid: str
74
- eager: bool = field(default=True, repr=False)
74
+ complete: bool = field(default=True, repr=False)
75
75
  _workflow: Any = field(default=None, init=False)
76
76
  _cache: dict[str, Any] = field(default_factory=dict, init=False)
77
77
 
@@ -83,8 +83,11 @@ class WorkflowResult:
83
83
  def __post_init__(self) -> None:
84
84
  """Parse workflow data into stjames object for typed access."""
85
85
  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)
86
+ try:
87
+ obj = self._stjames_class.model_validate(self.workflow_data) # type: ignore[attr-defined]
88
+ object.__setattr__(self, "_workflow", obj)
89
+ except ValidationError:
90
+ pass # incomplete or invalid data; _workflow stays None
88
91
 
89
92
  @property
90
93
  def data(self) -> dict[str, Any]:
@@ -115,7 +118,7 @@ def register_result(workflow_type: str) -> Callable[[type[WorkflowResult]], type
115
118
 
116
119
 
117
120
  def create_result(
118
- workflow_data: dict[str, Any], workflow_type: str, workflow_uuid: str, eager: bool = True
121
+ workflow_data: dict[str, Any], workflow_type: str, workflow_uuid: str, complete: bool = True
119
122
  ) -> WorkflowResult:
120
123
  """
121
124
  Factory function to create the appropriate result type for a workflow.
@@ -123,7 +126,7 @@ def create_result(
123
126
  :param workflow_data: Raw data dict from the workflow.
124
127
  :param workflow_type: Workflow type string.
125
128
  :param workflow_uuid: UUID of the parent workflow.
126
- :param eager: If True (default), eagerly fetch related data (e.g. calculations)
129
+ :param complete: If True (default), eagerly fetch related data (e.g. calculations)
127
130
  in ``__post_init__``. Set to False when polling partial results with
128
131
  ``result(wait=False)`` to avoid unnecessary API calls.
129
132
  :returns: Typed WorkflowResult subclass, or base WorkflowResult if unknown.
@@ -133,7 +136,7 @@ def create_result(
133
136
  workflow_data=workflow_data,
134
137
  workflow_type=workflow_type,
135
138
  workflow_uuid=workflow_uuid,
136
- eager=eager,
139
+ complete=complete,
137
140
  )
138
141
 
139
142
 
@@ -158,6 +161,7 @@ class Workflow(BaseModel):
158
161
  :param data: Data of the workflow.
159
162
  :param email_when_complete: Whether to send an email when the workflow completes.
160
163
  :param max_credits: Maximum number of credits to use for the workflow.
164
+ :param webhook_url: URL that Rowan will POST to when the workflow completes.
161
165
  :param elapsed: Elapsed time of the workflow.
162
166
  :param credits_charged: Number of credits charged for the workflow.
163
167
  :param logfile: Workflow logfile.
@@ -323,8 +327,8 @@ Workflow: {self.name}
323
327
  raise WorkflowError(
324
328
  f"Workflow '{self.name}' has no results yet (status={status}, uuid={self.uuid})"
325
329
  )
326
- eager = self.status == stjames.Status.COMPLETED_OK
327
- return create_result(self.data, self.workflow_type, self.uuid, eager=eager)
330
+ complete = self.status == stjames.Status.COMPLETED_OK
331
+ return create_result(self.data, self.workflow_type, self.uuid, complete=complete)
328
332
 
329
333
  def stream_result(self, poll_interval: int = 5) -> Iterator["WorkflowResult"]:
330
334
  """
@@ -525,6 +529,8 @@ def molecule_to_dict(mol: MoleculeInput) -> dict[str, Any]:
525
529
  :returns: Dict representation suitable for API submission.
526
530
  """
527
531
  match mol:
532
+ case str():
533
+ return RowanMolecule.from_smiles(mol).to_stjames().model_dump(mode="json")
528
534
  case RowanMolecule():
529
535
  return mol.to_stjames().model_dump(mode="json")
530
536
  case stjames.Molecule():
@@ -545,6 +551,7 @@ def submit_workflow(
545
551
  name: str | None = None,
546
552
  folder_uuid: str | Folder | None = None,
547
553
  max_credits: int | None = None,
554
+ webhook_url: str | None = None,
548
555
  ) -> Workflow:
549
556
  """
550
557
  Submits a workflow to the API.
@@ -556,6 +563,7 @@ def submit_workflow(
556
563
  :param name: Name for the workflow.
557
564
  :param folder_uuid: UUID of the folder to store the workflow in, or a Folder object.
558
565
  :param max_credits: Maximum number of credits to use for the workflow.
566
+ :param webhook_url: URL that Rowan will POST to when the workflow completes.
559
567
  :returns: Workflow object representing the submitted workflow.
560
568
  :raises ValueError: If neither `initial_smiles` nor a valid `initial_molecule` is provided.
561
569
  :raises HTTPError: If the API request fails.
@@ -573,6 +581,7 @@ def submit_workflow(
573
581
  "workflow_type": workflow_type,
574
582
  "workflow_data": workflow_data,
575
583
  "max_credits": max_credits,
584
+ "webhook_url": webhook_url,
576
585
  }
577
586
 
578
587
  if initial_smiles is not None:
@@ -709,6 +718,7 @@ def batch_submit_workflow(
709
718
  names: list[str] | None = None,
710
719
  folder_uuid: str | Folder | None = None,
711
720
  max_credits: int | None = None,
721
+ webhook_url: str | None = None,
712
722
  ) -> list[Workflow]:
713
723
  """
714
724
  Submits a batch of workflows to the API.
@@ -723,6 +733,7 @@ def batch_submit_workflow(
723
733
  :param names: Names for the submitted workflows.
724
734
  :param folder_uuid: UUID of the folder to store the workflows in.
725
735
  :param max_credits: Maximum number of credits to use per workflow.
736
+ :param webhook_url: URL to call when each workflow completes.
726
737
  :returns: List of Workflow objects representing the submitted workflows.
727
738
  """
728
739
  if names is not None:
@@ -745,6 +756,7 @@ def batch_submit_workflow(
745
756
  name=name,
746
757
  folder_uuid=folder_uuid,
747
758
  max_credits=max_credits,
759
+ webhook_url=webhook_url,
748
760
  )
749
761
  )
750
762
  elif initial_molecules is not None:
@@ -758,6 +770,7 @@ def batch_submit_workflow(
758
770
  name=name,
759
771
  folder_uuid=folder_uuid,
760
772
  max_credits=max_credits,
773
+ webhook_url=webhook_url,
761
774
  )
762
775
  )
763
776
  else: