Coverage for bbconf/config_parser/bedbaseconfig.py: 69%

278 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 22:05 +0000

1import io 

2import logging 

3import os 

4import warnings 

5from pathlib import Path 

6from typing import Literal 

7 

8import boto3 

9import joblib 

10import qdrant_client 

11import requests 

12import s3fs 

13import yacman 

14import zarr 

15from botocore.client import BaseClient 

16from botocore.exceptions import BotoCoreError, EndpointConnectionError 

17from fastembed import TextEmbedding 

18from geniml.region2vec.main import Region2VecExModel 

19from geniml.search import BED2BEDSearchInterface 

20from geniml.search.backends import BiVectorBackend, QdrantBackend 

21from geniml.search.interfaces import BiVectorSearchInterface 

22from geniml.search.query2vec import BED2Vec 

23from qdrant_client import QdrantClient, models 

24from sentence_transformers import SparseEncoder 

25from umap import UMAP 

26from zarr import Group as Z_GROUP 

27 

28from bbconf.config_parser.const import ( 

29 S3_BEDSET_PATH_FOLDER, 

30 S3_FILE_PATH_FOLDER, 

31 S3_PLOTS_PATH_FOLDER, 

32) 

33from bbconf.config_parser.models import ConfigFile 

34from bbconf.const import PKG_NAME, ZARR_TOKENIZED_FOLDER 

35from bbconf.db_utils import BaseEngine 

36from bbconf.exceptions import ( 

37 BadAccessMethodError, 

38 BedBaseConfError, 

39 BedbaseS3ConnectionError, 

40) 

41from bbconf.helpers import get_absolute_path, get_bedbase_cfg 

42from bbconf.models.base_models import FileModel 

43from bbconf.models.bed_models import BedFiles, BedPlots 

44from bbconf.models.bedset_models import BedSetPlots 

45from bbconf.models.drs_models import AccessMethod, AccessURL 

46 

47_LOGGER = logging.getLogger(PKG_NAME) 

48 

49 

50class BedBaseConfig: 

51 """ 

52 Class to handle BEDbase configuration file and create objects for different modules. 

53 """ 

54 

55 def __init__(self, config: Path | str, init_ml: bool = True): 

56 """ 

57 Initialize BedBaseConfig object. 

58 

59 Args: 

60 config: Path to the configuration file. 

61 init_ml: Initialize machine learning models used for search. 

62 """ 

63 

64 self.cfg_path = get_bedbase_cfg(config) 

65 self._config = self._read_config_file(self.cfg_path) 

66 self._db_engine = self._init_db_engine() 

67 

68 try: 

69 self.qdrant_client: QdrantClient = self._init_qdrant_client() 

70 except Exception as err: 

71 _LOGGER.error( 

72 f"Unable to create Qdrant client. Skipping ML model initialization. Error: {err}" 

73 ) 

74 init_ml = False 

75 

76 if init_ml: 

77 self.dense_encoder: TextEmbedding = self._init_dense_encoder() 

78 self.sparse_encoder: SparseEncoder | None = self._init_sparce_model() 

79 self.umap_encoder: UMAP | None = self._init_umap_model() 

80 self.r2v_encoder: Region2VecExModel | None = self._init_r2v_encoder() 

81 

82 self._init_qdrant_hybrid( 

83 qdrant_cl=self.qdrant_client, 

84 dense_encoder=self.dense_encoder, 

85 ) 

86 

87 self.qdrant_file_backend: QdrantBackend | None = ( 

88 self._init_qdrant_file_backend(qdrant_cl=self.qdrant_client) 

89 ) # used for bivec search 

90 self._qdrant_text_backend: QdrantBackend | None = ( 

91 self._init_qdrant_text_backend( 

92 qdrant_cl=self.qdrant_client, 

93 dense_encoder=self.dense_encoder, 

94 ) 

95 ) # used for bivec search 

96 

97 self.b2b_search_interface = self._init_b2b_search_interface( 

98 qdrant_file_backend=self.qdrant_file_backend, 

99 region_encoder=self.r2v_encoder, 

100 ) 

101 

102 self.bivec_search_interface = self._init_bivec_interface( 

103 qdrant_file_backend=self.qdrant_file_backend, 

104 qdrant_text_backend=self._qdrant_text_backend, 

105 text_encoder=self.dense_encoder, 

106 ) 

107 else: 

108 _LOGGER.info( 

109 "Skipping initialization of ML models, init_ml parameter set to False." 

110 ) 

111 self.r2v_encoder = None 

112 self.b2b_search_interface = None 

113 self.bivec_search_interface = None 

114 self.umap_encoder: UMAP | None = None 

115 self.sparse_encoder = None 

116 

117 self._boto3_client = self._init_boto3_client() 

118 

119 @staticmethod 

120 def _read_config_file(config_path: str) -> ConfigFile: 

121 """ 

122 Read configuration file and insert default values if not set. 

123 

124 Args: 

125 config_path: Configuration file path. 

126 

127 Returns: 

128 Parsed ConfigFile object. 

129 """ 

130 

131 _config = yacman.YAMLConfigManager.from_yaml_file(filepath=config_path).exp 

132 return ConfigFile(**_config) 

133 

134 @property 

135 def config(self) -> ConfigFile: 

136 """ 

137 Get configuration. 

138 

139 Returns: 

140 Configuration object. 

141 """ 

142 return self._config 

143 

144 @property 

145 def db_engine(self) -> BaseEngine: 

146 """ 

147 Get database engine. 

148 

149 Returns: 

150 Database engine. 

151 """ 

152 return self._db_engine 

153 

154 @property 

155 def boto3_client(self) -> boto3.client: 

156 """ 

157 Get boto3 client. 

158 

159 Returns: 

160 Boto3 client. 

161 """ 

162 return self._boto3_client 

163 

164 @property 

165 def zarr_root(self) -> Z_GROUP | None: 

166 """ 

167 Get zarr root object (Group). 

168 

169 Returns: 

170 Zarr root group object. 

171 """ 

172 

173 try: 

174 s3fc_obj = s3fs.S3FileSystem( 

175 endpoint_url=self._config.s3.endpoint_url, 

176 key=self._config.s3.aws_access_key_id, 

177 secret=self._config.s3.aws_secret_access_key, 

178 ) 

179 except BotoCoreError as e: 

180 _LOGGER.error(f"Error in creating s3fs object: {e}") 

181 warnings.warn(f"Error in creating s3fs object: {e}", UserWarning) 

182 return None 

183 

184 s3_path = f"s3://{self._config.s3.bucket}/{ZARR_TOKENIZED_FOLDER}" 

185 

186 zarr_store = s3fs.S3Map( 

187 root=s3_path, s3=s3fc_obj, check=False, create=self._config.s3.modify_access 

188 ) 

189 cache = zarr.LRUStoreCache(zarr_store, max_size=2**28) 

190 

191 return zarr.group(store=cache, overwrite=False) 

192 

193 def _init_db_engine(self) -> BaseEngine: 

194 """ 

195 Create database engine object using credentials provided in config file 

196 """ 

197 

198 _LOGGER.info("Initializing database engine...") 

199 return BaseEngine( 

200 host=self._config.database.host, 

201 port=self._config.database.port, 

202 database=self._config.database.database, 

203 user=self._config.database.user, 

204 password=self._config.database.password, 

205 drivername=f"{self._config.database.dialect}+{self._config.database.driver}", 

206 run_migrations=self._config.database.run_migrations, 

207 ) 

208 

209 def _init_qdrant_client(self) -> QdrantClient: 

210 """ 

211 Create qdrant client object using credentials provided in config file 

212 """ 

213 

214 _LOGGER.info("Initializing qdrant client...") 

215 

216 try: 

217 qdrant_cl = QdrantClient( 

218 url=self.config.qdrant.host, 

219 port=self.config.qdrant.port, 

220 api_key=self.config.qdrant.api_key, 

221 ) 

222 

223 except qdrant_client.http.exceptions.ResponseHandlingException as err: 

224 raise BedBaseConfError( 

225 f"Error in Connection to qdrant! skipping... Error: {err}" 

226 ) 

227 

228 return qdrant_cl 

229 

230 def _init_qdrant_file_backend( 

231 self, qdrant_cl: QdrantClient 

232 ) -> QdrantBackend | None: 

233 """ 

234 Create qdrant client object using credentials provided in config file. 

235 

236 Args: 

237 qdrant_cl: QdrantClient object. 

238 

239 Returns: 

240 QdrantClient or None 

241 """ 

242 

243 _LOGGER.info("Initializing qdrant bivec file backend...") 

244 

245 if not isinstance(qdrant_cl, QdrantClient): 

246 _LOGGER.error( 

247 "Unable to create Qdrant bivec file collection, qdrant client is None." 

248 ) 

249 return None 

250 

251 try: 

252 return QdrantBackend( 

253 qdrant_client=qdrant_cl, 

254 collection=self.config.qdrant.file_collection, 

255 ) 

256 except Exception as e: 

257 _LOGGER.error(f"Unable to create Qdrant collection: {e}") 

258 return None 

259 

260 def _init_qdrant_text_backend( 

261 self, qdrant_cl: QdrantClient, dense_encoder: TextEmbedding 

262 ) -> QdrantBackend | None: 

263 """ 

264 Create qdrant client text embedding object using credentials provided in config file. 

265 

266 Args: 

267 qdrant_cl: QdrantClient object. 

268 dense_encoder: TextEmbedding model for encoding text queries. 

269 

270 Returns: 

271 QdrantClient or None 

272 """ 

273 

274 _LOGGER.info("Initializing qdrant bivec text backend...") 

275 

276 if not isinstance(qdrant_cl, QdrantClient): 

277 _LOGGER.error( 

278 "Unable to create Qdrant bivec text collection, qdrant client is None." 

279 ) 

280 return None 

281 if not isinstance(dense_encoder, TextEmbedding): 

282 _LOGGER.error( 

283 "Unable to create Qdrant bivec text collection, dense encoder is None." 

284 ) 

285 return None 

286 

287 dimensions = int(dense_encoder.get_embedding_size(self._config.path.text2vec)) 

288 try: 

289 return QdrantBackend( 

290 qdrant_client=qdrant_cl, 

291 dim=dimensions, 

292 collection=self.config.qdrant.text_collection, 

293 ) 

294 except Exception as e: 

295 _LOGGER.error(f"Unable to create Qdrant collection: {e}") 

296 return None 

297 

298 def _init_qdrant_hybrid( 

299 self, qdrant_cl: QdrantClient, dense_encoder: TextEmbedding 

300 ) -> None: 

301 """ 

302 Create qdrant client with sparse and text embedding object using credentials provided in config file. 

303 

304 Args: 

305 qdrant_cl: QdrantClient object. 

306 dense_encoder: TextEmbedding model for encoding text queries. 

307 

308 Returns: 

309 None, Initializes or creates the hybrid collection on the provided QdrantClient. 

310 """ 

311 

312 _LOGGER.info("Initializing qdrant sparse collection...") 

313 

314 if not isinstance(qdrant_cl, QdrantClient): 

315 _LOGGER.error( 

316 "Unable to create Qdrant hybrid collection, qdrant client is None." 

317 ) 

318 return None 

319 if not isinstance(dense_encoder, TextEmbedding): 

320 _LOGGER.error( 

321 "Unable to create Qdrant hybrid collection, dense encoder is None." 

322 ) 

323 return None 

324 

325 dimensions = int(dense_encoder.get_embedding_size(self._config.path.text2vec)) 

326 collection_name = self.config.qdrant.hybrid_collection 

327 

328 try: 

329 if not qdrant_cl.collection_exists(collection_name): 

330 _LOGGER.info( 

331 "Collection 'bedbase_query_search' does not exist, creating it." 

332 ) 

333 

334 qdrant_cl.create_collection( 

335 collection_name=collection_name, 

336 vectors_config={ 

337 "dense": models.VectorParams( 

338 size=dimensions, distance=models.Distance.COSINE 

339 ), 

340 }, 

341 sparse_vectors_config={ 

342 "sparse": models.SparseVectorParams( 

343 index=models.SparseIndexParams( 

344 on_disk=False, 

345 ) 

346 ) 

347 }, 

348 quantization_config=models.ScalarQuantization( 

349 scalar=models.ScalarQuantizationConfig( 

350 type=models.ScalarType.INT8, 

351 quantile=0.99, 

352 always_ram=True, 

353 ), 

354 ), 

355 ) 

356 

357 qdrant_cl.create_payload_index( 

358 collection_name=collection_name, 

359 field_name="assay", 

360 field_type=models.PayloadSchemaType.KEYWORD, 

361 ) 

362 qdrant_cl.create_payload_index( 

363 collection_name=collection_name, 

364 field_name="genome_alias", 

365 field_type=models.PayloadSchemaType.KEYWORD, 

366 ) 

367 

368 except Exception as err: 

369 _LOGGER.error( 

370 f"Error in creating Qdrant hybrid collection! skipping... Error: {err}. Qdrant host: {self._config.qdrant.host}" 

371 ) 

372 warnings.warn( 

373 f"error in creating Qdrant hybrid collection! skipping... Error: {err}", 

374 UserWarning, 

375 ) 

376 return None 

377 

378 def _init_bivec_interface( 

379 self, 

380 qdrant_file_backend: QdrantBackend, 

381 qdrant_text_backend: QdrantBackend, 

382 text_encoder: TextEmbedding, 

383 ) -> BiVectorSearchInterface | None: 

384 """ 

385 Create BiVectorSearchInterface object using credentials provided in config file. 

386 

387 Args: 

388 qdrant_file_backend: QdrantBackend for file vectors. 

389 qdrant_text_backend: QdrantBackend for text vectors. 

390 text_encoder: TextEmbedding model for encoding text queries. 

391 

392 Returns: 

393 BiVectorSearchInterface. 

394 """ 

395 

396 _LOGGER.info("Initializing BiVectorBackend...") 

397 search_backend = BiVectorBackend( 

398 metadata_backend=qdrant_text_backend, bed_backend=qdrant_file_backend 

399 ) 

400 _LOGGER.info("Initializing BiVectorSearchInterface...") 

401 search_interface = BiVectorSearchInterface( 

402 backend=search_backend, 

403 query2vec=text_encoder, 

404 ) 

405 return search_interface 

406 

407 def _init_b2b_search_interface( 

408 self, 

409 qdrant_file_backend: QdrantBackend, 

410 region_encoder: Region2VecExModel | str, 

411 ) -> BED2BEDSearchInterface | None: 

412 """ 

413 Create Bed 2 BED search interface and return this object. 

414 

415 Returns: 

416 Bed2BEDSearchInterface object. 

417 """ 

418 try: 

419 _LOGGER.info("Initializing search bed 2 bed search interfaces...") 

420 return BED2BEDSearchInterface( 

421 backend=qdrant_file_backend, 

422 query2vec=BED2Vec(model=region_encoder), 

423 ) 

424 except Exception as e: 

425 _LOGGER.error("Error in creating BED2BEDSearchInterface object: " + str(e)) 

426 warnings.warn( 

427 "Error in creating BED2BEDSearchInterface object: " + str(e), 

428 UserWarning, 

429 ) 

430 return None 

431 

432 def _init_r2v_encoder(self) -> Region2VecExModel | None: 

433 """ 

434 Create Region2VecExModel object using credentials provided in config file 

435 """ 

436 try: 

437 _LOGGER.info( 

438 f"Initializing region2vec encoder... Model used: {self.config.path.region2vec}" 

439 ) 

440 return Region2VecExModel(self.config.path.region2vec) 

441 except Exception as e: 

442 _LOGGER.error(f"Error in creating Region2VecExModel object: {e}") 

443 warnings.warn( 

444 f"Error in creating Region2VecExModel object: {e}", UserWarning 

445 ) 

446 return None 

447 

448 def _init_dense_encoder(self) -> TextEmbedding | None: 

449 """ 

450 Initialize dense model from the specified path or huggingface model hub 

451 """ 

452 

453 _LOGGER.info( 

454 f"Initializing dense encoder... Model used: {self.config.path.text2vec}" 

455 ) 

456 dense_encoder = TextEmbedding(self.config.path.text2vec) 

457 return dense_encoder 

458 

459 def _init_sparce_model(self) -> SparseEncoder | None: 

460 """ 

461 Initialize SparseEncoder model from the specified path or huggingface model hub 

462 """ 

463 try: 

464 _LOGGER.info( 

465 f"Initializing sparse encoder... Model used: {self.config.path.sparse_model}" 

466 ) 

467 sparse_encoder = SparseEncoder(self.config.path.sparse_model) 

468 except Exception as e: 

469 _LOGGER.error(f"Error in creating SparseEncoder object: {e}") 

470 warnings.warn(f"Error in creating SparseEncoder object: {e}", UserWarning) 

471 return None 

472 return sparse_encoder 

473 

474 def _init_umap_model(self) -> UMAP | None: 

475 """ 

476 Load UMAP model from the specified path, or url 

477 """ 

478 

479 if not self.config.path.umap_model: 

480 _LOGGER.warning( 

481 "UMAP model path is not specified in the configuration, and won't be used." 

482 ) 

483 return None 

484 

485 model_path = self.config.path.umap_model 

486 umap_model = None 

487 if model_path.startswith(("http://", "https://")): 

488 try: 

489 response = requests.get(model_path) 

490 response.raise_for_status() 

491 buffer = io.BytesIO(response.content) 

492 umap_model = joblib.load(buffer) 

493 _LOGGER.info(f"UMAP model loaded from URL: {model_path}") 

494 except requests.RequestException as e: 

495 _LOGGER.error(f"Error downloading UMAP model from URL: {e}") 

496 return None 

497 else: 

498 try: 

499 with open(model_path, "rb") as file: 

500 umap_model = joblib.load(file) 

501 _LOGGER.info(f"UMAP model loaded from local path: {model_path}") 

502 except FileNotFoundError as e: 

503 _LOGGER.error(f"Error loading UMAP model from local path: {e}") 

504 return None 

505 

506 if not isinstance(umap_model, UMAP): 

507 _LOGGER.error(f"Loaded object is not a UMAP instance: {type(umap_model)}") 

508 return None 

509 # np.random.seed(42) 

510 umap_model.random_state = 42 

511 return umap_model 

512 

513 def _init_boto3_client( 

514 self, 

515 ) -> BaseClient | None: 

516 """ 

517 Create boto3 client object using credentials provided in config file. 

518 

519 Returns: 

520 Boto3 client. 

521 """ 

522 try: 

523 return boto3.client( 

524 "s3", 

525 endpoint_url=self._config.s3.endpoint_url, 

526 aws_access_key_id=self._config.s3.aws_access_key_id, 

527 aws_secret_access_key=self._config.s3.aws_secret_access_key, 

528 ) 

529 except Exception as e: 

530 _LOGGER.error(f"Error in creating boto3 client object: {e}") 

531 warnings.warn(f"Error in creating boto3 client object: {e}", UserWarning) 

532 return None 

533 

534 def upload_s3(self, file_path: str, s3_path: Path | str) -> None: 

535 """ 

536 Upload file to s3. 

537 

538 Args: 

539 file_path: Local path to the file. 

540 s3_path: Path to the file in s3 with file name. 

541 

542 Returns: 

543 None. 

544 """ 

545 if not self._boto3_client: 

546 _LOGGER.warning( 

547 "Could not upload file to s3. Connection to s3 not established. Skipping.." 

548 ) 

549 raise BedbaseS3ConnectionError( 

550 "Could not upload file to s3. Connection error." 

551 ) 

552 if not os.path.exists(file_path): 

553 raise BedBaseConfError(f"File {os.path.abspath(file_path)} does not exist.") 

554 _LOGGER.info(f"Uploading file to s3: {s3_path}") 

555 return self._boto3_client.upload_file(file_path, self.config.s3.bucket, s3_path) 

556 

557 def upload_files_s3( 

558 self, 

559 identifier: str, 

560 files: BedFiles | BedPlots | BedSetPlots, 

561 base_path: str, 

562 type: Literal["files", "plots", "bedsets"] = "files", 

563 ) -> BedFiles | BedPlots | BedSetPlots: 

564 """ 

565 Upload files to s3. 

566 

567 Args: 

568 identifier: Bed file identifier. 

569 files: Dictionary with files to upload. 

570 base_path: Local path to the output files. 

571 type: Type of files to upload [files, plots, bedsets]. 

572 

573 Returns: 

574 None. 

575 """ 

576 

577 if type == "files": 

578 s3_output_base_folder = S3_FILE_PATH_FOLDER 

579 elif type == "plots": 

580 s3_output_base_folder = S3_PLOTS_PATH_FOLDER 

581 elif type == "bedsets": 

582 s3_output_base_folder = S3_BEDSET_PATH_FOLDER 

583 else: 

584 raise BedBaseConfError( 

585 f"Invalid type: {type}. Should be 'files', 'plots', or 'bedsets'" 

586 ) 

587 

588 for key, value in files: 

589 if not value: 

590 continue 

591 file_base_name = os.path.basename(value.path) 

592 file_path = get_absolute_path(value.path, base_path) 

593 s3_path = os.path.join( 

594 s3_output_base_folder, 

595 identifier[0], 

596 identifier[1], 

597 file_base_name, 

598 ) 

599 self.upload_s3(file_path, s3_path=s3_path) 

600 

601 setattr(value, "name", key) 

602 setattr(value, "size", os.path.getsize(file_path)) 

603 setattr(value, "path", s3_path) 

604 

605 if value.path_thumbnail: 

606 file_base_name_thumbnail = os.path.basename(value.path_thumbnail) 

607 file_path_thumbnail = get_absolute_path(value.path_thumbnail, base_path) 

608 s3_path_thumbnail = os.path.join( 

609 s3_output_base_folder, 

610 identifier[0], 

611 identifier[1], 

612 file_base_name_thumbnail, 

613 ) 

614 self.upload_s3(file_path_thumbnail, s3_path=s3_path_thumbnail) 

615 setattr(value, "path_thumbnail", s3_path_thumbnail) 

616 

617 return files 

618 

619 def delete_s3(self, s3_path: str) -> None: 

620 """ 

621 Delete file from s3. 

622 

623 Args: 

624 s3_path: Path to the file in s3. 

625 

626 Returns: 

627 None. 

628 """ 

629 if not self._boto3_client: 

630 _LOGGER.warning( 

631 "Could not delete file from s3. Connection to s3 not established. Skipping.." 

632 ) 

633 raise BedbaseS3ConnectionError( 

634 "Could not delete file from s3. Connection error." 

635 ) 

636 try: 

637 _LOGGER.info(f"Deleting file from s3: {s3_path}") 

638 return self._boto3_client.delete_object( 

639 Bucket=self.config.s3.bucket, Key=s3_path 

640 ) 

641 except EndpointConnectionError: 

642 raise BedbaseS3ConnectionError( 

643 "Could not delete file from s3. Connection error." 

644 ) 

645 

646 def delete_files_s3(self, files: list[FileModel]) -> None: 

647 """ 

648 Delete files from s3. 

649 

650 Args: 

651 files: List of file objects. 

652 

653 Returns: 

654 None. 

655 """ 

656 for file in files: 

657 self.delete_s3(file.path) 

658 if file.path_thumbnail: 

659 self.delete_s3(file.path_thumbnail) 

660 return None 

661 

662 def get_prefixed_uri(self, postfix: str, access_id: str) -> str: 

663 """ 

664 Return uri with correct prefix (schema). 

665 

666 Args: 

667 postfix: Postfix of the uri (or everything after uri schema). 

668 access_id: Access method name, e.g. http, s3, etc. 

669 

670 Returns: 

671 Full uri path. 

672 """ 

673 

674 if self.config.access_methods is None: 

675 _LOGGER.error("No access methods are defined in the config.") 

676 raise BadAccessMethodError("No access methods are defined in the config.") 

677 try: 

678 prefix = getattr(self.config.access_methods, access_id).prefix 

679 return os.path.join(prefix, postfix) 

680 except (KeyError, AttributeError): 

681 _LOGGER.error(f"Access method {access_id} is not defined.") 

682 raise BadAccessMethodError(f"Access method {access_id} is not defined.") 

683 

684 def construct_access_method_list(self, rel_path: str) -> list[AccessMethod]: 

685 """ 

686 Construct access method list for a given record. 

687 

688 Args: 

689 rel_path: Relative path to the record. 

690 

691 Returns: 

692 List of access methods. 

693 """ 

694 if self.config.access_methods is None: 

695 return [] 

696 access_methods = [] 

697 for access_id in self.config.access_methods.model_dump().keys(): 

698 access_dict = AccessMethod( 

699 type=access_id, 

700 access_id=access_id, 

701 access_url=AccessURL(url=self.get_prefixed_uri(rel_path, access_id)), 

702 region=self.config.access_methods.model_dump()[access_id].get( 

703 "region", None 

704 ), 

705 ) 

706 access_methods.append(access_dict) 

707 return access_methods