git-wrapped 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """git-wrapped: Spotify Wrapped, but for your Git history."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m git_wrapped."""
2
+
3
+ from git_wrapped.cli import main
4
+
5
+ main()
@@ -0,0 +1,497 @@
1
+ """Git history analyzer — parses git log and computes wrapped statistics."""
2
+
3
+ import subprocess
4
+ import re
5
+ from datetime import datetime, timedelta, date as date_type
6
+ from collections import Counter, defaultdict
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import List, Dict, Tuple, Optional, Set
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Language detection by file extension
13
+ # ---------------------------------------------------------------------------
14
+
15
+ EXTENSION_LANGUAGES: Dict[str, str] = {
16
+ ".py": "Python", ".pyi": "Python",
17
+ ".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
18
+ ".ts": "TypeScript", ".mts": "TypeScript",
19
+ ".jsx": "React JSX", ".tsx": "React TSX",
20
+ ".java": "Java",
21
+ ".go": "Go",
22
+ ".rs": "Rust",
23
+ ".rb": "Ruby",
24
+ ".php": "PHP",
25
+ ".c": "C", ".h": "C/C++",
26
+ ".cpp": "C++", ".cc": "C++", ".cxx": "C++", ".hpp": "C++",
27
+ ".cs": "C#",
28
+ ".swift": "Swift",
29
+ ".kt": "Kotlin", ".kts": "Kotlin",
30
+ ".scala": "Scala",
31
+ ".r": "R", ".R": "R",
32
+ ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell",
33
+ ".html": "HTML", ".htm": "HTML",
34
+ ".css": "CSS", ".scss": "SCSS", ".sass": "Sass", ".less": "Less",
35
+ ".sql": "SQL",
36
+ ".yaml": "YAML", ".yml": "YAML",
37
+ ".json": "JSON",
38
+ ".xml": "XML",
39
+ ".md": "Markdown", ".mdx": "Markdown",
40
+ ".toml": "TOML",
41
+ ".lua": "Lua",
42
+ ".dart": "Dart",
43
+ ".ex": "Elixir", ".exs": "Elixir",
44
+ ".erl": "Erlang",
45
+ ".hs": "Haskell",
46
+ ".ml": "OCaml",
47
+ ".clj": "Clojure",
48
+ ".vue": "Vue",
49
+ ".svelte": "Svelte",
50
+ ".tf": "Terraform",
51
+ ".proto": "Protobuf",
52
+ ".graphql": "GraphQL", ".gql": "GraphQL",
53
+ ".dockerfile": "Docker",
54
+ }
55
+
56
+ # Files that imply a language regardless of extension
57
+ SPECIAL_FILES: Dict[str, str] = {
58
+ "Dockerfile": "Docker",
59
+ "Makefile": "Make",
60
+ "CMakeLists.txt": "CMake",
61
+ "Vagrantfile": "Ruby",
62
+ "Gemfile": "Ruby",
63
+ "Rakefile": "Ruby",
64
+ }
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Data classes
69
+ # ---------------------------------------------------------------------------
70
+
71
+ @dataclass
72
+ class Commit:
73
+ hash: str
74
+ author: str
75
+ email: str
76
+ date: datetime
77
+ message: str
78
+ files: List[Tuple[int, int, str]] = field(default_factory=list)
79
+
80
+
81
+ @dataclass
82
+ class WrappedStats:
83
+ # Core numbers
84
+ total_commits: int = 0
85
+ total_files_changed: int = 0
86
+ total_insertions: int = 0
87
+ total_deletions: int = 0
88
+
89
+ # Date range
90
+ first_commit: Optional[datetime] = None
91
+ last_commit: Optional[datetime] = None
92
+ active_days: int = 0
93
+
94
+ # Time patterns (hour 0‑23, weekday 0=Mon, month 1‑12)
95
+ commits_by_hour: Dict[int, int] = field(default_factory=lambda: defaultdict(int))
96
+ commits_by_weekday: Dict[int, int] = field(default_factory=lambda: defaultdict(int))
97
+ commits_by_month: Dict[int, int] = field(default_factory=lambda: defaultdict(int))
98
+ daily_counts: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
99
+
100
+ # Top items
101
+ top_files: List[Tuple[str, int]] = field(default_factory=list)
102
+ languages: Dict[str, int] = field(default_factory=dict)
103
+
104
+ # Streaks
105
+ longest_streak: int = 0
106
+ current_streak: int = 0
107
+ busiest_day: Tuple[str, int] = ("", 0)
108
+
109
+ # Fun stats
110
+ longest_message: str = ""
111
+ shortest_message: str = ""
112
+ avg_message_length: float = 0.0
113
+ holiday_commits: List[str] = field(default_factory=list)
114
+ most_productive_month: str = ""
115
+
116
+ # Personality
117
+ personality: str = ""
118
+ personality_emoji: str = ""
119
+ personality_description: str = ""
120
+ traits: List[Tuple[str, str]] = field(default_factory=list)
121
+
122
+ # Authors
123
+ authors: Dict[str, int] = field(default_factory=dict)
124
+
125
+ # Meta
126
+ year: Optional[int] = None
127
+ repo_name: str = ""
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Notable dates (for fun‑fact detection)
132
+ # ---------------------------------------------------------------------------
133
+
134
+ HOLIDAYS: Dict[str, str] = {
135
+ "01-01": "New Year's Day",
136
+ "02-14": "Valentine's Day",
137
+ "03-17": "St. Patrick's Day",
138
+ "04-01": "April Fools' Day",
139
+ "07-04": "Independence Day",
140
+ "10-31": "Halloween",
141
+ "12-25": "Christmas",
142
+ "12-31": "New Year's Eve",
143
+ }
144
+
145
+ MONTH_NAMES = [
146
+ "", "January", "February", "March", "April", "May", "June",
147
+ "July", "August", "September", "October", "November", "December",
148
+ ]
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Git log parsing
153
+ # ---------------------------------------------------------------------------
154
+
155
+ _COMMIT_PREFIX = ">>>GW:"
156
+
157
+ def _run_git(args: List[str], cwd: str) -> str:
158
+ """Run a git command and return stdout."""
159
+ try:
160
+ result = subprocess.run(
161
+ ["git"] + args,
162
+ capture_output=True,
163
+ text=True,
164
+ cwd=cwd,
165
+ timeout=120,
166
+ )
167
+ except FileNotFoundError:
168
+ raise RuntimeError(
169
+ "git is not installed or not in your PATH. "
170
+ "Install git and try again."
171
+ )
172
+ except subprocess.TimeoutExpired:
173
+ raise RuntimeError("git command timed out — is the repository very large?")
174
+
175
+ if result.returncode != 0:
176
+ stderr = result.stderr.strip()
177
+ if "not a git repository" in stderr:
178
+ raise RuntimeError(
179
+ f"'{cwd}' is not a git repository. "
180
+ "Run git-wrapped inside a repo or use --path."
181
+ )
182
+ raise RuntimeError(f"git error: {stderr}")
183
+ return result.stdout
184
+
185
+
186
+ def parse_git_log(
187
+ repo_path: str = ".",
188
+ year: Optional[int] = None,
189
+ author: Optional[str] = None,
190
+ ) -> List[Commit]:
191
+ """Parse git log and return a list of Commit objects."""
192
+
193
+ fmt = f"{_COMMIT_PREFIX}%H%x00%an%x00%ae%x00%aI%x00%s"
194
+ cmd = ["log", f"--format={fmt}", "--numstat", "--no-merges"]
195
+
196
+ if year:
197
+ cmd += [f"--after={year}-01-01", f"--before={year + 1}-01-01"]
198
+ if author:
199
+ cmd += ["--author", author]
200
+
201
+ raw = _run_git(cmd, cwd=repo_path)
202
+ if not raw.strip():
203
+ return []
204
+
205
+ commits: List[Commit] = []
206
+ current: Optional[Commit] = None
207
+
208
+ for line in raw.splitlines():
209
+ if line.startswith(_COMMIT_PREFIX):
210
+ if current is not None:
211
+ commits.append(current)
212
+
213
+ payload = line[len(_COMMIT_PREFIX):]
214
+ parts = payload.split("\x00", 4)
215
+ if len(parts) < 5:
216
+ current = None
217
+ continue
218
+
219
+ sha, name, email, datestr, message = parts
220
+ try:
221
+ dt = datetime.fromisoformat(datestr)
222
+ except ValueError:
223
+ current = None
224
+ continue
225
+
226
+ current = Commit(
227
+ hash=sha, author=name, email=email, date=dt, message=message
228
+ )
229
+ continue
230
+
231
+ # numstat line: "10\t5\tfilename"
232
+ if current is not None and "\t" in line:
233
+ parts = line.split("\t", 2)
234
+ if len(parts) == 3:
235
+ try:
236
+ adds = int(parts[0]) if parts[0] != "-" else 0
237
+ dels = int(parts[1]) if parts[1] != "-" else 0
238
+ except ValueError:
239
+ continue
240
+ current.files.append((adds, dels, parts[2]))
241
+
242
+ if current is not None:
243
+ commits.append(current)
244
+
245
+ return commits
246
+
247
+
248
+ def _detect_language(filename: str) -> Optional[str]:
249
+ """Detect programming language from filename."""
250
+ basename = Path(filename).name
251
+ if basename in SPECIAL_FILES:
252
+ return SPECIAL_FILES[basename]
253
+ ext = Path(filename).suffix.lower()
254
+ return EXTENSION_LANGUAGES.get(ext)
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Statistics
259
+ # ---------------------------------------------------------------------------
260
+
261
+ def _calculate_streaks(stats: WrappedStats, all_dates: Set[str]) -> None:
262
+ if not all_dates:
263
+ return
264
+
265
+ date_objects = sorted(
266
+ datetime.strptime(d, "%Y-%m-%d").date() for d in all_dates
267
+ )
268
+
269
+ # Longest streak
270
+ longest = current = 1
271
+ for i in range(1, len(date_objects)):
272
+ if (date_objects[i] - date_objects[i - 1]).days == 1:
273
+ current += 1
274
+ longest = max(longest, current)
275
+ else:
276
+ current = 1
277
+ stats.longest_streak = longest
278
+
279
+ # Current streak (from today backwards)
280
+ today = date_type.today()
281
+ streak = 0
282
+ check = today
283
+ # Allow a 1‑day gap (today might not have commits yet)
284
+ if check.strftime("%Y-%m-%d") not in all_dates:
285
+ check -= timedelta(days=1)
286
+ while check.strftime("%Y-%m-%d") in all_dates:
287
+ streak += 1
288
+ check -= timedelta(days=1)
289
+ stats.current_streak = streak
290
+
291
+
292
+ def _detect_holidays(daily_counts: Dict[str, int]) -> List[str]:
293
+ found = []
294
+ for date_str in daily_counts:
295
+ md = date_str[5:] # "MM-DD"
296
+ if md in HOLIDAYS:
297
+ found.append(HOLIDAYS[md])
298
+ return found
299
+
300
+
301
+ def _determine_personality(stats: WrappedStats, commits: List[Commit]) -> None:
302
+ total = stats.total_commits or 1
303
+
304
+ # Time buckets
305
+ night = sum(stats.commits_by_hour.get(h, 0) for h in list(range(22, 24)) + list(range(0, 5)))
306
+ morning = sum(stats.commits_by_hour.get(h, 0) for h in range(5, 12))
307
+ evening = sum(stats.commits_by_hour.get(h, 0) for h in range(17, 22))
308
+
309
+ night_pct = night / total
310
+ morning_pct = morning / total
311
+
312
+ weekend = stats.commits_by_weekday.get(5, 0) + stats.commits_by_weekday.get(6, 0)
313
+ weekend_pct = weekend / total
314
+
315
+ # Primary personality
316
+ if night_pct > 0.30:
317
+ stats.personality = "Night Owl"
318
+ stats.personality_emoji = "\U0001f989"
319
+ peak = max(range(20, 24), key=lambda h: stats.commits_by_hour.get(h, 0))
320
+ stats.personality_description = (
321
+ f"While others sleep, you ship code. "
322
+ f"{night_pct:.0%} of your commits land after 10 PM. "
323
+ f"Peak hour: {peak}:00."
324
+ )
325
+ elif morning_pct > 0.45:
326
+ stats.personality = "Early Bird"
327
+ stats.personality_emoji = "\U0001f426"
328
+ stats.personality_description = (
329
+ f"You catch the worm! {morning_pct:.0%} of your commits "
330
+ f"are shipped before noon."
331
+ )
332
+ elif weekend_pct > 0.30:
333
+ stats.personality = "Weekend Warrior"
334
+ stats.personality_emoji = "\u2694\ufe0f"
335
+ stats.personality_description = (
336
+ f"Weekends are for coding! {weekend_pct:.0%} of your "
337
+ f"commits happen on Saturday & Sunday."
338
+ )
339
+ elif stats.longest_streak >= 14:
340
+ stats.personality = "Streak Master"
341
+ stats.personality_emoji = "\U0001f525"
342
+ stats.personality_description = (
343
+ f"Your incredible {stats.longest_streak}-day commit streak "
344
+ f"shows legendary dedication."
345
+ )
346
+ elif stats.total_insertions > stats.total_deletions * 3:
347
+ stats.personality = "Feature Machine"
348
+ stats.personality_emoji = "\U0001f680"
349
+ stats.personality_description = (
350
+ f"You're a builder! {stats.total_insertions:,} lines added "
351
+ f"vs {stats.total_deletions:,} deleted."
352
+ )
353
+ elif stats.total_deletions > stats.total_insertions * 0.7:
354
+ stats.personality = "Code Surgeon"
355
+ stats.personality_emoji = "\u2702\ufe0f"
356
+ stats.personality_description = (
357
+ f"Less is more. You removed {stats.total_deletions:,} lines — "
358
+ f"cleaning up the codebase one commit at a time."
359
+ )
360
+ else:
361
+ stats.personality = "Balanced Builder"
362
+ stats.personality_emoji = "\u2696\ufe0f"
363
+ stats.personality_description = (
364
+ "You strike the perfect balance between building new features "
365
+ "and keeping the codebase clean."
366
+ )
367
+
368
+ # Traits
369
+ traits: List[Tuple[str, str]] = []
370
+
371
+ avg_files = stats.total_files_changed / total
372
+ if avg_files > 5:
373
+ traits.append(("\U0001f3d7\ufe0f", f"Big Changer — avg {avg_files:.1f} files/commit"))
374
+ elif avg_files < 2:
375
+ traits.append(("\U0001f3af", f"Surgical Committer — avg {avg_files:.1f} files/commit"))
376
+
377
+ if stats.avg_message_length > 60:
378
+ traits.append(("\U0001f4dd", f"Storyteller — avg {stats.avg_message_length:.0f}-char messages"))
379
+ elif stats.avg_message_length < 15:
380
+ traits.append(("\u26a1", f"Terse Messenger — avg {stats.avg_message_length:.0f}-char messages"))
381
+
382
+ if stats.longest_streak >= 7:
383
+ traits.append(("\U0001f525", f"On Fire — {stats.longest_streak}-day commit streak"))
384
+
385
+ if weekend_pct > 0.15:
386
+ traits.append(("\U0001f3e0", f"Weekend Coder — {weekend_pct:.0%} on Sat/Sun"))
387
+
388
+ # File-pattern traits
389
+ md_count = sum(c for f, c in stats.top_files if f.endswith((".md", ".mdx", ".rst")))
390
+ test_count = sum(c for f, c in stats.top_files if "test" in f.lower() or "spec" in f.lower())
391
+
392
+ if md_count > total * 0.08:
393
+ traits.append(("\U0001f4da", "Documentation Hero"))
394
+ if test_count > total * 0.10:
395
+ traits.append(("\U0001f9ea", "Test Champion"))
396
+
397
+ stats.traits = traits[:6]
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Public API
402
+ # ---------------------------------------------------------------------------
403
+
404
+ def analyze(
405
+ repo_path: str = ".",
406
+ year: Optional[int] = None,
407
+ author: Optional[str] = None,
408
+ ) -> WrappedStats:
409
+ """Analyze a git repository and return WrappedStats."""
410
+
411
+ # Resolve repo name
412
+ try:
413
+ origin = _run_git(["remote", "get-url", "origin"], cwd=repo_path).strip()
414
+ repo_name = Path(origin.rstrip(".git")).stem
415
+ except RuntimeError:
416
+ repo_name = Path(repo_path).resolve().name
417
+
418
+ commits = parse_git_log(repo_path, year=year, author=author)
419
+ if not commits:
420
+ raise ValueError(
421
+ "No commits found. Check --year and --author filters, "
422
+ "or make sure you're inside a git repository."
423
+ )
424
+
425
+ stats = WrappedStats(repo_name=repo_name, year=year)
426
+ stats.total_commits = len(commits)
427
+
428
+ file_counter: Counter = Counter()
429
+ lang_counter: Counter = Counter()
430
+ msg_lengths: List[Tuple[int, str]] = []
431
+ all_dates: Set[str] = set()
432
+ author_counter: Counter = Counter()
433
+
434
+ for commit in commits:
435
+ stats.commits_by_hour[commit.date.hour] += 1
436
+ stats.commits_by_weekday[commit.date.weekday()] += 1
437
+ stats.commits_by_month[commit.date.month] += 1
438
+
439
+ ds = commit.date.strftime("%Y-%m-%d")
440
+ stats.daily_counts[ds] += 1
441
+ all_dates.add(ds)
442
+
443
+ author_counter[commit.author] += 1
444
+
445
+ for adds, dels, fname in commit.files:
446
+ stats.total_insertions += adds
447
+ stats.total_deletions += dels
448
+ file_counter[fname] += 1
449
+
450
+ lang = _detect_language(fname)
451
+ if lang:
452
+ lang_counter[lang] += adds + dels
453
+
454
+ stats.total_files_changed += len(commit.files)
455
+ msg_lengths.append((len(commit.message), commit.message))
456
+
457
+ # Dates
458
+ stats.first_commit = min(c.date for c in commits)
459
+ stats.last_commit = max(c.date for c in commits)
460
+ stats.active_days = len(all_dates)
461
+
462
+ # Top files
463
+ stats.top_files = file_counter.most_common(10)
464
+
465
+ # Languages
466
+ stats.languages = dict(lang_counter.most_common(10))
467
+
468
+ # Authors
469
+ stats.authors = dict(author_counter.most_common(10))
470
+
471
+ # Messages
472
+ if msg_lengths:
473
+ msg_lengths.sort(key=lambda x: x[0])
474
+ stats.shortest_message = msg_lengths[0][1]
475
+ stats.longest_message = msg_lengths[-1][1]
476
+ stats.avg_message_length = sum(m[0] for m in msg_lengths) / len(msg_lengths)
477
+
478
+ # Busiest day
479
+ if stats.daily_counts:
480
+ bd = max(stats.daily_counts.items(), key=lambda x: x[1])
481
+ stats.busiest_day = bd
482
+
483
+ # Most productive month
484
+ if stats.commits_by_month:
485
+ best_month = max(stats.commits_by_month, key=stats.commits_by_month.get) # type: ignore[arg-type]
486
+ stats.most_productive_month = MONTH_NAMES[best_month]
487
+
488
+ # Streaks
489
+ _calculate_streaks(stats, all_dates)
490
+
491
+ # Holidays
492
+ stats.holiday_commits = _detect_holidays(stats.daily_counts)
493
+
494
+ # Personality
495
+ _determine_personality(stats, commits)
496
+
497
+ return stats
git_wrapped/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """CLI entry point for git-wrapped."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from dataclasses import asdict
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from git_wrapped import __version__
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="git-wrapped",
16
+ description="Spotify Wrapped, but for your Git history.",
17
+ epilog="Example: git-wrapped --year 2025 --path ~/projects/myrepo",
18
+ )
19
+ parser.add_argument(
20
+ "--path", "-p",
21
+ default=".",
22
+ help="Path to the git repository (default: current directory)",
23
+ )
24
+ parser.add_argument(
25
+ "--year", "-y",
26
+ type=int,
27
+ default=None,
28
+ help="Year to analyze (default: all time)",
29
+ )
30
+ parser.add_argument(
31
+ "--author", "-a",
32
+ default=None,
33
+ help="Filter by author name or email (supports partial match)",
34
+ )
35
+ parser.add_argument(
36
+ "--no-animate",
37
+ action="store_true",
38
+ help="Disable loading animation and section pauses",
39
+ )
40
+ parser.add_argument(
41
+ "--json",
42
+ action="store_true",
43
+ dest="json_output",
44
+ help="Output raw stats as JSON instead of the visual display",
45
+ )
46
+ parser.add_argument(
47
+ "--compare",
48
+ nargs=2,
49
+ type=int,
50
+ metavar=("YEAR1", "YEAR2"),
51
+ help="Compare two years side by side (e.g. --compare 2024 2025)",
52
+ )
53
+ parser.add_argument(
54
+ "--share",
55
+ action="store_true",
56
+ help="Generate a copy-pasteable share card for social media",
57
+ )
58
+ parser.add_argument(
59
+ "--version", "-v",
60
+ action="version",
61
+ version=f"git-wrapped {__version__}",
62
+ )
63
+ return parser
64
+
65
+
66
+ def _serialize_stats(stats) -> dict:
67
+ """Convert WrappedStats to a JSON-serializable dict."""
68
+ data = {}
69
+ for key, value in stats.__dict__.items():
70
+ if isinstance(value, datetime):
71
+ data[key] = value.isoformat()
72
+ elif isinstance(value, dict):
73
+ data[key] = {str(k): v for k, v in value.items()}
74
+ elif isinstance(value, list):
75
+ serialized = []
76
+ for item in value:
77
+ if isinstance(item, tuple):
78
+ serialized.append(list(item))
79
+ else:
80
+ serialized.append(item)
81
+ data[key] = serialized
82
+ elif isinstance(value, tuple):
83
+ data[key] = list(value)
84
+ else:
85
+ data[key] = value
86
+ return data
87
+
88
+
89
+ def main() -> None:
90
+ parser = _build_parser()
91
+ args = parser.parse_args()
92
+
93
+ # Resolve repo path
94
+ repo_path = str(Path(args.path).resolve())
95
+
96
+ from git_wrapped.analyzer import analyze
97
+
98
+ # --compare mode: side-by-side two years
99
+ if args.compare:
100
+ y1, y2 = args.compare
101
+ try:
102
+ stats1 = analyze(repo_path=repo_path, year=y1, author=args.author)
103
+ stats2 = analyze(repo_path=repo_path, year=y2, author=args.author)
104
+ except (RuntimeError, ValueError) as exc:
105
+ print(f"Error: {exc}", file=sys.stderr)
106
+ sys.exit(1)
107
+ from git_wrapped.display import display_compare
108
+ display_compare(stats1, stats2, animate=not args.no_animate)
109
+ return
110
+
111
+ # Normal single-year mode
112
+ try:
113
+ stats = analyze(
114
+ repo_path=repo_path,
115
+ year=args.year,
116
+ author=args.author,
117
+ )
118
+ except (RuntimeError, ValueError) as exc:
119
+ print(f"Error: {exc}", file=sys.stderr)
120
+ sys.exit(1)
121
+
122
+ if args.json_output:
123
+ print(json.dumps(_serialize_stats(stats), indent=2))
124
+ return
125
+
126
+ if args.share:
127
+ from git_wrapped.display import display_share_card
128
+ display_share_card(stats)
129
+ return
130
+
131
+ from git_wrapped.display import display_wrapped
132
+ display_wrapped(stats, animate=not args.no_animate)