Coverage for src/secchi/api/base.py: 69%
42 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"""Base protocol and factory for registry API adapters."""
3from __future__ import annotations
5from typing import ClassVar, Protocol
7import httpx
9from secchi.models import (
10 Dependency,
11 DownloadCounts,
12 DownloadTrendPoint,
13 PackageInfo,
14 Registry,
15 ReverseDependency,
16 SearchResult,
17 Version,
18)
21class RegistryAdapter(Protocol):
22 """Protocol that all registry API adapters must implement.
24 The two capability methods at the bottom have real default bodies rather
25 than `...`, so a concrete adapter that lacks a given real signal simply
26 inherits an honest empty result instead of silently returning None.
27 """
29 @property
30 def registry(self) -> Registry: ...
32 async def fetch_package(self, name: str) -> PackageInfo: ...
34 async def fetch_versions(self, name: str) -> list[Version]: ...
36 async def fetch_dependencies(self, name: str, version: str) -> list[Dependency]: ...
38 async def fetch_download_trend(
39 self, name: str, days: int = 30
40 ) -> list[DownloadTrendPoint]: ...
42 async def fetch_download_counts(self, name: str) -> DownloadCounts: ...
44 async def fetch_release_notes(self, name: str, version: str) -> str: ...
46 async def fetch_reverse_dependencies(
47 self, name: str, limit: int = 5
48 ) -> list[ReverseDependency]:
49 """Packages depending on this one. Default: no reverse-dep API."""
50 return []
52 async def fetch_reverse_dependency_count(self, name: str) -> int | None:
53 """Total projects depending on this package. Default: no API."""
54 return None
56 async def fetch_version_download_breakdown(self, name: str) -> dict[int | str, int]:
57 """Per-version download totals keyed by version id. Default: no API."""
58 return {}
60 async def search(self, query: str, limit: int = 10) -> list[SearchResult]:
61 """Find packages in this registry; adapters may implement richer search."""
62 return []
65class AdapterBase:
66 """Shared client binding for concrete registry adapters."""
68 default_headers: ClassVar[dict[str, str]] = {}
70 def __init__(self, client: httpx.AsyncClient) -> None:
71 self.client = client
73 def _client_scope(self):
74 return _ClientLease(self.client, self.default_headers)
77class _ClientLease:
78 """Context-manager view that never closes the shared client."""
80 def __init__(
81 self, client: httpx.AsyncClient, headers: dict[str, str] | None = None
82 ) -> None:
83 self.client = client
84 self.headers = headers or {}
86 async def __aenter__(self):
87 return self
89 async def __aexit__(self, exc_type, exc, traceback) -> None:
90 return None
92 async def get(self, url: str, *args, **kwargs):
93 headers = dict(self.client.headers)
94 headers.update(self.headers)
95 headers.update(kwargs.pop("headers", {}) or {})
96 return await self.client.get(url, *args, headers=headers, **kwargs)
99def create_adapter(registry: Registry, *, client: httpx.AsyncClient) -> RegistryAdapter:
100 """Factory: return the correct adapter for a given registry."""
101 from secchi.api.cran import CranAdapter
102 from secchi.api.crates import CratesAdapter
103 from secchi.api.golang import GoModuleAdapter
104 from secchi.api.homebrew import HomebrewAdapter
105 from secchi.api.npm import NpmAdapter
106 from secchi.api.pypi import PyPIAdapter
108 adapters = {
109 Registry.PYPI: PyPIAdapter,
110 Registry.CRATES: CratesAdapter,
111 Registry.NPM: NpmAdapter,
112 Registry.HOMEBREW: HomebrewAdapter,
113 Registry.GO: GoModuleAdapter,
114 Registry.CRAN: CranAdapter,
115 }
116 cls = adapters[registry]
117 return cls(client)