dev-dna 0.1.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.
- dev_dna/__init__.py +11 -0
- dev_dna/analyzer.py +597 -0
- dev_dna/cli.py +509 -0
- dev_dna-0.1.0.dist-info/METADATA +147 -0
- dev_dna-0.1.0.dist-info/RECORD +9 -0
- dev_dna-0.1.0.dist-info/WHEEL +5 -0
- dev_dna-0.1.0.dist-info/entry_points.txt +2 -0
- dev_dna-0.1.0.dist-info/licenses/LICENSE +21 -0
- dev_dna-0.1.0.dist-info/top_level.txt +1 -0
dev_dna/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""dev-dna — 23andMe for developers.
|
|
2
|
+
|
|
3
|
+
Analyze any GitHub user's developer DNA: dominant languages, evolution
|
|
4
|
+
over time, commit personality archetype, specialization score, star power,
|
|
5
|
+
and a shareable summary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dev_dna.analyzer import analyze_user, fetch_repos
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["analyze_user", "fetch_repos"]
|
dev_dna/analyzer.py
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Core analysis logic for dev-dna.
|
|
2
|
+
|
|
3
|
+
Fetches GitHub public API data and computes developer DNA metrics.
|
|
4
|
+
All HTTP calls use urllib.request — no third-party HTTP libraries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
GITHUB_API = "https://api.github.com"
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# GitHub API helpers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_headers(token: Optional[str] = None) -> Dict[str, str]:
|
|
26
|
+
headers: Dict[str, str] = {
|
|
27
|
+
"Accept": "application/vnd.github+json",
|
|
28
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
29
|
+
"User-Agent": "dev-dna-cli/0.1.0",
|
|
30
|
+
}
|
|
31
|
+
resolved_token = token or os.environ.get("GITHUB_TOKEN")
|
|
32
|
+
if resolved_token:
|
|
33
|
+
headers["Authorization"] = f"Bearer {resolved_token}"
|
|
34
|
+
return headers
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get(url: str, token: Optional[str] = None) -> Any:
|
|
38
|
+
"""Perform a GET request and return parsed JSON, or raise on error."""
|
|
39
|
+
req = urllib.request.Request(url, headers=_build_headers(token))
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
42
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
43
|
+
except urllib.error.HTTPError as exc:
|
|
44
|
+
if exc.code == 403:
|
|
45
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
46
|
+
if "rate limit" in body.lower():
|
|
47
|
+
raise RateLimitError(
|
|
48
|
+
"GitHub API rate limit exceeded. "
|
|
49
|
+
"Set GITHUB_TOKEN or pass --token for higher limits."
|
|
50
|
+
) from exc
|
|
51
|
+
raise GitHubError(f"HTTP 403 from GitHub API: {body}") from exc
|
|
52
|
+
if exc.code == 404:
|
|
53
|
+
raise NotFoundError(f"GitHub user or resource not found: {url}") from exc
|
|
54
|
+
raise GitHubError(f"GitHub API error {exc.code}: {exc.reason}") from exc
|
|
55
|
+
except urllib.error.URLError as exc:
|
|
56
|
+
raise GitHubError(f"Network error: {exc.reason}") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RateLimitError(RuntimeError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NotFoundError(RuntimeError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GitHubError(RuntimeError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Data fetching
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fetch_user(username: str, token: Optional[str] = None) -> Dict[str, Any]:
|
|
77
|
+
"""Fetch the public profile for *username*."""
|
|
78
|
+
return _get(f"{GITHUB_API}/users/{username}", token=token)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fetch_repos(username: str, token: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
82
|
+
"""Fetch all public repos for *username* (up to 100, sorted by update date)."""
|
|
83
|
+
url = (
|
|
84
|
+
f"{GITHUB_API}/users/{username}/repos"
|
|
85
|
+
"?per_page=100&sort=updated&type=public"
|
|
86
|
+
)
|
|
87
|
+
repos: List[Dict[str, Any]] = _get(url, token=token)
|
|
88
|
+
if not isinstance(repos, list):
|
|
89
|
+
return []
|
|
90
|
+
# Filter out forks to focus on original work (keep forks if they have stars)
|
|
91
|
+
return [r for r in repos if not r.get("fork") or (r.get("stargazers_count", 0) > 0)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Metric computation
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_date(date_str: Optional[str]) -> Optional[datetime]:
|
|
100
|
+
if not date_str:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
# GitHub returns ISO 8601 with Z suffix
|
|
104
|
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
105
|
+
except ValueError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def compute_language_dna(repos: List[Dict[str, Any]]) -> List[Tuple[str, int]]:
|
|
110
|
+
"""Return [(language, repo_count), ...] sorted descending, top 8."""
|
|
111
|
+
counter: Counter = Counter()
|
|
112
|
+
for repo in repos:
|
|
113
|
+
lang = repo.get("language")
|
|
114
|
+
if lang:
|
|
115
|
+
counter[lang] += 1
|
|
116
|
+
return counter.most_common(8)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def compute_star_power(repos: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
120
|
+
total = sum(r.get("stargazers_count", 0) for r in repos)
|
|
121
|
+
if not repos:
|
|
122
|
+
return {"total": 0, "top_repo": None, "top_stars": 0}
|
|
123
|
+
top = max(repos, key=lambda r: r.get("stargazers_count", 0))
|
|
124
|
+
return {
|
|
125
|
+
"total": total,
|
|
126
|
+
"top_repo": top.get("name", ""),
|
|
127
|
+
"top_stars": top.get("stargazers_count", 0),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compute_velocity(
|
|
132
|
+
repos: List[Dict[str, Any]], user: Dict[str, Any]
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
"""Repos per month since account creation."""
|
|
135
|
+
created_at = _parse_date(user.get("created_at"))
|
|
136
|
+
if not created_at:
|
|
137
|
+
return {"repos_per_month": 0.0, "total_repos": len(repos), "months": 0}
|
|
138
|
+
|
|
139
|
+
now = datetime.now(timezone.utc)
|
|
140
|
+
months = max(
|
|
141
|
+
1,
|
|
142
|
+
(now.year - created_at.year) * 12 + (now.month - created_at.month),
|
|
143
|
+
)
|
|
144
|
+
rpm = round(len(repos) / months, 1)
|
|
145
|
+
return {
|
|
146
|
+
"repos_per_month": rpm,
|
|
147
|
+
"total_repos": len(repos),
|
|
148
|
+
"months": months,
|
|
149
|
+
"account_created": created_at,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def compute_evolution(
|
|
154
|
+
repos: List[Dict[str, Any]],
|
|
155
|
+
) -> Dict[str, Any]:
|
|
156
|
+
"""Compare primary language in oldest 20% vs newest 20% of repos."""
|
|
157
|
+
dated = []
|
|
158
|
+
for r in repos:
|
|
159
|
+
dt = _parse_date(r.get("created_at"))
|
|
160
|
+
if dt:
|
|
161
|
+
dated.append((dt, r))
|
|
162
|
+
if not dated:
|
|
163
|
+
return {"early": "N/A", "recent": "N/A", "earliest_year": None}
|
|
164
|
+
|
|
165
|
+
dated.sort(key=lambda x: x[0])
|
|
166
|
+
n = max(1, len(dated) // 5) # 20%
|
|
167
|
+
|
|
168
|
+
def top_lang(subset: List[Tuple[datetime, Dict[str, Any]]]) -> str:
|
|
169
|
+
c: Counter = Counter()
|
|
170
|
+
for _, r in subset:
|
|
171
|
+
lang = r.get("language")
|
|
172
|
+
if lang:
|
|
173
|
+
c[lang] += 1
|
|
174
|
+
return c.most_common(1)[0][0] if c else "N/A"
|
|
175
|
+
|
|
176
|
+
earliest_year = dated[0][0].year
|
|
177
|
+
early_lang = top_lang(dated[:n])
|
|
178
|
+
recent_langs: Counter = Counter()
|
|
179
|
+
for _, r in dated[-n:]:
|
|
180
|
+
lang = r.get("language")
|
|
181
|
+
if lang:
|
|
182
|
+
recent_langs[lang] += 1
|
|
183
|
+
|
|
184
|
+
# Trajectory label
|
|
185
|
+
top_recent = [l for l, _ in recent_langs.most_common(3)]
|
|
186
|
+
if len(top_recent) >= 2:
|
|
187
|
+
recent_str = " + ".join(top_recent[:2])
|
|
188
|
+
trajectory = "Diversifying"
|
|
189
|
+
elif top_recent:
|
|
190
|
+
recent_str = top_recent[0]
|
|
191
|
+
trajectory = "Deepening specialization" if recent_str == early_lang else "Language shift"
|
|
192
|
+
else:
|
|
193
|
+
recent_str = "N/A"
|
|
194
|
+
trajectory = "Not enough data"
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"early": early_lang,
|
|
198
|
+
"recent": recent_str,
|
|
199
|
+
"trajectory": trajectory,
|
|
200
|
+
"earliest_year": earliest_year,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def compute_open_source_signal(repos: List[Dict[str, Any]]) -> int:
|
|
205
|
+
"""Percentage of repos that have a license."""
|
|
206
|
+
if not repos:
|
|
207
|
+
return 0
|
|
208
|
+
licensed = sum(1 for r in repos if r.get("license"))
|
|
209
|
+
return round(licensed / len(repos) * 100)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def compute_documentation_score(repos: List[Dict[str, Any]]) -> int:
|
|
213
|
+
"""Percentage of repos with both a description and (assumed) README.
|
|
214
|
+
|
|
215
|
+
The repos endpoint doesn't expose README presence directly, so we
|
|
216
|
+
use description as a proxy — it's a reasonable signal for how much
|
|
217
|
+
care the developer puts into documentation.
|
|
218
|
+
"""
|
|
219
|
+
if not repos:
|
|
220
|
+
return 0
|
|
221
|
+
with_desc = sum(
|
|
222
|
+
1
|
|
223
|
+
for r in repos
|
|
224
|
+
if r.get("description") and r["description"].strip()
|
|
225
|
+
)
|
|
226
|
+
return round(with_desc / len(repos) * 100)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def compute_specialization_score(
|
|
230
|
+
repos: List[Dict[str, Any]], lang_dna: List[Tuple[str, int]]
|
|
231
|
+
) -> int:
|
|
232
|
+
"""0-100. High = deep in one domain, Low = generalist.
|
|
233
|
+
|
|
234
|
+
Formula: weighted by language concentration (Herfindahl-style) + topic
|
|
235
|
+
coherence signal.
|
|
236
|
+
"""
|
|
237
|
+
if not repos or not lang_dna:
|
|
238
|
+
return 0
|
|
239
|
+
total_with_lang = sum(c for _, c in lang_dna)
|
|
240
|
+
if total_with_lang == 0:
|
|
241
|
+
return 0
|
|
242
|
+
# Herfindahl index: sum of squares of shares
|
|
243
|
+
hhi = sum((c / total_with_lang) ** 2 for _, c in lang_dna)
|
|
244
|
+
# Map 0..1 to 0..100 (pure mono-language = 1.0 → 100, equal split → near 0)
|
|
245
|
+
# We scale so that HHI of 0.5 → ~50
|
|
246
|
+
score = round(min(100, hhi * 100 * 1.5))
|
|
247
|
+
return score
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _score_velocity(rpm: float) -> int:
|
|
251
|
+
"""Map repos-per-month to a 0-100 score."""
|
|
252
|
+
if rpm >= 5:
|
|
253
|
+
return 99
|
|
254
|
+
if rpm >= 3:
|
|
255
|
+
return 90
|
|
256
|
+
if rpm >= 2:
|
|
257
|
+
return 80
|
|
258
|
+
if rpm >= 1:
|
|
259
|
+
return 65
|
|
260
|
+
if rpm >= 0.5:
|
|
261
|
+
return 45
|
|
262
|
+
if rpm >= 0.2:
|
|
263
|
+
return 25
|
|
264
|
+
return 10
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _velocity_percentile_label(rpm: float) -> str:
|
|
268
|
+
if rpm >= 5:
|
|
269
|
+
return "faster than 95% of GitHub developers"
|
|
270
|
+
if rpm >= 3:
|
|
271
|
+
return "faster than 90% of GitHub developers"
|
|
272
|
+
if rpm >= 2:
|
|
273
|
+
return "faster than 80% of GitHub developers"
|
|
274
|
+
if rpm >= 1:
|
|
275
|
+
return "faster than 65% of GitHub developers"
|
|
276
|
+
if rpm >= 0.5:
|
|
277
|
+
return "around the median GitHub developer"
|
|
278
|
+
return "a deliberate, quality-focused pace"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Archetype detection
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
_ARCHETYPE_KEYWORDS: Dict[str, List[str]] = {
|
|
286
|
+
"toolsmith": [
|
|
287
|
+
"cli", "tool", "utility", "script", "automation", "helper",
|
|
288
|
+
"generator", "formatter", "linter", "plugin", "extension",
|
|
289
|
+
"command", "terminal", "shell", "devtool",
|
|
290
|
+
],
|
|
291
|
+
"researcher": [
|
|
292
|
+
"ml", "ai", "deep-learning", "neural", "nlp", "llm", "gpt",
|
|
293
|
+
"bert", "transformer", "dataset", "research", "paper",
|
|
294
|
+
"experiment", "model", "training", "embedding", "rag",
|
|
295
|
+
"diffusion", "vision", "reinforcement",
|
|
296
|
+
],
|
|
297
|
+
"fullstack": [
|
|
298
|
+
"frontend", "backend", "fullstack", "full-stack", "api",
|
|
299
|
+
"webapp", "web-app", "dashboard", "react", "vue", "angular",
|
|
300
|
+
"nextjs", "django", "flask", "fastapi", "express", "node",
|
|
301
|
+
"rest", "graphql", "tailwind", "css",
|
|
302
|
+
],
|
|
303
|
+
"data_engineer": [
|
|
304
|
+
"etl", "pipeline", "data", "analytics", "warehouse",
|
|
305
|
+
"spark", "kafka", "airflow", "dbt", "postgres", "mysql",
|
|
306
|
+
"bigquery", "snowflake", "pandas", "numpy", "visualization",
|
|
307
|
+
"chart", "dashboard", "metrics",
|
|
308
|
+
],
|
|
309
|
+
"hacker": [], # fallback for wide variety, many repos
|
|
310
|
+
"craftsperson": [], # fallback for few, polished repos
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_ARCHETYPE_META: Dict[str, Dict[str, str]] = {
|
|
314
|
+
"toolsmith": {
|
|
315
|
+
"label": "The Toolsmith",
|
|
316
|
+
"blurb": (
|
|
317
|
+
"Ships fast. Builds tools that make other developers' lives "
|
|
318
|
+
"easier. Prefers CLI over GUI. Has opinions about your commit "
|
|
319
|
+
"messages."
|
|
320
|
+
),
|
|
321
|
+
},
|
|
322
|
+
"researcher": {
|
|
323
|
+
"label": "The Researcher",
|
|
324
|
+
"blurb": (
|
|
325
|
+
"Lives at the intersection of academia and engineering. "
|
|
326
|
+
"Reads papers on weekends. Turns ideas into prototypes before "
|
|
327
|
+
"most people have finished the abstract."
|
|
328
|
+
),
|
|
329
|
+
},
|
|
330
|
+
"fullstack": {
|
|
331
|
+
"label": "The Full-Stack Builder",
|
|
332
|
+
"blurb": (
|
|
333
|
+
"Comfortable everywhere from the database to the browser. "
|
|
334
|
+
"Thinks in user journeys. Can wire up an API and style a "
|
|
335
|
+
"landing page before lunch."
|
|
336
|
+
),
|
|
337
|
+
},
|
|
338
|
+
"data_engineer": {
|
|
339
|
+
"label": "The Data Engineer",
|
|
340
|
+
"blurb": (
|
|
341
|
+
"Speaks fluent SQL and pipeline DSL. Believes clean data is "
|
|
342
|
+
"a prerequisite for everything. Has a strong opinion on "
|
|
343
|
+
"column naming conventions."
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
"hacker": {
|
|
347
|
+
"label": "The Hacker",
|
|
348
|
+
"blurb": (
|
|
349
|
+
"Wide variety, fast shipping, zero apologies. Tries "
|
|
350
|
+
"everything. Learns by building. The repo count alone is a "
|
|
351
|
+
"flex."
|
|
352
|
+
),
|
|
353
|
+
},
|
|
354
|
+
"craftsperson": {
|
|
355
|
+
"label": "The Craftsperson",
|
|
356
|
+
"blurb": (
|
|
357
|
+
"Fewer repos, but each one is polished. Sweats the details. "
|
|
358
|
+
"Writes the README before the code. Quality over quantity, "
|
|
359
|
+
"always."
|
|
360
|
+
),
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def detect_archetype(
|
|
366
|
+
repos: List[Dict[str, Any]], lang_dna: List[Tuple[str, int]]
|
|
367
|
+
) -> str:
|
|
368
|
+
"""Return an archetype key based on repo metadata signals."""
|
|
369
|
+
scores: Counter = Counter()
|
|
370
|
+
|
|
371
|
+
for repo in repos:
|
|
372
|
+
text = " ".join(
|
|
373
|
+
filter(
|
|
374
|
+
None,
|
|
375
|
+
[
|
|
376
|
+
repo.get("name", ""),
|
|
377
|
+
repo.get("description", "") or "",
|
|
378
|
+
repo.get("language", "") or "",
|
|
379
|
+
" ".join(repo.get("topics", []) or []),
|
|
380
|
+
],
|
|
381
|
+
)
|
|
382
|
+
).lower()
|
|
383
|
+
|
|
384
|
+
for archetype, keywords in _ARCHETYPE_KEYWORDS.items():
|
|
385
|
+
if not keywords:
|
|
386
|
+
continue
|
|
387
|
+
hits = sum(1 for kw in keywords if kw in text)
|
|
388
|
+
if hits:
|
|
389
|
+
scores[archetype] += hits
|
|
390
|
+
|
|
391
|
+
if not scores:
|
|
392
|
+
# No keyword matches — use structural heuristics
|
|
393
|
+
if len(repos) >= 30:
|
|
394
|
+
return "hacker"
|
|
395
|
+
return "craftsperson"
|
|
396
|
+
|
|
397
|
+
top_archetype, top_score = scores.most_common(1)[0]
|
|
398
|
+
|
|
399
|
+
# If top score is very low and we have many repos, prefer hacker
|
|
400
|
+
if top_score <= 2 and len(repos) >= 30:
|
|
401
|
+
return "hacker"
|
|
402
|
+
|
|
403
|
+
return top_archetype
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Traits / insights
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def compute_traits(
|
|
412
|
+
repos: List[Dict[str, Any]],
|
|
413
|
+
lang_dna: List[Tuple[str, int]],
|
|
414
|
+
velocity: Dict[str, Any],
|
|
415
|
+
star_power: Dict[str, Any],
|
|
416
|
+
doc_score: int,
|
|
417
|
+
oss_score: int,
|
|
418
|
+
) -> List[Dict[str, str]]:
|
|
419
|
+
"""Return a list of {sign: '+'/'-', text: '...'} trait items."""
|
|
420
|
+
traits = []
|
|
421
|
+
total = len(repos)
|
|
422
|
+
rpm = velocity.get("repos_per_month", 0)
|
|
423
|
+
|
|
424
|
+
# Prolific builder
|
|
425
|
+
if total >= 50:
|
|
426
|
+
traits.append({"sign": "+", "text": f"Prolific builder — top 5% by repo count ({total} repos)"})
|
|
427
|
+
elif total >= 20:
|
|
428
|
+
traits.append({"sign": "+", "text": f"Active builder — {total} public repos"})
|
|
429
|
+
|
|
430
|
+
# AI native
|
|
431
|
+
ai_keywords = {"ml", "ai", "llm", "gpt", "nlp", "neural", "rag", "diffusion"}
|
|
432
|
+
ai_repos = sum(
|
|
433
|
+
1
|
|
434
|
+
for r in repos
|
|
435
|
+
if any(
|
|
436
|
+
kw in " ".join([
|
|
437
|
+
r.get("name", ""),
|
|
438
|
+
r.get("description", "") or "",
|
|
439
|
+
" ".join(r.get("topics", []) or []),
|
|
440
|
+
r.get("language", "") or "",
|
|
441
|
+
]).lower()
|
|
442
|
+
for kw in ai_keywords
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
if total > 0 and ai_repos / total >= 0.4:
|
|
446
|
+
traits.append({"sign": "+", "text": f"AI-native — {round(ai_repos/total*100)}%+ of repos involve ML/LLMs"})
|
|
447
|
+
|
|
448
|
+
# Velocity
|
|
449
|
+
if rpm >= 3:
|
|
450
|
+
traits.append({"sign": "+", "text": f"Rapid shipper — {rpm} repos/month average"})
|
|
451
|
+
|
|
452
|
+
# Stars
|
|
453
|
+
if star_power["total"] >= 100:
|
|
454
|
+
traits.append({"sign": "+", "text": f"Community recognition — {star_power['total']} total stars"})
|
|
455
|
+
elif star_power["total"] == 0 and total >= 10:
|
|
456
|
+
traits.append({"sign": "-", "text": "Discovery gap — great code, low visibility"})
|
|
457
|
+
|
|
458
|
+
# Documentation
|
|
459
|
+
if doc_score >= 70:
|
|
460
|
+
traits.append({"sign": "+", "text": f"Well-documented — {doc_score}% of repos have descriptions"})
|
|
461
|
+
elif doc_score < 40:
|
|
462
|
+
traits.append({"sign": "-", "text": f"Documentation debt — many repos lack descriptions ({doc_score}%)"})
|
|
463
|
+
|
|
464
|
+
# Open source
|
|
465
|
+
if oss_score >= 60:
|
|
466
|
+
traits.append({"sign": "+", "text": f"Open source contributor — {oss_score}% repos have licenses"})
|
|
467
|
+
elif oss_score < 30:
|
|
468
|
+
traits.append({"sign": "-", "text": f"Few repos have open-source licenses ({oss_score}%)"})
|
|
469
|
+
|
|
470
|
+
return traits
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def compute_insight(
|
|
474
|
+
repos: List[Dict[str, Any]],
|
|
475
|
+
archetype: str,
|
|
476
|
+
velocity: Dict[str, Any],
|
|
477
|
+
star_power: Dict[str, Any],
|
|
478
|
+
doc_score: int,
|
|
479
|
+
oss_score: int,
|
|
480
|
+
spec_score: int,
|
|
481
|
+
) -> str:
|
|
482
|
+
rpm = velocity.get("repos_per_month", 0)
|
|
483
|
+
total = len(repos)
|
|
484
|
+
|
|
485
|
+
if archetype == "researcher":
|
|
486
|
+
if star_power["total"] < 10:
|
|
487
|
+
return (
|
|
488
|
+
"Your research depth is rare. Consider publishing one "
|
|
489
|
+
"polished project with a demo, a paper summary, and a "
|
|
490
|
+
"clear README — that's what turns followers into stars."
|
|
491
|
+
)
|
|
492
|
+
return (
|
|
493
|
+
"Strong research portfolio. Packaging your best experiments "
|
|
494
|
+
"as installable libraries could multiply your impact."
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if archetype == "toolsmith":
|
|
498
|
+
if rpm >= 3 and star_power["total"] < 20:
|
|
499
|
+
return (
|
|
500
|
+
"You build at a rare velocity. The bottleneck isn't output — "
|
|
501
|
+
"it's surface area. Adding descriptions, PyPI packages, and "
|
|
502
|
+
"one viral project would unlock the stars your code deserves."
|
|
503
|
+
)
|
|
504
|
+
return (
|
|
505
|
+
"Quality tooling with good visibility. "
|
|
506
|
+
"Consider writing about your tools on Dev.to or Hacker News "
|
|
507
|
+
"to compound your reach."
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
if archetype == "fullstack":
|
|
511
|
+
if doc_score < 50:
|
|
512
|
+
return (
|
|
513
|
+
"Full-stack range is a genuine superpower. "
|
|
514
|
+
"A few well-documented showcase projects would make this "
|
|
515
|
+
"profile stand out to any hiring team."
|
|
516
|
+
)
|
|
517
|
+
return (
|
|
518
|
+
"Well-rounded full-stack presence. "
|
|
519
|
+
"Pinning your three best projects on GitHub would create a "
|
|
520
|
+
"strong first impression."
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if archetype == "data_engineer":
|
|
524
|
+
return (
|
|
525
|
+
"Data pipelines are the invisible backbone of every great "
|
|
526
|
+
"product. Sharing one end-to-end example with a dataset "
|
|
527
|
+
"and dashboard would make your skills tangible to outsiders."
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if archetype == "hacker":
|
|
531
|
+
return (
|
|
532
|
+
f"You've shipped {total} repos — that's creative energy most "
|
|
533
|
+
"developers never tap. Picking your top 3 and investing in "
|
|
534
|
+
"their README, docs, and packaging would unlock outsized returns."
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# craftsperson or fallback
|
|
538
|
+
return (
|
|
539
|
+
"Quality-over-quantity is a defensible strategy. "
|
|
540
|
+
"Your next step is distribution — make sure each polished "
|
|
541
|
+
"project has a demo link, license, and 3-line install guide."
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
# Main analysis entry point
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def analyze_user(
|
|
551
|
+
username: str, token: Optional[str] = None
|
|
552
|
+
) -> Dict[str, Any]:
|
|
553
|
+
"""Fetch GitHub data and return a structured DNA report dict."""
|
|
554
|
+
user = fetch_user(username, token=token)
|
|
555
|
+
repos = fetch_repos(username, token=token)
|
|
556
|
+
|
|
557
|
+
lang_dna = compute_language_dna(repos)
|
|
558
|
+
star_power = compute_star_power(repos)
|
|
559
|
+
velocity = compute_velocity(repos, user)
|
|
560
|
+
evolution = compute_evolution(repos)
|
|
561
|
+
oss_score = compute_open_source_signal(repos)
|
|
562
|
+
doc_score = compute_documentation_score(repos)
|
|
563
|
+
spec_score = compute_specialization_score(repos, lang_dna)
|
|
564
|
+
archetype_key = detect_archetype(repos, lang_dna)
|
|
565
|
+
archetype_meta = _ARCHETYPE_META[archetype_key]
|
|
566
|
+
traits = compute_traits(
|
|
567
|
+
repos, lang_dna, velocity, star_power, doc_score, oss_score
|
|
568
|
+
)
|
|
569
|
+
velocity_score = _score_velocity(velocity["repos_per_month"])
|
|
570
|
+
velocity_label = _velocity_percentile_label(velocity["repos_per_month"])
|
|
571
|
+
insight = compute_insight(
|
|
572
|
+
repos, archetype_key, velocity, star_power, doc_score, oss_score, spec_score
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
"username": username,
|
|
577
|
+
"name": user.get("name") or username,
|
|
578
|
+
"bio": user.get("bio") or "",
|
|
579
|
+
"followers": user.get("followers", 0),
|
|
580
|
+
"following": user.get("following", 0),
|
|
581
|
+
"public_repos_api": user.get("public_repos", len(repos)),
|
|
582
|
+
"repos_analyzed": len(repos),
|
|
583
|
+
"lang_dna": lang_dna,
|
|
584
|
+
"star_power": star_power,
|
|
585
|
+
"velocity": velocity,
|
|
586
|
+
"velocity_score": velocity_score,
|
|
587
|
+
"velocity_label": velocity_label,
|
|
588
|
+
"evolution": evolution,
|
|
589
|
+
"oss_score": oss_score,
|
|
590
|
+
"doc_score": doc_score,
|
|
591
|
+
"spec_score": spec_score,
|
|
592
|
+
"archetype_key": archetype_key,
|
|
593
|
+
"archetype_label": archetype_meta["label"],
|
|
594
|
+
"archetype_blurb": archetype_meta["blurb"],
|
|
595
|
+
"traits": traits,
|
|
596
|
+
"insight": insight,
|
|
597
|
+
}
|
dev_dna/cli.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""CLI entry point for dev-dna.
|
|
2
|
+
|
|
3
|
+
Commands
|
|
4
|
+
--------
|
|
5
|
+
dev-dna <username>
|
|
6
|
+
dev-dna <username> --json
|
|
7
|
+
dev-dna <username> --token <ghp_...>
|
|
8
|
+
dev-dna <username> --compare <other_username>
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import textwrap
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
from rich import box
|
|
21
|
+
from rich.columns import Columns
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from dev_dna.analyzer import (
|
|
28
|
+
GitHubError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
analyze_user,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Rendering helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _bar(fraction: float, width: int = 20, fill: str = "█", empty: str = "░") -> str:
|
|
42
|
+
"""Return a Unicode block bar of given width."""
|
|
43
|
+
filled = round(fraction * width)
|
|
44
|
+
filled = max(0, min(width, filled))
|
|
45
|
+
return fill * filled + empty * (width - filled)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _terminal_width() -> int:
|
|
49
|
+
try:
|
|
50
|
+
return os.get_terminal_size().columns
|
|
51
|
+
except OSError:
|
|
52
|
+
return 80
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _wrap(text: str, indent: int = 2) -> str:
|
|
56
|
+
width = max(40, _terminal_width() - indent - 4)
|
|
57
|
+
lines = textwrap.wrap(text, width=width)
|
|
58
|
+
prefix = " " * indent
|
|
59
|
+
return ("\n" + prefix).join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lang_bar_width() -> int:
|
|
63
|
+
"""Dynamic bar chart width based on terminal size."""
|
|
64
|
+
cols = _terminal_width()
|
|
65
|
+
# Reserve: 2 indent + 12 name + 1 space + bar + 1 space + 4 pct = ~20 fixed
|
|
66
|
+
bar_width = max(10, min(40, cols - 30))
|
|
67
|
+
return bar_width
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Rich panel renderers
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
_LANG_COLORS = [
|
|
75
|
+
"bright_cyan",
|
|
76
|
+
"bright_green",
|
|
77
|
+
"bright_yellow",
|
|
78
|
+
"bright_magenta",
|
|
79
|
+
"bright_blue",
|
|
80
|
+
"bright_red",
|
|
81
|
+
"cyan",
|
|
82
|
+
"green",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_header(data: Dict[str, Any]) -> Panel:
|
|
87
|
+
username = data["username"]
|
|
88
|
+
name = data["name"]
|
|
89
|
+
bio = data["bio"]
|
|
90
|
+
|
|
91
|
+
lines = Text()
|
|
92
|
+
lines.append(f" github.com/{username}\n", style="bold bright_white")
|
|
93
|
+
if name and name != username:
|
|
94
|
+
lines.append(f" {name}\n", style="dim white")
|
|
95
|
+
if bio:
|
|
96
|
+
lines.append(f" {bio}\n", style="italic dim white")
|
|
97
|
+
|
|
98
|
+
return Panel(
|
|
99
|
+
lines,
|
|
100
|
+
title="[bold bright_green]DEV DNA ANALYSIS[/bold bright_green]",
|
|
101
|
+
title_align="center",
|
|
102
|
+
border_style="bright_green",
|
|
103
|
+
padding=(0, 1),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_archetype(data: Dict[str, Any]) -> Panel:
|
|
108
|
+
label = data["archetype_label"]
|
|
109
|
+
blurb = data["archetype_blurb"]
|
|
110
|
+
wrapped = _wrap(blurb, indent=0)
|
|
111
|
+
content = Text()
|
|
112
|
+
content.append(f"{label}\n\n", style="bold bright_yellow")
|
|
113
|
+
content.append(f'"{wrapped}"', style="italic dim white")
|
|
114
|
+
return Panel(
|
|
115
|
+
content,
|
|
116
|
+
title="[bold]ARCHETYPE[/bold]",
|
|
117
|
+
border_style="yellow",
|
|
118
|
+
padding=(0, 2),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_language_dna(data: Dict[str, Any]) -> Panel:
|
|
123
|
+
lang_dna: List[Tuple[str, int]] = data["lang_dna"]
|
|
124
|
+
bar_width = _lang_bar_width()
|
|
125
|
+
|
|
126
|
+
if not lang_dna:
|
|
127
|
+
return Panel(
|
|
128
|
+
Text("No language data available.", style="dim"),
|
|
129
|
+
title="[bold]LANGUAGE DNA[/bold]",
|
|
130
|
+
border_style="cyan",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
total = sum(c for _, c in lang_dna)
|
|
134
|
+
content = Text()
|
|
135
|
+
for idx, (lang, count) in enumerate(lang_dna[:6]):
|
|
136
|
+
frac = count / total if total else 0
|
|
137
|
+
pct = round(frac * 100)
|
|
138
|
+
bar = _bar(frac, width=bar_width)
|
|
139
|
+
color = _LANG_COLORS[idx % len(_LANG_COLORS)]
|
|
140
|
+
lang_str = (lang[:13] + "…" if len(lang) > 14 else lang).ljust(14)
|
|
141
|
+
content.append(f" {lang_str}", style="bold white")
|
|
142
|
+
content.append(bar, style=color)
|
|
143
|
+
content.append(f" {pct:>3}%\n", style="dim white")
|
|
144
|
+
|
|
145
|
+
return Panel(
|
|
146
|
+
content,
|
|
147
|
+
title="[bold cyan]LANGUAGE DNA[/bold cyan]",
|
|
148
|
+
border_style="cyan",
|
|
149
|
+
padding=(0, 1),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_evolution(data: Dict[str, Any]) -> Panel:
|
|
154
|
+
evo = data["evolution"]
|
|
155
|
+
content = Text()
|
|
156
|
+
year = evo.get("earliest_year")
|
|
157
|
+
year_str = f" ({year})" if year else ""
|
|
158
|
+
|
|
159
|
+
content.append(" Started with: ", style="dim white")
|
|
160
|
+
content.append(f"{evo['early']}{year_str}\n", style="bold white")
|
|
161
|
+
content.append(" Now building: ", style="dim white")
|
|
162
|
+
content.append(f"{evo['recent']}\n", style="bold bright_cyan")
|
|
163
|
+
content.append(" Trajectory: ", style="dim white")
|
|
164
|
+
content.append(evo.get("trajectory", ""), style="italic bright_yellow")
|
|
165
|
+
|
|
166
|
+
return Panel(
|
|
167
|
+
content,
|
|
168
|
+
title="[bold green]EVOLUTION[/bold green]",
|
|
169
|
+
border_style="green",
|
|
170
|
+
padding=(0, 1),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def render_velocity(data: Dict[str, Any]) -> Panel:
|
|
175
|
+
vel = data["velocity"]
|
|
176
|
+
rpm = vel.get("repos_per_month", 0)
|
|
177
|
+
total = vel.get("total_repos", 0)
|
|
178
|
+
months = vel.get("months", 0)
|
|
179
|
+
label = data["velocity_label"]
|
|
180
|
+
|
|
181
|
+
content = Text()
|
|
182
|
+
content.append(f" {total} repos", style="bold bright_white")
|
|
183
|
+
content.append(" over ", style="dim white")
|
|
184
|
+
content.append(f"~{months} months", style="bold bright_white")
|
|
185
|
+
content.append(f" = {rpm} repos/month\n", style="bright_yellow")
|
|
186
|
+
content.append(f" That's {label}\n", style="italic dim white")
|
|
187
|
+
|
|
188
|
+
return Panel(
|
|
189
|
+
content,
|
|
190
|
+
title="[bold yellow]VELOCITY[/bold yellow]",
|
|
191
|
+
border_style="yellow",
|
|
192
|
+
padding=(0, 1),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render_star_power(data: Dict[str, Any]) -> Panel:
|
|
197
|
+
sp = data["star_power"]
|
|
198
|
+
total = sp["total"]
|
|
199
|
+
top_repo = sp.get("top_repo") or "—"
|
|
200
|
+
top_stars = sp.get("top_stars", 0)
|
|
201
|
+
repos_analyzed = data["repos_analyzed"]
|
|
202
|
+
|
|
203
|
+
content = Text()
|
|
204
|
+
content.append(f" Total stars: ", style="dim white")
|
|
205
|
+
content.append(f"{total}\n", style="bold bright_yellow")
|
|
206
|
+
content.append(f" Best performer: ", style="dim white")
|
|
207
|
+
content.append(f"{top_repo}", style="bold white")
|
|
208
|
+
content.append(f" ({top_stars} {'star' if top_stars == 1 else 'stars'})\n", style="dim white")
|
|
209
|
+
|
|
210
|
+
if total == 0 and repos_analyzed >= 5:
|
|
211
|
+
content.append(
|
|
212
|
+
" Untapped potential: high output, early-stage discovery\n",
|
|
213
|
+
style="italic dim yellow",
|
|
214
|
+
)
|
|
215
|
+
elif total >= 100:
|
|
216
|
+
content.append(
|
|
217
|
+
" Community recognition: established open-source presence\n",
|
|
218
|
+
style="italic bright_green",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return Panel(
|
|
222
|
+
content,
|
|
223
|
+
title="[bold yellow]STAR POWER[/bold yellow]",
|
|
224
|
+
border_style="yellow",
|
|
225
|
+
padding=(0, 1),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_scores(data: Dict[str, Any]) -> Panel:
|
|
230
|
+
# Dynamically choose bar width so the full line fits in the terminal
|
|
231
|
+
# Layout: 2 indent + 16 label + bar + 2 + 7 score + 2 + note(~20) + 2 border = ~51 + bar
|
|
232
|
+
avail = max(10, _terminal_width() - 53)
|
|
233
|
+
bar_w = min(20, avail)
|
|
234
|
+
scores = [
|
|
235
|
+
("Specialization", data["spec_score"], "(focused)"),
|
|
236
|
+
("Documentation", data["doc_score"], "(has description)"),
|
|
237
|
+
("Open Source", data["oss_score"], "(has license)"),
|
|
238
|
+
("Velocity", data["velocity_score"], "(ships fast)"),
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
content = Text()
|
|
242
|
+
for label, score, note in scores:
|
|
243
|
+
frac = score / 100
|
|
244
|
+
bar = _bar(frac, width=bar_w)
|
|
245
|
+
color = "bright_green" if score >= 70 else ("bright_yellow" if score >= 40 else "bright_red")
|
|
246
|
+
label_str = label.ljust(16)
|
|
247
|
+
content.append(f" {label_str}", style="dim white")
|
|
248
|
+
content.append(bar, style=color)
|
|
249
|
+
content.append(f" {score:>3}/100 ", style="bold white")
|
|
250
|
+
content.append(f"{note}\n", style="dim white")
|
|
251
|
+
|
|
252
|
+
return Panel(
|
|
253
|
+
content,
|
|
254
|
+
title="[bold magenta]SCORES[/bold magenta]",
|
|
255
|
+
border_style="magenta",
|
|
256
|
+
padding=(0, 1),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render_traits(data: Dict[str, Any]) -> Panel:
|
|
261
|
+
traits: List[Dict[str, str]] = data["traits"]
|
|
262
|
+
if not traits:
|
|
263
|
+
return Panel(
|
|
264
|
+
Text("Not enough data to compute traits.", style="dim"),
|
|
265
|
+
title="[bold]DEVELOPER TRAITS[/bold]",
|
|
266
|
+
border_style="bright_white",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
content = Text()
|
|
270
|
+
for t in traits:
|
|
271
|
+
sign = t["sign"]
|
|
272
|
+
if sign == "+":
|
|
273
|
+
content.append(" + ", style="bold bright_green")
|
|
274
|
+
else:
|
|
275
|
+
content.append(" - ", style="bold bright_red")
|
|
276
|
+
content.append(t["text"] + "\n", style="white")
|
|
277
|
+
|
|
278
|
+
return Panel(
|
|
279
|
+
content,
|
|
280
|
+
title="[bold]DEVELOPER TRAITS[/bold]",
|
|
281
|
+
border_style="bright_white",
|
|
282
|
+
padding=(0, 1),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def render_insight(data: Dict[str, Any]) -> Panel:
|
|
287
|
+
insight = data["insight"]
|
|
288
|
+
# Use panel padding for left indent; let Rich handle wrapping
|
|
289
|
+
return Panel(
|
|
290
|
+
Text(insight, style="italic bright_white"),
|
|
291
|
+
title="[bold bright_green]DNA INSIGHT[/bold bright_green]",
|
|
292
|
+
border_style="bright_green",
|
|
293
|
+
padding=(0, 2),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def render_footer(data: Dict[str, Any]) -> Text:
|
|
298
|
+
t = Text(justify="center")
|
|
299
|
+
t.append("\n Generated by ", style="dim")
|
|
300
|
+
t.append("dev-dna", style="bold bright_green")
|
|
301
|
+
t.append(" — pip install dev-dna\n", style="dim")
|
|
302
|
+
return t
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Full report rendering
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def print_report(data: Dict[str, Any]) -> None:
|
|
311
|
+
console.print()
|
|
312
|
+
console.print(render_header(data))
|
|
313
|
+
console.print()
|
|
314
|
+
console.print(render_archetype(data))
|
|
315
|
+
console.print()
|
|
316
|
+
console.print(render_language_dna(data))
|
|
317
|
+
console.print()
|
|
318
|
+
console.print(render_evolution(data))
|
|
319
|
+
console.print()
|
|
320
|
+
console.print(render_velocity(data))
|
|
321
|
+
console.print()
|
|
322
|
+
console.print(render_star_power(data))
|
|
323
|
+
console.print()
|
|
324
|
+
console.print(render_scores(data))
|
|
325
|
+
console.print()
|
|
326
|
+
console.print(render_traits(data))
|
|
327
|
+
console.print()
|
|
328
|
+
console.print(render_insight(data))
|
|
329
|
+
console.print(render_footer(data))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# Comparison mode
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _compact_lang_bar(lang_dna: List[Tuple[str, int]], bar_width: int = 16) -> Text:
|
|
338
|
+
total = sum(c for _, c in lang_dna) or 1
|
|
339
|
+
t = Text()
|
|
340
|
+
for idx, (lang, count) in enumerate(lang_dna[:5]):
|
|
341
|
+
frac = count / total
|
|
342
|
+
pct = round(frac * 100)
|
|
343
|
+
bar = _bar(frac, width=bar_width)
|
|
344
|
+
color = _LANG_COLORS[idx % len(_LANG_COLORS)]
|
|
345
|
+
t.append(f" {lang:<12}", style="bold white")
|
|
346
|
+
t.append(bar, style=color)
|
|
347
|
+
t.append(f" {pct:>3}%\n", style="dim white")
|
|
348
|
+
return t
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def print_comparison(data_a: Dict[str, Any], data_b: Dict[str, Any]) -> None:
|
|
352
|
+
console.print()
|
|
353
|
+
console.rule("[bold bright_green]DEV DNA — HEAD TO HEAD[/bold bright_green]")
|
|
354
|
+
console.print()
|
|
355
|
+
|
|
356
|
+
def _side_panel(data: Dict[str, Any]) -> Panel:
|
|
357
|
+
content = Text()
|
|
358
|
+
content.append(f"github.com/{data['username']}\n", style="bold bright_white")
|
|
359
|
+
if data["name"] != data["username"]:
|
|
360
|
+
content.append(f"{data['name']}\n", style="dim white")
|
|
361
|
+
content.append(f"\nARCHETYPE: {data['archetype_label']}\n\n", style="bold bright_yellow")
|
|
362
|
+
|
|
363
|
+
# Language DNA
|
|
364
|
+
content.append("LANGUAGE DNA\n", style="bold cyan")
|
|
365
|
+
total = sum(c for _, c in data["lang_dna"]) or 1
|
|
366
|
+
for idx, (lang, count) in enumerate(data["lang_dna"][:5]):
|
|
367
|
+
frac = count / total
|
|
368
|
+
pct = round(frac * 100)
|
|
369
|
+
bar = _bar(frac, width=14)
|
|
370
|
+
color = _LANG_COLORS[idx % len(_LANG_COLORS)]
|
|
371
|
+
lang_short = (lang[:11] + "…" if len(lang) > 12 else lang).ljust(12)
|
|
372
|
+
content.append(f" {lang_short}", style="bold white")
|
|
373
|
+
content.append(bar, style=color)
|
|
374
|
+
content.append(f" {pct:>3}%\n", style="dim white")
|
|
375
|
+
|
|
376
|
+
# Scores
|
|
377
|
+
content.append("\nSCORES\n", style="bold magenta")
|
|
378
|
+
for label, score in [
|
|
379
|
+
("Specialization", data["spec_score"]),
|
|
380
|
+
("Documentation", data["doc_score"]),
|
|
381
|
+
("Open Source", data["oss_score"]),
|
|
382
|
+
("Velocity", data["velocity_score"]),
|
|
383
|
+
]:
|
|
384
|
+
bar = _bar(score / 100, width=14)
|
|
385
|
+
color = "bright_green" if score >= 70 else ("bright_yellow" if score >= 40 else "bright_red")
|
|
386
|
+
content.append(f" {label:<16}", style="dim white")
|
|
387
|
+
content.append(bar, style=color)
|
|
388
|
+
content.append(f" {score:>3}\n", style="bold white")
|
|
389
|
+
|
|
390
|
+
# Star power
|
|
391
|
+
sp = data["star_power"]
|
|
392
|
+
content.append(f"\nSTARS: {sp['total']}", style="bold bright_yellow")
|
|
393
|
+
content.append(f" (best: {sp.get('top_repo','—')} {sp['top_stars']})\n", style="dim white")
|
|
394
|
+
|
|
395
|
+
# Velocity
|
|
396
|
+
vel = data["velocity"]
|
|
397
|
+
content.append(
|
|
398
|
+
f"VELOCITY: {vel['repos_per_month']} repos/month "
|
|
399
|
+
f"({vel['total_repos']} repos)\n",
|
|
400
|
+
style="bright_white",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Traits
|
|
404
|
+
content.append("\nTRAITS\n", style="bold white")
|
|
405
|
+
for t in data["traits"][:4]:
|
|
406
|
+
sign_color = "bright_green" if t["sign"] == "+" else "bright_red"
|
|
407
|
+
content.append(f" {t['sign']} ", style=f"bold {sign_color}")
|
|
408
|
+
content.append(t["text"] + "\n", style="white")
|
|
409
|
+
|
|
410
|
+
return Panel(
|
|
411
|
+
content,
|
|
412
|
+
title=f"[bold]{data['username']}[/bold]",
|
|
413
|
+
border_style="bright_cyan" if data == data_a else "bright_magenta",
|
|
414
|
+
padding=(0, 1),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
cols = Columns([_side_panel(data_a), _side_panel(data_b)], equal=True, expand=True)
|
|
418
|
+
console.print(cols)
|
|
419
|
+
console.print()
|
|
420
|
+
|
|
421
|
+
# Verdict
|
|
422
|
+
a_total = data_a["spec_score"] + data_a["doc_score"] + data_a["oss_score"] + data_a["velocity_score"]
|
|
423
|
+
b_total = data_b["spec_score"] + data_b["doc_score"] + data_b["oss_score"] + data_b["velocity_score"]
|
|
424
|
+
if a_total > b_total:
|
|
425
|
+
verdict = f"[bold bright_cyan]{data_a['username']}[/bold bright_cyan] edges ahead on aggregate scores."
|
|
426
|
+
elif b_total > a_total:
|
|
427
|
+
verdict = f"[bold bright_magenta]{data_b['username']}[/bold bright_magenta] edges ahead on aggregate scores."
|
|
428
|
+
else:
|
|
429
|
+
verdict = "These developers are evenly matched — different strengths, same drive."
|
|
430
|
+
console.print(Panel(verdict, title="[bold]VERDICT[/bold]", border_style="bright_white"))
|
|
431
|
+
console.print()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# CLI definition
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
440
|
+
@click.argument("username")
|
|
441
|
+
@click.option(
|
|
442
|
+
"--token", "-t",
|
|
443
|
+
default=None,
|
|
444
|
+
envvar="GITHUB_TOKEN",
|
|
445
|
+
metavar="TOKEN",
|
|
446
|
+
help="GitHub personal access token (or set GITHUB_TOKEN env var). Increases rate limits.",
|
|
447
|
+
)
|
|
448
|
+
@click.option(
|
|
449
|
+
"--json", "output_json",
|
|
450
|
+
is_flag=True,
|
|
451
|
+
default=False,
|
|
452
|
+
help="Output raw JSON instead of the formatted report.",
|
|
453
|
+
)
|
|
454
|
+
@click.option(
|
|
455
|
+
"--compare", "-c",
|
|
456
|
+
default=None,
|
|
457
|
+
metavar="USERNAME",
|
|
458
|
+
help="Compare with another GitHub user side-by-side.",
|
|
459
|
+
)
|
|
460
|
+
def main(username: str, token: Optional[str], output_json: bool, compare: Optional[str]) -> None:
|
|
461
|
+
"""Analyze a GitHub developer's DNA.
|
|
462
|
+
|
|
463
|
+
\b
|
|
464
|
+
Examples:
|
|
465
|
+
dev-dna torvalds
|
|
466
|
+
dev-dna torvalds --json
|
|
467
|
+
dev-dna gvanrossum --compare torvalds
|
|
468
|
+
dev-dna myuser --token ghp_xxxx
|
|
469
|
+
"""
|
|
470
|
+
try:
|
|
471
|
+
with console.status(
|
|
472
|
+
f"[bright_green]Fetching GitHub data for [bold]{username}[/bold]...[/bright_green]",
|
|
473
|
+
spinner="dots",
|
|
474
|
+
):
|
|
475
|
+
data = analyze_user(username, token=token)
|
|
476
|
+
|
|
477
|
+
if compare:
|
|
478
|
+
with console.status(
|
|
479
|
+
f"[bright_magenta]Fetching GitHub data for [bold]{compare}[/bold]...[/bright_magenta]",
|
|
480
|
+
spinner="dots",
|
|
481
|
+
):
|
|
482
|
+
data_b = analyze_user(compare, token=token)
|
|
483
|
+
|
|
484
|
+
if output_json:
|
|
485
|
+
click.echo(json.dumps({"a": data, "b": data_b}, indent=2, default=str))
|
|
486
|
+
else:
|
|
487
|
+
print_comparison(data, data_b)
|
|
488
|
+
else:
|
|
489
|
+
if output_json:
|
|
490
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
491
|
+
else:
|
|
492
|
+
print_report(data)
|
|
493
|
+
|
|
494
|
+
except NotFoundError as exc:
|
|
495
|
+
console.print(f"\n[bold red]Not found:[/bold red] {exc}\n")
|
|
496
|
+
sys.exit(1)
|
|
497
|
+
except RateLimitError as exc:
|
|
498
|
+
console.print(f"\n[bold yellow]Rate limited:[/bold yellow] {exc}\n")
|
|
499
|
+
sys.exit(1)
|
|
500
|
+
except GitHubError as exc:
|
|
501
|
+
console.print(f"\n[bold red]GitHub API error:[/bold red] {exc}\n")
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
except KeyboardInterrupt:
|
|
504
|
+
console.print("\n[dim]Interrupted.[/dim]\n")
|
|
505
|
+
sys.exit(0)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
main()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dev-dna
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 23andMe for developers — analyze any GitHub user's developer DNA
|
|
5
|
+
Author: dev-dna contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lakshmisravyavedantham/dev-dna
|
|
8
|
+
Project-URL: Repository, https://github.com/lakshmisravyavedantham/dev-dna
|
|
9
|
+
Project-URL: Issues, https://github.com/lakshmisravyavedantham/dev-dna/issues
|
|
10
|
+
Keywords: github,developer,analytics,cli,profile
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.0
|
|
27
|
+
Requires-Dist: rich>=13.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# dev-dna
|
|
31
|
+
|
|
32
|
+
**23andMe for developers.** Point it at any GitHub username and get a rich terminal report of their Developer DNA — dominant languages, evolution over time, commit personality archetype, specialization vs. generalist score, star power, and a shareable summary.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install dev-dna
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or install from source:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/lakshmisravyavedantham/dev-dna
|
|
44
|
+
cd dev-dna
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Analyze any public GitHub profile
|
|
52
|
+
dev-dna torvalds
|
|
53
|
+
|
|
54
|
+
# Output raw JSON
|
|
55
|
+
dev-dna torvalds --json
|
|
56
|
+
|
|
57
|
+
# Pass a GitHub token for higher rate limits (60 → 5000 req/hr)
|
|
58
|
+
dev-dna torvalds --token ghp_xxx
|
|
59
|
+
# or set the env var:
|
|
60
|
+
export GITHUB_TOKEN=ghp_xxx
|
|
61
|
+
dev-dna torvalds
|
|
62
|
+
|
|
63
|
+
# Compare two developers side-by-side
|
|
64
|
+
dev-dna gvanrossum --compare torvalds
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Sample Output
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
╔══════════════════════════════════════════════════════════╗
|
|
71
|
+
║ DEV DNA ANALYSIS ║
|
|
72
|
+
║ github.com/lakshmisravyavedantham ║
|
|
73
|
+
╚══════════════════════════════════════════════════════════╝
|
|
74
|
+
|
|
75
|
+
ARCHETYPE: The Toolsmith
|
|
76
|
+
"Ships fast. Builds tools that make other developers' lives
|
|
77
|
+
easier. Prefers CLI over GUI. Has opinions about your commit
|
|
78
|
+
messages."
|
|
79
|
+
|
|
80
|
+
LANGUAGE DNA
|
|
81
|
+
Python ████████████████████ 67%
|
|
82
|
+
TypeScript ██████░░░░░░░░░░░░░░ 18%
|
|
83
|
+
JavaScript ████░░░░░░░░░░░░░░░░ 10%
|
|
84
|
+
HTML ██░░░░░░░░░░░░░░░░░░ 5%
|
|
85
|
+
|
|
86
|
+
EVOLUTION
|
|
87
|
+
Started with: Python (2022)
|
|
88
|
+
Now building: Python + TypeScript
|
|
89
|
+
Trajectory: Expanding into full-stack
|
|
90
|
+
|
|
91
|
+
VELOCITY
|
|
92
|
+
108 repos over ~24 months = 4.5 repos/month
|
|
93
|
+
That's faster than 94% of GitHub developers
|
|
94
|
+
|
|
95
|
+
STAR POWER
|
|
96
|
+
Total stars: 4
|
|
97
|
+
Best performer: Coherence (1 star)
|
|
98
|
+
Untapped potential: high output, early-stage discovery
|
|
99
|
+
|
|
100
|
+
SCORES
|
|
101
|
+
Specialization ████████████░░░░░░░░ 72/100 (focused depth)
|
|
102
|
+
Documentation ████████░░░░░░░░░░░░ 41/100 (descriptions + readability)
|
|
103
|
+
Open Source ███████░░░░░░░░░░░░░ 38/100 (repos with license)
|
|
104
|
+
Velocity ████████████████████ 96/100 (shipping speed)
|
|
105
|
+
|
|
106
|
+
DEVELOPER TRAITS
|
|
107
|
+
+ Prolific builder — top 5% by repo count
|
|
108
|
+
+ AI-native — 60%+ of recent work involves LLMs
|
|
109
|
+
- Discovery gap — great code, low visibility
|
|
110
|
+
- Documentation debt — many repos lack descriptions
|
|
111
|
+
|
|
112
|
+
DNA INSIGHT
|
|
113
|
+
You build at a rare velocity. The bottleneck isn't output —
|
|
114
|
+
it's surface area. Adding descriptions, PyPI packages, and
|
|
115
|
+
one viral project would unlock the stars your code deserves.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## What it analyzes
|
|
119
|
+
|
|
120
|
+
| Signal | Description |
|
|
121
|
+
|---|---|
|
|
122
|
+
| **Language DNA** | Top 6 languages by repo count, shown as a terminal bar chart |
|
|
123
|
+
| **Archetype** | Personality classification: Toolsmith, Researcher, Full-Stack Builder, Data Engineer, Hacker, or Craftsperson |
|
|
124
|
+
| **Specialization Score** | 0–100. High = deep in one domain. Low = polyglot generalist |
|
|
125
|
+
| **Velocity** | Repos created per month; scored against a GitHub-wide distribution |
|
|
126
|
+
| **Star Power** | Total stars + top repo; flags discovery gap for prolific but under-starred devs |
|
|
127
|
+
| **Evolution** | Primary language in oldest 20% of repos vs newest 20% — shows trajectory |
|
|
128
|
+
| **Open Source Signal** | % of repos with a license |
|
|
129
|
+
| **Documentation Score** | % of repos with a description |
|
|
130
|
+
|
|
131
|
+
## Architecture
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
src/dev_dna/
|
|
135
|
+
├── __init__.py — public API
|
|
136
|
+
├── cli.py — Click CLI + Rich rendering
|
|
137
|
+
└── analyzer.py — GitHub API calls + metric computation
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- Uses only `urllib.request` for HTTP (no `requests` library)
|
|
141
|
+
- Zero external dependencies beyond `click` and `rich`
|
|
142
|
+
- Supports `GITHUB_TOKEN` env var or `--token` flag for 5000 req/hr rate limit
|
|
143
|
+
- Gracefully handles: rate limits, private-only accounts, 0-star accounts, missing data
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dev_dna/__init__.py,sha256=Snip_hK6IY02DME-vHKoBscQxuzdp5ZEka-AJcF00V8,336
|
|
2
|
+
dev_dna/analyzer.py,sha256=cGiHnRLITPlgC3SPhRHQuWfKWQdR0Xkicbxt-8eYzHk,20262
|
|
3
|
+
dev_dna/cli.py,sha256=zC_8qcbF-ahFY-Njd2u0F8XQnzrmIBfoIaDi_eiBoms,16872
|
|
4
|
+
dev_dna-0.1.0.dist-info/licenses/LICENSE,sha256=a1JqlBXUwW3JVspjcrTggLsSzoVyM80GVlA6J9debzU,1077
|
|
5
|
+
dev_dna-0.1.0.dist-info/METADATA,sha256=EI3LCVF4nmZ2az4U7vHtmqlQqkUXMfxHjekaUzD_jjI,5517
|
|
6
|
+
dev_dna-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
7
|
+
dev_dna-0.1.0.dist-info/entry_points.txt,sha256=ZpBFNo2kUBUTBi6oNULecy20dx9ZmnfjPoyP3KByRfs,45
|
|
8
|
+
dev_dna-0.1.0.dist-info/top_level.txt,sha256=M6gdaYdfGaJ8LEs-Gv1Xgynbf51lPNH3gyJbg313eAE,8
|
|
9
|
+
dev_dna-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dev-dna contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dev_dna
|