Coverage for pepdbagent/modules/annotation.py: 75%

186 statements  

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

1import logging 

2from datetime import datetime 

3from typing import Literal 

4 

5from sqlalchemy import and_, func, or_, select 

6from sqlalchemy.orm import Session 

7from sqlalchemy.sql.selectable import Select 

8 

9from pepdbagent.const import ( 

10 DEFAULT_LIMIT, 

11 DEFAULT_OFFSET, 

12 DEFAULT_TAG, 

13 LAST_UPDATE_DATE_KEY, 

14 PKG_NAME, 

15 SUBMISSION_DATE_KEY, 

16) 

17from pepdbagent.db_utils import BaseEngine, Projects 

18from pepdbagent.exceptions import FilterError, ProjectNotFoundError, RegistryPathError 

19from pepdbagent.models import AnnotationList, AnnotationModel, RegistryPath 

20from pepdbagent.utils import ( 

21 convert_date_string_to_date, 

22 registry_path_converter, 

23 tuple_converter, 

24) 

25 

26_LOGGER = logging.getLogger(PKG_NAME) 

27 

28 

29class PEPDatabaseAnnotation: 

30 """ 

31 Class that represents project Annotations in the Database. 

32 

33 While using this class, user can retrieve all necessary metadata about PEPs 

34 """ 

35 

36 def __init__(self, pep_db_engine: BaseEngine): 

37 """ 

38 Args: 

39 pep_db_engine: PEPDatabaseAgent engine object. 

40 """ 

41 self._sa_engine = pep_db_engine.engine 

42 self._pep_db_engine = pep_db_engine 

43 

44 def get( 

45 self, 

46 namespace: str = None, 

47 name: str = None, 

48 tag: str = None, 

49 query: str = None, 

50 admin: list[str] | str | None = None, 

51 limit: int = DEFAULT_LIMIT, 

52 offset: int = DEFAULT_OFFSET, 

53 order_by: str = "update_date", 

54 order_desc: bool = False, 

55 filter_by: Literal["submission_date", "last_update_date"] | None = None, 

56 filter_start_date: str | None = None, 

57 filter_end_date: str | None = None, 

58 pep_type: Literal["pep", "pop"] | None = None, 

59 ) -> AnnotationList: 

60 """Get project annotations. 

61 

62 Five retrieval scenarios: 

63 - namespace + name + tag: exact match. 

64 - namespace only: all projects in that namespace. 

65 - nothing: all projects in the database. 

66 - query: full-text search across name, tag, description. 

67 - query + namespace: full-text search within a namespace. 

68 

69 Args: 

70 namespace: Namespace to filter by. 

71 name: Project name (use with namespace and tag for exact lookup). 

72 tag: Project tag. 

73 query: Search string matched against name, tag, and description. 

74 admin: Namespace(s) where the caller has admin rights. 

75 limit: Maximum number of results. 

76 offset: Number of results to skip. 

77 order_by: Sort field — "name", "update_date", or "submission_date". 

78 order_desc: Sort in descending order if True. 

79 filter_by: Date field to apply range filter on — "submission_date" or "last_update_date". 

80 filter_start_date: Range start in YYYY/MM/DD format. 

81 filter_end_date: Range end in YYYY/MM/DD format (default: today). 

82 pep_type: Restrict to "pep" or "pop" (default: all). 

83 

84 Returns: 

85 AnnotationList with count, limit, offset, and results. 

86 """ 

87 if all([namespace, name, tag]): 

88 found_annotation = [ 

89 self._get_single_annotation( 

90 namespace=namespace, 

91 name=name, 

92 tag=tag, 

93 admin=admin, 

94 ) 

95 ] 

96 return AnnotationList( 

97 count=len(found_annotation), 

98 limit=1, 

99 offset=0, 

100 results=found_annotation, 

101 ) 

102 

103 if pep_type not in [None, "pep", "pop"]: 

104 raise ValueError( 

105 f"pep_type should be one of ['pep', 'pop'], got {pep_type}" 

106 ) 

107 

108 return AnnotationList( 

109 limit=limit, 

110 offset=offset, 

111 count=self._count_projects( 

112 namespace=namespace, 

113 search_str=query, 

114 tag=tag, 

115 admin=admin, 

116 filter_by=filter_by, 

117 filter_end_date=filter_end_date, 

118 filter_start_date=filter_start_date, 

119 pep_type=pep_type, 

120 ), 

121 results=self._get_projects( 

122 namespace=namespace, 

123 search_str=query, 

124 tag=tag, 

125 admin=admin, 

126 offset=offset, 

127 limit=limit, 

128 order_by=order_by, 

129 order_desc=order_desc, 

130 filter_by=filter_by, 

131 filter_end_date=filter_end_date, 

132 filter_start_date=filter_start_date, 

133 pep_type=pep_type, 

134 ), 

135 ) 

136 

137 def get_by_rp( 

138 self, 

139 registry_paths: list[str] | str, 

140 admin: list[str] | str | None = None, 

141 ) -> AnnotationList: 

142 """Get project annotations by registry path or list of registry paths. 

143 

144 Args: 

145 registry_paths: Single registry path or list of paths ("namespace/name:tag"). 

146 admin: Namespace(s) where the caller has admin rights. 

147 

148 Returns: 

149 AnnotationList with count, limit, offset, and results. 

150 """ 

151 if isinstance(registry_paths, list): 

152 anno_results = [] 

153 for path in registry_paths: 

154 try: 

155 namespace, name, tag = registry_path_converter(path) 

156 except RegistryPathError as err: 

157 _LOGGER.error(str(err), registry_paths) 

158 continue 

159 try: 

160 single_return = self._get_single_annotation( 

161 namespace, name, tag, admin 

162 ) 

163 if single_return: 

164 anno_results.append(single_return) 

165 except ProjectNotFoundError: 

166 pass 

167 return_len = len(anno_results) 

168 return AnnotationList( 

169 count=return_len, 

170 limit=len(registry_paths), 

171 offset=0, 

172 results=anno_results, 

173 ) 

174 

175 else: 

176 namespace, name, tag = registry_path_converter(registry_paths) 

177 return self.get(namespace=namespace, name=name, tag=tag, admin=admin) 

178 

179 def _get_single_annotation( 

180 self, 

181 namespace: str, 

182 name: str, 

183 tag: str = DEFAULT_TAG, 

184 admin: list[str] | str | None = None, 

185 ) -> AnnotationModel | None: 

186 """Retrieve annotation for a single project. 

187 

188 Args: 

189 namespace: Project namespace. 

190 name: Project name. 

191 tag: Project tag. 

192 admin: Namespace(s) with admin/private access. 

193 

194 Returns: 

195 AnnotationModel for the project, or None if not found. 

196 

197 Raises: 

198 ProjectNotFoundError: If the project does not exist or is not accessible. 

199 """ 

200 _LOGGER.info(f"Getting annotation of the project: '{namespace}/{name}:{tag}'") 

201 admin_tuple = tuple_converter(admin) 

202 

203 statement = select(Projects).where( 

204 and_( 

205 Projects.name == name, 

206 Projects.namespace == namespace, 

207 Projects.tag == tag, 

208 or_( 

209 Projects.namespace.in_(admin_tuple), 

210 Projects.private.is_(False), 

211 ), 

212 ) 

213 ) 

214 with Session(self._sa_engine) as session: 

215 query_result = session.scalar(statement) 

216 

217 if query_result: 

218 annot = AnnotationModel( 

219 namespace=query_result.namespace, 

220 name=query_result.name, 

221 tag=query_result.tag, 

222 is_private=query_result.private, 

223 description=query_result.description, 

224 number_of_samples=query_result.number_of_samples, 

225 submission_date=str(query_result.submission_date), 

226 last_update_date=str(query_result.last_update_date), 

227 digest=query_result.digest, 

228 pep_schema=( 

229 f"{query_result.schema_mapping.schema_mapping.namespace}/{query_result.schema_mapping.schema_mapping.name}:{query_result.schema_mapping.version}" 

230 if query_result.schema_mapping 

231 else None 

232 ), 

233 pop=query_result.pop, 

234 stars_number=query_result.number_of_stars, 

235 forked_from=( 

236 f"{query_result.forked_from_mapping.namespace}/{query_result.forked_from_mapping.name}:{query_result.forked_from_mapping.tag}" 

237 if query_result.forked_from_id 

238 else None 

239 ), 

240 ) 

241 _LOGGER.info( 

242 f"Annotation of the project '{namespace}/{name}:{tag}' has been found!" 

243 ) 

244 return annot 

245 else: 

246 raise ProjectNotFoundError( 

247 f"Project '{namespace}/{name}:{tag}' was not found." 

248 ) 

249 

250 def _count_projects( 

251 self, 

252 namespace: str = None, 

253 search_str: str = None, 

254 tag: str = None, 

255 admin: str | list[str] | None = None, 

256 filter_by: Literal["submission_date", "last_update_date"] | None = None, 

257 filter_start_date: str | None = None, 

258 filter_end_date: str | None = None, 

259 pep_type: Literal["pep", "pop"] | None = None, 

260 ) -> int: 

261 """Count projects matching the given filters. 

262 

263 Args: 

264 namespace: Namespace to restrict the search to. 

265 search_str: String to search in name, tag, and description. 

266 tag: Exact tag to match. 

267 admin: Namespace(s) with admin/private access. 

268 filter_by: Date field for range filter — "submission_date" or "last_update_date". 

269 filter_start_date: Range start in YYYY/MM/DD format. 

270 filter_end_date: Range end in YYYY/MM/DD format (default: today). 

271 pep_type: Restrict to "pep" or "pop" (default: all). 

272 

273 Returns: 

274 Number of matching projects. 

275 """ 

276 if admin is None: 

277 admin = [] 

278 statement = select(func.count()).select_from(Projects) 

279 statement = self._add_condition( 

280 statement, 

281 namespace=namespace, 

282 search_str=search_str, 

283 admin_list=admin, 

284 ) 

285 statement = self._add_date_filter_if_provided( 

286 statement, filter_by, filter_start_date, filter_end_date 

287 ) 

288 if pep_type: 

289 statement = statement.where(Projects.pop.is_(pep_type == "pop")) 

290 if tag: 

291 statement = statement.where(Projects.tag == tag) 

292 result = self._pep_db_engine.session_execute(statement).first() 

293 

294 try: 

295 return result[0] 

296 except IndexError: 

297 return 0 

298 

299 def _get_projects( 

300 self, 

301 namespace: str = None, 

302 tag: str = None, 

303 search_str: str = None, 

304 admin: str | list[str] | None = None, 

305 limit: int = DEFAULT_LIMIT, 

306 offset: int = DEFAULT_OFFSET, 

307 order_by: str = "update_date", 

308 order_desc: bool = False, 

309 filter_by: Literal["submission_date", "last_update_date"] | None = None, 

310 filter_start_date: str | None = None, 

311 filter_end_date: str | None = None, 

312 pep_type: Literal["pep", "pop"] | None = None, 

313 ) -> list[AnnotationModel]: 

314 """Get projects matching the given filters. 

315 

316 Args: 

317 namespace: Namespace to restrict the search to. 

318 tag: Exact tag to match. 

319 search_str: String to search in name, tag, and description. 

320 admin: Namespace(s) with admin/private access. 

321 limit: Maximum number of results. 

322 offset: Number of results to skip. 

323 order_by: Sort field — "update_date", "name", "submission_date", or "stars". 

324 order_desc: Sort in descending order if True. 

325 filter_by: Date field for range filter — "submission_date" or "last_update_date". 

326 filter_start_date: Range start in YYYY/MM/DD format. 

327 filter_end_date: Range end in YYYY/MM/DD format (default: today). 

328 pep_type: Restrict to "pep" or "pop" (default: all). 

329 

330 Returns: 

331 List of AnnotationModel objects. 

332 """ 

333 _LOGGER.info( 

334 f"Running annotation search: (namespace: {namespace}, query: {search_str}." 

335 ) 

336 

337 if admin is None: 

338 admin = [] 

339 statement = select(Projects) 

340 

341 statement = self._add_condition( 

342 statement, 

343 namespace=namespace, 

344 search_str=search_str, 

345 admin_list=admin, 

346 tag=tag, 

347 ) 

348 statement = self._add_date_filter_if_provided( 

349 statement, filter_by, filter_start_date, filter_end_date 

350 ) 

351 statement = statement 

352 statement = self._add_order_by_keyword(statement, by=order_by, desc=order_desc) 

353 statement = statement.limit(limit).offset(offset) 

354 if pep_type: 

355 statement = statement.where(Projects.pop.is_(pep_type == "pop")) 

356 

357 results_list = [] 

358 with Session(self._sa_engine) as session: 

359 # Unique should be called because of the join with schema_mapping 

360 results = session.scalars(statement).unique() 

361 for result in results: 

362 results_list.append( 

363 AnnotationModel( 

364 namespace=result.namespace, 

365 name=result.name, 

366 tag=result.tag, 

367 is_private=result.private, 

368 description=result.description, 

369 number_of_samples=result.number_of_samples, 

370 submission_date=str(result.submission_date), 

371 last_update_date=str(result.last_update_date), 

372 digest=result.digest, 

373 pep_schema=( 

374 f"{result.schema_mapping.schema_mapping.namespace}/{result.schema_mapping.schema_mapping.name}:{result.schema_mapping.version}" 

375 if result.schema_mapping 

376 else None 

377 ), 

378 pop=result.pop, 

379 stars_number=result.number_of_stars, 

380 forked_from=( 

381 f"{result.forked_from_mapping.namespace}/{result.forked_from_mapping.name}:{result.forked_from_mapping.tag}" 

382 if result.forked_from_id 

383 else None 

384 ), 

385 ) 

386 ) 

387 return results_list 

388 

389 @staticmethod 

390 def _add_order_by_keyword( 

391 statement: Select, by: str = "update_date", desc: bool = False 

392 ) -> Select: 

393 """Add an ORDER BY clause to a SELECT statement. 

394 

395 Args: 

396 statement: SQLAlchemy SELECT statement to augment. 

397 by: Sort field — "name", "update_date", "submission_date", or "stars". 

398 desc: Sort in descending order if True. 

399 

400 Returns: 

401 Statement with ORDER BY applied. 

402 """ 

403 if by == "update_date": 

404 order_by_obj = Projects.last_update_date 

405 elif by == "name": 

406 order_by_obj = Projects.name 

407 elif by == SUBMISSION_DATE_KEY: 

408 order_by_obj = Projects.submission_date 

409 elif by == "stars": 

410 order_by_obj = Projects.number_of_stars 

411 else: 

412 _LOGGER.warning( 

413 f"order by: '{by}' statement is unavailable. Projects are sorted by 'update_date'" 

414 ) 

415 order_by_obj = Projects.last_update_date 

416 

417 if desc and by == "name": 

418 order_by_obj = order_by_obj.desc() 

419 

420 elif by != "name" and not desc: 

421 order_by_obj = order_by_obj.desc() 

422 

423 return statement.order_by(order_by_obj) 

424 

425 @staticmethod 

426 def _add_condition( 

427 statement: Select, 

428 namespace: str = None, 

429 search_str: str = None, 

430 admin_list: str | list[str] | None = None, 

431 tag: str = None, 

432 ) -> Select: 

433 """Add a WHERE clause to a project search statement. 

434 

435 Args: 

436 statement: SQLAlchemy SELECT statement to augment. 

437 namespace: Filter to this namespace. 

438 search_str: String to search in name, tag, and description. 

439 admin_list: Namespace(s) with admin/private access. 

440 tag: Exact tag to match. 

441 

442 Returns: 

443 Statement with WHERE clause applied. 

444 """ 

445 admin_list = tuple_converter(admin_list) 

446 if search_str: 

447 sql_search_str = f"%{search_str}%" 

448 search_query = or_( 

449 Projects.name.ilike(sql_search_str), 

450 Projects.tag.ilike(sql_search_str), 

451 Projects.description.ilike(sql_search_str), 

452 ) 

453 statement = statement.where(search_query) 

454 if namespace: 

455 statement = statement.where(Projects.namespace == namespace) 

456 

457 if tag: 

458 statement = statement.where(Projects.tag == tag) 

459 

460 statement = statement.where( 

461 or_(Projects.private.is_(False), Projects.namespace.in_(admin_list)) 

462 ) 

463 

464 return statement 

465 

466 @staticmethod 

467 def _add_date_filter_if_provided( 

468 statement: Select, 

469 filter_by: Literal["submission_date", "last_update_date"] | None, 

470 filter_start_date: str | None, 

471 filter_end_date: str | None = None, 

472 ) -> Select: 

473 """Add a date range filter to a SELECT statement. 

474 

475 Args: 

476 statement: SQLAlchemy SELECT statement to augment. 

477 filter_by: Date field to filter on — "submission_date" or "last_update_date". 

478 filter_start_date: Range start in YYYY/MM/DD format. 

479 filter_end_date: Range end in YYYY/MM/DD format (default: today). 

480 

481 Returns: 

482 Statement with date filter applied. 

483 """ 

484 if filter_by and filter_start_date: 

485 start_date = convert_date_string_to_date(filter_start_date) 

486 if filter_end_date: 

487 end_date = convert_date_string_to_date(filter_end_date) 

488 else: 

489 end_date = datetime.now() 

490 if filter_by == SUBMISSION_DATE_KEY: 

491 statement = statement.filter( 

492 Projects.submission_date.between(start_date, end_date) 

493 ) 

494 elif filter_by == LAST_UPDATE_DATE_KEY: 

495 statement = statement.filter( 

496 Projects.last_update_date.between(start_date, end_date) 

497 ) 

498 else: 

499 raise FilterError("Invalid filter_by was provided!") 

500 return statement 

501 else: 

502 if filter_by: 

503 _LOGGER.warning( 

504 "filter_start_date was not provided, skipping filter..." 

505 ) 

506 return statement 

507 

508 def get_project_number_in_namespace( 

509 self, 

510 namespace: str, 

511 admin: str | list[str] | None = None, 

512 ) -> int: 

513 """Get the number of projects in a namespace. 

514 

515 Args: 

516 namespace: Namespace to count projects in. 

517 admin: Namespace(s) with admin/private access. 

518 

519 Returns: 

520 Number of accessible projects in the namespace. 

521 """ 

522 if admin is None: 

523 admin = [] 

524 statement = ( 

525 select(func.count()) 

526 .select_from(Projects) 

527 .where(Projects.namespace == namespace) 

528 ) 

529 statement = statement.where( 

530 or_(Projects.private.is_(False), Projects.namespace.in_(admin)) 

531 ) 

532 

533 result = self._pep_db_engine.session_execute(statement).first() 

534 

535 try: 

536 return result[0] 

537 except IndexError: 

538 return 0 

539 

540 def get_by_rp_list( 

541 self, 

542 registry_paths: list[str], 

543 admin: str | list[str] | None = None, 

544 ) -> AnnotationList: 

545 """Get project annotations for a list of registry paths in a single query. 

546 

547 Args: 

548 registry_paths: List of registry paths ("namespace/name:tag"). 

549 admin: Namespace(s) where the caller has admin rights. 

550 

551 Returns: 

552 AnnotationList preserving the input order (missing projects returned as None). 

553 """ 

554 admin_tuple = tuple_converter(admin) 

555 

556 if isinstance(registry_paths, list): 

557 or_statement_list = [] 

558 for path in registry_paths: 

559 try: 

560 namespace, name, tag = registry_path_converter(path) 

561 or_statement_list.append( 

562 and_( 

563 Projects.name == name, 

564 Projects.namespace == namespace, 

565 Projects.tag == tag, 

566 or_( 

567 Projects.namespace.in_(admin_tuple), 

568 Projects.private.is_(False), 

569 ), 

570 ) 

571 ) 

572 except RegistryPathError as err: 

573 _LOGGER.error(str(err), registry_paths) 

574 continue 

575 if not or_statement_list: 

576 _LOGGER.error("No valid registry paths were provided!") 

577 return AnnotationList( 

578 count=0, 

579 limit=len(registry_paths), 

580 offset=0, 

581 results=[], 

582 ) 

583 

584 statement = select(Projects).where(or_(*or_statement_list)) 

585 anno_results = [] 

586 with Session(self._sa_engine) as session: 

587 query_result = session.scalars(statement).unique() 

588 for result in query_result: 

589 project_obj = result 

590 annot = AnnotationModel( 

591 namespace=project_obj.namespace, 

592 name=project_obj.name, 

593 tag=project_obj.tag, 

594 is_private=project_obj.private, 

595 description=project_obj.description, 

596 number_of_samples=project_obj.number_of_samples, 

597 submission_date=str(project_obj.submission_date), 

598 last_update_date=str(project_obj.last_update_date), 

599 digest=project_obj.digest, 

600 pep_schema=( 

601 f"{project_obj.schema_mapping.schema_mapping.namespace}/{project_obj.schema_mapping.schema_mapping.name}:{project_obj.schema_mapping.version}" 

602 if project_obj.schema_mapping 

603 else None 

604 ), 

605 pop=project_obj.pop, 

606 stars_number=project_obj.number_of_stars, 

607 forked_from=( 

608 f"{project_obj.forked_from_mapping.namespace}/{project_obj.forked_from_mapping.name}:{project_obj.forked_from_mapping.tag}" 

609 if project_obj.forked_from_mapping 

610 else None 

611 ), 

612 ) 

613 anno_results.append(annot) 

614 

615 found_dict = {f"{r.namespace}/{r.name}:{r.tag}": r for r in anno_results} 

616 end_results = [found_dict.get(project) for project in registry_paths] 

617 

618 return_len = len(anno_results) 

619 return AnnotationList( 

620 count=return_len, 

621 limit=len(registry_paths), 

622 offset=0, 

623 results=end_results, 

624 ) 

625 

626 else: 

627 return self.get_by_rp(registry_paths, admin) 

628 

629 def get_projects_list( 

630 self, 

631 namespace: str = None, 

632 search_str: str = None, 

633 admin: str | list[str] | None = None, 

634 limit: int = DEFAULT_LIMIT, 

635 offset: int = DEFAULT_OFFSET, 

636 order_by: str = "update_date", 

637 order_desc: bool = False, 

638 filter_by: Literal["submission_date", "last_update_date"] | None = None, 

639 filter_start_date: str | None = None, 

640 filter_end_date: str | None = None, 

641 pep_type: Literal["pep", "pop"] | None = None, 

642 ) -> list[RegistryPath]: 

643 """Retrieve a list of registry paths matching the given filters. 

644 

645 Lightweight alternative to get() — returns only registry paths, no annotation data. 

646 

647 Args: 

648 namespace: Namespace to restrict the search to. 

649 search_str: String to search in name, tag, and description. 

650 admin: Namespace(s) with admin/private access. 

651 limit: Maximum number of results. 

652 offset: Number of results to skip. 

653 order_by: Sort field — "name", "update_date", "submission_date", or "stars". 

654 order_desc: Sort in descending order if True. 

655 filter_by: Date field for range filter — "submission_date" or "last_update_date". 

656 filter_start_date: Range start in YYYY/MM/DD format. 

657 filter_end_date: Range end in YYYY/MM/DD format (default: today). 

658 pep_type: Restrict to "pep" or "pop" (default: all). 

659 

660 Returns: 

661 List of RegistryPath objects. 

662 """ 

663 _LOGGER.info( 

664 f"Running project search: (namespace: {namespace}, query: {search_str}." 

665 ) 

666 

667 if admin is None: 

668 admin = [] 

669 statement = select(Projects.namespace, Projects.name, Projects.tag) 

670 

671 statement = self._add_condition( 

672 statement, 

673 namespace=namespace, 

674 search_str=search_str, 

675 admin_list=admin, 

676 ) 

677 statement = self._add_date_filter_if_provided( 

678 statement, filter_by, filter_start_date, filter_end_date 

679 ) 

680 statement = self._add_order_by_keyword(statement, by=order_by, desc=order_desc) 

681 statement = statement.limit(limit).offset(offset) 

682 if pep_type: 

683 statement = statement.where(Projects.pop.is_(pep_type == "pop")) 

684 

685 results_list = [] 

686 with Session(self._sa_engine) as session: 

687 results = session.execute(statement) 

688 

689 for result in results: 

690 results_list.append( 

691 RegistryPath(namespace=result[0], name=result[1], tag=result[2]) 

692 ) 

693 return results_list