codewrapped 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.
- codewrapped/__init__.py +2 -0
- codewrapped/__main__.py +3 -0
- codewrapped/card.py +95 -0
- codewrapped/cli.py +140 -0
- codewrapped/collect.py +123 -0
- codewrapped/fonts/Inter-Bold.otf +0 -0
- codewrapped/fonts/Inter-Regular.otf +0 -0
- codewrapped/fonts/InterDisplay-Black.otf +0 -0
- codewrapped/fonts/OFL.txt +88 -0
- codewrapped/show.py +108 -0
- codewrapped/stats.py +207 -0
- codewrapped-0.1.0.dist-info/METADATA +90 -0
- codewrapped-0.1.0.dist-info/RECORD +17 -0
- codewrapped-0.1.0.dist-info/WHEEL +4 -0
- codewrapped-0.1.0.dist-info/entry_points.txt +2 -0
- codewrapped-0.1.0.dist-info/licenses/LICENSE +21 -0
- codewrapped-0.1.0.dist-info/licenses/src/codewrapped/fonts/OFL.txt +88 -0
codewrapped/__init__.py
ADDED
codewrapped/__main__.py
ADDED
codewrapped/card.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Render a 1200x675 share card (the size X, LinkedIn and Slack previews use)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
8
|
+
|
|
9
|
+
W, H = 1200, 675
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
|
|
13
|
+
with resources.as_file(resources.files(__package__) / "fonts" / name) as p:
|
|
14
|
+
return ImageFont.truetype(str(p), size)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _gradient(top: tuple, bottom: tuple) -> Image.Image:
|
|
18
|
+
img = Image.new("RGB", (W, H), top)
|
|
19
|
+
d = ImageDraw.Draw(img)
|
|
20
|
+
for y in range(H):
|
|
21
|
+
t = y / (H - 1)
|
|
22
|
+
d.line([(0, y), (W, y)], fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)))
|
|
23
|
+
return img
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fit(draw: ImageDraw.ImageDraw, text: str, font_name: str, size: int, max_w: int) -> ImageFont.FreeTypeFont:
|
|
27
|
+
f = _font(font_name, size)
|
|
28
|
+
while draw.textlength(text, font=f) > max_w and size > 14:
|
|
29
|
+
size -= 2
|
|
30
|
+
f = _font(font_name, size)
|
|
31
|
+
return f
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render(path: str | Path, *, brand: str, year_label: str, persona: str, tagline: str,
|
|
35
|
+
stats: list[tuple[str, str]], bars: list[tuple[str, int]], bars_title: str, footer: str,
|
|
36
|
+
accent=(255, 107, 87), top=(24, 18, 43), bottom=(10, 10, 18)) -> Path:
|
|
37
|
+
img = _gradient(top, bottom)
|
|
38
|
+
d = ImageDraw.Draw(img)
|
|
39
|
+
ink, dim = (245, 243, 255), (160, 156, 190)
|
|
40
|
+
|
|
41
|
+
# glow behind the title
|
|
42
|
+
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
43
|
+
gd = ImageDraw.Draw(glow)
|
|
44
|
+
gd.ellipse((-260, -320, 640, 360), fill=accent + (70,))
|
|
45
|
+
gd.ellipse((820, 380, 1500, 900), fill=(120, 90, 255, 40))
|
|
46
|
+
glow = glow.filter(ImageFilter.GaussianBlur(120))
|
|
47
|
+
img = Image.alpha_composite(img.convert("RGBA"), glow)
|
|
48
|
+
tiles = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
49
|
+
td = ImageDraw.Draw(tiles)
|
|
50
|
+
|
|
51
|
+
for i in range(min(len(stats), 4)):
|
|
52
|
+
x = 64 + i * (248 + 22)
|
|
53
|
+
td.rounded_rectangle((x, 300, x + 248, 432), radius=18, fill=(255, 255, 255, 14),
|
|
54
|
+
outline=(255, 255, 255, 40), width=2)
|
|
55
|
+
img = Image.alpha_composite(img, tiles).convert("RGB")
|
|
56
|
+
d = ImageDraw.Draw(img)
|
|
57
|
+
|
|
58
|
+
d.text((64, 52), brand, font=_font("Inter-Bold.otf", 26), fill=accent)
|
|
59
|
+
bw = d.textlength(brand, font=_font("Inter-Bold.otf", 26))
|
|
60
|
+
d.text((64 + bw + 14, 52), year_label, font=_font("Inter-Regular.otf", 26), fill=dim)
|
|
61
|
+
|
|
62
|
+
title_f = _fit(d, persona, "InterDisplay-Black.otf", 76, 1060)
|
|
63
|
+
d.text((62, 96), persona, font=title_f, fill=ink)
|
|
64
|
+
d.text((66, 96 + title_f.size + 14), tagline, font=_fit(d, tagline, "Inter-Regular.otf", 28, 1060), fill=dim)
|
|
65
|
+
|
|
66
|
+
# stat tiles
|
|
67
|
+
y0, tile_w, gap = 300, 248, 22
|
|
68
|
+
for i, (label, value) in enumerate(stats[:4]):
|
|
69
|
+
x = 64 + i * (tile_w + gap)
|
|
70
|
+
vf = _fit(d, value, "InterDisplay-Black.otf", 52, tile_w - 36)
|
|
71
|
+
d.text((x + 20, y0 + 18), value, font=vf, fill=ink)
|
|
72
|
+
d.text((x + 20, y0 + 90), label.upper(), font=_fit(d, label.upper(), "Inter-Bold.otf", 17, tile_w - 36),
|
|
73
|
+
fill=dim)
|
|
74
|
+
|
|
75
|
+
# bar chart
|
|
76
|
+
by = 470
|
|
77
|
+
d.text((64, by), bars_title.upper(), font=_font("Inter-Bold.otf", 17), fill=dim)
|
|
78
|
+
if bars:
|
|
79
|
+
mx = max(v for _, v in bars) or 1
|
|
80
|
+
name_f, val_f = _font("Inter-Bold.otf", 22), _font("Inter-Regular.otf", 20)
|
|
81
|
+
for i, (name, v) in enumerate(bars[:5]):
|
|
82
|
+
y = by + 36 + i * 30
|
|
83
|
+
d.text((64, y), name, font=name_f, fill=ink)
|
|
84
|
+
x0, x1 = 230, 230 + int(560 * v / mx)
|
|
85
|
+
d.rounded_rectangle((x0, y + 6, max(x1, x0 + 8), y + 22), radius=8, fill=accent)
|
|
86
|
+
d.text((max(x1, x0 + 8) + 12, y + 1), f"{v:,}", font=val_f, fill=dim)
|
|
87
|
+
|
|
88
|
+
ff = _font("Inter-Bold.otf", 22)
|
|
89
|
+
fw = d.textlength(footer, font=ff)
|
|
90
|
+
d.rounded_rectangle((W - 64 - fw - 36, H - 82, W - 64, H - 40), radius=21, fill=accent)
|
|
91
|
+
d.text((W - 64 - fw - 18, H - 74), footer, font=ff, fill=(20, 16, 34))
|
|
92
|
+
|
|
93
|
+
path = Path(path)
|
|
94
|
+
img.save(path, optimize=True)
|
|
95
|
+
return path
|
codewrapped/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""codewrapped: Spotify Wrapped for your code, from the git history on your machine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from . import __version__, card
|
|
15
|
+
from .collect import collect, is_repo
|
|
16
|
+
from .show import Story
|
|
17
|
+
from .stats import Wrapped, clock12, compute
|
|
18
|
+
|
|
19
|
+
ACCENT = "#7c6cff"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def human(n: int) -> str:
|
|
23
|
+
return f"{n / 1_000_000:.1f}M" if n >= 1_000_000 else f"{n / 1000:.1f}k" if n >= 10_000 else f"{n:,}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def story(w: Wrapped, s: Story) -> None:
|
|
27
|
+
s.words("codewrapped", f"Your {w.year} in code", "Reading your git history. Nothing leaves this machine.",
|
|
28
|
+
hold=1.8)
|
|
29
|
+
s.number("commits", w.commits, f"across {w.repos} repo{'s' if w.repos != 1 else ''}, on {w.active_days} days")
|
|
30
|
+
s.number("lines added", w.added, f"and {w.deleted:,} deleted. Net {w.added - w.deleted:+,}.")
|
|
31
|
+
if w.longest_streak > 1:
|
|
32
|
+
s.number("day streak", w.longest_streak,
|
|
33
|
+
f"{w.streak_start:%b} {w.streak_start.day} to {w.streak_end:%b} {w.streak_end.day}, without a day off")
|
|
34
|
+
if w.languages:
|
|
35
|
+
s.bars("your languages, by lines written", [(l, n) for l, n in w.languages], f"Mostly {w.languages[0][0]}.")
|
|
36
|
+
if len(w.top_repos) > 1:
|
|
37
|
+
s.bars("where your commits went", w.top_repos, f"{w.top_repos[0][0]} got the most love")
|
|
38
|
+
s.words("you code best at", f"{clock12(w.peak_hour)} on {w.peak_weekday}s",
|
|
39
|
+
f"Busiest day: {w.busiest_day:%B} {w.busiest_day.day}, {w.busiest_day_commits} commits"
|
|
40
|
+
+ (f". Busiest month: {w.busiest_month}." if w.busiest_month else ""))
|
|
41
|
+
if w.latest_commit:
|
|
42
|
+
t, subject, repo = w.latest_commit
|
|
43
|
+
s.words("your latest-night commit", t, f'"{subject[:70]}" in {repo}')
|
|
44
|
+
if w.top_word:
|
|
45
|
+
s.words("your favorite word in commit messages", w.top_word, f"{w.top_word_count} times"
|
|
46
|
+
+ (f". Plus {w.fix_commits} commits that were fixes." if w.fix_commits else ""))
|
|
47
|
+
if w.persona:
|
|
48
|
+
s.words("your developer personality", w.persona.title, w.persona.line, hold=3.0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def summary(w: Wrapped) -> Panel:
|
|
52
|
+
t = Table.grid(padding=(0, 3))
|
|
53
|
+
t.add_column(style="#a09cbe", justify="right")
|
|
54
|
+
t.add_column(style="bold #f5f3ff")
|
|
55
|
+
t.add_row("commits", f"{w.commits:,} [#a09cbe]in {w.repos} repos · {w.active_days} active days[/]")
|
|
56
|
+
t.add_row("lines", f"[#3fd17a]+{w.added:,}[/] [#ff5c6c]-{w.deleted:,}[/] [#a09cbe]{w.files:,} files touched[/]")
|
|
57
|
+
t.add_row("longest streak", f"{w.longest_streak} days" + (
|
|
58
|
+
f" [#a09cbe]{w.streak_start} → {w.streak_end}[/]" if w.longest_streak > 1 else ""))
|
|
59
|
+
if w.languages:
|
|
60
|
+
t.add_row("languages", " ".join(f"{l} [#a09cbe]{human(n)}[/]" for l, n in w.languages))
|
|
61
|
+
if w.top_repos:
|
|
62
|
+
t.add_row("top repos", " ".join(f"{r} [#a09cbe]{n}[/]" for r, n in w.top_repos[:4]))
|
|
63
|
+
t.add_row("rhythm", f"{clock12(w.peak_hour)} · {w.peak_weekday}s · {w.busiest_month} "
|
|
64
|
+
f"[#a09cbe]{w.night_share:.0%} at night · {w.weekend_share:.0%} weekends[/]")
|
|
65
|
+
if w.biggest_commit:
|
|
66
|
+
t.add_row("biggest commit", f"{w.biggest_commit[0][:60]} [#a09cbe]{w.biggest_commit[1]:,} lines · "
|
|
67
|
+
f"{w.biggest_commit[2]}[/]")
|
|
68
|
+
if w.shortest_message:
|
|
69
|
+
t.add_row("shortest message", f'"{w.shortest_message}"')
|
|
70
|
+
if w.persona:
|
|
71
|
+
t.add_row("personality", f"[{ACCENT}]{w.persona.title}[/] [#a09cbe]{w.persona.line}[/]")
|
|
72
|
+
return Panel(t, title=f"[bold {ACCENT}]codewrapped[/] [#a09cbe]{w.year}[/]", title_align="left",
|
|
73
|
+
border_style=ACCENT, padding=(1, 2))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_card(w: Wrapped, path: Path) -> Path:
|
|
77
|
+
stats = [("commits", f"{w.commits:,}"), ("lines added", human(w.added)),
|
|
78
|
+
("day streak", f"{w.longest_streak}"), ("active days", f"{w.active_days}")]
|
|
79
|
+
# repo names can be private work projects, so the card only shows languages
|
|
80
|
+
return card.render(path, brand="codewrapped", year_label=str(w.year), persona=w.persona.title,
|
|
81
|
+
tagline=w.persona.line, stats=stats, bars=w.languages, bars_title="languages",
|
|
82
|
+
footer="uvx codewrapped", accent=(124, 108, 255), top=(18, 16, 44), bottom=(8, 8, 20))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv=None) -> int:
|
|
86
|
+
p = argparse.ArgumentParser(prog="codewrapped",
|
|
87
|
+
description="Spotify Wrapped for your code: your year in commits, from every git "
|
|
88
|
+
"repo on your machine. Nothing leaves your computer.")
|
|
89
|
+
p.add_argument("paths", nargs="*", type=Path, help="repos or folders to search for repos (default: here)")
|
|
90
|
+
p.add_argument("--year", type=int, default=date.today().year)
|
|
91
|
+
p.add_argument("--author", action="append", help="email or name to count as you (repeatable; default: "
|
|
92
|
+
"your git config)")
|
|
93
|
+
p.add_argument("--everyone", action="store_true", help="include every author (a whole team's wrapped)")
|
|
94
|
+
p.add_argument("--depth", type=int, default=5, help="how deep to look for repos (default 5)")
|
|
95
|
+
p.add_argument("--no-anim", action="store_true")
|
|
96
|
+
p.add_argument("--speed", type=float, default=1.0)
|
|
97
|
+
p.add_argument("--card", type=Path, help="share card path (default ./codewrapped-<year>.png)")
|
|
98
|
+
p.add_argument("--no-card", action="store_true")
|
|
99
|
+
p.add_argument("--version", action="version", version=f"codewrapped {__version__}")
|
|
100
|
+
a = p.parse_args(argv)
|
|
101
|
+
console = Console()
|
|
102
|
+
roots = a.paths or [Path.cwd()]
|
|
103
|
+
if not any(is_repo(r) for r in roots) and roots == [Path.cwd()] and Path.cwd() == Path.home():
|
|
104
|
+
console.print("[#a09cbe]Searching your whole home folder for repos. Point me at your code folder to be "
|
|
105
|
+
"faster: codewrapped ~/code[/]")
|
|
106
|
+
with console.status("[#a09cbe]reading git history…[/]", spinner="dots"):
|
|
107
|
+
commits, repos, ids = collect(roots, a.year, a.author, a.everyone, a.depth)
|
|
108
|
+
if not repos:
|
|
109
|
+
console.print("No git repositories found here. Try: codewrapped ~/code")
|
|
110
|
+
return 1
|
|
111
|
+
if not commits:
|
|
112
|
+
who = ", ".join(sorted(ids)) or "(no git identity configured)"
|
|
113
|
+
console.print(f"Found {len(repos)} repos but no {a.year} commits by {who}.\n"
|
|
114
|
+
"Add the emails you commit with: codewrapped --author you@work.com --author you@home.com")
|
|
115
|
+
return 1
|
|
116
|
+
name = subprocess.run(["git", "config", "--global", "user.name"], capture_output=True, text=True).stdout.strip()
|
|
117
|
+
w = compute(commits, a.year, name)
|
|
118
|
+
if not a.no_anim and console.is_terminal:
|
|
119
|
+
try:
|
|
120
|
+
story(w, Story(console, ACCENT, a.speed))
|
|
121
|
+
except KeyboardInterrupt:
|
|
122
|
+
pass
|
|
123
|
+
console.print(summary(w))
|
|
124
|
+
if not a.no_card:
|
|
125
|
+
out = a.card or Path(f"codewrapped-{a.year}.png")
|
|
126
|
+
try:
|
|
127
|
+
make_card(w, out)
|
|
128
|
+
console.print(f"\n[bold {ACCENT}]Share card saved:[/] {out} [#a09cbe](no repo names or code on it, "
|
|
129
|
+
f"just your numbers and languages)[/]")
|
|
130
|
+
except OSError as e:
|
|
131
|
+
console.print(f"[#a09cbe]Couldn't save the share card: {e}[/]")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run() -> None:
|
|
136
|
+
sys.exit(main())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
run()
|
codewrapped/collect.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Find git repositories and read one person's commits for a year. Everything stays local."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable, Iterator
|
|
10
|
+
|
|
11
|
+
SKIP_DIRS = {
|
|
12
|
+
"node_modules", ".venv", "venv", "env", ".tox", ".cache", "__pycache__", "target", "build", "dist",
|
|
13
|
+
"Library", "Applications", ".Trash", "AppData", ".npm", ".cargo", ".rustup", ".pyenv", ".nvm",
|
|
14
|
+
"vendor", "Pods", "DerivedData", ".gradle", ".m2", "site-packages", ".local", ".git",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
RS, US = "\x1e", "\x1f" # record / unit separators keep commit messages unambiguous
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Commit:
|
|
22
|
+
sha: str
|
|
23
|
+
repo: str
|
|
24
|
+
email: str
|
|
25
|
+
name: str
|
|
26
|
+
when: datetime # author time, in the author's own timezone
|
|
27
|
+
subject: str
|
|
28
|
+
files: list[tuple[str, int, int]] = field(default_factory=list) # (path, added, deleted)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _git(repo: Path, *args: str) -> str:
|
|
32
|
+
out = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
|
|
33
|
+
encoding="utf-8", errors="replace")
|
|
34
|
+
return out.stdout if out.returncode == 0 else ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_repo(path: Path) -> bool:
|
|
38
|
+
return (path / ".git").exists()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_repos(roots: Iterable[Path], max_depth: int = 5) -> list[Path]:
|
|
42
|
+
"""Walk `roots` for git repositories (not descending into a repo once found)."""
|
|
43
|
+
found: list[Path] = []
|
|
44
|
+
for root in roots:
|
|
45
|
+
root = root.expanduser().resolve()
|
|
46
|
+
if is_repo(root):
|
|
47
|
+
found.append(root)
|
|
48
|
+
continue
|
|
49
|
+
base_depth = len(root.parts)
|
|
50
|
+
for dirpath, dirnames, _ in os.walk(root, onerror=lambda e: None):
|
|
51
|
+
here = Path(dirpath)
|
|
52
|
+
if is_repo(here):
|
|
53
|
+
found.append(here)
|
|
54
|
+
dirnames[:] = []
|
|
55
|
+
continue
|
|
56
|
+
if len(here.parts) - base_depth >= max_depth:
|
|
57
|
+
dirnames[:] = []
|
|
58
|
+
continue
|
|
59
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not (d.startswith(".") and d != ".config")]
|
|
60
|
+
seen, unique = set(), []
|
|
61
|
+
for r in found:
|
|
62
|
+
if r not in seen:
|
|
63
|
+
seen.add(r)
|
|
64
|
+
unique.append(r)
|
|
65
|
+
return unique
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def my_identities(repos: list[Path]) -> set[str]:
|
|
69
|
+
"""Emails and names from global git config plus each repo's local config."""
|
|
70
|
+
ids: set[str] = set()
|
|
71
|
+
for key in ("user.email", "user.name"):
|
|
72
|
+
v = subprocess.run(["git", "config", "--global", key], capture_output=True, text=True).stdout.strip()
|
|
73
|
+
if v:
|
|
74
|
+
ids.add(v.lower())
|
|
75
|
+
for r in repos:
|
|
76
|
+
for key in ("user.email", "user.name"):
|
|
77
|
+
v = _git(r, "config", "--local", key).strip()
|
|
78
|
+
if v:
|
|
79
|
+
ids.add(v.lower())
|
|
80
|
+
return ids
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_commits(repo: Path, year: int, identities: set[str] | None) -> Iterator[Commit]:
|
|
84
|
+
"""Non-merge commits from all refs, authored in `year`, by anyone in `identities` (None = everyone)."""
|
|
85
|
+
fmt = f"{RS}%H{US}%ae{US}%an{US}%aI{US}%s"
|
|
86
|
+
log = _git(repo, "log", "--all", "--no-merges", "--numstat", "--no-renames",
|
|
87
|
+
f"--since={year - 1}-12-30", f"--until={year + 1}-01-02", f"--format={fmt}")
|
|
88
|
+
name = repo.name
|
|
89
|
+
for block in log.split(RS)[1:]:
|
|
90
|
+
head, _, rest = block.partition("\n")
|
|
91
|
+
parts = head.split(US)
|
|
92
|
+
if len(parts) < 5:
|
|
93
|
+
continue
|
|
94
|
+
sha, email, author, iso, subject = parts[0], parts[1], parts[2], parts[3], US.join(parts[4:])
|
|
95
|
+
try:
|
|
96
|
+
when = datetime.fromisoformat(iso)
|
|
97
|
+
except ValueError:
|
|
98
|
+
continue
|
|
99
|
+
if when.year != year: # author-local calendar year
|
|
100
|
+
continue
|
|
101
|
+
if identities is not None and email.lower() not in identities and author.lower() not in identities:
|
|
102
|
+
continue
|
|
103
|
+
files = []
|
|
104
|
+
for line in rest.splitlines():
|
|
105
|
+
cols = line.split("\t")
|
|
106
|
+
if len(cols) == 3:
|
|
107
|
+
a, d, path = cols
|
|
108
|
+
files.append((path, int(a) if a.isdigit() else 0, int(d) if d.isdigit() else 0))
|
|
109
|
+
yield Commit(sha, name, email, author, when, subject, files)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def collect(roots: list[Path], year: int, authors: list[str] | None = None, everyone: bool = False,
|
|
113
|
+
max_depth: int = 5) -> tuple[list[Commit], list[Path], set[str]]:
|
|
114
|
+
repos = find_repos(roots, max_depth=max_depth)
|
|
115
|
+
if everyone:
|
|
116
|
+
ids = None
|
|
117
|
+
else:
|
|
118
|
+
ids = {a.lower() for a in authors} if authors else my_identities(repos)
|
|
119
|
+
commits: dict[str, Commit] = {}
|
|
120
|
+
for r in repos:
|
|
121
|
+
for c in read_commits(r, year, ids):
|
|
122
|
+
commits.setdefault(c.sha, c) # the same commit can live in several clones/forks
|
|
123
|
+
return sorted(commits.values(), key=lambda c: c.when), repos, ids or set()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Inter font: Copyright (c) 2016-2023 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
|
|
5
|
+
PREAMBLE
|
|
6
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
7
|
+
development of collaborative font projects, to support the font
|
|
8
|
+
creation efforts of academic and linguistic communities, and to
|
|
9
|
+
provide a free and open framework in which fonts may be shared and
|
|
10
|
+
improved in partnership with others.
|
|
11
|
+
|
|
12
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
13
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
14
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
15
|
+
redistributed and/or sold with any software provided that any
|
|
16
|
+
reserved names are not used by derivative works. The fonts and
|
|
17
|
+
derivatives, however, cannot be released under any other type of
|
|
18
|
+
license. The requirement for fonts to remain under this license
|
|
19
|
+
does not apply to any document created using the fonts or their
|
|
20
|
+
derivatives.
|
|
21
|
+
|
|
22
|
+
DEFINITIONS
|
|
23
|
+
Font Software refers to the set of files released by the
|
|
24
|
+
Copyright Holder(s) under this license and clearly marked as such.
|
|
25
|
+
This may include source files, build scripts and documentation.
|
|
26
|
+
|
|
27
|
+
Reserved Font Name refers to any names specified as such after
|
|
28
|
+
the copyright statement(s).
|
|
29
|
+
|
|
30
|
+
Original Version refers to the collection of Font Software
|
|
31
|
+
components as distributed by the Copyright Holder(s).
|
|
32
|
+
|
|
33
|
+
Modified Version refers to any derivative made by adding to,
|
|
34
|
+
deleting, or substituting in part or in whole any of the components
|
|
35
|
+
of the Original Version, by changing formats or by porting the Font
|
|
36
|
+
Software to a new environment.
|
|
37
|
+
|
|
38
|
+
Author refers to any designer, engineer, programmer, technical
|
|
39
|
+
writer or other person who contributed to the Font Software.
|
|
40
|
+
|
|
41
|
+
PERMISSION & CONDITIONS
|
|
42
|
+
Permission is hereby granted, free of charge, to any person
|
|
43
|
+
obtaining a copy of the Font Software, to use, study, copy, merge,
|
|
44
|
+
embed, modify, redistribute, and sell modified and unmodified copies
|
|
45
|
+
of the Font Software, subject to the following conditions:
|
|
46
|
+
|
|
47
|
+
1) Neither the Font Software nor any of its individual components,
|
|
48
|
+
in Original or Modified Versions, may be sold by itself.
|
|
49
|
+
|
|
50
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
51
|
+
redistributed and/or sold with any software, provided that each
|
|
52
|
+
copy contains the above copyright notice and this license. These
|
|
53
|
+
can be included either as stand-alone text files, human-readable
|
|
54
|
+
headers or in the appropriate machine-readable metadata fields
|
|
55
|
+
within text or binary files as long as those fields can be easily
|
|
56
|
+
viewed by the user.
|
|
57
|
+
|
|
58
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
59
|
+
Name(s) unless explicit written permission is granted by the
|
|
60
|
+
corresponding Copyright Holder. This restriction only applies to
|
|
61
|
+
the primary font name as presented to the users.
|
|
62
|
+
|
|
63
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the
|
|
64
|
+
Font Software shall not be used to promote, endorse or advertise
|
|
65
|
+
any Modified Version, except to acknowledge the contribution(s)
|
|
66
|
+
of the Copyright Holder(s) and the Author(s) or with their
|
|
67
|
+
explicit written permission.
|
|
68
|
+
|
|
69
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
70
|
+
must be distributed entirely under this license, and must not be
|
|
71
|
+
distributed under any other license. The requirement for fonts to
|
|
72
|
+
remain under this license does not apply to any document created
|
|
73
|
+
using the Font Software.
|
|
74
|
+
|
|
75
|
+
TERMINATION
|
|
76
|
+
This license becomes null and void if any of the above conditions
|
|
77
|
+
are not met.
|
|
78
|
+
|
|
79
|
+
DISCLAIMER
|
|
80
|
+
THE FONT SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND,
|
|
81
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
82
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
83
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
|
84
|
+
THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
85
|
+
LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR
|
|
86
|
+
CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
87
|
+
OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT
|
|
88
|
+
SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
codewrapped/show.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Animated terminal story: one stat per screen, numbers counting up in block digits."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from rich.align import Align
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
GLYPHS = {
|
|
13
|
+
"0": ["▄▀▀▄", "█ █", "▀▄▄▀"], "1": [" ▄█ ", " █ ", " ▄█▄"], "2": ["▄▀▀▄", " ▄▀", "▄█▄▄"],
|
|
14
|
+
"3": ["▀▀▀█", " ▀▀▄", "▄▄▄▀"], "4": ["█ █", "▀▀▀█", " █"], "5": ["█▀▀▀", "▀▀▀▄", "▄▄▄▀"],
|
|
15
|
+
"6": ["▄▀▀ ", "█▄▄ ", "▀▄▄▀"], "7": ["▀▀▀█", " █ ", " █ "], "8": ["▄▀▀▄", "▄▀▀▄", "▀▄▄▀"],
|
|
16
|
+
"9": ["▄▀▀▄", "▀▄▄█", " ▄▄▀"], ",": [" ", " ", "▄▀"], ".": [" ", " ", "▄"], ":": [" ", "▀", "▀"],
|
|
17
|
+
"%": ["▀ ▄▀", " ▄▀ ", "▄▀ ▄"], " ": [" ", " ", " "], "-": [" ", "▀▀▀", " "],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def big(text: str, style: str) -> Text:
|
|
22
|
+
rows = ["", "", ""]
|
|
23
|
+
for ch in text:
|
|
24
|
+
g = GLYPHS.get(ch)
|
|
25
|
+
if g is None:
|
|
26
|
+
continue
|
|
27
|
+
for i in range(3):
|
|
28
|
+
rows[i] += g[i] + " "
|
|
29
|
+
return Text("\n".join(r.rstrip() for r in rows), style=style, justify="center")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def headline_text(text: str, style: str, max_width: int) -> Text:
|
|
33
|
+
"""Large FIGlet lettering when it fits, plain bold text otherwise. Times/numbers use block digits."""
|
|
34
|
+
import re
|
|
35
|
+
|
|
36
|
+
m = re.fullmatch(r"([\d:,.%]+)\s*(am|pm)?", text.strip())
|
|
37
|
+
if m:
|
|
38
|
+
out = big(m.group(1), style)
|
|
39
|
+
if m.group(2):
|
|
40
|
+
out.append("\n" + m.group(2), style=style)
|
|
41
|
+
return out
|
|
42
|
+
try:
|
|
43
|
+
import pyfiglet
|
|
44
|
+
|
|
45
|
+
art = pyfiglet.figlet_format(text, font="future", width=1000).rstrip("\n")
|
|
46
|
+
if art.strip() and max(len(l) for l in art.splitlines()) <= max_width:
|
|
47
|
+
return Text(art, style=style, justify="center")
|
|
48
|
+
except Exception: # missing glyphs, missing font: fall back to plain text
|
|
49
|
+
pass
|
|
50
|
+
return Text(text, style=style, justify="center")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Story:
|
|
54
|
+
def __init__(self, console: Console, accent: str = "#ff6b57", speed: float = 1.0, animate: bool = True):
|
|
55
|
+
self.c, self.accent, self.speed, self.animate = console, accent, speed, animate
|
|
56
|
+
|
|
57
|
+
def _panel(self, label: str, body, caption: str = "") -> Panel:
|
|
58
|
+
parts = [Text(label.upper(), style="bold #a09cbe", justify="center"), Text(""), body]
|
|
59
|
+
if caption:
|
|
60
|
+
parts += [Text(""), Text(caption, style="#d8d4f0", justify="center")]
|
|
61
|
+
height = max(13, self.c.size.height - 2)
|
|
62
|
+
return Panel(Align.center(Group(*parts), vertical="middle"), border_style=self.accent, height=height)
|
|
63
|
+
|
|
64
|
+
def number(self, label: str, value: int, caption: str = "", fmt: str = "{:,}", hold: float = 2.2) -> None:
|
|
65
|
+
if not self.animate:
|
|
66
|
+
return
|
|
67
|
+
steps = 28
|
|
68
|
+
with Live(self._panel(label, big(fmt.format(0), f"bold {self.accent}")), console=self.c,
|
|
69
|
+
refresh_per_second=40, transient=True, screen=True) as live:
|
|
70
|
+
for i in range(1, steps + 1):
|
|
71
|
+
v = round(value * (1 - (1 - i / steps) ** 3)) # ease-out
|
|
72
|
+
live.update(self._panel(label, big(fmt.format(v), f"bold {self.accent}"),
|
|
73
|
+
caption if i == steps else ""))
|
|
74
|
+
time.sleep(0.9 / steps / self.speed)
|
|
75
|
+
time.sleep(hold / self.speed)
|
|
76
|
+
|
|
77
|
+
def words(self, label: str, headline: str, caption: str = "", hold: float = 2.4) -> None:
|
|
78
|
+
if not self.animate:
|
|
79
|
+
return
|
|
80
|
+
with Live(self._panel(label, Text("")), console=self.c, refresh_per_second=40, transient=True,
|
|
81
|
+
screen=True) as live:
|
|
82
|
+
shown = ""
|
|
83
|
+
for ch in headline:
|
|
84
|
+
shown += ch
|
|
85
|
+
live.update(self._panel(label, Text(shown, style=f"bold {self.accent}", justify="center")))
|
|
86
|
+
time.sleep(0.035 / self.speed)
|
|
87
|
+
live.update(self._panel(label, headline_text(headline, f"bold {self.accent}", self.c.size.width - 8),
|
|
88
|
+
caption))
|
|
89
|
+
time.sleep(hold / self.speed)
|
|
90
|
+
|
|
91
|
+
def bars(self, label: str, rows: list[tuple[str, int]], caption: str = "", hold: float = 2.6) -> None:
|
|
92
|
+
if not self.animate or not rows:
|
|
93
|
+
return
|
|
94
|
+
mx = max(v for _, v in rows) or 1
|
|
95
|
+
w = max(10, min(46, self.c.size.width - 40))
|
|
96
|
+
nw = max(len(n) for n, _ in rows)
|
|
97
|
+
with Live(self._panel(label, Text("")), console=self.c, refresh_per_second=40, transient=True,
|
|
98
|
+
screen=True) as live:
|
|
99
|
+
for step in range(1, 21):
|
|
100
|
+
t = Text(justify="left")
|
|
101
|
+
for name, v in rows:
|
|
102
|
+
n = round(w * v / mx * step / 20)
|
|
103
|
+
t.append(f"{name:>{nw}} ", style="bold #f5f3ff")
|
|
104
|
+
t.append("█" * n, style=self.accent)
|
|
105
|
+
t.append(f" {round(v * step / 20):,}\n", style="#a09cbe")
|
|
106
|
+
live.update(self._panel(label, Align.center(t), caption if step == 20 else ""))
|
|
107
|
+
time.sleep(0.025 / self.speed)
|
|
108
|
+
time.sleep(hold / self.speed)
|
codewrapped/stats.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Turn commits into a year in review."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
|
|
10
|
+
from .collect import Commit
|
|
11
|
+
|
|
12
|
+
# Generated / vendored files would drown out real work in line counts.
|
|
13
|
+
IGNORED_FILES = re.compile(
|
|
14
|
+
r"(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|Pipfile\.lock|uv\.lock|Cargo\.lock|"
|
|
15
|
+
r"Gemfile\.lock|composer\.lock|go\.sum|bun\.lockb)$"
|
|
16
|
+
r"|(^|/)(node_modules|vendor|dist|build|\.next|__snapshots__)/"
|
|
17
|
+
r"|\.min\.(js|css)$|\.map$|\.(png|jpe?g|gif|svg|ico|pdf|zip|gz|lock|ipynb)$"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
LANGUAGES = {
|
|
21
|
+
"py": "Python", "pyi": "Python", "ipynb": "Python", "js": "JavaScript", "mjs": "JavaScript", "cjs": "JavaScript",
|
|
22
|
+
"jsx": "JavaScript", "ts": "TypeScript", "tsx": "TypeScript", "go": "Go", "rs": "Rust", "java": "Java",
|
|
23
|
+
"kt": "Kotlin", "kts": "Kotlin", "swift": "Swift", "c": "C", "h": "C", "cc": "C++", "cpp": "C++", "cxx": "C++",
|
|
24
|
+
"hpp": "C++", "hh": "C++", "cs": "C#", "rb": "Ruby", "php": "PHP", "scala": "Scala", "dart": "Dart",
|
|
25
|
+
"lua": "Lua", "r": "R", "jl": "Julia", "ex": "Elixir", "exs": "Elixir", "erl": "Erlang", "hs": "Haskell",
|
|
26
|
+
"ml": "OCaml", "clj": "Clojure", "zig": "Zig", "nim": "Nim", "sh": "Shell", "bash": "Shell", "zsh": "Shell",
|
|
27
|
+
"fish": "Shell", "ps1": "PowerShell", "sql": "SQL", "html": "HTML", "htm": "HTML", "css": "CSS", "scss": "CSS",
|
|
28
|
+
"sass": "CSS", "less": "CSS", "vue": "Vue", "svelte": "Svelte", "md": "Markdown", "mdx": "Markdown",
|
|
29
|
+
"rst": "reStructuredText", "tex": "LaTeX", "yml": "YAML", "yaml": "YAML", "toml": "TOML", "json": "JSON",
|
|
30
|
+
"tf": "Terraform", "hcl": "Terraform", "sol": "Solidity", "v": "Verilog", "sv": "Verilog", "vhd": "VHDL",
|
|
31
|
+
"m": "Objective-C", "mm": "Objective-C", "pl": "Perl", "dockerfile": "Dockerfile", "proto": "Protobuf",
|
|
32
|
+
"graphql": "GraphQL", "nix": "Nix", "elm": "Elm", "fs": "F#", "cu": "CUDA", "glsl": "GLSL", "wgsl": "WGSL",
|
|
33
|
+
}
|
|
34
|
+
# Config/markup "languages" count toward lines but never headline someone's year.
|
|
35
|
+
SUPPORTING = {"Markdown", "reStructuredText", "YAML", "TOML", "JSON"}
|
|
36
|
+
|
|
37
|
+
STOPWORDS = set("""a an the and or to of in on for with from by at as is are be it this that into via
|
|
38
|
+
use using add added adds update updated updates fix fixed fixes remove removed make made more less new
|
|
39
|
+
some small minor wip initial commit merge branch main master refactor cleanup clean bump version readme
|
|
40
|
+
docs doc test tests change changes changed improve improved support allow when not no up out get set""".split())
|
|
41
|
+
|
|
42
|
+
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def language_of(path: str) -> str | None:
|
|
46
|
+
p = PurePosixPath(path)
|
|
47
|
+
if p.name.lower() == "dockerfile":
|
|
48
|
+
return "Dockerfile"
|
|
49
|
+
return LANGUAGES.get(p.suffix.lower().lstrip("."))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Persona:
|
|
54
|
+
title: str
|
|
55
|
+
line: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Wrapped:
|
|
60
|
+
year: int
|
|
61
|
+
name: str
|
|
62
|
+
commits: int = 0
|
|
63
|
+
repos: int = 0
|
|
64
|
+
active_days: int = 0
|
|
65
|
+
added: int = 0
|
|
66
|
+
deleted: int = 0
|
|
67
|
+
files: int = 0
|
|
68
|
+
longest_streak: int = 0
|
|
69
|
+
streak_start: date | None = None
|
|
70
|
+
streak_end: date | None = None
|
|
71
|
+
busiest_day: date | None = None
|
|
72
|
+
busiest_day_commits: int = 0
|
|
73
|
+
busiest_month: str = ""
|
|
74
|
+
peak_hour: int = 0
|
|
75
|
+
peak_weekday: str = ""
|
|
76
|
+
night_share: float = 0.0
|
|
77
|
+
weekend_share: float = 0.0
|
|
78
|
+
heatmap: list[list[int]] = field(default_factory=lambda: [[0] * 24 for _ in range(7)])
|
|
79
|
+
by_month: list[int] = field(default_factory=lambda: [0] * 12)
|
|
80
|
+
languages: list[tuple[str, int]] = field(default_factory=list) # (language, lines added)
|
|
81
|
+
top_repos: list[tuple[str, int]] = field(default_factory=list)
|
|
82
|
+
top_word: str | None = None
|
|
83
|
+
top_word_count: int = 0
|
|
84
|
+
fix_commits: int = 0
|
|
85
|
+
shortest_message: str | None = None
|
|
86
|
+
biggest_commit: tuple[str, int, str] | None = None # (subject, lines, repo)
|
|
87
|
+
latest_commit: tuple[str, str, str] | None = None # (time, subject, repo) of the latest-night commit
|
|
88
|
+
persona: Persona | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def clock12(hour: int, minute: int | None = None) -> str:
|
|
92
|
+
h = (hour % 12) or 12
|
|
93
|
+
return f"{h}{'' if minute is None else f':{minute:02d}'} {'am' if hour < 12 else 'pm'}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def longest_run(days: list[date]) -> tuple[int, date | None, date | None]:
|
|
97
|
+
if not days:
|
|
98
|
+
return 0, None, None
|
|
99
|
+
best = (1, days[0], days[0])
|
|
100
|
+
start = prev = days[0]
|
|
101
|
+
for d in days[1:]:
|
|
102
|
+
if d - prev == timedelta(days=1):
|
|
103
|
+
if (d - start).days + 1 > best[0]:
|
|
104
|
+
best = ((d - start).days + 1, start, d)
|
|
105
|
+
else:
|
|
106
|
+
start = d
|
|
107
|
+
prev = d
|
|
108
|
+
return best
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compute(commits: list[Commit], year: int, name: str = "") -> Wrapped:
|
|
112
|
+
w = Wrapped(year=year, name=name)
|
|
113
|
+
if not commits:
|
|
114
|
+
return w
|
|
115
|
+
w.commits = len(commits)
|
|
116
|
+
w.repos = len({c.repo for c in commits})
|
|
117
|
+
|
|
118
|
+
per_day = Counter(c.when.date() for c in commits)
|
|
119
|
+
days = sorted(per_day)
|
|
120
|
+
w.active_days = len(days)
|
|
121
|
+
w.longest_streak, w.streak_start, w.streak_end = longest_run(days)
|
|
122
|
+
w.busiest_day, w.busiest_day_commits = max(per_day.items(), key=lambda kv: (kv[1], kv[0]))
|
|
123
|
+
|
|
124
|
+
for c in commits:
|
|
125
|
+
w.heatmap[c.when.weekday()][c.when.hour] += 1
|
|
126
|
+
w.by_month[c.when.month - 1] += 1
|
|
127
|
+
w.busiest_month = date(year, w.by_month.index(max(w.by_month)) + 1, 1).strftime("%B")
|
|
128
|
+
hours = [sum(w.heatmap[d][h] for d in range(7)) for h in range(24)]
|
|
129
|
+
w.peak_hour = hours.index(max(hours))
|
|
130
|
+
weekdays = [sum(row) for row in w.heatmap]
|
|
131
|
+
w.peak_weekday = WEEKDAYS[weekdays.index(max(weekdays))]
|
|
132
|
+
w.night_share = sum(hours[h] for h in (22, 23, 0, 1, 2, 3, 4)) / w.commits
|
|
133
|
+
w.weekend_share = (weekdays[5] + weekdays[6]) / w.commits
|
|
134
|
+
|
|
135
|
+
langs: Counter[str] = Counter()
|
|
136
|
+
files = set()
|
|
137
|
+
biggest = None
|
|
138
|
+
for c in commits:
|
|
139
|
+
size = 0
|
|
140
|
+
for path, a, d in c.files:
|
|
141
|
+
if IGNORED_FILES.search(path):
|
|
142
|
+
continue
|
|
143
|
+
files.add((c.repo, path))
|
|
144
|
+
w.added += a
|
|
145
|
+
w.deleted += d
|
|
146
|
+
size += a + d
|
|
147
|
+
lang = language_of(path)
|
|
148
|
+
if lang:
|
|
149
|
+
langs[lang] += a
|
|
150
|
+
if biggest is None or size > biggest[1]:
|
|
151
|
+
biggest = (c.subject, size, c.repo)
|
|
152
|
+
w.files = len(files)
|
|
153
|
+
w.biggest_commit = biggest if biggest and biggest[1] > 0 else None
|
|
154
|
+
main = [(l, n) for l, n in langs.most_common() if l not in SUPPORTING and n > 0]
|
|
155
|
+
w.languages = (main or [(l, n) for l, n in langs.most_common() if n > 0])[:5]
|
|
156
|
+
w.top_repos = Counter(c.repo for c in commits).most_common(5)
|
|
157
|
+
|
|
158
|
+
words: Counter[str] = Counter()
|
|
159
|
+
for c in commits:
|
|
160
|
+
s = c.subject.lower()
|
|
161
|
+
if re.search(r"\b(fix(e[sd])?|bug|hotfix|patch)\b", s):
|
|
162
|
+
w.fix_commits += 1
|
|
163
|
+
for tok in re.findall(r"[a-z][a-z0-9_+#-]{2,}", s):
|
|
164
|
+
if tok not in STOPWORDS:
|
|
165
|
+
words[tok] += 1
|
|
166
|
+
if words:
|
|
167
|
+
w.top_word, w.top_word_count = words.most_common(1)[0]
|
|
168
|
+
subjects = [c.subject.strip() for c in commits if c.subject.strip()]
|
|
169
|
+
if subjects:
|
|
170
|
+
w.shortest_message = min(subjects, key=len)
|
|
171
|
+
|
|
172
|
+
def lateness(c: Commit) -> int: # minutes past 6pm, wrapping past midnight
|
|
173
|
+
m = c.when.hour * 60 + c.when.minute
|
|
174
|
+
return m - 18 * 60 if m >= 18 * 60 else m + 6 * 60 if m < 5 * 60 else -1
|
|
175
|
+
late = max(commits, key=lateness)
|
|
176
|
+
if lateness(late) >= 0:
|
|
177
|
+
w.latest_commit = (clock12(late.when.hour, late.when.minute), late.subject, late.repo)
|
|
178
|
+
|
|
179
|
+
w.persona = persona(w)
|
|
180
|
+
return w
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def persona(w: Wrapped) -> Persona:
|
|
184
|
+
"""Pick the trait that stands out most; each score is how far past its threshold the user is."""
|
|
185
|
+
avg_per_day = w.commits / max(w.active_days, 1)
|
|
186
|
+
rules = [
|
|
187
|
+
(w.night_share / 0.25, "The Night Owl",
|
|
188
|
+
f"{w.night_share:.0%} of your commits landed between 10pm and 5am."),
|
|
189
|
+
(w.weekend_share / 0.30, "The Weekend Warrior",
|
|
190
|
+
f"{w.weekend_share:.0%} of your commits happened on Saturdays and Sundays."),
|
|
191
|
+
(w.longest_streak / 21, "The Marathoner",
|
|
192
|
+
f"You committed {w.longest_streak} days in a row."),
|
|
193
|
+
((w.deleted / max(w.added, 1)) / 0.8 if w.added > 500 else 0, "The Refactorer",
|
|
194
|
+
f"You deleted {w.deleted:,} lines. Less code, fewer bugs."),
|
|
195
|
+
((w.fix_commits / w.commits) / 0.35 if w.commits >= 20 else 0, "The Bug Hunter",
|
|
196
|
+
f"{w.fix_commits} of your commits were fixes."),
|
|
197
|
+
(len([l for l, n in w.languages if n > 0.08 * max(w.added, 1)]) / 4, "The Polyglot",
|
|
198
|
+
f"You shipped real code in {len(w.languages)} languages."),
|
|
199
|
+
((w.busiest_day_commits / avg_per_day) / 6 if w.active_days >= 10 else 0, "The Sprinter",
|
|
200
|
+
f"On your biggest day you made {w.busiest_day_commits} commits."),
|
|
201
|
+
((sum(w.heatmap[d][h] for d in range(7) for h in range(5, 9)) / w.commits) / 0.2,
|
|
202
|
+
"The Early Bird", "You did your best work before most people's first coffee."),
|
|
203
|
+
]
|
|
204
|
+
score, title, line = max(rules, key=lambda r: r[0])
|
|
205
|
+
if score < 1:
|
|
206
|
+
return Persona("The Steady Shipper", f"Commits on {w.active_days} days. Consistency is a superpower.")
|
|
207
|
+
return Persona(title, line)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codewrapped
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spotify Wrapped for your code: your year in commits across every git repo on your machine, with a shareable card
|
|
5
|
+
Project-URL: Homepage, https://github.com/ArkaKhorchidian/codewrapped
|
|
6
|
+
Project-URL: Issues, https://github.com/ArkaKhorchidian/codewrapped/issues
|
|
7
|
+
Author: Arka Khorchidian
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: src/codewrapped/fonts/OFL.txt
|
|
11
|
+
Keywords: cli,commits,developer,fun,git,github,stats,wrapped,year-in-review
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: pillow>=9.2
|
|
21
|
+
Requires-Dist: pyfiglet>=0.8
|
|
22
|
+
Requires-Dist: rich>=13
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<h1 align="center">codewrapped</h1>
|
|
28
|
+
|
|
29
|
+
<p align="center"><b>Spotify Wrapped for your code.</b><br>
|
|
30
|
+
Your year in commits, from <i>every</i> git repo on your machine, work and personal, public and private. Plus a card to share.</p>
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<a href="https://pypi.org/project/codewrapped/"><img src="https://img.shields.io/pypi/v/codewrapped.svg" alt="PyPI"></a>
|
|
34
|
+
<a href="https://github.com/ArkaKhorchidian/codewrapped/actions/workflows/ci.yml"><img src="https://github.com/ArkaKhorchidian/codewrapped/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
35
|
+
<img src="https://img.shields.io/pypi/pyversions/codewrapped.svg" alt="Python">
|
|
36
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uvx codewrapped ~/code # or: pipx run codewrapped ~/code
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<img src="docs/demo.gif" alt="codewrapped playing an animated year in review of git history" width="720">
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<p align="center"><img src="docs/card.png" alt="codewrapped share card: The Marathoner" width="720"></p>
|
|
48
|
+
|
|
49
|
+
<sub>Demo and card generated from synthetic repositories.</sub>
|
|
50
|
+
|
|
51
|
+
## Why another Wrapped?
|
|
52
|
+
|
|
53
|
+
GitHub-based year-in-review tools only see what's on GitHub. Your year also lives in your
|
|
54
|
+
employer's GitLab, in private repos, and in side projects that never got pushed. codewrapped
|
|
55
|
+
reads the git history already on your disk, so it sees all of it, and none of it leaves your
|
|
56
|
+
machine.
|
|
57
|
+
|
|
58
|
+
## What you get
|
|
59
|
+
|
|
60
|
+
- **Commits, lines added and deleted, active days, longest streak**, deduplicated across clones and forks
|
|
61
|
+
- **Your languages** by lines written (lockfiles, `node_modules`, minified and generated files are ignored)
|
|
62
|
+
- **Your rhythm**: the hour and weekday you code best, your busiest day and month, your latest-night commit
|
|
63
|
+
- **Your commit messages**: your favorite word, how many were fixes, your shortest message
|
|
64
|
+
- **Your developer personality**: The Night Owl, The Marathoner, The Refactorer, The Weekend Warrior, The Polyglot, ...
|
|
65
|
+
- **A 1200×675 share card**, sized for X, LinkedIn and Slack previews
|
|
66
|
+
|
|
67
|
+
## Private by design
|
|
68
|
+
|
|
69
|
+
- It reads your local git history. No API calls, no tokens, no network.
|
|
70
|
+
- **The card never shows repo names or code**, so private work projects stay private. It shows your numbers and languages only.
|
|
71
|
+
- Repo names appear in your terminal summary, which only you see.
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
codewrapped # repos under the current folder
|
|
77
|
+
codewrapped ~/code ~/work # search several places
|
|
78
|
+
codewrapped --year 2025
|
|
79
|
+
codewrapped --author me@work.com --author me@personal.dev # all the emails you commit with
|
|
80
|
+
codewrapped --everyone # a whole team's wrapped, for one repo
|
|
81
|
+
codewrapped --no-anim # just the summary
|
|
82
|
+
codewrapped --card wrapped.png # or --no-card
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
By default it counts commits matching the `user.email` / `user.name` in your git config (global and per-repo).
|
|
86
|
+
If you've committed under several emails, pass each with `--author`.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT. Bundles the [Inter](https://github.com/rsms/inter) typeface under the SIL Open Font License.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
codewrapped/__init__.py,sha256=NNrkb-SxBMMA0SIdkKDYiNj4SFEdiX3MPzG_g-xih8U,72
|
|
2
|
+
codewrapped/__main__.py,sha256=yQP0sf-rkTO-3MOotv4blvukvNGwbJFz10hQhMsxcJM,28
|
|
3
|
+
codewrapped/card.py,sha256=ri9X-h1C9lqdeaQdyXAfpuAjScu01JXSPgEzKNdnNcI,3944
|
|
4
|
+
codewrapped/cli.py,sha256=HTVOUnt7gDxrrdY4Pw0fsGt-4UcKmZNB7Td7YffXmfE,7325
|
|
5
|
+
codewrapped/collect.py,sha256=jxu1c_aY4ePvwSoXPVzzOV8PB8Q873jQk_jd0ze8CUQ,4771
|
|
6
|
+
codewrapped/show.py,sha256=Vn8bh-UW34jBMoeyoOqU6RsVqsmJUBanC7gqcU7B9HQ,5206
|
|
7
|
+
codewrapped/stats.py,sha256=QzTid4xjHbGjxpF7EjKSpdNt2xnP2uXcc9f6YYruglQ,9200
|
|
8
|
+
codewrapped/fonts/Inter-Bold.otf,sha256=AP1vRpHuiISjO0a_RYUbNBknw3wwBk9VjhvvskKXHEk,626056
|
|
9
|
+
codewrapped/fonts/Inter-Regular.otf,sha256=vm1wnctzDdrwUMva5v6JvVbV0U3GaIVjm79vBr0DUhs,605092
|
|
10
|
+
codewrapped/fonts/InterDisplay-Black.otf,sha256=wOu2dpM6Rdc9Fv6nDIdfnytxG1hcabFZYrfyqJC1-Og,617468
|
|
11
|
+
codewrapped/fonts/OFL.txt,sha256=DxohhtfdVQS2KAiP-zWeMnb-CGS_A5qE4QwLedvy0vo,4163
|
|
12
|
+
codewrapped-0.1.0.dist-info/METADATA,sha256=PZqU9xWBepW7dTkAGY_UdbbnHSKWlsJkKSShxDWS_h8,4128
|
|
13
|
+
codewrapped-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
codewrapped-0.1.0.dist-info/entry_points.txt,sha256=2iDw9WCIA4WbPU6XhO2yi4hn_ctgLuUNxD3FcYo9y8w,52
|
|
15
|
+
codewrapped-0.1.0.dist-info/licenses/LICENSE,sha256=mxFQpL6plnvuVMkJNW-M1_rxIlAzORcfDhnP1UETuKI,1073
|
|
16
|
+
codewrapped-0.1.0.dist-info/licenses/src/codewrapped/fonts/OFL.txt,sha256=DxohhtfdVQS2KAiP-zWeMnb-CGS_A5qE4QwLedvy0vo,4163
|
|
17
|
+
codewrapped-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arka Khorchidian
|
|
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,88 @@
|
|
|
1
|
+
Inter font: Copyright (c) 2016-2023 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
|
|
5
|
+
PREAMBLE
|
|
6
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
7
|
+
development of collaborative font projects, to support the font
|
|
8
|
+
creation efforts of academic and linguistic communities, and to
|
|
9
|
+
provide a free and open framework in which fonts may be shared and
|
|
10
|
+
improved in partnership with others.
|
|
11
|
+
|
|
12
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
13
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
14
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
15
|
+
redistributed and/or sold with any software provided that any
|
|
16
|
+
reserved names are not used by derivative works. The fonts and
|
|
17
|
+
derivatives, however, cannot be released under any other type of
|
|
18
|
+
license. The requirement for fonts to remain under this license
|
|
19
|
+
does not apply to any document created using the fonts or their
|
|
20
|
+
derivatives.
|
|
21
|
+
|
|
22
|
+
DEFINITIONS
|
|
23
|
+
Font Software refers to the set of files released by the
|
|
24
|
+
Copyright Holder(s) under this license and clearly marked as such.
|
|
25
|
+
This may include source files, build scripts and documentation.
|
|
26
|
+
|
|
27
|
+
Reserved Font Name refers to any names specified as such after
|
|
28
|
+
the copyright statement(s).
|
|
29
|
+
|
|
30
|
+
Original Version refers to the collection of Font Software
|
|
31
|
+
components as distributed by the Copyright Holder(s).
|
|
32
|
+
|
|
33
|
+
Modified Version refers to any derivative made by adding to,
|
|
34
|
+
deleting, or substituting in part or in whole any of the components
|
|
35
|
+
of the Original Version, by changing formats or by porting the Font
|
|
36
|
+
Software to a new environment.
|
|
37
|
+
|
|
38
|
+
Author refers to any designer, engineer, programmer, technical
|
|
39
|
+
writer or other person who contributed to the Font Software.
|
|
40
|
+
|
|
41
|
+
PERMISSION & CONDITIONS
|
|
42
|
+
Permission is hereby granted, free of charge, to any person
|
|
43
|
+
obtaining a copy of the Font Software, to use, study, copy, merge,
|
|
44
|
+
embed, modify, redistribute, and sell modified and unmodified copies
|
|
45
|
+
of the Font Software, subject to the following conditions:
|
|
46
|
+
|
|
47
|
+
1) Neither the Font Software nor any of its individual components,
|
|
48
|
+
in Original or Modified Versions, may be sold by itself.
|
|
49
|
+
|
|
50
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
51
|
+
redistributed and/or sold with any software, provided that each
|
|
52
|
+
copy contains the above copyright notice and this license. These
|
|
53
|
+
can be included either as stand-alone text files, human-readable
|
|
54
|
+
headers or in the appropriate machine-readable metadata fields
|
|
55
|
+
within text or binary files as long as those fields can be easily
|
|
56
|
+
viewed by the user.
|
|
57
|
+
|
|
58
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
59
|
+
Name(s) unless explicit written permission is granted by the
|
|
60
|
+
corresponding Copyright Holder. This restriction only applies to
|
|
61
|
+
the primary font name as presented to the users.
|
|
62
|
+
|
|
63
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the
|
|
64
|
+
Font Software shall not be used to promote, endorse or advertise
|
|
65
|
+
any Modified Version, except to acknowledge the contribution(s)
|
|
66
|
+
of the Copyright Holder(s) and the Author(s) or with their
|
|
67
|
+
explicit written permission.
|
|
68
|
+
|
|
69
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
70
|
+
must be distributed entirely under this license, and must not be
|
|
71
|
+
distributed under any other license. The requirement for fonts to
|
|
72
|
+
remain under this license does not apply to any document created
|
|
73
|
+
using the Font Software.
|
|
74
|
+
|
|
75
|
+
TERMINATION
|
|
76
|
+
This license becomes null and void if any of the above conditions
|
|
77
|
+
are not met.
|
|
78
|
+
|
|
79
|
+
DISCLAIMER
|
|
80
|
+
THE FONT SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND,
|
|
81
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
82
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
83
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
|
84
|
+
THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
85
|
+
LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR
|
|
86
|
+
CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
87
|
+
OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT
|
|
88
|
+
SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|