Coverage for src/secchi/api/base.py: 69%
45 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +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 @property
93 def diagnostics(self):
94 return getattr(self.client, "diagnostics", None)
96 async def get(self, url: str, *args, **kwargs):
97 headers = httpx.Headers(self.client.headers)
98 headers.update(self.headers)
99 headers.update(kwargs.pop("headers", {}) or {})
100 return await self.client.get(url, *args, headers=headers, **kwargs)
103def create_adapter(registry: Registry, *, client: httpx.AsyncClient) -> RegistryAdapter:
104 """Factory: return the correct adapter for a given registry."""
105 from secchi.api.cran import CranAdapter
106 from secchi.api.crates import CratesAdapter
107 from secchi.api.golang import GoModuleAdapter
108 from secchi.api.homebrew import HomebrewAdapter
109 from secchi.api.npm import NpmAdapter
110 from secchi.api.pypi import PyPIAdapter
112 adapters = {
113 Registry.PYPI: PyPIAdapter,
114 Registry.CRATES: CratesAdapter,
115 Registry.NPM: NpmAdapter,
116 Registry.HOMEBREW: HomebrewAdapter,
117 Registry.GO: GoModuleAdapter,
118 Registry.CRAN: CranAdapter,
119 }
120 cls = adapters[registry]
121 return cls(client)