Skip to content

Commit 3615421

Browse files
committed
[feat] Add component directory scripts
1 parent f8d7dcb commit 3615421

30 files changed

Lines changed: 3761 additions & 5 deletions

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
uv.lock linguist-generated=true
2+
components/registry/compiled/** linguist-generated=true

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,7 @@ __marimo__/
217217

218218
# MacOS
219219
.DS_Store
220+
221+
# Temporary files
222+
tmp/
223+
work-tmp/

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,3 @@ pre-commit run --all-files
2929
```
3030

3131
Note: `pre-commit` will also set up an isolated Node environment to run Prettier.
32-
33-
## Layout
34-
35-
- `scripts/`: runnable scripts

components/registry/components/.gitkeep

Whitespace-only changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"schemaVersion": 1,
3+
"halfLifeDays": 90.0,
4+
"weights": {
5+
"stars": 1.0,
6+
"recency": 2.0,
7+
"contributors": 0.5,
8+
"downloads": 0.35
9+
}
10+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from __future__ import annotations
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from .github import GitHubEnricher # type: ignore[import-not-found]
4+
from .pypi import PyPiEnricher # type: ignore[import-not-found]
5+
from .pypistats import PyPiStatsEnricher # type: ignore[import-not-found]
6+
7+
8+
def get_default_enrichers(*, github_token_env: str = "GH_TOKEN") -> list:
9+
return [
10+
GitHubEnricher(token_env=github_token_env),
11+
PyPiEnricher(),
12+
PyPiStatsEnricher(),
13+
]
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from dataclasses import dataclass
5+
from typing import Any
6+
from urllib.parse import parse_qs, urlparse
7+
8+
from _utils.enrich import should_refetch
9+
from _utils.enrichment_engine import FetchResult, Patch
10+
from _utils.github import parse_owner_repo
11+
from _utils.github_token import get_github_token
12+
from _utils.http import RetryConfig, fetch_json
13+
from _utils.time import utc_now_iso
14+
15+
GITHUB_API_BASE = "https://api.github.com"
16+
17+
18+
@dataclass(frozen=True)
19+
class GitHubResult:
20+
owner: str
21+
repo: str
22+
stars: int | None
23+
forks: int | None
24+
contributors_count: int | None
25+
open_issues: int | None
26+
pushed_at: str | None
27+
28+
29+
def _github_repo_api_url(owner: str, repo: str) -> str:
30+
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}"
31+
32+
33+
def _github_contributors_api_url(owner: str, repo: str) -> str:
34+
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contributors?per_page=1"
35+
36+
37+
_LINK_LAST_RE = re.compile(r'<([^>]+)>;\s*rel="last"')
38+
39+
40+
def _parse_last_page_from_link_header(link: str | None) -> int | None:
41+
if not isinstance(link, str) or not link.strip():
42+
return None
43+
m = _LINK_LAST_RE.search(link)
44+
if not m:
45+
return None
46+
try:
47+
last_url = m.group(1)
48+
parsed = urlparse(last_url)
49+
qs = parse_qs(parsed.query)
50+
page_vals = qs.get("page")
51+
if not page_vals:
52+
return None
53+
page = int(page_vals[0])
54+
return page if page >= 0 else None
55+
except Exception:
56+
return None
57+
58+
59+
class GitHubEnricher:
60+
name = "github"
61+
bucket = "github"
62+
63+
def __init__(self, *, token_env: str = "GH_TOKEN") -> None:
64+
self._token_env = token_env
65+
self._token = get_github_token(preferred_env=token_env)
66+
# GitHub rate limiting can require waiting for X-RateLimit-Reset; allow longer sleeps.
67+
self._retry_cfg = RetryConfig(
68+
retry_statuses=(403, 429, 500, 502, 503, 504),
69+
backoff_cap_s=600.0,
70+
)
71+
72+
def key_for_component(self, comp: dict[str, Any]) -> tuple[str, str] | None:
73+
gh_url = comp.get("gitHubUrl")
74+
if not isinstance(gh_url, str) or not gh_url.strip():
75+
return None
76+
try:
77+
owner, repo = parse_owner_repo(gh_url)
78+
except Exception:
79+
return None
80+
return (owner.lower(), repo.lower())
81+
82+
def needs_fetch(self, comp: dict[str, Any], refresh_older_than_hours: float | None) -> bool:
83+
metrics = comp.get("metrics")
84+
gh_metrics = metrics.get("github") if isinstance(metrics, dict) else None
85+
existing_fetched_at = gh_metrics.get("fetchedAt") if isinstance(gh_metrics, dict) else None
86+
stale = gh_metrics.get("isStale") if isinstance(gh_metrics, dict) else None
87+
return should_refetch(
88+
fetched_at=(existing_fetched_at if isinstance(existing_fetched_at, str) else None),
89+
is_stale=stale if isinstance(stale, bool) else None,
90+
refresh_older_than_hours=refresh_older_than_hours,
91+
)
92+
93+
def _headers(self) -> dict[str, str]:
94+
headers = {
95+
"Accept": "application/vnd.github+json",
96+
"User-Agent": "component-gallery-enrich-github",
97+
"X-GitHub-Api-Version": "2022-11-28",
98+
}
99+
if self._token:
100+
headers["Authorization"] = f"Bearer {self._token}"
101+
return headers
102+
103+
def _fetch_contributors_count(
104+
self, *, ctx, owner: str, repo: str
105+
) -> tuple[int | None, int, int | None, str | None]:
106+
url = _github_contributors_api_url(owner, repo)
107+
r = ctx.request_json(
108+
url=url,
109+
headers=self._headers(),
110+
fetcher=fetch_json,
111+
retry_cfg=self._retry_cfg,
112+
)
113+
if not r.ok or not isinstance(r.data, list):
114+
return None, r.attempts, r.status, r.error
115+
link = None
116+
if isinstance(r.headers, dict):
117+
link = r.headers.get("Link") or r.headers.get("link")
118+
last_page = _parse_last_page_from_link_header(link)
119+
if isinstance(last_page, int):
120+
return last_page, r.attempts, r.status, None
121+
return (1 if len(r.data) >= 1 else 0), r.attempts, r.status, None
122+
123+
def fetch(self, key: tuple[str, str], ctx) -> FetchResult:
124+
owner, repo = key
125+
url = _github_repo_api_url(owner, repo)
126+
r = ctx.request_json(
127+
url=url,
128+
headers=self._headers(),
129+
fetcher=fetch_json,
130+
retry_cfg=self._retry_cfg,
131+
)
132+
attempts = int(r.attempts)
133+
if not r.ok or not isinstance(r.data, dict):
134+
return FetchResult(
135+
ok=False,
136+
data=None,
137+
error=r.error or "Request failed.",
138+
attempts=attempts,
139+
status=r.status,
140+
)
141+
142+
data = r.data
143+
stars = data.get("stargazers_count")
144+
forks = data.get("forks_count")
145+
open_issues = data.get("open_issues_count")
146+
pushed_at = data.get("pushed_at")
147+
148+
contributors_count, contrib_attempts, status, err = self._fetch_contributors_count(
149+
ctx=ctx, owner=owner, repo=repo
150+
)
151+
attempts += int(contrib_attempts)
152+
if err:
153+
return FetchResult(
154+
ok=False,
155+
data=None,
156+
error=err,
157+
attempts=attempts,
158+
status=status,
159+
)
160+
161+
result = GitHubResult(
162+
owner=owner,
163+
repo=repo,
164+
stars=int(stars) if isinstance(stars, int) else None,
165+
forks=int(forks) if isinstance(forks, int) else None,
166+
contributors_count=(
167+
int(contributors_count)
168+
if isinstance(contributors_count, int) and contributors_count >= 0
169+
else None
170+
),
171+
open_issues=int(open_issues) if isinstance(open_issues, int) else None,
172+
pushed_at=str(pushed_at) if isinstance(pushed_at, str) else None,
173+
)
174+
return FetchResult(ok=True, data=result, error=None, attempts=attempts, status=r.status)
175+
176+
def patch_success(self, result: GitHubResult, fetched_at: str) -> Patch:
177+
updates: dict[str, Any] = {}
178+
if isinstance(result.stars, int):
179+
updates["stars"] = result.stars
180+
if isinstance(result.forks, int):
181+
updates["forks"] = result.forks
182+
if isinstance(result.contributors_count, int):
183+
updates["contributorsCount"] = result.contributors_count
184+
if isinstance(result.open_issues, int):
185+
updates["openIssues"] = result.open_issues
186+
if isinstance(result.pushed_at, str):
187+
updates["lastPushAt"] = result.pushed_at
188+
updates["fetchedAt"] = fetched_at or utc_now_iso()
189+
updates["isStale"] = False
190+
191+
# Treat successful fetch as an update; fields besides stars can change too.
192+
return Patch(bucket=self.bucket, updates=updates, changed=True)
193+
194+
def patch_failure(self, comp: dict[str, Any], error: str | None, status: int | None) -> Patch:
195+
updates: dict[str, Any] = {
196+
"isStale": True,
197+
"fetchedAt": utc_now_iso(),
198+
}
199+
return Patch(bucket=self.bucket, updates=updates, changed=True)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from _utils.enrich import should_refetch
7+
from _utils.enrichment_engine import FetchResult, Patch
8+
from _utils.http import RetryConfig, fetch_json
9+
from _utils.pypi_helpers import infer_pypi_project_from_piplink
10+
from _utils.time import utc_now_iso
11+
12+
PYPI_BASE = "https://pypi.org/pypi"
13+
14+
15+
@dataclass(frozen=True)
16+
class PyPiResult:
17+
project: str
18+
latest_version: str | None
19+
latest_release_at: str | None
20+
21+
22+
def _get_project_for_component(comp: dict[str, Any]) -> str | None:
23+
p = comp.get("pypi")
24+
if isinstance(p, str) and p.strip():
25+
return p.strip()
26+
return infer_pypi_project_from_piplink(comp.get("pipLink"))
27+
28+
29+
def _pypi_api_url(project: str) -> str:
30+
return f"{PYPI_BASE}/{project}/json"
31+
32+
33+
def _max_upload_time_iso(release_files: Any) -> str | None:
34+
if not isinstance(release_files, list):
35+
return None
36+
times: list[str] = []
37+
for f in release_files:
38+
if not isinstance(f, dict):
39+
continue
40+
t = f.get("upload_time_iso_8601") or f.get("upload_time")
41+
if isinstance(t, str) and t:
42+
times.append(t)
43+
return max(times) if times else None
44+
45+
46+
class PyPiEnricher:
47+
name = "pypi"
48+
bucket = "pypi"
49+
50+
def __init__(self) -> None:
51+
self._retry_cfg = RetryConfig(retry_statuses=(429, 500, 502, 503, 504))
52+
53+
def key_for_component(self, comp: dict[str, Any]) -> str | None:
54+
return _get_project_for_component(comp)
55+
56+
def needs_fetch(self, comp: dict[str, Any], refresh_older_than_hours: float | None) -> bool:
57+
metrics = comp.get("metrics")
58+
pypi_metrics = metrics.get("pypi") if isinstance(metrics, dict) else None
59+
existing_fetched_at = (
60+
pypi_metrics.get("fetchedAt") if isinstance(pypi_metrics, dict) else None
61+
)
62+
stale = pypi_metrics.get("isStale") if isinstance(pypi_metrics, dict) else None
63+
return should_refetch(
64+
fetched_at=(existing_fetched_at if isinstance(existing_fetched_at, str) else None),
65+
is_stale=stale if isinstance(stale, bool) else None,
66+
refresh_older_than_hours=refresh_older_than_hours,
67+
)
68+
69+
def fetch(self, key: str, ctx) -> FetchResult:
70+
url = _pypi_api_url(key)
71+
headers = {
72+
"Accept": "application/json",
73+
"User-Agent": "component-gallery-enrich-pypi",
74+
}
75+
r = ctx.request_json(
76+
url=url,
77+
headers=headers,
78+
fetcher=fetch_json,
79+
retry_cfg=self._retry_cfg,
80+
)
81+
if not r.ok or not isinstance(r.data, dict):
82+
return FetchResult(
83+
ok=False,
84+
data=None,
85+
error=r.error or "Request failed.",
86+
attempts=int(r.attempts),
87+
status=r.status,
88+
)
89+
data = r.data
90+
info = data.get("info")
91+
releases = data.get("releases")
92+
if not isinstance(info, dict) or not isinstance(releases, dict):
93+
return FetchResult(
94+
ok=False,
95+
data=None,
96+
error="Missing info/releases.",
97+
attempts=int(r.attempts),
98+
status=r.status,
99+
)
100+
latest_version = info.get("version")
101+
latest_version = (
102+
str(latest_version) if isinstance(latest_version, str) and latest_version else None
103+
)
104+
105+
latest_release_at: str | None = None
106+
if latest_version and latest_version in releases:
107+
latest_release_at = _max_upload_time_iso(releases.get(latest_version))
108+
if latest_release_at is None:
109+
best: str | None = None
110+
for _, files in releases.items():
111+
t = _max_upload_time_iso(files)
112+
if t and (best is None or t > best):
113+
best = t
114+
latest_release_at = best
115+
116+
result = PyPiResult(
117+
project=key,
118+
latest_version=latest_version,
119+
latest_release_at=latest_release_at,
120+
)
121+
return FetchResult(
122+
ok=True, data=result, error=None, attempts=int(r.attempts), status=r.status
123+
)
124+
125+
def patch_success(self, result: PyPiResult, fetched_at: str) -> Patch:
126+
updates = {
127+
"latestVersion": result.latest_version,
128+
"latestReleaseAt": result.latest_release_at,
129+
"fetchedAt": fetched_at or utc_now_iso(),
130+
"isStale": False,
131+
}
132+
return Patch(bucket=self.bucket, updates=updates, changed=True)
133+
134+
def patch_failure(self, comp: dict[str, Any], error: str | None, status: int | None) -> Patch:
135+
updates = {
136+
"isStale": True,
137+
"fetchedAt": utc_now_iso(),
138+
}
139+
return Patch(bucket=self.bucket, updates=updates, changed=True)

0 commit comments

Comments
 (0)