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,213 @@
1
+ # ---------------------------------------------------------------
2
+ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
3
+ # ---------------------------------------------------------------
4
+
5
+
6
+ """
7
+ Custom exceptions for the Boltz-2 Python client.
8
+
9
+ This module defines exception classes that provide specific error handling
10
+ for different types of failures that can occur when using the Boltz-2 API.
11
+ """
12
+
13
+ from typing import Optional, Dict, Any
14
+
15
+
16
+ class Boltz2Error(Exception):
17
+ """Base exception class for all Boltz-2 client errors."""
18
+
19
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.details = details or {}
23
+
24
+ def __str__(self) -> str:
25
+ if self.details:
26
+ return f"{self.message} (Details: {self.details})"
27
+ return self.message
28
+
29
+
30
+ # Alias for backward compatibility
31
+ Boltz2ClientError = Boltz2Error
32
+
33
+
34
+ class Boltz2APIError(Boltz2Error):
35
+ """Exception raised when the API returns an error response."""
36
+
37
+ def __init__(
38
+ self,
39
+ message: str,
40
+ status_code: Optional[int] = None,
41
+ response_data: Optional[Dict[str, Any]] = None,
42
+ details: Optional[Dict[str, Any]] = None
43
+ ):
44
+ super().__init__(message, details)
45
+ self.status_code = status_code
46
+ self.response_data = response_data or {}
47
+
48
+ def __str__(self) -> str:
49
+ parts = [self.message]
50
+ if self.status_code:
51
+ parts.append(f"Status: {self.status_code}")
52
+ if self.response_data:
53
+ parts.append(f"Response: {self.response_data}")
54
+ if self.details:
55
+ parts.append(f"Details: {self.details}")
56
+ return " | ".join(parts)
57
+
58
+
59
+ class Boltz2TimeoutError(Boltz2Error):
60
+ """Exception raised when a request times out."""
61
+
62
+ def __init__(self, message: str, timeout_seconds: Optional[float] = None):
63
+ super().__init__(message)
64
+ self.timeout_seconds = timeout_seconds
65
+
66
+ def __str__(self) -> str:
67
+ if self.timeout_seconds:
68
+ return f"{self.message} (Timeout: {self.timeout_seconds}s)"
69
+ return self.message
70
+
71
+
72
+ class Boltz2ConnectionError(Boltz2Error):
73
+ """Exception raised when there are connection issues."""
74
+
75
+ def __init__(self, message: str, endpoint: Optional[str] = None):
76
+ super().__init__(message)
77
+ self.endpoint = endpoint
78
+
79
+ def __str__(self) -> str:
80
+ if self.endpoint:
81
+ return f"{self.message} (Endpoint: {self.endpoint})"
82
+ return self.message
83
+
84
+
85
+ class Boltz2ValidationError(Boltz2Error):
86
+ """Exception raised when input validation fails."""
87
+
88
+ def __init__(self, message: str, field: Optional[str] = None, value: Optional[Any] = None, details: Optional[Dict[str, Any]] = None):
89
+ super().__init__(message, details)
90
+ self.field = field
91
+ self.value = value
92
+
93
+ def __str__(self) -> str:
94
+ parts = [self.message]
95
+ if self.field:
96
+ parts.append(f"Field: {self.field}")
97
+ if self.value is not None:
98
+ parts.append(f"Value: {self.value}")
99
+ if self.details:
100
+ parts.append(f"Details: {self.details}")
101
+ return " | ".join(parts)
102
+
103
+
104
+ class Boltz2AuthenticationError(Boltz2Error):
105
+ """Exception raised when authentication fails."""
106
+ pass
107
+
108
+
109
+ class Boltz2RateLimitError(Boltz2APIError):
110
+ """Exception raised when rate limits are exceeded."""
111
+
112
+ def __init__(
113
+ self,
114
+ message: str = "Rate limit exceeded",
115
+ retry_after: Optional[int] = None,
116
+ **kwargs
117
+ ):
118
+ super().__init__(message, **kwargs)
119
+ self.retry_after = retry_after
120
+
121
+ def __str__(self) -> str:
122
+ base_str = super().__str__()
123
+ if self.retry_after:
124
+ return f"{base_str} | Retry after: {self.retry_after}s"
125
+ return base_str
126
+
127
+
128
+ class Boltz2ServiceUnavailableError(Boltz2APIError):
129
+ """Exception raised when the service is unavailable."""
130
+
131
+ def __init__(self, message: str = "Service temporarily unavailable", **kwargs):
132
+ super().__init__(message, **kwargs)
133
+
134
+
135
+ class Boltz2InvalidResponseError(Boltz2Error):
136
+ """Exception raised when the API returns an invalid or unexpected response."""
137
+
138
+ def __init__(self, message: str, response_content: Optional[str] = None):
139
+ super().__init__(message)
140
+ self.response_content = response_content
141
+
142
+ def __str__(self) -> str:
143
+ if self.response_content:
144
+ # Truncate very long responses
145
+ content = self.response_content[:500]
146
+ if len(self.response_content) > 500:
147
+ content += "..."
148
+ return f"{self.message} | Response: {content}"
149
+ return self.message
150
+
151
+
152
+ class Boltz2ConfigurationError(Boltz2Error):
153
+ """Exception raised when there are configuration issues."""
154
+ pass
155
+
156
+
157
+ # Convenience function to create appropriate exceptions from HTTP responses
158
+ def create_api_exception(
159
+ status_code: int,
160
+ response_text: str,
161
+ endpoint: Optional[str] = None
162
+ ) -> Boltz2APIError:
163
+ """
164
+ Create an appropriate API exception based on the HTTP status code.
165
+
166
+ Args:
167
+ status_code: HTTP status code
168
+ response_text: Response body text
169
+ endpoint: API endpoint that was called
170
+
171
+ Returns:
172
+ Appropriate Boltz2APIError subclass
173
+ """
174
+ details = {"endpoint": endpoint} if endpoint else {}
175
+
176
+ if status_code == 401:
177
+ return Boltz2AuthenticationError(
178
+ "Authentication failed",
179
+ details=details
180
+ )
181
+ elif status_code == 429:
182
+ return Boltz2RateLimitError(
183
+ "Rate limit exceeded",
184
+ status_code=status_code,
185
+ response_data={"text": response_text},
186
+ details=details
187
+ )
188
+ elif status_code == 503:
189
+ return Boltz2ServiceUnavailableError(
190
+ "Service unavailable",
191
+ status_code=status_code,
192
+ response_data={"text": response_text},
193
+ details=details
194
+ )
195
+ elif 400 <= status_code < 500:
196
+ return Boltz2ValidationError(
197
+ f"Client error: {response_text}",
198
+ details={**details, "status_code": status_code}
199
+ )
200
+ elif 500 <= status_code < 600:
201
+ return Boltz2APIError(
202
+ f"Server error: {response_text}",
203
+ status_code=status_code,
204
+ response_data={"text": response_text},
205
+ details=details
206
+ )
207
+ else:
208
+ return Boltz2APIError(
209
+ f"Unexpected status code {status_code}: {response_text}",
210
+ status_code=status_code,
211
+ response_data={"text": response_text},
212
+ details=details
213
+ )
@@ -0,0 +1,466 @@
1
+ # ---------------------------------------------------------------
2
+ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
3
+ # ---------------------------------------------------------------
4
+
5
+
6
+ """
7
+ Data models for Boltz-2 API requests and responses.
8
+
9
+ This module defines Pydantic models that represent the data structures
10
+ used by the Boltz-2 API, providing type safety and validation for all
11
+ available parameters and features.
12
+ """
13
+
14
+ from typing import List, Optional, Dict, Any, Literal, Union
15
+ from pydantic import BaseModel, Field, validator
16
+ from datetime import datetime
17
+ import re
18
+ from .models_affinity import AffinityPrediction
19
+
20
+
21
+ class Modification(BaseModel):
22
+ """Represents a molecular modification."""
23
+ type: str = Field(..., description="Type of modification")
24
+ position: int = Field(..., description="Position of modification")
25
+ details: Optional[Dict[str, Any]] = Field(None, description="Additional modification details")
26
+
27
+
28
+ class AlignmentFileRecord(BaseModel):
29
+ """Represents a single alignment file record."""
30
+ alignment: str = Field(..., description="Raw alignment file content as string")
31
+ format: Literal["sto", "a3m", "csv", "fasta"] = Field(..., description="Alignment file format")
32
+ rank: int = Field(-1, description="Integer rank to define ordering of alignments")
33
+
34
+ @validator('alignment')
35
+ def validate_alignment_content(cls, v):
36
+ """Validate alignment content is not empty."""
37
+ if not v.strip():
38
+ raise ValueError("Alignment content cannot be empty")
39
+ return v
40
+
41
+
42
+ class Polymer(BaseModel):
43
+ """Represents a polymer (protein, DNA, or RNA) in the prediction request."""
44
+
45
+ id: str = Field(..., description="Unique identifier for the polymer (A-Z or 4 alphanumeric chars)")
46
+ molecule_type: Literal["protein", "dna", "rna"] = Field(..., description="Type of molecule")
47
+ sequence: str = Field(..., description="Sequence string")
48
+ cyclic: bool = Field(False, description="Whether the polymer is cyclic")
49
+ modifications: List[Modification] = Field(default_factory=list, description="List of modifications")
50
+ msa: Optional[List[AlignmentFileRecord]] = Field(None, description="Multiple Sequence Alignments")
51
+
52
+ @validator('sequence')
53
+ def validate_sequence(cls, v, values):
54
+ """Validate sequence based on molecule type."""
55
+ if 'molecule_type' not in values:
56
+ return v
57
+
58
+ molecule_type = values['molecule_type']
59
+
60
+ if molecule_type == "protein":
61
+ # Standard amino acid codes
62
+ valid_chars = set("ACDEFGHIKLMNPQRSTVWY")
63
+ if not all(c.upper() in valid_chars for c in v):
64
+ raise ValueError(f"Invalid amino acid characters in protein sequence: {v}")
65
+ elif molecule_type == "dna":
66
+ # Standard DNA bases
67
+ valid_chars = set("ATCG")
68
+ if not all(c.upper() in valid_chars for c in v):
69
+ raise ValueError(f"Invalid DNA base characters in sequence: {v}")
70
+ elif molecule_type == "rna":
71
+ # Standard RNA bases
72
+ valid_chars = set("AUCG")
73
+ if not all(c.upper() in valid_chars for c in v):
74
+ raise ValueError(f"Invalid RNA base characters in sequence: {v}")
75
+
76
+ return v.upper()
77
+
78
+ @validator('id')
79
+ def validate_id(cls, v):
80
+ """Validate polymer ID format (single letter A-Z or 4 alphanumeric chars)."""
81
+ if re.match(r'^[A-Z]$', v):
82
+ return v # Single letter A-Z
83
+ elif re.match(r'^[A-Za-z0-9]{4}$', v):
84
+ return v # 4 alphanumeric characters
85
+ else:
86
+ raise ValueError("Polymer ID must be either a single letter (A-Z) or 4 alphanumeric characters")
87
+
88
+
89
+ class Ligand(BaseModel):
90
+ """Represents a ligand in the prediction request."""
91
+
92
+ id: str = Field(..., description="Unique identifier for the ligand")
93
+ smiles: Optional[str] = Field(None, description="SMILES string representation")
94
+ ccd: Optional[str] = Field(None, description="Chemical Component Dictionary (CCD) code")
95
+ predict_affinity: Optional[bool] = Field(False, description="Run affinity prediction for this ligand. Note: only one ligand per request can have this enabled")
96
+
97
+ @validator('smiles')
98
+ def validate_smiles(cls, v):
99
+ """Basic SMILES validation."""
100
+ if v is not None:
101
+ if not v.strip():
102
+ raise ValueError("SMILES string cannot be empty")
103
+ # Basic character validation - could be enhanced with RDKit
104
+ if any(char in v for char in [' ', '\t', '\n']):
105
+ raise ValueError("SMILES string should not contain whitespace")
106
+ return v.strip()
107
+ return v
108
+
109
+ @validator('ccd')
110
+ def validate_ccd(cls, v):
111
+ """Basic CCD code validation."""
112
+ if v is not None:
113
+ if not v.strip():
114
+ raise ValueError("CCD code cannot be empty")
115
+ # CCD codes are typically 3-4 character alphanumeric codes
116
+ if not re.match(r'^[A-Za-z0-9]{2,4}$', v.strip()):
117
+ raise ValueError("CCD code must be 2-4 alphanumeric characters")
118
+ return v.strip().upper()
119
+ return v
120
+
121
+ @validator('ccd', always=True)
122
+ def validate_smiles_or_ccd(cls, v, values):
123
+ """Ensure either SMILES or CCD is provided, but not both."""
124
+ smiles = values.get('smiles')
125
+ if smiles and v:
126
+ raise ValueError("Cannot specify both SMILES and CCD code")
127
+ if not smiles and not v:
128
+ raise ValueError("Must specify either SMILES or CCD code")
129
+ return v
130
+
131
+ @validator('id')
132
+ def validate_id(cls, v):
133
+ """Validate ligand ID format."""
134
+ if not re.match(r'^[A-Za-z0-9_-]+$', v):
135
+ raise ValueError("Ligand ID must contain only alphanumeric characters, underscores, and hyphens")
136
+ return v
137
+
138
+
139
+ class Atom(BaseModel):
140
+ """Represents an atom in constraints."""
141
+ id: Optional[str] = Field(None, description="Polymer/ligand ID (chain identifier)")
142
+ residue_index: int = Field(..., description="Residue index (1-based)")
143
+ atom_name: str = Field(..., description="Atom name (e.g., 'CA', 'SG', 'C22')")
144
+
145
+ @validator('id')
146
+ def validate_id(cls, v):
147
+ """Validate atom ID format to match server-side validation."""
148
+ if v is not None:
149
+ # Server pattern: ^([A-Z]+|[A-Za-z0-9]{4})$
150
+ # One or more letters (A-Z+) OR exactly 4 alphanumeric characters
151
+ if re.match(r'^[A-Z]+$', v) or re.match(r'^[A-Za-z0-9]{4}$', v):
152
+ return v
153
+ else:
154
+ raise ValueError("Atom ID must be either one or more letters (A-Z) or exactly 4 alphanumeric characters")
155
+ return v
156
+
157
+
158
+ class PocketConstraint(BaseModel):
159
+ """Represents a pocket constraint."""
160
+ constraint_type: str = Field("pocket", description="Type of constraint")
161
+ ligand_id: str = Field(..., description="ID of the ligand")
162
+ polymer_id: str = Field(..., description="ID of the polymer")
163
+ residue_ids: List[int] = Field(..., description="List of residue IDs defining the pocket")
164
+ binder: str = Field(..., description="ID of the binding molecule")
165
+ contacts: List[int] = Field(default_factory=list, description="Contact residue indices")
166
+
167
+
168
+ class BondConstraint(BaseModel):
169
+ """Represents a bond constraint between atoms."""
170
+ constraint_type: str = Field("bond", description="Type of constraint")
171
+ atoms: List[Atom] = Field(..., description="List of atoms involved in the bond (exactly 2)")
172
+
173
+ @validator('atoms')
174
+ def validate_atoms_count(cls, v):
175
+ """Validate that exactly 2 atoms are specified for a bond."""
176
+ if len(v) != 2:
177
+ raise ValueError("Bond constraint must specify exactly 2 atoms")
178
+ return v
179
+
180
+
181
+ class PredictionRequest(BaseModel):
182
+ """Complete prediction request model with all available Boltz-2 parameters."""
183
+
184
+ # Required parameters
185
+ polymers: List[Polymer] = Field(..., description="List of polymers (DNA, RNA, or Protein) - max 5, min 1")
186
+
187
+ # Optional molecular components
188
+ ligands: Optional[List[Ligand]] = Field(None, description="List of ligands - max 5, min 0")
189
+
190
+ # Constraints
191
+ constraints: Optional[List[Union[PocketConstraint, BondConstraint]]] = Field(
192
+ None, description="Optional constraints for the prediction (pocket or bond constraints)"
193
+ )
194
+
195
+ # Diffusion and sampling parameters
196
+ recycling_steps: Optional[int] = Field(
197
+ 3, ge=1, le=6,
198
+ description="The number of recycling steps to use for prediction (1-6, default: 3)"
199
+ )
200
+ sampling_steps: Optional[int] = Field(
201
+ 50, ge=10, le=1000,
202
+ description="The number of sampling steps to use for prediction (10-1000, default: 50)"
203
+ )
204
+ diffusion_samples: Optional[int] = Field(
205
+ 1, ge=1, le=5,
206
+ description="The number of diffusion samples to use for prediction (1-5, default: 1)"
207
+ )
208
+ step_scale: Optional[float] = Field(
209
+ 1.638, ge=0.5, le=5.0,
210
+ description="Step size related to temperature of diffusion sampling. Lower = higher diversity (0.5-5.0, default: 1.638)"
211
+ )
212
+
213
+ # Advanced parameters
214
+ without_potentials: Optional[bool] = Field(
215
+ False,
216
+ description="Whether to run without potentials (default: False)"
217
+ )
218
+ output_format: Optional[Literal["mmcif"]] = Field(
219
+ "mmcif",
220
+ description="Output format for structures (default: mmcif)"
221
+ )
222
+ concatenate_msas: Optional[bool] = Field(
223
+ False,
224
+ description="Concatenate Multiple Sequence Alignments for a polymer into one alignment (default: False)"
225
+ )
226
+
227
+ # Affinity prediction parameters
228
+ sampling_steps_affinity: Optional[int] = Field(
229
+ 200, ge=10, le=1000,
230
+ description="The number of sampling steps for affinity prediction. Higher values may improve accuracy but increase runtime (10-1000, default: 200)"
231
+ )
232
+ diffusion_samples_affinity: Optional[int] = Field(
233
+ 5, ge=1, le=10,
234
+ description="The number of diffusion samples for affinity prediction. Higher values may improve reliability but increase runtime (1-10, default: 5)"
235
+ )
236
+ affinity_mw_correction: Optional[bool] = Field(
237
+ False,
238
+ description="Whether to add Molecular Weight correction to the affinity prediction (default: False)"
239
+ )
240
+
241
+ @validator('polymers')
242
+ def validate_polymers_count(cls, v):
243
+ """Validate polymer count."""
244
+ if len(v) > 5:
245
+ raise ValueError("Maximum 5 polymers allowed")
246
+ if len(v) == 0:
247
+ raise ValueError("At least 1 polymer required")
248
+ return v
249
+
250
+ @validator('ligands')
251
+ def validate_ligands_count(cls, v):
252
+ """Validate ligand count and affinity prediction constraints."""
253
+ if v is not None:
254
+ if len(v) > 5:
255
+ raise ValueError("Maximum 5 ligands allowed")
256
+
257
+ # Check that only one ligand has predict_affinity=True
258
+ affinity_ligands = [lig for lig in v if getattr(lig, 'predict_affinity', False)]
259
+ if len(affinity_ligands) > 1:
260
+ raise ValueError("Only one ligand per request can have predict_affinity=True")
261
+ return v
262
+
263
+ @validator('constraints')
264
+ def validate_constraints(cls, v):
265
+ """Validate constraints format."""
266
+ if v is not None:
267
+ for constraint in v:
268
+ if isinstance(constraint, dict):
269
+ constraint_type = constraint.get('constraint_type')
270
+ if constraint_type not in ['pocket', 'bond']:
271
+ raise ValueError(f"Invalid constraint type: {constraint_type}")
272
+ return v
273
+
274
+
275
+ class StructureData(BaseModel):
276
+ """Represents structure data in the response."""
277
+
278
+ format: str = Field(..., description="Structure format (e.g., 'mmcif')")
279
+ structure: str = Field(..., description="Structure data content")
280
+ name: Optional[str] = Field(None, description="Structure name")
281
+ source: Optional[str] = Field(None, description="Structure source file")
282
+
283
+
284
+ class PredictionResponse(BaseModel):
285
+ """Complete prediction response model."""
286
+
287
+ structures: List[StructureData] = Field(..., description="Predicted structures")
288
+ confidence_scores: Optional[List[float]] = Field(None, description="Confidence scores for predictions")
289
+ metrics: Optional[Dict[str, Any]] = Field(None, description="Runtime metrics and statistics")
290
+
291
+ # Affinity prediction results
292
+ affinities: Optional[Dict[str, AffinityPrediction]] = Field(
293
+ None,
294
+ description="Predicted affinity values for ligands (keyed by ligand ID)"
295
+ )
296
+
297
+ # Additional confidence metrics
298
+ ptm_scores: Optional[List[float]] = Field(None, description="Predicted TM score for the complex")
299
+ iptm_scores: Optional[List[float]] = Field(None, description="Predicted TM score when aggregating at interfaces")
300
+ ligand_iptm_scores: Optional[List[float]] = Field(None, description="ipTM but only at protein-ligand interfaces")
301
+ protein_iptm_scores: Optional[List[float]] = Field(None, description="ipTM but only at protein-protein interfaces")
302
+ complex_plddt_scores: Optional[List[float]] = Field(None, description="Average pLDDT score for the complex")
303
+ complex_iplddt_scores: Optional[List[float]] = Field(None, description="Average pLDDT score when upweighting interface tokens")
304
+ complex_pde_scores: Optional[List[float]] = Field(None, description="Average PDE score for the complex")
305
+ complex_ipde_scores: Optional[List[float]] = Field(None, description="Average PDE score when aggregating at interfaces")
306
+ chains_ptm_scores: Optional[List[float]] = Field(None, description="Predicted TM score within each chain")
307
+ pair_chains_iptm_scores: Optional[List[Dict[str, Any]]] = Field(None, description="Predicted TM score between each pair of chains")
308
+
309
+ @validator('structures')
310
+ def validate_structures(cls, v):
311
+ """Validate structures list."""
312
+ if len(v) == 0:
313
+ raise ValueError("At least one structure must be returned")
314
+ return v
315
+
316
+
317
+ class HealthStatus(BaseModel):
318
+ """Health status response model."""
319
+
320
+ status: str = Field(..., description="Health status")
321
+ timestamp: Optional[datetime] = Field(None, description="Status timestamp")
322
+ details: Optional[Dict[str, Any]] = Field(None, description="Additional health details")
323
+
324
+
325
+ class ModelInfo(BaseModel):
326
+ """Model information."""
327
+
328
+ modelUrl: str = Field(..., description="Model URL")
329
+ shortName: str = Field(..., description="Model short name")
330
+
331
+
332
+ class LicenseInfo(BaseModel):
333
+ """License information."""
334
+
335
+ name: str = Field(..., description="License name")
336
+ path: str = Field(..., description="License file path")
337
+ sha: str = Field(..., description="License file SHA")
338
+ size: int = Field(..., description="License file size")
339
+ url: str = Field(..., description="License URL")
340
+ type: str = Field(..., description="License type")
341
+ content: str = Field(..., description="License content")
342
+
343
+
344
+ class ServiceMetadata(BaseModel):
345
+ """Service metadata response model."""
346
+
347
+ assetInfo: List[str] = Field(..., description="Asset information")
348
+ licenseInfo: LicenseInfo = Field(..., description="License information")
349
+ modelInfo: List[ModelInfo] = Field(..., description="Model information")
350
+ repository_override: str = Field(..., description="Repository override")
351
+ version: str = Field(..., description="Service version")
352
+
353
+
354
+ class PredictionJob(BaseModel):
355
+ """Represents a prediction job for tracking."""
356
+
357
+ job_id: str = Field(..., description="Unique job identifier")
358
+ request: PredictionRequest = Field(..., description="Original request")
359
+ status: Literal["pending", "running", "completed", "failed"] = Field(..., description="Job status")
360
+ created_at: datetime = Field(..., description="Job creation time")
361
+ started_at: Optional[datetime] = Field(None, description="Job start time")
362
+ completed_at: Optional[datetime] = Field(None, description="Job completion time")
363
+ result: Optional[PredictionResponse] = Field(None, description="Job result")
364
+ error: Optional[str] = Field(None, description="Error message if failed")
365
+ progress: Optional[float] = Field(None, ge=0.0, le=1.0, description="Job progress (0-1)")
366
+
367
+
368
+ # Convenience type aliases
369
+ PolymerType = Literal["protein", "dna", "rna"]
370
+ OutputFormat = Literal["mmcif"]
371
+ JobStatus = Literal["pending", "running", "completed", "failed"]
372
+ AlignmentFormat = Literal["sto", "a3m", "csv", "fasta"]
373
+ ConstraintType = Literal["pocket", "bond"]
374
+
375
+
376
+ # Add YAML configuration models at the end of the file
377
+
378
+ class YAMLProtein(BaseModel):
379
+ """YAML protein configuration matching official Boltz format."""
380
+ id: str = Field(..., description="Protein identifier")
381
+ sequence: str = Field(..., description="Protein sequence")
382
+ msa: Optional[str] = Field(None, description="Path to MSA file or 'empty'")
383
+
384
+
385
+ class YAMLLigand(BaseModel):
386
+ """YAML ligand configuration matching official Boltz format."""
387
+ id: str = Field(..., description="Ligand identifier")
388
+ smiles: str = Field(..., description="SMILES string")
389
+
390
+
391
+ class YAMLSequence(BaseModel):
392
+ """YAML sequence entry (protein or ligand)."""
393
+ protein: Optional[YAMLProtein] = Field(None, description="Protein configuration")
394
+ ligand: Optional[YAMLLigand] = Field(None, description="Ligand configuration")
395
+
396
+ @validator('ligand', always=True)
397
+ def validate_protein_or_ligand(cls, v, values):
398
+ """Ensure either protein or ligand is specified, but not both."""
399
+ protein = values.get('protein')
400
+ if protein and v:
401
+ raise ValueError("Cannot specify both protein and ligand in the same sequence entry")
402
+ if not protein and not v:
403
+ raise ValueError("Must specify either protein or ligand in sequence entry")
404
+ return v
405
+
406
+
407
+ class YAMLAffinity(BaseModel):
408
+ """YAML affinity property configuration."""
409
+ binder: str = Field(..., description="ID of the binding molecule (ligand)")
410
+
411
+
412
+ class YAMLProperties(BaseModel):
413
+ """YAML properties configuration."""
414
+ affinity: Optional[YAMLAffinity] = Field(None, description="Affinity prediction configuration")
415
+
416
+
417
+ class YAMLConfig(BaseModel):
418
+ """Complete YAML configuration matching official Boltz format."""
419
+ version: int = Field(1, description="Configuration version")
420
+ sequences: List[YAMLSequence] = Field(..., description="List of sequences (proteins and ligands)")
421
+ properties: Optional[YAMLProperties] = Field(None, description="Properties to predict")
422
+
423
+ @validator('sequences')
424
+ def validate_sequences(cls, v):
425
+ """Validate sequences list."""
426
+ if len(v) == 0:
427
+ raise ValueError("At least one sequence must be specified")
428
+ return v
429
+
430
+ def to_prediction_request(self) -> PredictionRequest:
431
+ """Convert YAML config to PredictionRequest."""
432
+ polymers = []
433
+ ligands = []
434
+
435
+ for seq in self.sequences:
436
+ if seq.protein:
437
+ # Handle MSA
438
+ msa_records = None
439
+ if seq.protein.msa and seq.protein.msa != "empty":
440
+ # For now, we'll handle MSA files separately
441
+ # This would need to be loaded from the file path
442
+ pass
443
+
444
+ polymer = Polymer(
445
+ id=seq.protein.id,
446
+ molecule_type="protein",
447
+ sequence=seq.protein.sequence,
448
+ msa=msa_records
449
+ )
450
+ polymers.append(polymer)
451
+
452
+ elif seq.ligand:
453
+ ligand = Ligand(
454
+ id=seq.ligand.id,
455
+ smiles=seq.ligand.smiles
456
+ )
457
+ ligands.append(ligand)
458
+
459
+ return PredictionRequest(
460
+ polymers=polymers,
461
+ ligands=ligands if ligands else None
462
+ )
463
+
464
+
465
+ # Convenience type aliases
466
+ YAMLConfigType = YAMLConfig