boltz2-python-client 0.2__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.
@@ -0,0 +1,986 @@
1
+ # ---------------------------------------------------------------
2
+ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
3
+ # ---------------------------------------------------------------
4
+
5
+
6
+ """
7
+ Boltz-2 Python Client
8
+
9
+ This module provides both synchronous and asynchronous clients for interacting
10
+ with the Boltz-2 NIM API, with comprehensive support for all available parameters
11
+ and advanced features.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import time
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Union, Any, Tuple, Callable
20
+ from urllib.parse import urljoin
21
+ import os
22
+
23
+ import httpx
24
+ import yaml
25
+ from rich.console import Console
26
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
27
+
28
+ from .models import (
29
+ PredictionRequest, PredictionResponse, HealthStatus, ServiceMetadata,
30
+ Polymer, Ligand, PocketConstraint, BondConstraint, Atom, AlignmentFileRecord,
31
+ StructureData, PredictionJob, PolymerType, AlignmentFormat, ConstraintType,
32
+ YAMLConfig, YAMLConfigType
33
+ )
34
+ from .exceptions import (
35
+ Boltz2ClientError, Boltz2APIError, Boltz2ValidationError,
36
+ Boltz2TimeoutError, Boltz2ConnectionError
37
+ )
38
+
39
+
40
+ class EndpointType:
41
+ """Endpoint type constants."""
42
+ LOCAL = "local"
43
+ NVIDIA_HOSTED = "nvidia_hosted"
44
+
45
+
46
+ class Boltz2Client:
47
+ """
48
+ Asynchronous client for Boltz-2 NIM service.
49
+
50
+ Supports both local deployments and NVIDIA hosted endpoints with API key authentication.
51
+ Provides comprehensive structure prediction capabilities with all available parameters.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ base_url: str = "http://localhost:8000",
57
+ api_key: Optional[str] = None,
58
+ endpoint_type: str = EndpointType.LOCAL,
59
+ timeout: float = 300.0,
60
+ max_retries: int = 3,
61
+ retry_delay: float = 1.0,
62
+ poll_seconds: int = 10,
63
+ console: Optional[Console] = None
64
+ ):
65
+ """
66
+ Initialize the Boltz-2 client.
67
+
68
+ Args:
69
+ base_url: Base URL of the service
70
+ - Local: "http://localhost:8000"
71
+ - NVIDIA Hosted: "https://health.api.nvidia.com"
72
+ api_key: API key for NVIDIA hosted endpoints (can also be set via NVIDIA_API_KEY env var)
73
+ endpoint_type: Type of endpoint ("local" or "nvidia_hosted")
74
+ timeout: Request timeout in seconds
75
+ max_retries: Maximum number of retry attempts
76
+ retry_delay: Delay between retries in seconds
77
+ poll_seconds: Polling interval for NVIDIA hosted endpoints (NVCF-POLL-SECONDS)
78
+ console: Rich console for output (optional)
79
+ """
80
+ self.base_url = base_url.rstrip('/')
81
+ self.endpoint_type = endpoint_type
82
+ self.timeout = timeout
83
+ self.max_retries = max_retries
84
+ self.retry_delay = retry_delay
85
+ self.poll_seconds = poll_seconds
86
+ self.console = console or Console()
87
+
88
+ # Handle API key for NVIDIA hosted endpoints
89
+ if endpoint_type == EndpointType.NVIDIA_HOSTED:
90
+ self.api_key = api_key or os.getenv("NVIDIA_API_KEY")
91
+ if not self.api_key:
92
+ raise Boltz2ValidationError(
93
+ "API key is required for NVIDIA hosted endpoints. "
94
+ "Provide it via api_key parameter or NVIDIA_API_KEY environment variable."
95
+ )
96
+ else:
97
+ self.api_key = None
98
+
99
+ # Set up URLs based on endpoint type
100
+ if endpoint_type == EndpointType.NVIDIA_HOSTED:
101
+ self.predict_url = f"{self.base_url}/v1/biology/mit/boltz2/predict"
102
+ self.health_url = f"{self.base_url}/v1/health/live"
103
+ self.ready_url = f"{self.base_url}/v1/health/ready"
104
+ self.metadata_url = f"{self.base_url}/v1/models"
105
+ self.status_url = "https://api.nvcf.nvidia.com/v2/nvcf/pexec/status/{task_id}"
106
+ else:
107
+ # Local endpoints
108
+ self.predict_url = f"{self.base_url}/biology/mit/boltz2/predict"
109
+ self.health_url = f"{self.base_url}/v1/health/live"
110
+ self.ready_url = f"{self.base_url}/v1/health/ready"
111
+ self.metadata_url = f"{self.base_url}/v1/models"
112
+ self.status_url = None
113
+
114
+ def _get_headers(self, additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
115
+ """Get headers for requests based on endpoint type."""
116
+ headers = {"Content-Type": "application/json"}
117
+
118
+ if self.endpoint_type == EndpointType.NVIDIA_HOSTED:
119
+ headers["Authorization"] = f"Bearer {self.api_key}"
120
+ headers["NVCF-POLL-SECONDS"] = str(self.poll_seconds)
121
+
122
+ if additional_headers:
123
+ headers.update(additional_headers)
124
+
125
+ return headers
126
+
127
+ async def _handle_nvidia_polling(
128
+ self,
129
+ client: httpx.AsyncClient,
130
+ response: httpx.Response,
131
+ progress_callback: Optional[Callable] = None
132
+ ) -> httpx.Response:
133
+ """Handle NVIDIA hosted endpoint polling for 202 responses."""
134
+ if response.status_code != 202:
135
+ return response
136
+
137
+ task_id = response.headers.get("nvcf-reqid")
138
+ if not task_id:
139
+ raise Boltz2APIError("No task ID found in 202 response headers")
140
+
141
+ if progress_callback:
142
+ progress_callback(f"Request queued, polling task {task_id}...")
143
+
144
+ headers = self._get_headers()
145
+
146
+ while True:
147
+ await asyncio.sleep(self.poll_seconds)
148
+
149
+ status_response = await client.get(
150
+ self.status_url.format(task_id=task_id),
151
+ headers=headers,
152
+ timeout=self.timeout
153
+ )
154
+
155
+ if status_response.status_code == 200:
156
+ if progress_callback:
157
+ progress_callback("Task completed successfully")
158
+ return status_response
159
+ elif status_response.status_code in [400, 401, 404, 422, 500]:
160
+ error_detail = status_response.text
161
+ raise Boltz2APIError(f"Task failed with status {status_response.status_code}: {error_detail}")
162
+
163
+ if progress_callback:
164
+ progress_callback(f"Task still processing... (status: {status_response.status_code})")
165
+
166
+ async def health_check(self) -> HealthStatus:
167
+ """Check the health status of the Boltz-2 service."""
168
+ try:
169
+ headers = self._get_headers()
170
+ async with httpx.AsyncClient(timeout=10.0) as client:
171
+ response = await client.get(self.health_url, headers=headers)
172
+ response.raise_for_status()
173
+
174
+ return HealthStatus(
175
+ status="healthy" if response.status_code == 200 else "unhealthy",
176
+ timestamp=datetime.now(),
177
+ details={"status_code": response.status_code}
178
+ )
179
+ except Exception as e:
180
+ raise Boltz2ConnectionError(f"Health check failed: {e}")
181
+
182
+ async def get_service_metadata(self) -> ServiceMetadata:
183
+ """Get service metadata and model information."""
184
+ try:
185
+ headers = self._get_headers()
186
+ async with httpx.AsyncClient(timeout=30.0) as client:
187
+ response = await client.get(self.metadata_url, headers=headers)
188
+ response.raise_for_status()
189
+ data = response.json()
190
+ return ServiceMetadata(**data)
191
+ except Exception as e:
192
+ raise Boltz2APIError(f"Failed to get service metadata: {e}")
193
+
194
+ async def predict(
195
+ self,
196
+ request: PredictionRequest,
197
+ save_structures: bool = True,
198
+ output_dir: Optional[Path] = None,
199
+ progress_callback: Optional[callable] = None
200
+ ) -> PredictionResponse:
201
+ """
202
+ Make a structure prediction request with comprehensive parameter support.
203
+
204
+ Args:
205
+ request: Complete prediction request with all parameters
206
+ save_structures: Whether to save structures to files
207
+ output_dir: Directory to save structures (default: current directory)
208
+ progress_callback: Optional callback for progress updates
209
+
210
+ Returns:
211
+ Prediction response with structures and metadata
212
+ """
213
+ if output_dir is None:
214
+ output_dir = Path.cwd()
215
+
216
+ try:
217
+ # Validate request
218
+ request_dict = request.dict(exclude_none=True)
219
+ headers = self._get_headers()
220
+
221
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
222
+ if progress_callback:
223
+ progress_callback("Sending prediction request...")
224
+
225
+ start_time = time.time()
226
+ response = await client.post(
227
+ self.predict_url,
228
+ json=request_dict,
229
+ headers=headers
230
+ )
231
+
232
+ # Handle NVIDIA hosted endpoint polling
233
+ if self.endpoint_type == EndpointType.NVIDIA_HOSTED:
234
+ response = await self._handle_nvidia_polling(client, response, progress_callback)
235
+
236
+ if response.status_code != 200:
237
+ error_detail = response.text
238
+ raise Boltz2APIError(f"Prediction failed: {response.status_code} - {error_detail}")
239
+
240
+ end_time = time.time()
241
+ prediction_time = end_time - start_time
242
+
243
+ if progress_callback:
244
+ progress_callback(f"Prediction completed in {prediction_time:.2f}s")
245
+
246
+ # Parse response
247
+ response_data = response.json()
248
+ prediction_response = PredictionResponse(**response_data)
249
+
250
+ # Save structures if requested
251
+ if save_structures:
252
+ await self._save_structures(prediction_response, output_dir, progress_callback)
253
+
254
+ return prediction_response
255
+
256
+ except httpx.TimeoutException:
257
+ raise Boltz2TimeoutError(f"Request timed out after {self.timeout} seconds")
258
+ except httpx.RequestError as e:
259
+ raise Boltz2ConnectionError(f"Connection error: {e}")
260
+ except Exception as e:
261
+ if isinstance(e, (Boltz2ClientError, Boltz2APIError, Boltz2TimeoutError)):
262
+ raise
263
+ raise Boltz2ClientError(f"Unexpected error: {e}")
264
+
265
+ async def predict_protein_structure(
266
+ self,
267
+ sequence: str,
268
+ polymer_id: str = "A",
269
+ recycling_steps: int = 3,
270
+ sampling_steps: int = 50,
271
+ diffusion_samples: int = 1,
272
+ step_scale: float = 1.638,
273
+ msa_files: Optional[List[Tuple[str, AlignmentFormat]]] = None,
274
+ **kwargs
275
+ ) -> PredictionResponse:
276
+ """
277
+ Predict protein structure with optional MSA guidance.
278
+
279
+ Args:
280
+ sequence: Protein sequence
281
+ polymer_id: Polymer identifier
282
+ recycling_steps: Number of recycling steps (1-6)
283
+ sampling_steps: Number of sampling steps (10-1000)
284
+ diffusion_samples: Number of diffusion samples (1-5)
285
+ step_scale: Step scale for diffusion (0.5-5.0)
286
+ msa_files: List of (file_path, format) tuples for MSA files
287
+ **kwargs: Additional parameters for predict()
288
+
289
+ Returns:
290
+ Prediction response
291
+ """
292
+
293
+
294
+ # Create a proper PredictionRequest and use the main predict method
295
+ # which handles file saving, progress callbacks, and all other functionality
296
+
297
+ # Build MSA records for proper integration
298
+ msa_records = None
299
+ if msa_files:
300
+ msa_records = []
301
+ for file_path, format_type in msa_files:
302
+ with open(file_path, "r") as fh:
303
+ content = fh.read()
304
+ msa_record = AlignmentFileRecord(
305
+ alignment=content,
306
+ format=format_type,
307
+ rank=len(msa_records)
308
+ )
309
+ msa_records.append(msa_record)
310
+
311
+ polymer = Polymer(
312
+ id=polymer_id,
313
+ molecule_type="protein",
314
+ sequence=sequence,
315
+ msa=msa_records
316
+ )
317
+
318
+ request = PredictionRequest(
319
+ polymers=[polymer],
320
+ recycling_steps=recycling_steps,
321
+ sampling_steps=sampling_steps,
322
+ diffusion_samples=diffusion_samples,
323
+ step_scale=step_scale
324
+ )
325
+
326
+ return await self.predict(request, **kwargs)
327
+
328
+ async def predict_protein_ligand_complex(
329
+ self,
330
+ protein_sequence: str,
331
+ ligand_smiles: Optional[str] = None,
332
+ ligand_ccd: Optional[str] = None,
333
+ protein_id: str = "A",
334
+ ligand_id: str = "LIG",
335
+ pocket_residues: Optional[List[int]] = None,
336
+ recycling_steps: int = 3,
337
+ sampling_steps: int = 50,
338
+ **kwargs
339
+ ) -> PredictionResponse:
340
+ """
341
+ Predict protein-ligand complex structure.
342
+
343
+ Args:
344
+ protein_sequence: Protein sequence
345
+ ligand_smiles: SMILES string for ligand (mutually exclusive with ligand_ccd)
346
+ ligand_ccd: CCD code for ligand (mutually exclusive with ligand_smiles)
347
+ protein_id: Protein polymer identifier
348
+ ligand_id: Ligand identifier
349
+ pocket_residues: List of residue indices defining binding pocket
350
+ recycling_steps: Number of recycling steps
351
+ sampling_steps: Number of sampling steps
352
+ **kwargs: Additional parameters for predict()
353
+
354
+ Returns:
355
+ Prediction response
356
+ """
357
+ if not ligand_smiles and not ligand_ccd:
358
+ raise Boltz2ValidationError("Must provide either ligand_smiles or ligand_ccd")
359
+
360
+ polymer = Polymer(
361
+ id=protein_id,
362
+ molecule_type="protein",
363
+ sequence=protein_sequence
364
+ )
365
+
366
+ ligand = Ligand(
367
+ id=ligand_id,
368
+ smiles=ligand_smiles,
369
+ ccd=ligand_ccd
370
+ )
371
+
372
+ constraints = []
373
+ if pocket_residues:
374
+ pocket_constraint = PocketConstraint(
375
+ ligand_id=ligand_id,
376
+ polymer_id=protein_id,
377
+ residue_ids=pocket_residues,
378
+ binder=ligand_id,
379
+ contacts=[] # Leave empty to avoid server validation issues
380
+ )
381
+ constraints.append(pocket_constraint)
382
+
383
+ request = PredictionRequest(
384
+ polymers=[polymer],
385
+ ligands=[ligand],
386
+ constraints=constraints if constraints else None,
387
+ recycling_steps=recycling_steps,
388
+ sampling_steps=sampling_steps
389
+ )
390
+
391
+ return await self.predict(request, **kwargs)
392
+
393
+ async def predict_covalent_complex(
394
+ self,
395
+ protein_sequence: str,
396
+ ligand_ccd: str, # Only CCD codes supported for covalent bonding
397
+ covalent_bonds: List[Tuple[int, str, str]] = None,
398
+ protein_id: str = "A",
399
+ ligand_id: str = "LIG",
400
+ recycling_steps: int = 3,
401
+ sampling_steps: int = 50,
402
+ **kwargs
403
+ ) -> PredictionResponse:
404
+ """
405
+ Predict covalent protein-ligand complex with bond constraints.
406
+
407
+ Note: Covalent bonding only supports CCD codes for ligands, not SMILES.
408
+
409
+ Args:
410
+ protein_sequence: Protein sequence
411
+ ligand_ccd: CCD code for ligand (SMILES not supported for covalent bonding)
412
+ covalent_bonds: List of (residue_index, protein_atom, ligand_atom) tuples
413
+ protein_id: Protein polymer identifier
414
+ ligand_id: Ligand identifier
415
+ recycling_steps: Number of recycling steps
416
+ sampling_steps: Number of sampling steps
417
+ **kwargs: Additional parameters for predict()
418
+
419
+ Returns:
420
+ Prediction response
421
+ """
422
+ if not ligand_ccd:
423
+ raise Boltz2ValidationError("CCD code is required for covalent bonding (SMILES not supported)")
424
+
425
+ if not covalent_bonds:
426
+ raise Boltz2ValidationError("Must provide at least one covalent bond")
427
+
428
+ polymer = Polymer(
429
+ id=protein_id,
430
+ molecule_type="protein",
431
+ sequence=protein_sequence
432
+ )
433
+
434
+ ligand = Ligand(
435
+ id=ligand_id,
436
+ ccd=ligand_ccd # Only CCD supported for covalent bonding
437
+ )
438
+
439
+ # Create bond constraints
440
+ constraints = []
441
+ for residue_idx, protein_atom, ligand_atom in covalent_bonds:
442
+ bond_constraint = BondConstraint(
443
+ constraint_type="bond",
444
+ atoms=[
445
+ Atom(id=protein_id, residue_index=residue_idx, atom_name=protein_atom),
446
+ Atom(id=ligand_id, residue_index=1, atom_name=ligand_atom)
447
+ ]
448
+ )
449
+ constraints.append(bond_constraint)
450
+
451
+ request = PredictionRequest(
452
+ polymers=[polymer],
453
+ ligands=[ligand],
454
+ constraints=constraints,
455
+ recycling_steps=recycling_steps,
456
+ sampling_steps=sampling_steps
457
+ )
458
+
459
+ return await self.predict(request, **kwargs)
460
+
461
+ async def predict_dna_protein_complex(
462
+ self,
463
+ protein_sequences: List[str],
464
+ dna_sequences: List[str],
465
+ protein_ids: Optional[List[str]] = None,
466
+ dna_ids: Optional[List[str]] = None,
467
+ recycling_steps: int = 3,
468
+ sampling_steps: int = 50,
469
+ concatenate_msas: bool = False,
470
+ **kwargs
471
+ ) -> PredictionResponse:
472
+ """
473
+ Predict DNA-protein complex structure.
474
+
475
+ Args:
476
+ protein_sequences: List of protein sequences
477
+ dna_sequences: List of DNA sequences
478
+ protein_ids: List of protein identifiers (default: A, B, ...)
479
+ dna_ids: List of DNA identifiers (default: C, D, ...)
480
+ recycling_steps: Number of recycling steps
481
+ sampling_steps: Number of sampling steps
482
+ concatenate_msas: Whether to concatenate MSAs
483
+ **kwargs: Additional parameters for predict()
484
+
485
+ Returns:
486
+ Prediction response
487
+ """
488
+ if not protein_ids:
489
+ protein_ids = [chr(65 + i) for i in range(len(protein_sequences))] # A, B, C...
490
+
491
+ if not dna_ids:
492
+ start_idx = len(protein_sequences)
493
+ dna_ids = [chr(65 + start_idx + i) for i in range(len(dna_sequences))]
494
+
495
+ polymers = []
496
+
497
+ # Add proteins
498
+ for seq, pid in zip(protein_sequences, protein_ids):
499
+ polymers.append(Polymer(
500
+ id=pid,
501
+ molecule_type="protein",
502
+ sequence=seq
503
+ ))
504
+
505
+ # Add DNA
506
+ for seq, did in zip(dna_sequences, dna_ids):
507
+ polymers.append(Polymer(
508
+ id=did,
509
+ molecule_type="dna",
510
+ sequence=seq
511
+ ))
512
+
513
+ request = PredictionRequest(
514
+ polymers=polymers,
515
+ recycling_steps=recycling_steps,
516
+ sampling_steps=sampling_steps,
517
+ concatenate_msas=concatenate_msas
518
+ )
519
+
520
+ return await self.predict(request, **kwargs)
521
+
522
+ async def predict_with_advanced_parameters(
523
+ self,
524
+ polymers: List[Polymer],
525
+ ligands: Optional[List[Ligand]] = None,
526
+ constraints: Optional[List[Union[PocketConstraint, BondConstraint]]] = None,
527
+ recycling_steps: int = 3,
528
+ sampling_steps: int = 50,
529
+ diffusion_samples: int = 1,
530
+ step_scale: float = 1.638,
531
+ without_potentials: bool = False,
532
+ concatenate_msas: bool = False,
533
+ **kwargs
534
+ ) -> PredictionResponse:
535
+ """
536
+ Predict structure with full control over all advanced parameters.
537
+
538
+ Args:
539
+ polymers: List of polymers (proteins, DNA, RNA)
540
+ ligands: Optional list of ligands
541
+ constraints: Optional list of constraints
542
+ recycling_steps: Number of recycling steps (1-6)
543
+ sampling_steps: Number of sampling steps (10-1000)
544
+ diffusion_samples: Number of diffusion samples (1-5)
545
+ step_scale: Step scale for diffusion sampling (0.5-5.0)
546
+ without_potentials: Whether to run without potentials
547
+ concatenate_msas: Whether to concatenate MSAs
548
+ **kwargs: Additional parameters for predict()
549
+
550
+ Returns:
551
+ Prediction response
552
+ """
553
+ request = PredictionRequest(
554
+ polymers=polymers,
555
+ ligands=ligands,
556
+ constraints=constraints,
557
+ recycling_steps=recycling_steps,
558
+ sampling_steps=sampling_steps,
559
+ diffusion_samples=diffusion_samples,
560
+ step_scale=step_scale,
561
+ without_potentials=without_potentials,
562
+ concatenate_msas=concatenate_msas
563
+ )
564
+
565
+ return await self.predict(request, **kwargs)
566
+
567
+ async def predict_from_yaml_config(
568
+ self,
569
+ yaml_config: Union[str, Path, YAMLConfig],
570
+ msa_dir: Optional[Path] = None,
571
+ save_structures: bool = True,
572
+ output_dir: Optional[Path] = None,
573
+ progress_callback: Optional[callable] = None,
574
+ recycling_steps: Optional[int] = None,
575
+ sampling_steps: Optional[int] = None,
576
+ diffusion_samples: Optional[int] = None,
577
+ step_scale: Optional[float] = None,
578
+ without_potentials: Optional[bool] = None,
579
+ concatenate_msas: Optional[bool] = None,
580
+ **kwargs
581
+ ) -> PredictionResponse:
582
+ """
583
+ Predict structure from YAML configuration file (official Boltz format).
584
+
585
+ This method supports the official Boltz YAML configuration format as used
586
+ in the original Boltz repository examples.
587
+
588
+ Args:
589
+ yaml_config: YAML configuration (file path, string content, or YAMLConfig object)
590
+ msa_dir: Directory containing MSA files referenced in YAML
591
+ save_structures: Whether to save structures to files
592
+ output_dir: Directory to save structures
593
+ progress_callback: Optional callback for progress updates
594
+ recycling_steps: Override recycling steps parameter
595
+ sampling_steps: Override sampling steps parameter
596
+ diffusion_samples: Override diffusion samples parameter
597
+ step_scale: Override step scale parameter
598
+ without_potentials: Override without potentials parameter
599
+ concatenate_msas: Override concatenate MSAs parameter
600
+ **kwargs: Additional parameters for predict()
601
+
602
+ Returns:
603
+ Prediction response
604
+
605
+ Example YAML format:
606
+ version: 1
607
+ sequences:
608
+ - protein:
609
+ id: A
610
+ sequence: "MKTVRQERLK..."
611
+ msa: "protein_A.a3m" # optional
612
+ - ligand:
613
+ id: B
614
+ smiles: "CC(=O)O"
615
+ properties: # optional
616
+ affinity:
617
+ binder: B
618
+ """
619
+ # Parse YAML config
620
+ if isinstance(yaml_config, YAMLConfig):
621
+ config = yaml_config
622
+ else:
623
+ if isinstance(yaml_config, (str, Path)):
624
+ yaml_path = Path(yaml_config)
625
+ if yaml_path.exists():
626
+ # Load from file
627
+ yaml_content = yaml_path.read_text()
628
+ yaml_data = yaml.safe_load(yaml_content)
629
+ config_dir = yaml_path.parent
630
+ else:
631
+ # Treat as YAML string content
632
+ yaml_data = yaml.safe_load(yaml_config)
633
+ config_dir = Path.cwd()
634
+ else:
635
+ raise ValueError("yaml_config must be a file path, YAML string, or YAMLConfig object")
636
+
637
+ config = YAMLConfig(**yaml_data)
638
+
639
+ # Convert to PredictionRequest
640
+ request = config.to_prediction_request()
641
+
642
+ # Override parameters if provided
643
+ if recycling_steps is not None:
644
+ request.recycling_steps = recycling_steps
645
+ if sampling_steps is not None:
646
+ request.sampling_steps = sampling_steps
647
+ if diffusion_samples is not None:
648
+ request.diffusion_samples = diffusion_samples
649
+ if step_scale is not None:
650
+ request.step_scale = step_scale
651
+ if without_potentials is not None:
652
+ request.without_potentials = without_potentials
653
+ if concatenate_msas is not None:
654
+ request.concatenate_msas = concatenate_msas
655
+
656
+ # Handle MSA files
657
+ if msa_dir is None:
658
+ msa_dir = config_dir if 'config_dir' in locals() else Path.cwd()
659
+
660
+ # Load MSA files for proteins that reference them
661
+ for i, seq in enumerate(config.sequences):
662
+ if seq.protein and seq.protein.msa and seq.protein.msa != "empty":
663
+ msa_path = msa_dir / seq.protein.msa
664
+ if msa_path.exists():
665
+ msa_content = msa_path.read_text()
666
+ # Determine format from extension
667
+ format_map = {
668
+ '.a3m': 'a3m',
669
+ '.sto': 'sto',
670
+ '.fasta': 'fasta',
671
+ '.csv': 'csv'
672
+ }
673
+ format_type = format_map.get(msa_path.suffix.lower(), 'a3m')
674
+
675
+ msa_record = AlignmentFileRecord(
676
+ alignment=msa_content,
677
+ format=format_type,
678
+ rank=0
679
+ )
680
+
681
+ # Update the corresponding polymer with MSA
682
+ polymer_idx = sum(1 for s in config.sequences[:i] if s.protein)
683
+ if polymer_idx < len(request.polymers):
684
+ request.polymers[polymer_idx].msa = [msa_record]
685
+ else:
686
+ self.console.print(f"⚠️ MSA file not found: {msa_path}", style="yellow")
687
+
688
+ return await self.predict(
689
+ request,
690
+ save_structures=save_structures,
691
+ output_dir=output_dir,
692
+ progress_callback=progress_callback,
693
+ **kwargs
694
+ )
695
+
696
+ async def predict_from_yaml_file(
697
+ self,
698
+ yaml_file: Union[str, Path],
699
+ **kwargs
700
+ ) -> PredictionResponse:
701
+ """
702
+ Predict structure from YAML configuration file.
703
+
704
+ Args:
705
+ yaml_file: Path to YAML configuration file
706
+ **kwargs: Additional parameters for predict()
707
+
708
+ Returns:
709
+ Prediction response
710
+ """
711
+ yaml_path = Path(yaml_file)
712
+ if not yaml_path.exists():
713
+ raise FileNotFoundError(f"YAML file not found: {yaml_path}")
714
+
715
+ # Set msa_dir to yaml parent directory only if not already provided
716
+ if 'msa_dir' not in kwargs:
717
+ kwargs['msa_dir'] = yaml_path.parent
718
+
719
+ return await self.predict_from_yaml_config(
720
+ yaml_path,
721
+ **kwargs
722
+ )
723
+
724
+ def create_yaml_config(
725
+ self,
726
+ proteins: Optional[List[Tuple[str, str, Optional[str]]]] = None,
727
+ ligands: Optional[List[Tuple[str, str]]] = None,
728
+ predict_affinity: bool = False,
729
+ binder_id: Optional[str] = None
730
+ ) -> YAMLConfig:
731
+ """
732
+ Create a YAML configuration object programmatically.
733
+
734
+ Args:
735
+ proteins: List of (id, sequence, msa_file) tuples
736
+ ligands: List of (id, smiles) tuples
737
+ predict_affinity: Whether to predict binding affinity
738
+ binder_id: ID of the binding molecule for affinity prediction
739
+
740
+ Returns:
741
+ YAMLConfig object
742
+
743
+ Example:
744
+ config = client.create_yaml_config(
745
+ proteins=[("A", "MKTVRQERLK...", None)],
746
+ ligands=[("B", "CC(=O)O")],
747
+ predict_affinity=True,
748
+ binder_id="B"
749
+ )
750
+ """
751
+ from .models import YAMLProtein, YAMLLigand, YAMLSequence, YAMLAffinity, YAMLProperties
752
+
753
+ sequences = []
754
+
755
+ # Add proteins
756
+ if proteins:
757
+ for protein_id, sequence, msa_file in proteins:
758
+ protein = YAMLProtein(
759
+ id=protein_id,
760
+ sequence=sequence,
761
+ msa=msa_file
762
+ )
763
+ sequences.append(YAMLSequence(protein=protein))
764
+
765
+ # Add ligands
766
+ if ligands:
767
+ for ligand_id, smiles in ligands:
768
+ ligand = YAMLLigand(
769
+ id=ligand_id,
770
+ smiles=smiles
771
+ )
772
+ sequences.append(YAMLSequence(ligand=ligand))
773
+
774
+ # Add properties
775
+ properties = None
776
+ if predict_affinity:
777
+ if not binder_id:
778
+ raise ValueError("binder_id must be specified when predict_affinity=True")
779
+ properties = YAMLProperties(
780
+ affinity=YAMLAffinity(binder=binder_id)
781
+ )
782
+
783
+ return YAMLConfig(
784
+ version=1,
785
+ sequences=sequences,
786
+ properties=properties
787
+ )
788
+
789
+ def save_yaml_config(
790
+ self,
791
+ config: YAMLConfig,
792
+ output_path: Union[str, Path]
793
+ ) -> Path:
794
+ """
795
+ Save YAML configuration to file.
796
+
797
+ Args:
798
+ config: YAMLConfig object
799
+ output_path: Output file path
800
+
801
+ Returns:
802
+ Path to saved file
803
+ """
804
+ output_path = Path(output_path)
805
+
806
+ # Convert to dict and save as YAML
807
+ config_dict = config.dict(exclude_none=True)
808
+
809
+ with open(output_path, 'w') as f:
810
+ yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False)
811
+
812
+ return output_path
813
+
814
+ async def _save_structures(
815
+ self,
816
+ response: PredictionResponse,
817
+ output_dir: Path,
818
+ progress_callback: Optional[callable] = None
819
+ ) -> List[Path]:
820
+ """Save prediction structures to files."""
821
+ output_dir.mkdir(parents=True, exist_ok=True)
822
+ saved_files = []
823
+
824
+ for i, structure in enumerate(response.structures):
825
+ # Save structure file
826
+ if structure.format.lower() == 'mmcif':
827
+ structure_file = output_dir / f"structure_{i}.cif"
828
+ else:
829
+ structure_file = output_dir / f"structure_{i}.pdb"
830
+
831
+ structure_file.write_text(structure.structure)
832
+ saved_files.append(structure_file)
833
+
834
+ if progress_callback:
835
+ progress_callback(f"Saved structure to {structure_file}")
836
+
837
+ # Save metadata
838
+ metadata = {
839
+ "confidence_scores": response.confidence_scores,
840
+ "metrics": response.metrics,
841
+ "timestamp": datetime.now().isoformat()
842
+ }
843
+
844
+ metadata_file = output_dir / "prediction_metadata.json"
845
+ metadata_file.write_text(json.dumps(metadata, indent=2))
846
+ saved_files.append(metadata_file)
847
+
848
+ if progress_callback:
849
+ progress_callback(f"Saved metadata to {metadata_file}")
850
+
851
+ return saved_files
852
+
853
+
854
+ class Boltz2SyncClient:
855
+ """
856
+ Synchronous wrapper for the Boltz-2 client.
857
+
858
+ Provides the same functionality as Boltz2Client but with synchronous methods.
859
+ Supports both local deployments and NVIDIA hosted endpoints.
860
+ """
861
+
862
+ def __init__(
863
+ self,
864
+ base_url: str = "http://localhost:8000",
865
+ api_key: Optional[str] = None,
866
+ endpoint_type: str = EndpointType.LOCAL,
867
+ timeout: float = 300.0,
868
+ max_retries: int = 3,
869
+ retry_delay: float = 1.0,
870
+ poll_seconds: int = 10,
871
+ console: Optional[Console] = None
872
+ ):
873
+ """Initialize the synchronous client."""
874
+ self._async_client = Boltz2Client(
875
+ base_url=base_url,
876
+ api_key=api_key,
877
+ endpoint_type=endpoint_type,
878
+ timeout=timeout,
879
+ max_retries=max_retries,
880
+ retry_delay=retry_delay,
881
+ poll_seconds=poll_seconds,
882
+ console=console
883
+ )
884
+
885
+ @property
886
+ def base_url(self) -> str:
887
+ """Get the base URL."""
888
+ return self._async_client.base_url
889
+
890
+ @property
891
+ def timeout(self) -> float:
892
+ """Get the timeout value."""
893
+ return self._async_client.timeout
894
+
895
+ def health_check(self) -> HealthStatus:
896
+ """Check the health status of the Boltz-2 service."""
897
+ return asyncio.run(self._async_client.health_check())
898
+
899
+ def get_service_metadata(self) -> ServiceMetadata:
900
+ """Get service metadata and model information."""
901
+ return asyncio.run(self._async_client.get_service_metadata())
902
+
903
+ def predict(self, request: PredictionRequest, **kwargs) -> PredictionResponse:
904
+ """Make a structure prediction request."""
905
+ return asyncio.run(self._async_client.predict(request, **kwargs))
906
+
907
+ def predict_protein_structure(self, **kwargs) -> PredictionResponse:
908
+ """Predict protein structure."""
909
+ return asyncio.run(self._async_client.predict_protein_structure(**kwargs))
910
+
911
+ def predict_protein_ligand_complex(self, **kwargs) -> PredictionResponse:
912
+ """Predict protein-ligand complex structure."""
913
+ return asyncio.run(self._async_client.predict_protein_ligand_complex(**kwargs))
914
+
915
+ def predict_covalent_complex(self, **kwargs) -> PredictionResponse:
916
+ """Predict covalent protein-ligand complex."""
917
+ return asyncio.run(self._async_client.predict_covalent_complex(**kwargs))
918
+
919
+ def predict_dna_protein_complex(self, **kwargs) -> PredictionResponse:
920
+ """Predict DNA-protein complex structure."""
921
+ return asyncio.run(self._async_client.predict_dna_protein_complex(**kwargs))
922
+
923
+ def predict_with_advanced_parameters(self, **kwargs) -> PredictionResponse:
924
+ """Make prediction with full parameter control."""
925
+ return asyncio.run(self._async_client.predict_with_advanced_parameters(**kwargs))
926
+
927
+
928
+ # Convenience functions for quick predictions
929
+ async def predict_protein(
930
+ sequence: str,
931
+ base_url: str = "http://localhost:8000",
932
+ api_key: Optional[str] = None,
933
+ endpoint_type: str = EndpointType.LOCAL,
934
+ **kwargs
935
+ ) -> PredictionResponse:
936
+ """Quick protein structure prediction."""
937
+ client = Boltz2Client(
938
+ base_url=base_url,
939
+ api_key=api_key,
940
+ endpoint_type=endpoint_type
941
+ )
942
+ return await client.predict_protein_structure(sequence=sequence, **kwargs)
943
+
944
+
945
+ async def predict_protein_ligand(
946
+ protein_sequence: str,
947
+ ligand_smiles: str,
948
+ base_url: str = "http://localhost:8000",
949
+ api_key: Optional[str] = None,
950
+ endpoint_type: str = EndpointType.LOCAL,
951
+ **kwargs
952
+ ) -> PredictionResponse:
953
+ """Quick protein-ligand complex prediction."""
954
+ client = Boltz2Client(
955
+ base_url=base_url,
956
+ api_key=api_key,
957
+ endpoint_type=endpoint_type
958
+ )
959
+ return await client.predict_protein_ligand_complex(
960
+ protein_sequence=protein_sequence,
961
+ ligand_smiles=ligand_smiles,
962
+ **kwargs
963
+ )
964
+
965
+
966
+ async def predict_covalent(
967
+ protein_sequence: str,
968
+ ligand_ccd: str,
969
+ covalent_bonds: List[Tuple[int, str, str]],
970
+ base_url: str = "http://localhost:8000",
971
+ api_key: Optional[str] = None,
972
+ endpoint_type: str = EndpointType.LOCAL,
973
+ **kwargs
974
+ ) -> PredictionResponse:
975
+ """Quick covalent complex prediction."""
976
+ client = Boltz2Client(
977
+ base_url=base_url,
978
+ api_key=api_key,
979
+ endpoint_type=endpoint_type
980
+ )
981
+ return await client.predict_covalent_complex(
982
+ protein_sequence=protein_sequence,
983
+ ligand_ccd=ligand_ccd,
984
+ covalent_bonds=covalent_bonds,
985
+ **kwargs
986
+ )