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.
@@ -0,0 +1,2 @@
1
+ """codewrapped: Spotify Wrapped for your code."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import run
2
+
3
+ run()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codewrapped = codewrapped.cli:run
@@ -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.