utopia-analyzer 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,517 @@
1
+ """GitHub REST API client with caching, pagination, and rate-limit handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import urljoin
13
+
14
+ import requests
15
+
16
+
17
+ GITHUB_API_BASE = "https://api.github.com"
18
+ CACHE_TTL_SECONDS = 3600 # 1 hour
19
+ MAX_RETRIES = 3
20
+
21
+
22
+ class GitHubError(Exception):
23
+ """Base exception for GitHub client errors."""
24
+
25
+ def __init__(self, message: str, status_code: int | None = None):
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status_code = status_code
29
+
30
+
31
+ class RateLimitError(GitHubError):
32
+ """Raised when GitHub API rate limit is hit."""
33
+
34
+ pass
35
+
36
+
37
+ class UserNotFoundError(GitHubError):
38
+ """Raised when the requested GitHub user does not exist."""
39
+
40
+ pass
41
+
42
+
43
+ def _cache_dir() -> Path:
44
+ cache = Path.home() / ".utopia_cache"
45
+ cache.mkdir(parents=True, exist_ok=True)
46
+ return cache
47
+
48
+
49
+ def _cache_path(username: str) -> Path:
50
+ safe_name = hashlib.sha256(username.encode()).hexdigest()[:16]
51
+ return _cache_dir() / f"{safe_name}.json"
52
+
53
+
54
+ def _load_cache(username: str) -> dict[str, Any] | None:
55
+ path = _cache_path(username)
56
+ if not path.exists():
57
+ return None
58
+ try:
59
+ data = json.loads(path.read_text(encoding="utf-8"))
60
+ fetched_at = data.get("_fetched_at", 0)
61
+ if time.time() - fetched_at > CACHE_TTL_SECONDS:
62
+ return None
63
+ return data
64
+ except (json.JSONDecodeError, OSError):
65
+ return None
66
+
67
+
68
+ def _save_cache(username: str, data: dict[str, Any]) -> None:
69
+ data["_fetched_at"] = time.time()
70
+ path = _cache_path(username)
71
+ try:
72
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
73
+ except OSError:
74
+ pass
75
+
76
+
77
+ def _clear_cache(username: str) -> None:
78
+ path = _cache_path(username)
79
+ if path.exists():
80
+ path.unlink(missing_ok=True)
81
+
82
+
83
+ @dataclass
84
+ class RepoData:
85
+ """Normalized repository data collected by the client."""
86
+
87
+ name: str
88
+ description: str | None
89
+ language: str | None
90
+ stars: int
91
+ forks: int
92
+ size_kb: int
93
+ created_at: str
94
+ updated_at: str
95
+ pushed_at: str | None
96
+ has_readme: bool
97
+ readme_word_count: int
98
+ open_issues_count: int
99
+ topics: list[str]
100
+ license: str | None
101
+ is_fork: bool
102
+ is_archived: bool
103
+ languages: dict[str, int] = field(default_factory=dict)
104
+ commit_activity: list[int] = field(default_factory=list)
105
+ has_tests: bool = False
106
+ has_workflows: bool = False
107
+ has_dockerfile: bool = False
108
+
109
+
110
+ @dataclass
111
+ class ProfileData:
112
+ """Complete data bundle for a GitHub profile analysis."""
113
+
114
+ username: str
115
+ name: str | None
116
+ bio: str | None
117
+ followers: int
118
+ following: int
119
+ public_repos_count: int
120
+ created_at: str
121
+ repos: list[RepoData] = field(default_factory=list)
122
+ merged_prs_count: int = 0
123
+ total_languages: dict[str, int] = field(default_factory=dict)
124
+
125
+
126
+ def _default_headers(token: str | None) -> dict[str, str]:
127
+ headers = {"Accept": "application/vnd.github.v3+json"}
128
+ if token:
129
+ headers["Authorization"] = f"token {token}"
130
+ return headers
131
+
132
+
133
+ def _handle_response(response: requests.Response) -> Any:
134
+ if response.status_code == 404:
135
+ raise UserNotFoundError("GitHub user not found.", status_code=404)
136
+ if response.status_code in (403, 429):
137
+ reset = response.headers.get("X-RateLimit-Reset")
138
+ msg = "GitHub API rate limit exceeded."
139
+ if reset:
140
+ reset_time = time.strftime("%H:%M:%S", time.localtime(int(reset)))
141
+ msg += f" Resets at {reset_time}."
142
+ msg += " Use --token <PAT> for higher limits."
143
+ raise RateLimitError(msg, status_code=response.status_code)
144
+ if response.status_code >= 400:
145
+ raise GitHubError(
146
+ f"GitHub API error: {response.status_code} {response.text[:200]}",
147
+ status_code=response.status_code,
148
+ )
149
+ if response.status_code == 204:
150
+ return None
151
+ if not response.content:
152
+ return None
153
+ return response.json()
154
+
155
+
156
+ def _get(
157
+ session: requests.Session,
158
+ url: str,
159
+ token: str | None,
160
+ params: dict[str, Any] | None = None,
161
+ ) -> Any:
162
+ response = session.get(url, headers=_default_headers(token), params=params or {}, timeout=30)
163
+ return _handle_response(response)
164
+
165
+
166
+ def _get_all_pages(
167
+ session: requests.Session,
168
+ url: str,
169
+ token: str | None,
170
+ params: dict[str, Any] | None = None,
171
+ ) -> list[dict[str, Any]]:
172
+ items: list[dict[str, Any]] = []
173
+ page = 1
174
+ per_page = 100
175
+ query_params = dict(params or {})
176
+ query_params["per_page"] = per_page
177
+
178
+ while True:
179
+ query_params["page"] = page
180
+ data = _get(session, url, token, query_params)
181
+ if not isinstance(data, list):
182
+ break
183
+ items.extend(data)
184
+ if len(data) < per_page:
185
+ break
186
+ page += 1
187
+
188
+ return items
189
+
190
+
191
+ def _fetch_with_202_retry(
192
+ session: requests.Session,
193
+ url: str,
194
+ token: str | None,
195
+ retries: int = MAX_RETRIES,
196
+ ) -> Any:
197
+ """Fetch an endpoint that may return 202 while computing data."""
198
+ for attempt in range(retries):
199
+ response = session.get(url, headers=_default_headers(token), timeout=30)
200
+ if response.status_code == 202:
201
+ time.sleep(1.5 * (attempt + 1))
202
+ continue
203
+ return _handle_response(response)
204
+ return None
205
+
206
+
207
+ def _check_file_exists(
208
+ session: requests.Session,
209
+ owner: str,
210
+ repo: str,
211
+ path: str,
212
+ token: str | None,
213
+ ) -> bool:
214
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
215
+ try:
216
+ response = session.get(url, headers=_default_headers(token), timeout=30)
217
+ return response.status_code == 200
218
+ except requests.RequestException:
219
+ return False
220
+
221
+
222
+ def _fetch_readme_word_count(
223
+ session: requests.Session,
224
+ owner: str,
225
+ repo: str,
226
+ token: str | None,
227
+ ) -> int:
228
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/readme"
229
+ try:
230
+ data = _get(session, url, token)
231
+ if not isinstance(data, dict):
232
+ return 0
233
+ content = data.get("content", "")
234
+ import base64
235
+
236
+ try:
237
+ decoded = base64.b64decode(content).decode("utf-8", errors="ignore")
238
+ except Exception:
239
+ return 0
240
+ return len(decoded.split())
241
+ except (GitHubError, requests.RequestException):
242
+ return 0
243
+
244
+
245
+ def _fetch_repo_tree_flags(
246
+ session: requests.Session,
247
+ owner: str,
248
+ repo: str,
249
+ token: str | None,
250
+ ) -> dict[str, bool]:
251
+ """Check for test files, workflows, and Dockerfile via repository tree."""
252
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/git/trees/HEAD?recursive=1"
253
+ try:
254
+ data = _get(session, url, token)
255
+ if not isinstance(data, dict):
256
+ return {"has_tests": False, "has_workflows": False, "has_dockerfile": False}
257
+ tree = data.get("tree", [])
258
+ paths = [item.get("path", "").lower() for item in tree]
259
+
260
+ has_tests = any(
261
+ "test" in p
262
+ and (
263
+ p.endswith((".py", ".js", ".ts", ".java", ".go", ".rs", ".rb", ".cpp", ".c"))
264
+ or "test" in p.split("/")[-1]
265
+ )
266
+ for p in paths
267
+ ) or any(p.startswith("tests/") or p.startswith("test/") for p in paths)
268
+
269
+ has_workflows = any(p.startswith(".github/workflows/") for p in paths)
270
+ has_dockerfile = any("dockerfile" in p or "docker-compose" in p for p in paths)
271
+
272
+ return {
273
+ "has_tests": has_tests,
274
+ "has_workflows": has_workflows,
275
+ "has_dockerfile": has_dockerfile,
276
+ }
277
+ except (GitHubError, requests.RequestException):
278
+ return {"has_tests": False, "has_workflows": False, "has_dockerfile": False}
279
+
280
+
281
+ def _fetch_repo_details(
282
+ session: requests.Session,
283
+ owner: str,
284
+ repo_name: str,
285
+ token: str | None,
286
+ ) -> RepoData:
287
+ repo_url = f"{GITHUB_API_BASE}/repos/{owner}/{repo_name}"
288
+ repo = _get(session, repo_url, token)
289
+ if not isinstance(repo, dict):
290
+ raise GitHubError(f"Unexpected response for repo {repo_name}")
291
+
292
+ has_readme = _check_file_exists(session, owner, repo_name, "README.md", token)
293
+ readme_word_count = _fetch_readme_word_count(session, owner, repo_name, token) if has_readme else 0
294
+
295
+ languages_url = repo.get("languages_url", "")
296
+ languages: dict[str, int] = {}
297
+ if languages_url:
298
+ try:
299
+ langs = _get(session, languages_url, token)
300
+ if isinstance(langs, dict):
301
+ languages = {k: v for k, v in langs.items() if isinstance(v, int)}
302
+ except GitHubError:
303
+ languages = {}
304
+
305
+ commit_url = f"{GITHUB_API_BASE}/repos/{owner}/{repo_name}/stats/commit_activity"
306
+ commit_activity_raw = _fetch_with_202_retry(session, commit_url, token)
307
+ commit_activity: list[int] = []
308
+ if isinstance(commit_activity_raw, list):
309
+ commit_activity = [
310
+ int(week.get("total", 0)) if isinstance(week, dict) else 0
311
+ for week in commit_activity_raw
312
+ ]
313
+
314
+ tree_flags = _fetch_repo_tree_flags(session, owner, repo_name, token)
315
+
316
+ license_info = repo.get("license") or {}
317
+ license_name = license_info.get("spdx_id") if isinstance(license_info, dict) else None
318
+
319
+ return RepoData(
320
+ name=repo_name,
321
+ description=repo.get("description"),
322
+ language=repo.get("language"),
323
+ stars=int(repo.get("stargazers_count", 0) or 0),
324
+ forks=int(repo.get("forks_count", 0) or 0),
325
+ size_kb=int(repo.get("size", 0) or 0),
326
+ created_at=repo.get("created_at", ""),
327
+ updated_at=repo.get("updated_at", ""),
328
+ pushed_at=repo.get("pushed_at"),
329
+ has_readme=has_readme,
330
+ readme_word_count=readme_word_count,
331
+ open_issues_count=int(repo.get("open_issues_count", 0) or 0),
332
+ topics=list(repo.get("topics", []) or []),
333
+ license=license_name,
334
+ is_fork=bool(repo.get("fork", False)),
335
+ is_archived=bool(repo.get("archived", False)),
336
+ languages=languages,
337
+ commit_activity=commit_activity,
338
+ has_tests=tree_flags["has_tests"],
339
+ has_workflows=tree_flags["has_workflows"],
340
+ has_dockerfile=tree_flags["has_dockerfile"],
341
+ )
342
+
343
+
344
+ def _fetch_merged_prs_count(
345
+ session: requests.Session,
346
+ username: str,
347
+ token: str | None,
348
+ ) -> int:
349
+ url = urljoin(
350
+ GITHUB_API_BASE,
351
+ "/search/issues",
352
+ )
353
+ params = {
354
+ "q": f"author:{username} type:pr is:merged",
355
+ "per_page": 1,
356
+ }
357
+ try:
358
+ data = _get(session, url, token, params)
359
+ if isinstance(data, dict):
360
+ return int(data.get("total_count", 0) or 0)
361
+ except (GitHubError, requests.RequestException):
362
+ pass
363
+ return 0
364
+
365
+
366
+ def fetch_profile(
367
+ username: str,
368
+ token: str | None = None,
369
+ no_cache: bool = False,
370
+ progress_callback: callable | None = None,
371
+ ) -> ProfileData:
372
+ """Fetch and normalize all data needed for a UTOPIA report."""
373
+ if no_cache:
374
+ _clear_cache(username)
375
+
376
+ cached = _load_cache(username)
377
+ if cached is not None:
378
+ profile = _deserialize_profile(cached)
379
+ if progress_callback:
380
+ progress_callback("Using cached data", 1.0)
381
+ return profile
382
+
383
+ session = requests.Session()
384
+ try:
385
+ user_url = f"{GITHUB_API_BASE}/users/{username}"
386
+ user = _get(session, user_url, token)
387
+ if not isinstance(user, dict):
388
+ raise GitHubError("Unexpected response from GitHub user endpoint.")
389
+
390
+ repos_url = user.get("repos_url", "")
391
+ repos_raw = _get_all_pages(
392
+ session,
393
+ repos_url,
394
+ token,
395
+ {"type": "owner", "sort": "pushed", "direction": "desc"},
396
+ )
397
+
398
+ repos: list[RepoData] = []
399
+ total = len(repos_raw)
400
+ for idx, repo in enumerate(repos_raw):
401
+ if progress_callback:
402
+ progress_callback(f"Analyzing {repo.get('name', 'repo')}", idx / max(total, 1))
403
+ try:
404
+ repos.append(_fetch_repo_details(session, username, repo["name"], token))
405
+ except GitHubError as exc:
406
+ if exc.status_code in (403, 429):
407
+ raise
408
+ continue
409
+
410
+ if progress_callback:
411
+ progress_callback("Finalizing analysis", 1.0)
412
+
413
+ merged_prs = _fetch_merged_prs_count(session, username, token)
414
+
415
+ total_languages: dict[str, int] = {}
416
+ for repo in repos:
417
+ for lang, bytes_count in repo.languages.items():
418
+ total_languages[lang] = total_languages.get(lang, 0) + bytes_count
419
+
420
+ profile = ProfileData(
421
+ username=username,
422
+ name=user.get("name"),
423
+ bio=user.get("bio"),
424
+ followers=int(user.get("followers", 0) or 0),
425
+ following=int(user.get("following", 0) or 0),
426
+ public_repos_count=int(user.get("public_repos", 0) or 0),
427
+ created_at=user.get("created_at", ""),
428
+ repos=repos,
429
+ merged_prs_count=merged_prs,
430
+ total_languages=total_languages,
431
+ )
432
+
433
+ _save_cache(username, _serialize_profile(profile))
434
+ return profile
435
+ finally:
436
+ session.close()
437
+
438
+
439
+ def _serialize_profile(profile: ProfileData) -> dict[str, Any]:
440
+ return {
441
+ "username": profile.username,
442
+ "name": profile.name,
443
+ "bio": profile.bio,
444
+ "followers": profile.followers,
445
+ "following": profile.following,
446
+ "public_repos_count": profile.public_repos_count,
447
+ "created_at": profile.created_at,
448
+ "repos": [
449
+ {
450
+ "name": r.name,
451
+ "description": r.description,
452
+ "language": r.language,
453
+ "stars": r.stars,
454
+ "forks": r.forks,
455
+ "size_kb": r.size_kb,
456
+ "created_at": r.created_at,
457
+ "updated_at": r.updated_at,
458
+ "pushed_at": r.pushed_at,
459
+ "has_readme": r.has_readme,
460
+ "readme_word_count": r.readme_word_count,
461
+ "open_issues_count": r.open_issues_count,
462
+ "topics": r.topics,
463
+ "license": r.license,
464
+ "is_fork": r.is_fork,
465
+ "is_archived": r.is_archived,
466
+ "languages": r.languages,
467
+ "commit_activity": r.commit_activity,
468
+ "has_tests": r.has_tests,
469
+ "has_workflows": r.has_workflows,
470
+ "has_dockerfile": r.has_dockerfile,
471
+ }
472
+ for r in profile.repos
473
+ ],
474
+ "merged_prs_count": profile.merged_prs_count,
475
+ "total_languages": profile.total_languages,
476
+ }
477
+
478
+
479
+ def _deserialize_profile(data: dict[str, Any]) -> ProfileData:
480
+ repos = [
481
+ RepoData(
482
+ name=r["name"],
483
+ description=r.get("description"),
484
+ language=r.get("language"),
485
+ stars=r["stars"],
486
+ forks=r["forks"],
487
+ size_kb=r["size_kb"],
488
+ created_at=r["created_at"],
489
+ updated_at=r["updated_at"],
490
+ pushed_at=r.get("pushed_at"),
491
+ has_readme=r["has_readme"],
492
+ readme_word_count=r["readme_word_count"],
493
+ open_issues_count=r["open_issues_count"],
494
+ topics=r["topics"],
495
+ license=r.get("license"),
496
+ is_fork=r["is_fork"],
497
+ is_archived=r["is_archived"],
498
+ languages=r.get("languages", {}),
499
+ commit_activity=r.get("commit_activity", []),
500
+ has_tests=r.get("has_tests", False),
501
+ has_workflows=r.get("has_workflows", False),
502
+ has_dockerfile=r.get("has_dockerfile", False),
503
+ )
504
+ for r in data.get("repos", [])
505
+ ]
506
+ return ProfileData(
507
+ username=data["username"],
508
+ name=data.get("name"),
509
+ bio=data.get("bio"),
510
+ followers=data["followers"],
511
+ following=data["following"],
512
+ public_repos_count=data["public_repos_count"],
513
+ created_at=data["created_at"],
514
+ repos=repos,
515
+ merged_prs_count=data.get("merged_prs_count", 0),
516
+ total_languages=data.get("total_languages", {}),
517
+ )
@@ -0,0 +1,120 @@
1
+ """Generate a personalized 30-day improvement plan based on weak scoring categories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass, field
7
+
8
+ from utopia.github_client import ProfileData, RepoData
9
+ from utopia.scoring import ScoreBreakdown, UtopiaScore, weakest_categories
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Action templates mapped to scoring categories.
14
+ # ---------------------------------------------------------------------------
15
+ ACTION_TEMPLATES: dict[str, list[str]] = {
16
+ "project_quality": [
17
+ "Refactor your most-starred project and add missing features",
18
+ "Add a CI pipeline to at least one project",
19
+ "Clean up empty or fork-only repos that dilute your profile",
20
+ ],
21
+ "activity": [
22
+ "Commit to at least 3 repos this week",
23
+ "Resume work on {stale_repo}",
24
+ "Close stale issues and merge small PRs to show momentum",
25
+ ],
26
+ "documentation": [
27
+ "Improve README files across your top repos",
28
+ "Add usage examples and screenshots to {top_repo}",
29
+ "Write a one-paragraph project summary for every repo missing a description",
30
+ ],
31
+ "tech_diversity": [
32
+ "Try a small project in {missing_language}",
33
+ "Add a new language or framework to an existing side project",
34
+ "Contribute to an open-source project outside your main stack",
35
+ ],
36
+ "community_impact": [
37
+ "Add topics/tags to your top repos for discoverability",
38
+ "Write a blog post about {top_repo}",
39
+ "Share your best project on social media or developer forums",
40
+ ],
41
+ "open_source": [
42
+ "Open a pull request to a project you use",
43
+ "Review and merge community PRs in your own repos",
44
+ "Turn a useful script into a real open-source project",
45
+ ],
46
+ }
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Personalization helpers
51
+ # ---------------------------------------------------------------------------
52
+ def _most_starred_repo(repos: list[RepoData]) -> str | None:
53
+ original = [r for r in repos if not r.is_fork]
54
+ if not original:
55
+ return None
56
+ return max(original, key=lambda r: r.stars).name
57
+
58
+
59
+ def _most_stale_repo(repos: list[RepoData]) -> str | None:
60
+ from datetime import datetime, timezone
61
+
62
+ def days_since(repo: RepoData) -> float:
63
+ if not repo.pushed_at:
64
+ return float("inf")
65
+ try:
66
+ dt = datetime.fromisoformat(repo.pushed_at.replace("Z", "+00:00"))
67
+ return (datetime.now(timezone.utc) - dt).total_seconds() / 86400
68
+ except (ValueError, TypeError):
69
+ return float("inf")
70
+
71
+ original = [r for r in repos if not r.is_fork]
72
+ if not original:
73
+ return None
74
+ return max(original, key=days_since).name
75
+
76
+
77
+ def _missing_common_language(profile: ProfileData) -> str:
78
+ """Suggest a common language not already prominent in the profile."""
79
+ common = ["Python", "JavaScript", "TypeScript", "Go", "Rust", "Java"]
80
+ existing = {lang.lower() for lang in profile.total_languages}
81
+ for lang in common:
82
+ if lang.lower() not in existing:
83
+ return lang
84
+ return random.choice(common)
85
+
86
+
87
+ @dataclass
88
+ class ImprovementPlan:
89
+ weeks: list[list[str]] = field(default_factory=list)
90
+
91
+
92
+ def generate_improvement_plan(
93
+ profile: ProfileData,
94
+ score: UtopiaScore,
95
+ ) -> ImprovementPlan:
96
+ """Build a 4-week plan focusing on the weakest scoring categories."""
97
+ repos = profile.repos
98
+ top_repo = _most_starred_repo(repos) or "your best project"
99
+ stale_repo = _most_stale_repo(repos) or "an old project"
100
+ missing_language = _missing_common_language(profile)
101
+
102
+ weak = weakest_categories(score, n=4)
103
+ if len(weak) < 4:
104
+ # Pad with remaining categories if fewer than 4 are weak.
105
+ used = {b.category for b in weak}
106
+ remaining = [b for b in score.breakdown if b.category not in used]
107
+ weak.extend(remaining[: 4 - len(weak)])
108
+
109
+ weeks: list[list[str]] = []
110
+ for breakdown in weak[:4]:
111
+ templates = list(ACTION_TEMPLATES.get(breakdown.category, []))
112
+ random.shuffle(templates)
113
+ action = templates[0].format(
114
+ top_repo=top_repo,
115
+ stale_repo=stale_repo,
116
+ missing_language=missing_language,
117
+ )
118
+ weeks.append([action])
119
+
120
+ return ImprovementPlan(weeks=weeks)