Coverage for src/secchi/api/golang.py: 92%
49 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"""Go Modules adapter using the public Go module proxy."""
3from __future__ import annotations
5from datetime import datetime
6from urllib.parse import quote
8import httpx
10from secchi.api.sparse import SparseAdapter
11from secchi.models import PackageInfo, Registry, SearchResult, Version
13GO_PROXY = "https://proxy.golang.org"
16def _escape_case(component: str) -> str:
17 """Go module proxy case-encoding: each uppercase letter becomes ``!`` +
18 its lowercase form (e.g. ``BurntSushi`` -> ``!burnt!sushi``).
20 Required so the proxy can serve module paths as files on case-insensitive
21 filesystems; see https://go.dev/ref/mod#module-proxy. Without it, any
22 module path containing an uppercase letter 404s against the real proxy.
23 """
24 return "".join(f"!{char.lower()}" if char.isupper() else char for char in component)
27def _module_path(name: str) -> str:
28 return quote(_escape_case(name), safe="/@!")
31class GoModuleAdapter(SparseAdapter):
32 @property
33 def registry(self) -> Registry:
34 return Registry.GO
36 async def fetch_package(self, name: str) -> PackageInfo:
37 module = _module_path(name)
38 async with self._client_scope() as client:
39 response = await client.get(f"{GO_PROXY}/{module}/@latest")
40 response.raise_for_status()
41 data = response.json()
42 version = data.get("Version", "")
43 repository = _repository_url(name)
44 return PackageInfo(
45 name=name,
46 registry=Registry.GO,
47 description=f"Go module {name}",
48 homepage=f"https://pkg.go.dev/{name}",
49 repository_url=repository,
50 documentation_url=f"https://pkg.go.dev/{name}",
51 latest_version=version,
52 latest_release_date=_parse_time(data.get("Time")),
53 versions=[
54 Version(version=version, release_date=_parse_time(data.get("Time")))
55 ]
56 if version
57 else [],
58 )
60 async def fetch_versions(self, name: str) -> list[Version]:
61 module = _module_path(name)
62 async with self._client_scope() as client:
63 response = await client.get(f"{GO_PROXY}/{module}/@v/list")
64 response.raise_for_status()
65 versions = [
66 Version(version=line) for line in response.text.splitlines() if line
67 ]
68 return versions[-100:]
70 async def search(self, query: str, limit: int = 10) -> list[SearchResult]:
71 # Go has no central keyword-search endpoint. A module path is resolved
72 # exactly through the proxy; broader discovery belongs to pkg.go.dev.
73 try:
74 info = await self.fetch_package(query)
75 except httpx.HTTPError:
76 return []
77 return [
78 SearchResult(
79 name=query,
80 registry=Registry.GO,
81 version=info.latest_version,
82 description=info.description,
83 url=info.documentation_url,
84 score=1.0,
85 exact=True,
86 )
87 ]
90def _repository_url(name: str) -> str:
91 parts = name.split("/")
92 if len(parts) >= 3 and parts[0] == "github.com":
93 return "/".join(parts[:3])
94 return ""
97def _parse_time(raw: str | None) -> datetime | None:
98 if not raw:
99 return None
100 try:
101 return datetime.fromisoformat(raw.replace("Z", "+00:00"))
102 except ValueError:
103 return None