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.
utopia/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """UTOPIA: Ultimate Technical Overview & Portfolio Intelligence Analyzer."""
2
+
3
+ __version__ = "1.0.0"
utopia/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running UTOPIA as a module: python -m utopia <username>."""
2
+
3
+ from utopia.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
utopia/archetypes.py ADDED
@@ -0,0 +1,168 @@
1
+ """Developer archetype detection based on languages, topics, and repo signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Callable
8
+
9
+ from utopia.github_client import ProfileData, RepoData
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Archetype signal definitions
14
+ # ---------------------------------------------------------------------------
15
+ ARCHETYPE_LANGUAGES: dict[str, set[str]] = {
16
+ "backend": {"python", "go", "java", "rust", "ruby", "php", "c#", "c++", "c"},
17
+ "frontend": {"javascript", "typescript", "html", "css"},
18
+ "mobile": {"swift", "kotlin", "dart", "java", "objective-c"},
19
+ "ai_ml": {"python", "r", "julia"},
20
+ "data": {"python", "sql", "scala", "r"},
21
+ "devops": {"dockerfile", "shell", "powershell", "go", "yaml"},
22
+ }
23
+
24
+ ARCHETYPE_TOPICS: dict[str, set[str]] = {
25
+ "backend": {"api", "backend", "database", "server", "rest", "graphql", "microservices"},
26
+ "frontend": {"react", "vue", "angular", "css", "ui", "frontend", "webpack", "nextjs"},
27
+ "mobile": {"android", "ios", "flutter", "react-native", "mobile", "swiftui"},
28
+ "ai_ml": {
29
+ "tensorflow",
30
+ "pytorch",
31
+ "machine-learning",
32
+ "ai",
33
+ "deep-learning",
34
+ "nlp",
35
+ "llm",
36
+ "neural-network",
37
+ },
38
+ "data": {"data", "etl", "pipeline", "spark", "kafka", "hadoop", "airflow", "dbt"},
39
+ "devops": {"docker", "kubernetes", "terraform", "ci-cd", "github-actions", "ansible"},
40
+ }
41
+
42
+ ARCHETYPE_LABELS = {
43
+ "backend": "Backend Engineer",
44
+ "frontend": "Frontend Engineer",
45
+ "full_stack": "Full Stack Developer",
46
+ "mobile": "Mobile Developer",
47
+ "ai_ml": "AI/ML Developer",
48
+ "data": "Data Engineer",
49
+ "devops": "DevOps Engineer",
50
+ }
51
+
52
+ # Thresholds used to convert raw signal counts into percentage scores.
53
+ ARCHETYPE_IDEAL_LANGUAGE_BYTES = 1_000_000 # ~1 MB of archetype-aligned code
54
+ ARCHETYPE_IDEAL_TOPIC_MATCHES = 5
55
+ ARCHETYPE_IDEAL_REPO_SIGNALS = 3 # e.g. workflow + dockerfile + topic matches
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Signal aggregation
60
+ # ---------------------------------------------------------------------------
61
+ def _collect_signals(profile: ProfileData) -> dict[str, dict[str, float]]:
62
+ """Aggregate raw signals per archetype across all repos."""
63
+ signals: dict[str, dict[str, float]] = {
64
+ key: {"language_bytes": 0.0, "topic_matches": 0.0, "repo_signals": 0.0}
65
+ for key in ARCHETYPE_LABELS
66
+ if key != "full_stack"
67
+ }
68
+
69
+ for repo in profile.repos:
70
+ repo_topics = {t.lower() for t in repo.topics}
71
+ repo_main_lang = (repo.language or "").lower()
72
+
73
+ for archetype in signals:
74
+ # Language bytes
75
+ if repo_main_lang in ARCHETYPE_LANGUAGES.get(archetype, set()):
76
+ signals[archetype]["language_bytes"] += 1.0 # count repos, not bytes
77
+ for lang, bytes_count in repo.languages.items():
78
+ if lang.lower() in ARCHETYPE_LANGUAGES.get(archetype, set()):
79
+ signals[archetype]["language_bytes"] += bytes_count
80
+
81
+ # Topic matches
82
+ matching_topics = repo_topics & ARCHETYPE_TOPICS.get(archetype, set())
83
+ signals[archetype]["topic_matches"] += len(matching_topics)
84
+
85
+ # Repo-level file/topic signals
86
+ repo_signal = 0.0
87
+ if archetype == "devops":
88
+ if repo.has_workflows:
89
+ repo_signal += 1.0
90
+ if repo.has_dockerfile:
91
+ repo_signal += 1.0
92
+ if repo_topics & ARCHETYPE_TOPICS["devops"]:
93
+ repo_signal += 1.0
94
+ else:
95
+ if repo_main_lang in ARCHETYPE_LANGUAGES.get(archetype, set()):
96
+ repo_signal += 0.5
97
+ if matching_topics:
98
+ repo_signal += 0.5
99
+ signals[archetype]["repo_signals"] += repo_signal
100
+
101
+ return signals
102
+
103
+
104
+ def _normalize(value: float, ideal: float) -> float:
105
+ if ideal <= 0:
106
+ return 0.0
107
+ return min(value / ideal, 1.0)
108
+
109
+
110
+ def _log_normalize(value: float, ideal: float) -> float:
111
+ if value <= 0 or ideal <= 0:
112
+ return 0.0
113
+ return min(math.log10(value) / math.log10(ideal), 1.0)
114
+
115
+
116
+ def _archetype_percentage(raw_signals: dict[str, float]) -> float:
117
+ """Convert raw signals into a 0-100 percentage score."""
118
+ language_part = _log_normalize(
119
+ raw_signals["language_bytes"], ARCHETYPE_IDEAL_LANGUAGE_BYTES
120
+ )
121
+ topic_part = _normalize(raw_signals["topic_matches"], ARCHETYPE_IDEAL_TOPIC_MATCHES)
122
+ repo_part = _normalize(raw_signals["repo_signals"], ARCHETYPE_IDEAL_REPO_SIGNALS)
123
+
124
+ # Weighted blend: languages are the strongest signal, topics second, repo files third.
125
+ blended = language_part * 0.5 + topic_part * 0.3 + repo_part * 0.2
126
+ return round(blended * 100, 1)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Public API
131
+ # ---------------------------------------------------------------------------
132
+ @dataclass
133
+ class ArchetypeResult:
134
+ key: str
135
+ label: str
136
+ percentage: float
137
+
138
+
139
+ def detect_archetypes(profile: ProfileData) -> list[ArchetypeResult]:
140
+ """Detect and rank developer archetypes for the given profile."""
141
+ raw = _collect_signals(profile)
142
+
143
+ base_scores: dict[str, float] = {}
144
+ for key, sigs in raw.items():
145
+ base_scores[key] = _archetype_percentage(sigs)
146
+
147
+ # Full stack is derived from backend + frontend balance.
148
+ backend = base_scores.get("backend", 0)
149
+ frontend = base_scores.get("frontend", 0)
150
+ if backend > 0 and frontend > 0:
151
+ base_scores["full_stack"] = round((min(backend, frontend) * 0.6 + max(backend, frontend) * 0.2), 1)
152
+ else:
153
+ base_scores["full_stack"] = 0.0
154
+
155
+ results = [
156
+ ArchetypeResult(key=key, label=ARCHETYPE_LABELS[key], percentage=score)
157
+ for key, score in base_scores.items()
158
+ ]
159
+ results.sort(key=lambda r: r.percentage, reverse=True)
160
+ return results
161
+
162
+
163
+ def top_archetypes(
164
+ profile: ProfileData, n: int = 4, min_percentage: float = 3.0
165
+ ) -> list[ArchetypeResult]:
166
+ """Return the top N archetypes, filtering out negligible matches."""
167
+ all_archetypes = detect_archetypes(profile)
168
+ return [a for a in all_archetypes if a.percentage >= min_percentage][:n]
utopia/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """CLI entry point for UTOPIA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ # Ensure UTF-8 output on Windows terminals so Unicode bar charts render correctly.
9
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
10
+ try:
11
+ sys.stdout.reconfigure(encoding="utf-8")
12
+ except Exception:
13
+ pass
14
+
15
+ import click
16
+ from dotenv import load_dotenv
17
+ from rich.console import Console
18
+ from rich.progress import Progress, SpinnerColumn, TextColumn
19
+
20
+ from utopia.formatters import json_output, markdown_output, terminal
21
+ from utopia.github_client import (
22
+ GitHubError,
23
+ RateLimitError,
24
+ UserNotFoundError,
25
+ fetch_profile,
26
+ )
27
+ from utopia.report import build_report
28
+
29
+
30
+ load_dotenv()
31
+
32
+
33
+ @click.command()
34
+ @click.argument("username")
35
+ @click.option("--token", default=None, help="GitHub Personal Access Token for higher rate limits.")
36
+ @click.option(
37
+ "--output",
38
+ "output_format",
39
+ type=click.Choice(["terminal", "json", "markdown"], case_sensitive=False),
40
+ default="terminal",
41
+ help="Output format (default: terminal).",
42
+ )
43
+ @click.option("--save", "save_path", default=None, help="File path to save the report.")
44
+ @click.option("--roast", is_flag=True, help="Enable Portfolio Roast Mode.")
45
+ @click.option("--no-color", is_flag=True, help="Disable colored terminal output.")
46
+ @click.option("--no-cache", is_flag=True, help="Skip cached data and refresh from GitHub.")
47
+ def main(
48
+ username: str,
49
+ token: str | None,
50
+ output_format: str,
51
+ save_path: str | None,
52
+ roast: bool,
53
+ no_color: bool,
54
+ no_cache: bool,
55
+ ) -> None:
56
+ """Analyze a GitHub profile and generate a UTOPIA portfolio report."""
57
+ token = token or os.getenv("GITHUB_TOKEN")
58
+ output_format = output_format.lower()
59
+
60
+ console = Console(no_color=no_color)
61
+
62
+ try:
63
+ with Progress(
64
+ SpinnerColumn(),
65
+ TextColumn("[progress.description]{task.description}"),
66
+ console=console,
67
+ transient=True,
68
+ ) as progress:
69
+ task = progress.add_task("Fetching profile...", total=None)
70
+
71
+ def callback(message: str, _ratio: float) -> None:
72
+ progress.update(task, description=message)
73
+
74
+ profile = fetch_profile(username, token=token, no_cache=no_cache, progress_callback=callback)
75
+
76
+ report = build_report(profile, roast_mode=roast)
77
+
78
+ if output_format == "json":
79
+ rendered = json_output.render(report)
80
+ elif output_format == "markdown":
81
+ rendered = markdown_output.render(report)
82
+ else:
83
+ rendered = terminal.render(report, no_color=no_color)
84
+
85
+ if save_path:
86
+ with open(save_path, "w", encoding="utf-8") as f:
87
+ f.write(rendered)
88
+ console.print(f"Report saved to [bold]{save_path}[/bold]")
89
+
90
+ print(rendered, end="")
91
+
92
+ except UserNotFoundError:
93
+ console.print(f"[red]Error:[/red] GitHub user '{username}' not found.")
94
+ sys.exit(1)
95
+ except RateLimitError as exc:
96
+ console.print(f"[red]Rate limit:[/red] {exc.message}")
97
+ sys.exit(1)
98
+ except GitHubError as exc:
99
+ console.print(f"[red]GitHub API error:[/red] {exc.message}")
100
+ sys.exit(1)
101
+ except Exception as exc:
102
+ console.print(f"[red]Unexpected error:[/red] {exc}")
103
+ sys.exit(1)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -0,0 +1 @@
1
+ """Output formatters for UTOPIA reports."""
@@ -0,0 +1,50 @@
1
+ """JSON output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from typing import Any
8
+
9
+ from utopia.report import Report
10
+
11
+
12
+ def render(report: Report) -> str:
13
+ """Render the report as a structured JSON string."""
14
+ data: dict[str, Any] = {
15
+ "username": report.username,
16
+ "score": {
17
+ "total": report.score.total,
18
+ "max": report.score.max_score,
19
+ "grade": report.score.grade,
20
+ "breakdown": [
21
+ {
22
+ "category": b.category,
23
+ "label": b.label,
24
+ "score": b.score,
25
+ "max_score": b.max_score,
26
+ }
27
+ for b in report.score.breakdown
28
+ ],
29
+ },
30
+ "archetypes": [
31
+ {"key": a.key, "label": a.label, "percentage": a.percentage}
32
+ for a in report.archetypes
33
+ ],
34
+ "recruiter": {
35
+ "verdict": report.recruiter.verdict,
36
+ "reasons": report.recruiter.reasons,
37
+ "concerns": report.recruiter.concerns,
38
+ },
39
+ "strengths": report.strengths,
40
+ "weaknesses": report.weaknesses,
41
+ "tech_stack": {
42
+ "languages": report.profile.total_languages,
43
+ },
44
+ "improvement_plan": report.improvement_plan.weeks,
45
+ }
46
+
47
+ if report.roast:
48
+ data["roast"] = report.roast.lines
49
+
50
+ return json.dumps(data, indent=2)
@@ -0,0 +1,96 @@
1
+ """Markdown output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from utopia.report import Report
6
+
7
+
8
+ def _bar(value: float, width: int = 20) -> str:
9
+ filled = int(round(max(0.0, min(1.0, value)) * width))
10
+ return "|" * filled + "-" * (width - filled)
11
+
12
+
13
+ def render(report: Report) -> str:
14
+ """Render the report as a Markdown document."""
15
+ lines: list[str] = []
16
+ lines.append(f"# UTOPIA Portfolio Report: {report.username}")
17
+ lines.append("")
18
+ lines.append(f"**UTOPIA Score:** {report.score.total:.0f}/{report.score.max_score:.0f}")
19
+ lines.append(f"**Portfolio Grade:** {report.score.grade}")
20
+ lines.append("")
21
+
22
+ lines.append("## Score Breakdown")
23
+ lines.append("")
24
+ lines.append("| Category | Score | Percentage |")
25
+ lines.append("| --- | --- | --- |")
26
+ for b in report.score.breakdown:
27
+ ratio = b.score / b.max_score
28
+ lines.append(f"| {b.label} | {b.score:.0f}/{b.max_score:.0f} | {_bar(ratio)} {ratio*100:.0f}% |")
29
+ lines.append("")
30
+
31
+ if report.archetypes:
32
+ lines.append("## Developer Archetype")
33
+ lines.append("")
34
+ for arch in report.archetypes:
35
+ lines.append(f"- **{arch.label}:** {arch.percentage}%")
36
+ lines.append("")
37
+
38
+ lines.append("## Recruiter Verdict")
39
+ lines.append("")
40
+ lines.append(f"**Would I hire this developer?** {report.recruiter.verdict}")
41
+ if report.recruiter.reasons:
42
+ lines.append("")
43
+ lines.append("### Reasons")
44
+ for reason in report.recruiter.reasons:
45
+ lines.append(f"- {reason}")
46
+ if report.recruiter.concerns:
47
+ lines.append("")
48
+ lines.append("### Concerns")
49
+ for concern in report.recruiter.concerns:
50
+ lines.append(f"- {concern}")
51
+ lines.append("")
52
+
53
+ lines.append("## Strengths & Weaknesses")
54
+ lines.append("")
55
+ lines.append("### Strengths")
56
+ for s in report.strengths:
57
+ lines.append(f"- {s}")
58
+ lines.append("")
59
+ lines.append("### Weaknesses")
60
+ for w in report.weaknesses:
61
+ lines.append(f"- {w}")
62
+ lines.append("")
63
+
64
+ if report.profile.total_languages:
65
+ lines.append("## Tech Stack Breakdown")
66
+ lines.append("")
67
+ total = sum(report.profile.total_languages.values())
68
+ sorted_langs = sorted(
69
+ report.profile.total_languages.items(), key=lambda x: x[1], reverse=True
70
+ )
71
+ top = sorted_langs[:4]
72
+ other = sum(b for _, b in sorted_langs[4:])
73
+ rows = [(lang, b) for lang, b in top]
74
+ if other:
75
+ rows.append(("Other", other))
76
+ for lang, bytes_count in rows:
77
+ pct = bytes_count / total if total else 0
78
+ lines.append(f"- **{lang}:** {_bar(pct)} {pct*100:.1f}%")
79
+ lines.append("")
80
+
81
+ if report.roast:
82
+ lines.append("## Portfolio Roast")
83
+ lines.append("")
84
+ for line in report.roast.lines:
85
+ lines.append(f"> {line}")
86
+ lines.append("")
87
+
88
+ lines.append("## 30-Day Upgrade Plan")
89
+ lines.append("")
90
+ for i, week in enumerate(report.improvement_plan.weeks, start=1):
91
+ lines.append(f"### Week {i}")
92
+ for action in week:
93
+ lines.append(f"- {action}")
94
+ lines.append("")
95
+
96
+ return "\n".join(lines)
@@ -0,0 +1,130 @@
1
+ """Rich-based terminal output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from utopia.report import Report
11
+
12
+
13
+ def _color_for_ratio(ratio: float, no_color: bool) -> str:
14
+ if no_color:
15
+ return "white"
16
+ if ratio >= 0.7:
17
+ return "green"
18
+ if ratio >= 0.4:
19
+ return "yellow"
20
+ return "red"
21
+
22
+
23
+ def _bar(value: float, width: int = 30) -> str:
24
+ filled = int(round(_clamp(value, 0.0, 1.0) * width))
25
+ return "█" * filled + "░" * (width - filled)
26
+
27
+
28
+ def _clamp(value: float, lower: float, upper: float) -> float:
29
+ return max(lower, min(upper, value))
30
+
31
+
32
+ def render(report: Report, no_color: bool = False) -> str:
33
+ console = Console(
34
+ no_color=no_color,
35
+ force_terminal=True,
36
+ color_system=None if no_color else "auto",
37
+ highlight=False,
38
+ )
39
+ with console.capture() as capture:
40
+ console.print()
41
+
42
+ # Header panel
43
+ header_text = Text()
44
+ header_text.append(f"UTOPIA Portfolio Report: {report.username}\n", style="bold cyan")
45
+ header_text.append(f"UTOPIA SCORE: {report.score.total:.0f}/{report.score.max_score:.0f}\n", style="bold")
46
+ header_text.append(f"Portfolio Grade: {report.score.grade}", style="bold")
47
+ console.print(Panel(header_text, expand=False))
48
+
49
+ # Score breakdown
50
+ table = Table(title="Score Breakdown", show_header=True, header_style="bold magenta")
51
+ table.add_column("Category", justify="left")
52
+ table.add_column("Score", justify="right")
53
+ table.add_column("Bar", justify="left")
54
+
55
+ for b in report.score.breakdown:
56
+ ratio = b.score / b.max_score
57
+ color = _color_for_ratio(ratio, no_color)
58
+ table.add_row(
59
+ b.label,
60
+ f"{b.score:.0f}/{b.max_score:.0f}",
61
+ Text(f"{_bar(ratio)} {ratio*100:.0f}%", style=color),
62
+ )
63
+ console.print(table)
64
+
65
+ # Archetypes
66
+ if report.archetypes:
67
+ console.print()
68
+ console.print("[bold]You are:[/bold]")
69
+ for arch in report.archetypes:
70
+ bar = _bar(arch.percentage / 100, width=20)
71
+ console.print(f" {arch.label:<22} {bar} {arch.percentage:>5}%")
72
+
73
+ # Recruiter verdict
74
+ console.print()
75
+ verdict_color = {"YES": "green", "MAYBE": "yellow", "NOT YET": "red"}.get(
76
+ report.recruiter.verdict, "white"
77
+ )
78
+ console.print("[bold]Would I hire this developer?[/bold]")
79
+ console.print(f"[{verdict_color} bold]{report.recruiter.verdict}[/{verdict_color} bold]")
80
+ if report.recruiter.reasons:
81
+ console.print("[bold]Reasons:[/bold]")
82
+ for reason in report.recruiter.reasons:
83
+ console.print(f" ✓ {reason}")
84
+ if report.recruiter.concerns:
85
+ console.print("[bold]Concerns:[/bold]")
86
+ for concern in report.recruiter.concerns:
87
+ console.print(f" ✗ {concern}")
88
+
89
+ # Strengths / weaknesses
90
+ console.print()
91
+ console.print("[bold green]Strengths:[/bold green]")
92
+ for s in report.strengths:
93
+ console.print(f" ✓ {s}")
94
+ console.print("[bold red]Weaknesses:[/bold red]")
95
+ for w in report.weaknesses:
96
+ console.print(f" ✗ {w}")
97
+
98
+ # Tech stack
99
+ if report.profile.total_languages:
100
+ console.print()
101
+ console.print("[bold]Tech Stack Breakdown[/bold]")
102
+ total = sum(report.profile.total_languages.values())
103
+ sorted_langs = sorted(
104
+ report.profile.total_languages.items(), key=lambda x: x[1], reverse=True
105
+ )
106
+ top = sorted_langs[:4]
107
+ other = sum(b for _, b in sorted_langs[4:])
108
+ rows = [(lang, b) for lang, b in top]
109
+ if other:
110
+ rows.append(("Other", other))
111
+ for lang, bytes_count in rows:
112
+ pct = bytes_count / total if total else 0
113
+ bar = _bar(pct, width=20)
114
+ console.print(f" {lang:<14} {bar} {pct*100:>5.1f}%")
115
+
116
+ # Roast
117
+ if report.roast:
118
+ console.print()
119
+ console.print(Panel("\n".join(report.roast.lines), title="[bold red]Roast Mode[/bold red]", border_style="red"))
120
+
121
+ # Improvement plan
122
+ console.print()
123
+ console.print("[bold]30-Day Upgrade Plan[/bold]")
124
+ for i, week in enumerate(report.improvement_plan.weeks, start=1):
125
+ console.print(f"[bold]Week {i}:[/bold]")
126
+ for action in week:
127
+ console.print(f" • {action}")
128
+
129
+ console.print()
130
+ return capture.get()