Coverage for src/secchi/services/search.py: 100%
32 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
1"""Cross-registry package discovery and deterministic result ranking."""
3from __future__ import annotations
5import asyncio
6import logging
7import math
9import httpx
11from secchi.api.base import create_adapter
12from secchi.http import HttpClientFactory
13from secchi.models import Registry, SearchResult
15logger = logging.getLogger(__name__)
18class PackageSearchService:
19 """Search configured registries concurrently and normalize their results."""
21 async def search(
22 self,
23 query: str,
24 *,
25 registries: list[Registry] | None = None,
26 limit: int = 10,
27 ) -> list[SearchResult]:
28 selected = registries or list(Registry)
30 async with HttpClientFactory().create() as client:
32 async def search_registry(registry: Registry) -> list[SearchResult]:
33 try:
34 try:
35 adapter = create_adapter(registry, client=client)
36 except TypeError:
37 adapter = create_adapter(registry)
38 return await adapter.search(query, limit=limit)
39 except (
40 httpx.HTTPError,
41 OSError,
42 ValueError,
43 KeyError,
44 TypeError,
45 ) as exc:
46 # One unavailable registry should not hide results from the others.
47 logger.debug(
48 "Registry search failed for %s: %s",
49 registry.value,
50 exc,
51 exc_info=True,
52 )
53 return []
55 batches = await asyncio.gather(
56 *(search_registry(registry) for registry in selected)
57 )
58 results = [result for batch in batches for result in batch]
59 results.sort(key=lambda result: self._sort_key(result, query))
60 return results[: limit * len(selected)]
62 @staticmethod
63 def _sort_key(result: SearchResult, query: str) -> tuple[int, int, float, str]:
64 exact = 0 if result.exact or result.name.casefold() == query.casefold() else 1
65 # Registry APIs use incompatible score scales. Compress large download
66 # scores while preserving useful ordering within a registry.
67 normalized_score = (
68 math.log10(result.score + 1) if result.score > 1 else result.score
69 )
70 return (
71 exact,
72 0 if result.name.casefold().startswith(query.casefold()) else 1,
73 -normalized_score,
74 result.name.casefold(),
75 )