git-ember 1.2.1__tar.gz → 1.3.0__tar.gz
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.
- {git_ember-1.2.1 → git_ember-1.3.0}/PKG-INFO +1 -1
- {git_ember-1.2.1 → git_ember-1.3.0}/pyproject.toml +1 -1
- {git_ember-1.2.1 → git_ember-1.3.0}/src/git_ember.egg-info/PKG-INFO +1 -1
- {git_ember-1.2.1 → git_ember-1.3.0}/src/git_ember.egg-info/SOURCES.txt +5 -1
- {git_ember-1.2.1 → git_ember-1.3.0}/src/gitember/cli.py +40 -93
- {git_ember-1.2.1 → git_ember-1.3.0}/src/gitember/colors.py +9 -0
- git_ember-1.3.0/src/gitember/config.py +91 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/src/gitember/git.py +104 -27
- {git_ember-1.2.1 → git_ember-1.3.0}/src/gitember/render.py +53 -1
- git_ember-1.3.0/tests/test_config.py +62 -0
- git_ember-1.3.0/tests/test_git.py +94 -0
- git_ember-1.3.0/tests/test_render.py +158 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/README.md +0 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/setup.cfg +0 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/src/git_ember.egg-info/dependency_links.txt +0 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/src/git_ember.egg-info/entry_points.txt +0 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/src/git_ember.egg-info/requires.txt +0 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/src/git_ember.egg-info/top_level.txt +0 -0
- {git_ember-1.2.1 → git_ember-1.3.0}/src/gitember/__init__.py +0 -0
|
@@ -9,5 +9,9 @@ src/git_ember.egg-info/top_level.txt
|
|
|
9
9
|
src/gitember/__init__.py
|
|
10
10
|
src/gitember/cli.py
|
|
11
11
|
src/gitember/colors.py
|
|
12
|
+
src/gitember/config.py
|
|
12
13
|
src/gitember/git.py
|
|
13
|
-
src/gitember/render.py
|
|
14
|
+
src/gitember/render.py
|
|
15
|
+
tests/test_config.py
|
|
16
|
+
tests/test_git.py
|
|
17
|
+
tests/test_render.py
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import calendar
|
|
3
3
|
import datetime as dt
|
|
4
|
-
import os
|
|
5
4
|
import sys
|
|
6
5
|
import time
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
8
|
from gitember import __version__
|
|
10
9
|
from gitember.colors import get_color_scheme, RESET, LIGHT_GRAY
|
|
10
|
+
from gitember.config import config_file_path, ALLOWED_CONFIG_KEYS, load_config, save_config
|
|
11
11
|
from gitember.git import (
|
|
12
12
|
run_git_log,
|
|
13
13
|
get_recent_commits,
|
|
@@ -18,87 +18,10 @@ from gitember.git import (
|
|
|
18
18
|
get_default_branch,
|
|
19
19
|
get_streaks,
|
|
20
20
|
)
|
|
21
|
-
from gitember.render import render_grid, render_branch_tree, calculate_thresholds
|
|
21
|
+
from gitember.render import render_grid, render_branch_tree, calculate_thresholds, render_legend
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
"""Get the path to the config file.
|
|
26
|
-
|
|
27
|
-
Returns:
|
|
28
|
-
Path to config.toml in platform-specific config directory.
|
|
29
|
-
"""
|
|
30
|
-
if sys.platform.startswith("win"):
|
|
31
|
-
base = os.getenv("APPDATA")
|
|
32
|
-
if base:
|
|
33
|
-
base_path = Path(base)
|
|
34
|
-
else:
|
|
35
|
-
base_path = Path.home() / "AppData" / "Roaming"
|
|
36
|
-
else:
|
|
37
|
-
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
38
|
-
if xdg:
|
|
39
|
-
base_path = Path(xdg)
|
|
40
|
-
else:
|
|
41
|
-
base_path = Path.home() / ".config"
|
|
42
|
-
return base_path / "git-ember" / "config.toml"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
ALLOWED_CONFIG_KEYS = {"color", "border"}
|
|
46
|
-
# NOTE: branch is intentionally excluded from config to avoid user confusion
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load_config() -> dict:
|
|
50
|
-
"""Load config from file, returning defaults if not exists.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
Dict with keys: color, border.
|
|
54
|
-
"""
|
|
55
|
-
path = config_file_path()
|
|
56
|
-
if not path.exists():
|
|
57
|
-
return {
|
|
58
|
-
"color": "green",
|
|
59
|
-
"border": "=",
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
config = {"color": "green", "border": "="}
|
|
63
|
-
try:
|
|
64
|
-
text = path.read_text(encoding="utf-8")
|
|
65
|
-
except OSError:
|
|
66
|
-
return config
|
|
67
|
-
|
|
68
|
-
# Simple TOML-like parsing: key=value, ignore comments
|
|
69
|
-
for line in text.splitlines():
|
|
70
|
-
stripped = line.strip()
|
|
71
|
-
# Skip empty lines and comments
|
|
72
|
-
if not stripped or stripped.startswith("#"):
|
|
73
|
-
continue
|
|
74
|
-
if "=" in stripped:
|
|
75
|
-
key, _, value = stripped.partition("=")
|
|
76
|
-
key = key.strip()
|
|
77
|
-
# Strip quotes from value - handles both "value" and 'value'
|
|
78
|
-
value = value.strip().strip('"').strip("'")
|
|
79
|
-
# Only allow predefined keys (whitelist for security)
|
|
80
|
-
if key in ALLOWED_CONFIG_KEYS:
|
|
81
|
-
config[key] = value
|
|
82
|
-
return config
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def save_config(config: dict) -> None:
|
|
86
|
-
"""Save config to file.
|
|
87
|
-
|
|
88
|
-
Args:
|
|
89
|
-
config: Dict with keys: week_start, style, color, border.
|
|
90
|
-
"""
|
|
91
|
-
path = config_file_path()
|
|
92
|
-
try:
|
|
93
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
-
lines = []
|
|
95
|
-
for k, v in config.items():
|
|
96
|
-
if k in ALLOWED_CONFIG_KEYS:
|
|
97
|
-
escaped = v.replace('"', '\\"')
|
|
98
|
-
lines.append(f'{k} = "{escaped}"')
|
|
99
|
-
path.write_text("\n".join(lines), encoding="utf-8")
|
|
100
|
-
except OSError:
|
|
101
|
-
pass
|
|
24
|
+
__all__ = ["main"]
|
|
102
25
|
|
|
103
26
|
|
|
104
27
|
def parse_args() -> argparse.Namespace:
|
|
@@ -198,6 +121,19 @@ def parse_args() -> argparse.Namespace:
|
|
|
198
121
|
type=str,
|
|
199
122
|
help="Show commits before this date (YYYY-MM-DD)",
|
|
200
123
|
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--week-start",
|
|
126
|
+
type=str,
|
|
127
|
+
choices=["sunday", "monday"],
|
|
128
|
+
default="sunday",
|
|
129
|
+
help="First day of week: sunday or monday (default: sunday)",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--author",
|
|
133
|
+
"-A",
|
|
134
|
+
type=str,
|
|
135
|
+
help="Filter commits by author name or email (partial match)",
|
|
136
|
+
)
|
|
201
137
|
parser.add_argument(
|
|
202
138
|
"--version",
|
|
203
139
|
"-V",
|
|
@@ -244,6 +180,8 @@ def main() -> None:
|
|
|
244
180
|
border_char = args.border[0] if args.border else "=" # prevent escape codes
|
|
245
181
|
ascii_mode = args.ascii
|
|
246
182
|
branch = args.branch
|
|
183
|
+
week_start = args.week_start or config.get("week_start", "sunday")
|
|
184
|
+
author = args.author
|
|
247
185
|
|
|
248
186
|
if branch:
|
|
249
187
|
valid_branches = get_branches(repo_path)
|
|
@@ -255,6 +193,8 @@ def main() -> None:
|
|
|
255
193
|
new_config = config.copy()
|
|
256
194
|
if args.color:
|
|
257
195
|
new_config["color"] = args.color
|
|
196
|
+
if args.week_start is not None:
|
|
197
|
+
new_config["week_start"] = args.week_start
|
|
258
198
|
save_config(new_config)
|
|
259
199
|
|
|
260
200
|
color_scheme = get_color_scheme(color_name)
|
|
@@ -267,6 +207,9 @@ def main() -> None:
|
|
|
267
207
|
|
|
268
208
|
custom_range = args.after or args.before
|
|
269
209
|
|
|
210
|
+
start_date = dt.date.today() - dt.timedelta(days=365)
|
|
211
|
+
end_date = dt.date.today()
|
|
212
|
+
|
|
270
213
|
if args.after:
|
|
271
214
|
try:
|
|
272
215
|
start_date = dt.date.fromisoformat(args.after)
|
|
@@ -303,7 +246,9 @@ def main() -> None:
|
|
|
303
246
|
end_date = dt.date(year, 12, 31)
|
|
304
247
|
|
|
305
248
|
try:
|
|
306
|
-
counts = run_git_log(
|
|
249
|
+
counts = run_git_log(
|
|
250
|
+
start_date, end_date, repo_path, branch=branch, author=author
|
|
251
|
+
)
|
|
307
252
|
except ValueError as e:
|
|
308
253
|
print(f"Error: {e}")
|
|
309
254
|
sys.exit(1)
|
|
@@ -315,24 +260,26 @@ def main() -> None:
|
|
|
315
260
|
end_date,
|
|
316
261
|
counts,
|
|
317
262
|
color_scheme,
|
|
318
|
-
|
|
263
|
+
week_start,
|
|
319
264
|
border_char,
|
|
320
265
|
ascii_mode,
|
|
321
266
|
thresholds=thresholds,
|
|
322
267
|
)
|
|
323
268
|
|
|
269
|
+
legend = render_legend(thresholds, color_scheme, ascii_mode)
|
|
270
|
+
|
|
324
271
|
if custom_range:
|
|
325
|
-
output_parts.append(heatmap)
|
|
272
|
+
output_parts.append(f"{heatmap}\n {legend}")
|
|
326
273
|
elif num_iterations > 1:
|
|
327
|
-
output_parts.append(f"[{year}]\n{heatmap}")
|
|
274
|
+
output_parts.append(f"[{year}]\n{heatmap}\n {legend}")
|
|
328
275
|
else:
|
|
329
|
-
output_parts.append(heatmap)
|
|
276
|
+
output_parts.append(f"{heatmap}\n {legend}")
|
|
330
277
|
|
|
331
278
|
output_parts.reverse()
|
|
332
279
|
print("\n" + "\n".join(output_parts))
|
|
333
280
|
|
|
334
281
|
if args.tree:
|
|
335
|
-
tree_commits = get_branch_tree(repo_path, branch=branch)
|
|
282
|
+
tree_commits = get_branch_tree(repo_path, branch=branch, author=author)
|
|
336
283
|
default_branch = get_default_branch(repo_path)
|
|
337
284
|
tree_output = render_branch_tree(
|
|
338
285
|
tree_commits,
|
|
@@ -348,7 +295,7 @@ def main() -> None:
|
|
|
348
295
|
show_stats = args.stats or extended
|
|
349
296
|
|
|
350
297
|
if show_stats:
|
|
351
|
-
stats = get_repo_stats(repo_path, branch=branch)
|
|
298
|
+
stats = get_repo_stats(repo_path, branch=branch, author=author)
|
|
352
299
|
|
|
353
300
|
print("\n--- Statistics ---")
|
|
354
301
|
print(f" Total commits: {stats.get('total_commits', 0)}")
|
|
@@ -365,7 +312,7 @@ def main() -> None:
|
|
|
365
312
|
streak_start_date = dt.date.today() - dt.timedelta(days=years * 365)
|
|
366
313
|
streak_end_date = dt.date.today()
|
|
367
314
|
streak_counts = run_git_log(
|
|
368
|
-
streak_start_date, streak_end_date, repo_path, branch=branch
|
|
315
|
+
streak_start_date, streak_end_date, repo_path, branch=branch, author=author
|
|
369
316
|
)
|
|
370
317
|
streaks = get_streaks(streak_counts)
|
|
371
318
|
|
|
@@ -384,7 +331,7 @@ def main() -> None:
|
|
|
384
331
|
|
|
385
332
|
if extended:
|
|
386
333
|
print("\n--- Recent Commits ---")
|
|
387
|
-
for commit in get_recent_commits(repo_path, branch=branch):
|
|
334
|
+
for commit in get_recent_commits(repo_path, branch=branch, author=author):
|
|
388
335
|
commit_color = color_scheme.get(3)
|
|
389
336
|
author_color = color_scheme.get(2)
|
|
390
337
|
|
|
@@ -404,10 +351,10 @@ def main() -> None:
|
|
|
404
351
|
)
|
|
405
352
|
print(f" {commit['message']}")
|
|
406
353
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
for
|
|
410
|
-
print(f" {count:>4} {
|
|
354
|
+
print("\n--- Top Contributors ---")
|
|
355
|
+
contrib_color = color_scheme.get(2)
|
|
356
|
+
for contrib, count in get_top_contributors(repo_path, branch=branch, author=author):
|
|
357
|
+
print(f" {count:>4} {contrib_color}{contrib}{RESET}")
|
|
411
358
|
|
|
412
359
|
|
|
413
360
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"config_file_path",
|
|
8
|
+
"ALLOWED_CONFIG_KEYS",
|
|
9
|
+
"load_config",
|
|
10
|
+
"save_config",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_file_path() -> Path:
|
|
15
|
+
"""Get the path to the config file.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Path to config.toml in platform-specific config directory.
|
|
19
|
+
"""
|
|
20
|
+
if sys.platform.startswith("win"):
|
|
21
|
+
base = os.getenv("APPDATA")
|
|
22
|
+
if base:
|
|
23
|
+
base_path = Path(base)
|
|
24
|
+
else:
|
|
25
|
+
base_path = Path.home() / "AppData" / "Roaming"
|
|
26
|
+
else:
|
|
27
|
+
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
28
|
+
if xdg:
|
|
29
|
+
base_path = Path(xdg)
|
|
30
|
+
else:
|
|
31
|
+
base_path = Path.home() / ".config"
|
|
32
|
+
return base_path / "git-ember" / "config.toml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ALLOWED_CONFIG_KEYS = {"color", "border", "week_start"}
|
|
36
|
+
# NOTE: branch is intentionally excluded from config to avoid user confusion
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config() -> dict:
|
|
40
|
+
"""Load config from file, returning defaults if not exists.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Dict with keys: color, border.
|
|
44
|
+
"""
|
|
45
|
+
path = config_file_path()
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return {
|
|
48
|
+
"color": "green",
|
|
49
|
+
"border": "=",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
config = {"color": "green", "border": "="}
|
|
53
|
+
try:
|
|
54
|
+
text = path.read_text(encoding="utf-8")
|
|
55
|
+
except OSError:
|
|
56
|
+
return config
|
|
57
|
+
|
|
58
|
+
# Simple TOML-like parsing: key=value, ignore comments
|
|
59
|
+
for line in text.splitlines():
|
|
60
|
+
stripped = line.strip()
|
|
61
|
+
# Skip empty lines and comments
|
|
62
|
+
if not stripped or stripped.startswith("#"):
|
|
63
|
+
continue
|
|
64
|
+
if "=" in stripped:
|
|
65
|
+
key, _, value = stripped.partition("=")
|
|
66
|
+
key = key.strip()
|
|
67
|
+
# Strip quotes from value - handles both "value" and 'value'
|
|
68
|
+
value = value.strip().strip('"').strip("'")
|
|
69
|
+
# Only allow predefined keys (whitelist for security)
|
|
70
|
+
if key in ALLOWED_CONFIG_KEYS:
|
|
71
|
+
config[key] = value
|
|
72
|
+
return config
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_config(config: dict) -> None:
|
|
76
|
+
"""Save config to file.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
config: Dict with keys: week_start, style, color, border.
|
|
80
|
+
"""
|
|
81
|
+
path = config_file_path()
|
|
82
|
+
try:
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
lines = []
|
|
85
|
+
for k, v in config.items():
|
|
86
|
+
if k in ALLOWED_CONFIG_KEYS:
|
|
87
|
+
escaped = v.replace('"', '\\"')
|
|
88
|
+
lines.append(f'{k} = "{escaped}"')
|
|
89
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
@@ -7,6 +7,18 @@ import re
|
|
|
7
7
|
COMMIT_PREFIX = "COMMIT:"
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
__all__ = [
|
|
11
|
+
"run_git_log",
|
|
12
|
+
"get_branches",
|
|
13
|
+
"get_default_branch",
|
|
14
|
+
"get_recent_commits",
|
|
15
|
+
"get_top_contributors",
|
|
16
|
+
"get_repo_stats",
|
|
17
|
+
"get_streaks",
|
|
18
|
+
"get_branch_tree",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
10
22
|
def _validate_path(path: str) -> None:
|
|
11
23
|
"""Validate path doesn't contain git options or special characters.
|
|
12
24
|
|
|
@@ -39,12 +51,31 @@ def _validate_branch(branch: str) -> None:
|
|
|
39
51
|
raise ValueError(f"Invalid characters in branch name: {branch}")
|
|
40
52
|
|
|
41
53
|
|
|
42
|
-
def
|
|
54
|
+
def _validate_author(author: str) -> None:
|
|
55
|
+
"""Validate author string doesn't contain git options or special characters.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If author is invalid.
|
|
59
|
+
"""
|
|
60
|
+
if not author:
|
|
61
|
+
raise ValueError("Author cannot be empty")
|
|
62
|
+
|
|
63
|
+
if author.startswith("-"):
|
|
64
|
+
raise ValueError(f"Invalid author: {author}")
|
|
65
|
+
|
|
66
|
+
if re.search(r"[;&|`$()]", author):
|
|
67
|
+
raise ValueError(f"Invalid characters in author: {author}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_log_args(
|
|
71
|
+
branch: str | None, repo_path: str, default_branch: str | None = None
|
|
72
|
+
) -> List[str]:
|
|
43
73
|
"""Build git log arguments for branch filtering.
|
|
44
74
|
|
|
45
75
|
Args:
|
|
46
76
|
branch: Optional branch name to filter by.
|
|
47
77
|
repo_path: Path to git repository.
|
|
78
|
+
default_branch: Optional default branch name for comparison.
|
|
48
79
|
|
|
49
80
|
Returns:
|
|
50
81
|
List of git log arguments (may be empty).
|
|
@@ -52,8 +83,7 @@ def _build_log_args(branch: str | None, repo_path: str) -> List[str]:
|
|
|
52
83
|
if not branch:
|
|
53
84
|
return ["--all"]
|
|
54
85
|
|
|
55
|
-
default_branch
|
|
56
|
-
if branch != default_branch:
|
|
86
|
+
if default_branch and branch != default_branch:
|
|
57
87
|
return [branch, "--not", default_branch]
|
|
58
88
|
return [branch]
|
|
59
89
|
|
|
@@ -63,6 +93,7 @@ def run_git_log(
|
|
|
63
93
|
end_date: dt.date,
|
|
64
94
|
repo_path: str,
|
|
65
95
|
branch: str | None = None,
|
|
96
|
+
author: str | None = None,
|
|
66
97
|
) -> Dict[dt.date, int]:
|
|
67
98
|
"""Get commit counts per day within a date range.
|
|
68
99
|
|
|
@@ -71,6 +102,7 @@ def run_git_log(
|
|
|
71
102
|
end_date: End of date range (inclusive).
|
|
72
103
|
repo_path: Path to git repository.
|
|
73
104
|
branch: Optional branch name to filter by. Defaults to all branches.
|
|
105
|
+
author: Optional author to filter by. Defaults to all authors.
|
|
74
106
|
|
|
75
107
|
Returns:
|
|
76
108
|
Dict mapping date to commit count.
|
|
@@ -83,6 +115,9 @@ def run_git_log(
|
|
|
83
115
|
if branch:
|
|
84
116
|
_validate_branch(branch)
|
|
85
117
|
|
|
118
|
+
if author:
|
|
119
|
+
_validate_author(author)
|
|
120
|
+
|
|
86
121
|
try:
|
|
87
122
|
subprocess.run(
|
|
88
123
|
["git", "-C", repo_path, "rev-parse", "--is-inside-work-tree"],
|
|
@@ -103,7 +138,11 @@ def run_git_log(
|
|
|
103
138
|
"log",
|
|
104
139
|
]
|
|
105
140
|
|
|
106
|
-
|
|
141
|
+
default_branch = get_default_branch(repo_path) if branch else None
|
|
142
|
+
cmd.extend(_build_log_args(branch, repo_path, default_branch))
|
|
143
|
+
|
|
144
|
+
if author:
|
|
145
|
+
cmd.append(f"--author={author}")
|
|
107
146
|
|
|
108
147
|
cmd.extend(
|
|
109
148
|
[
|
|
@@ -178,7 +217,7 @@ def get_default_branch(repo_path: str) -> str:
|
|
|
178
217
|
|
|
179
218
|
# Fallback: get current HEAD branch if it's not main/master
|
|
180
219
|
result = subprocess.run(
|
|
181
|
-
["git", "-C", repo_path, "symbolic-ref", "
|
|
220
|
+
["git", "-C", repo_path, "symbolic-ref", "HEAD"],
|
|
182
221
|
capture_output=True,
|
|
183
222
|
text=True,
|
|
184
223
|
)
|
|
@@ -192,7 +231,7 @@ def get_default_branch(repo_path: str) -> str:
|
|
|
192
231
|
|
|
193
232
|
|
|
194
233
|
def get_recent_commits(
|
|
195
|
-
repo_path: str, count: int = 5, branch: str | None = None
|
|
234
|
+
repo_path: str, count: int = 5, branch: str | None = None, author: str | None = None
|
|
196
235
|
) -> List[Dict[str, str]]:
|
|
197
236
|
"""Get recent commits with metadata.
|
|
198
237
|
|
|
@@ -200,6 +239,7 @@ def get_recent_commits(
|
|
|
200
239
|
repo_path: Path to git repository.
|
|
201
240
|
count: Number of commits to retrieve.
|
|
202
241
|
branch: Optional branch name to filter by.
|
|
242
|
+
author: Optional author to filter by.
|
|
203
243
|
|
|
204
244
|
Returns:
|
|
205
245
|
List of dicts with keys: hash, author, date, timestamp, message.
|
|
@@ -209,6 +249,10 @@ def get_recent_commits(
|
|
|
209
249
|
if branch:
|
|
210
250
|
_validate_branch(branch)
|
|
211
251
|
|
|
252
|
+
if author:
|
|
253
|
+
_validate_author(author)
|
|
254
|
+
|
|
255
|
+
default_branch = get_default_branch(repo_path) if branch else None
|
|
212
256
|
cmd = [
|
|
213
257
|
"git",
|
|
214
258
|
"-C",
|
|
@@ -218,7 +262,10 @@ def get_recent_commits(
|
|
|
218
262
|
"--pretty=format:%h|%an|%ad %at|%s",
|
|
219
263
|
"--date=short",
|
|
220
264
|
]
|
|
221
|
-
cmd.extend(_build_log_args(branch, repo_path))
|
|
265
|
+
cmd.extend(_build_log_args(branch, repo_path, default_branch))
|
|
266
|
+
|
|
267
|
+
if author:
|
|
268
|
+
cmd.append(f"--author={author}")
|
|
222
269
|
|
|
223
270
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
224
271
|
|
|
@@ -247,7 +294,10 @@ def get_recent_commits(
|
|
|
247
294
|
|
|
248
295
|
|
|
249
296
|
def get_top_contributors(
|
|
250
|
-
repo_path: str,
|
|
297
|
+
repo_path: str,
|
|
298
|
+
limit: int = 10,
|
|
299
|
+
branch: str | None = None,
|
|
300
|
+
author: str | None = None,
|
|
251
301
|
) -> List[Tuple[str, int]]:
|
|
252
302
|
"""Get top contributors by commit count.
|
|
253
303
|
|
|
@@ -255,6 +305,7 @@ def get_top_contributors(
|
|
|
255
305
|
repo_path: Path to git repository.
|
|
256
306
|
limit: Maximum number of contributors to return.
|
|
257
307
|
branch: Optional branch name to filter by.
|
|
308
|
+
author: Optional author to filter by.
|
|
258
309
|
|
|
259
310
|
Returns:
|
|
260
311
|
List of (author, commit_count) tuples, sorted by count descending.
|
|
@@ -264,6 +315,10 @@ def get_top_contributors(
|
|
|
264
315
|
if branch:
|
|
265
316
|
_validate_branch(branch)
|
|
266
317
|
|
|
318
|
+
if author:
|
|
319
|
+
_validate_author(author)
|
|
320
|
+
|
|
321
|
+
default_branch = get_default_branch(repo_path) if branch else None
|
|
267
322
|
cmd = [
|
|
268
323
|
"git",
|
|
269
324
|
"-C",
|
|
@@ -272,7 +327,10 @@ def get_top_contributors(
|
|
|
272
327
|
"-s",
|
|
273
328
|
"-n",
|
|
274
329
|
]
|
|
275
|
-
cmd.extend(_build_log_args(branch, repo_path))
|
|
330
|
+
cmd.extend(_build_log_args(branch, repo_path, default_branch))
|
|
331
|
+
|
|
332
|
+
if author:
|
|
333
|
+
cmd.append(f"--author={author}")
|
|
276
334
|
|
|
277
335
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
278
336
|
|
|
@@ -294,12 +352,15 @@ def get_top_contributors(
|
|
|
294
352
|
return contributors[:limit]
|
|
295
353
|
|
|
296
354
|
|
|
297
|
-
def get_repo_stats(
|
|
355
|
+
def get_repo_stats(
|
|
356
|
+
repo_path: str, branch: str | None = None, author: str | None = None
|
|
357
|
+
) -> Dict[str, Any]:
|
|
298
358
|
"""Get repository statistics.
|
|
299
359
|
|
|
300
360
|
Args:
|
|
301
361
|
repo_path: Path to git repository.
|
|
302
362
|
branch: Optional branch name to filter by.
|
|
363
|
+
author: Optional author to filter by.
|
|
303
364
|
|
|
304
365
|
Returns:
|
|
305
366
|
Dict with keys: total_commits, num_authors, first_commit_date,
|
|
@@ -310,21 +371,26 @@ def get_repo_stats(repo_path: str, branch: str | None = None) -> Dict[str, Any]:
|
|
|
310
371
|
if branch:
|
|
311
372
|
_validate_branch(branch)
|
|
312
373
|
|
|
313
|
-
|
|
374
|
+
if author:
|
|
375
|
+
_validate_author(author)
|
|
314
376
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
377
|
+
default_branch = get_default_branch(repo_path) if branch else None
|
|
378
|
+
log_args = _build_log_args(branch, repo_path, default_branch)
|
|
379
|
+
|
|
380
|
+
cmd = [
|
|
381
|
+
"git",
|
|
382
|
+
"-C",
|
|
383
|
+
repo_path,
|
|
384
|
+
"log",
|
|
385
|
+
"--format=%an|%at|%ad",
|
|
386
|
+
"--date=format:%d-%m-%Y %H:%M",
|
|
387
|
+
]
|
|
388
|
+
cmd.extend(log_args)
|
|
389
|
+
|
|
390
|
+
if author:
|
|
391
|
+
cmd.append(f"--author={author}")
|
|
392
|
+
|
|
393
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
328
394
|
|
|
329
395
|
if result.returncode != 0:
|
|
330
396
|
err = result.stderr.strip() or result.stdout.strip()
|
|
@@ -448,7 +514,10 @@ def _parse_graph_line(line: str) -> tuple[str, str]:
|
|
|
448
514
|
|
|
449
515
|
|
|
450
516
|
def get_branch_tree(
|
|
451
|
-
repo_path: str,
|
|
517
|
+
repo_path: str,
|
|
518
|
+
branch: str | None = None,
|
|
519
|
+
limit: int = 20,
|
|
520
|
+
author: str | None = None,
|
|
452
521
|
) -> List[Dict[str, Any]]:
|
|
453
522
|
"""Get branch tree data for visualization.
|
|
454
523
|
|
|
@@ -456,10 +525,11 @@ def get_branch_tree(
|
|
|
456
525
|
repo_path: Path to git repository.
|
|
457
526
|
branch: Optional branch name to filter by.
|
|
458
527
|
limit: Maximum number of commits to return.
|
|
528
|
+
author: Optional author to filter by.
|
|
459
529
|
|
|
460
530
|
Returns:
|
|
461
531
|
List of dicts with keys: hash, message, date, branches, graph_line.
|
|
462
|
-
Connector-only rows (/,
|
|
532
|
+
Connector-only rows (/, \ lines) are included with an empty hash
|
|
463
533
|
so the renderer can display branch split/merge lines correctly.
|
|
464
534
|
"""
|
|
465
535
|
_validate_path(repo_path)
|
|
@@ -467,7 +537,11 @@ def get_branch_tree(
|
|
|
467
537
|
if branch:
|
|
468
538
|
_validate_branch(branch)
|
|
469
539
|
|
|
470
|
-
|
|
540
|
+
if author:
|
|
541
|
+
_validate_author(author)
|
|
542
|
+
|
|
543
|
+
default_branch = get_default_branch(repo_path) if branch else None
|
|
544
|
+
log_args = _build_log_args(branch, repo_path, default_branch)
|
|
471
545
|
|
|
472
546
|
cmd = (
|
|
473
547
|
[
|
|
@@ -483,6 +557,9 @@ def get_branch_tree(
|
|
|
483
557
|
+ [f"-{limit}"]
|
|
484
558
|
)
|
|
485
559
|
|
|
560
|
+
if author:
|
|
561
|
+
cmd.append(f"--author={author}")
|
|
562
|
+
|
|
486
563
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
487
564
|
if result.returncode != 0:
|
|
488
565
|
return []
|
|
@@ -5,9 +5,61 @@ from typing import Any, Dict, List, Optional
|
|
|
5
5
|
from gitember.colors import RESET, LIGHT_GRAY, ColorScheme
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
__all__ = [
|
|
9
|
+
"render_grid",
|
|
10
|
+
"render_branch_tree",
|
|
11
|
+
"calculate_thresholds",
|
|
12
|
+
"render_legend",
|
|
13
|
+
"choose_level",
|
|
14
|
+
"build_date_range",
|
|
15
|
+
"DEFAULT_THRESHOLDS",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
8
19
|
DEFAULT_THRESHOLDS = (0, 1, 3, 6, 10)
|
|
9
20
|
|
|
10
21
|
|
|
22
|
+
def render_legend(
|
|
23
|
+
thresholds: tuple, color_scheme: ColorScheme, ascii_mode: bool = False
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Render a legend row showing all intensity levels.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
thresholds: Tuple of 5 threshold values.
|
|
29
|
+
color_scheme: Color scheme for the blocks.
|
|
30
|
+
ascii_mode: Use ASCII characters instead of Unicode.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Single string with legend, no trailing newline.
|
|
34
|
+
"""
|
|
35
|
+
if ascii_mode:
|
|
36
|
+
block_chars = [" ", "..", "::", "==", "##"]
|
|
37
|
+
else:
|
|
38
|
+
block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
|
|
39
|
+
|
|
40
|
+
parts = []
|
|
41
|
+
|
|
42
|
+
t = thresholds
|
|
43
|
+
parts.append(f"{color_scheme.get(0)}No commits{RESET}")
|
|
44
|
+
|
|
45
|
+
if t[1] != t[2]:
|
|
46
|
+
label = f"{t[1]}-{t[2]}"
|
|
47
|
+
parts.append(f"{color_scheme.get(1)}{block_chars[1]}{label}{RESET}")
|
|
48
|
+
|
|
49
|
+
if t[2] != t[3]:
|
|
50
|
+
label = f"{t[3] + 1}-{t[4]}"
|
|
51
|
+
parts.append(f"{color_scheme.get(2)}{block_chars[2]}{label}{RESET}")
|
|
52
|
+
|
|
53
|
+
if t[3] != t[4]:
|
|
54
|
+
label = f"{t[4] + 1}+"
|
|
55
|
+
parts.append(f"{color_scheme.get(3)}{block_chars[3]}{label}{RESET}")
|
|
56
|
+
|
|
57
|
+
label = f"{t[4] + 1}+"
|
|
58
|
+
parts.append(f"{color_scheme.get(4)}{block_chars[4]}{label}{RESET}")
|
|
59
|
+
|
|
60
|
+
return " ".join(parts)
|
|
61
|
+
|
|
62
|
+
|
|
11
63
|
def calculate_thresholds(counts: Dict[dt.date, int], mode: str = "auto") -> tuple:
|
|
12
64
|
"""Calculate intensity thresholds based on scaling mode.
|
|
13
65
|
|
|
@@ -200,7 +252,7 @@ def render_branch_tree(
|
|
|
200
252
|
commits: List[Dict[str, Any]],
|
|
201
253
|
default_branch: Optional[str] = None,
|
|
202
254
|
selected_branch: Optional[str] = None,
|
|
203
|
-
color_scheme: Optional[
|
|
255
|
+
color_scheme: Optional[ColorScheme] = None,
|
|
204
256
|
ascii_mode: bool = False,
|
|
205
257
|
compact: bool = False,
|
|
206
258
|
) -> str:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from gitember import config, git
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_load_config_defaults(tmp_path, monkeypatch):
|
|
10
|
+
"""When no config file exists, returns dict with color and border keys."""
|
|
11
|
+
monkeypatch.setattr(
|
|
12
|
+
config, "config_file_path", lambda: tmp_path / "nonexistent.toml"
|
|
13
|
+
)
|
|
14
|
+
result = config.load_config()
|
|
15
|
+
assert "color" in result
|
|
16
|
+
assert "border" in result
|
|
17
|
+
assert result["color"] == "green"
|
|
18
|
+
assert result["border"] == "="
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_save_and_load_roundtrip(tmp_path, monkeypatch):
|
|
22
|
+
"""Saving a config dict and loading it back returns the same values."""
|
|
23
|
+
test_file = tmp_path / "config.toml"
|
|
24
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
25
|
+
|
|
26
|
+
config.save_config({"color": "blue", "border": "*", "week_start": "monday"})
|
|
27
|
+
result = config.load_config()
|
|
28
|
+
|
|
29
|
+
assert result["color"] == "blue"
|
|
30
|
+
assert result["border"] == "*"
|
|
31
|
+
assert result["week_start"] == "monday"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_load_config_ignores_unknown_keys(tmp_path, monkeypatch):
|
|
35
|
+
"""A config file containing an unknown key does not include it in the returned dict."""
|
|
36
|
+
test_file = tmp_path / "config.toml"
|
|
37
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
38
|
+
|
|
39
|
+
test_file.write_text('color = "blue"\nbadkey = "value"\n')
|
|
40
|
+
result = config.load_config()
|
|
41
|
+
|
|
42
|
+
assert "badkey" not in result
|
|
43
|
+
assert result["color"] == "blue"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_save_config_only_writes_allowed_keys(tmp_path, monkeypatch):
|
|
47
|
+
"""Passing a dict with an extra key does not write that key to disk."""
|
|
48
|
+
test_file = tmp_path / "config.toml"
|
|
49
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
50
|
+
|
|
51
|
+
config.save_config({"color": "blue", "secret": "password"})
|
|
52
|
+
content = test_file.read_text()
|
|
53
|
+
|
|
54
|
+
assert "secret" not in content
|
|
55
|
+
assert "color" in content
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_config_file_path_returns_path():
|
|
59
|
+
"""Returns a Path object ending in config.toml."""
|
|
60
|
+
result = config.config_file_path()
|
|
61
|
+
assert isinstance(result, Path)
|
|
62
|
+
assert result.name == "config.toml"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from gitember import git
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_run_git_log_empty_repo(repo):
|
|
9
|
+
"""Returns empty dict when no commits exist in range."""
|
|
10
|
+
result = git.run_git_log(
|
|
11
|
+
dt.date(2024, 1, 1),
|
|
12
|
+
dt.date(2024, 12, 31),
|
|
13
|
+
str(repo),
|
|
14
|
+
)
|
|
15
|
+
assert result == {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_run_git_log_counts_commits(repo_with_commits):
|
|
19
|
+
"""Returns non-empty dict when commits exist in date range."""
|
|
20
|
+
result = git.run_git_log(
|
|
21
|
+
dt.date(2024, 1, 1),
|
|
22
|
+
dt.date(2024, 12, 31),
|
|
23
|
+
str(repo_with_commits),
|
|
24
|
+
)
|
|
25
|
+
assert len(result) > 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_run_git_log_invalid_path(tmp_path):
|
|
29
|
+
"""Raises ValueError for a non-git directory."""
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
git.run_git_log(
|
|
32
|
+
dt.date(2024, 1, 1),
|
|
33
|
+
dt.date(2024, 12, 31),
|
|
34
|
+
str(tmp_path),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_branches_returns_list(repo):
|
|
39
|
+
"""Returns a list of branches, possibly empty for fresh repos."""
|
|
40
|
+
result = git.get_branches(str(repo))
|
|
41
|
+
assert isinstance(result, list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_get_default_branch(repo):
|
|
45
|
+
"""Returns a non-empty string for a valid repo."""
|
|
46
|
+
result = git.get_default_branch(str(repo))
|
|
47
|
+
assert isinstance(result, str)
|
|
48
|
+
assert len(result) > 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_get_streaks_empty():
|
|
52
|
+
"""Empty dict returns all zeros."""
|
|
53
|
+
result = git.get_streaks({})
|
|
54
|
+
assert result["current_streak"] == 0
|
|
55
|
+
assert result["longest_streak"] == 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_get_streaks_consecutive():
|
|
59
|
+
"""3 consecutive days with commits returns current_streak or longest_streak >= 3."""
|
|
60
|
+
counts = {
|
|
61
|
+
dt.date(2024, 1, 1): 1,
|
|
62
|
+
dt.date(2024, 1, 2): 1,
|
|
63
|
+
dt.date(2024, 1, 3): 1,
|
|
64
|
+
}
|
|
65
|
+
result = git.get_streaks(counts)
|
|
66
|
+
assert result["current_streak"] >= 3 or result["longest_streak"] >= 3
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_streaks_gap_resets():
|
|
70
|
+
"""A gap in dates resets the streak count."""
|
|
71
|
+
counts = {
|
|
72
|
+
dt.date(2024, 1, 1): 1,
|
|
73
|
+
dt.date(2024, 1, 2): 1,
|
|
74
|
+
dt.date(2024, 1, 4): 1,
|
|
75
|
+
}
|
|
76
|
+
result = git.get_streaks(counts)
|
|
77
|
+
assert result["longest_streak"] == 2
|
|
78
|
+
assert result["current_streak"] == 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_get_top_contributors_ordering(repo_with_commits):
|
|
82
|
+
"""Author with more commits appears first."""
|
|
83
|
+
result = git.get_top_contributors(str(repo_with_commits))
|
|
84
|
+
assert len(result) >= 2
|
|
85
|
+
assert result[0][1] >= result[1][1]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_get_repo_stats_keys(repo_with_commits):
|
|
89
|
+
"""Returned dict contains all expected keys."""
|
|
90
|
+
result = git.get_repo_stats(str(repo_with_commits))
|
|
91
|
+
assert "total_commits" in result
|
|
92
|
+
assert "num_authors" in result
|
|
93
|
+
assert "first_commit_date" in result
|
|
94
|
+
assert "last_commit_date" in result
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
|
|
3
|
+
from gitember import render
|
|
4
|
+
from gitember.colors import ColorScheme
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_choose_level_zero():
|
|
8
|
+
"""Count of 0 always returns level 0."""
|
|
9
|
+
for count in [0]:
|
|
10
|
+
level = render.choose_level(count, (0, 1, 3, 6, 10))
|
|
11
|
+
assert level == 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_choose_level_boundaries():
|
|
15
|
+
"""Counts at each threshold boundary map to the correct level."""
|
|
16
|
+
thresholds = (0, 2, 5, 8, 12)
|
|
17
|
+
assert render.choose_level(0, thresholds) == 0
|
|
18
|
+
assert render.choose_level(1, thresholds) == 1
|
|
19
|
+
assert render.choose_level(2, thresholds) == 1
|
|
20
|
+
assert render.choose_level(3, thresholds) == 2
|
|
21
|
+
assert render.choose_level(5, thresholds) == 2
|
|
22
|
+
assert render.choose_level(6, thresholds) == 3
|
|
23
|
+
assert render.choose_level(8, thresholds) == 3
|
|
24
|
+
assert render.choose_level(9, thresholds) == 4
|
|
25
|
+
assert render.choose_level(12, thresholds) == 4
|
|
26
|
+
assert render.choose_level(100, thresholds) == 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_choose_level_max():
|
|
30
|
+
"""A very large count returns level 4."""
|
|
31
|
+
level = render.choose_level(10000, (0, 1, 3, 6, 10))
|
|
32
|
+
assert level == 4
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_build_date_range_single_day():
|
|
36
|
+
"""Start == end returns a list of one date."""
|
|
37
|
+
date = dt.date(2024, 1, 1)
|
|
38
|
+
result = render.build_date_range(date, date)
|
|
39
|
+
assert len(result) == 1
|
|
40
|
+
assert result[0] == date
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_build_date_range_span():
|
|
44
|
+
"""Correct number of days returned for a known range."""
|
|
45
|
+
start = dt.date(2024, 1, 1)
|
|
46
|
+
end = dt.date(2024, 1, 10)
|
|
47
|
+
result = render.build_date_range(start, end)
|
|
48
|
+
assert len(result) == 10
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_build_date_range_order():
|
|
52
|
+
"""Dates are in ascending order."""
|
|
53
|
+
start = dt.date(2024, 1, 5)
|
|
54
|
+
end = dt.date(2024, 1, 10)
|
|
55
|
+
result = render.build_date_range(start, end)
|
|
56
|
+
for i in range(len(result) - 1):
|
|
57
|
+
assert result[i] < result[i + 1]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_calculate_thresholds_auto():
|
|
61
|
+
"""Verify the 5 returned values are non-decreasing and first value is 0."""
|
|
62
|
+
counts = {dt.date(2024, 1, i): i % 10 for i in range(1, 30)}
|
|
63
|
+
result = render.calculate_thresholds(counts, mode="auto")
|
|
64
|
+
assert len(result) == 5
|
|
65
|
+
assert result[0] == 0
|
|
66
|
+
for i in range(1, 5):
|
|
67
|
+
assert result[i] >= result[i - 1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_calculate_thresholds_adaptive():
|
|
71
|
+
"""Same monotonicity check with a realistic counts dict."""
|
|
72
|
+
counts = {
|
|
73
|
+
dt.date(2024, 1, 1): 0,
|
|
74
|
+
dt.date(2024, 1, 2): 1,
|
|
75
|
+
dt.date(2024, 1, 3): 2,
|
|
76
|
+
dt.date(2024, 1, 4): 5,
|
|
77
|
+
dt.date(2024, 1, 5): 10,
|
|
78
|
+
}
|
|
79
|
+
result = render.calculate_thresholds(counts, mode="adaptive")
|
|
80
|
+
assert len(result) == 5
|
|
81
|
+
assert result[0] == 0
|
|
82
|
+
for i in range(1, 5):
|
|
83
|
+
assert result[i] >= result[i - 1]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_calculate_thresholds_empty():
|
|
87
|
+
"""Empty counts dict does not raise and returns a valid 5-tuple."""
|
|
88
|
+
result = render.calculate_thresholds({}, mode="auto")
|
|
89
|
+
assert len(result) == 5
|
|
90
|
+
assert result[0] == 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_render_legend_skips_duplicate_thresholds():
|
|
94
|
+
"""When two adjacent threshold values are equal, that level does not appear."""
|
|
95
|
+
thresholds = (0, 1, 1, 1, 5)
|
|
96
|
+
scheme = ColorScheme("test", [""] * 5)
|
|
97
|
+
legend = render.render_legend(thresholds, scheme, ascii_mode=True)
|
|
98
|
+
assert "2-1" not in legend
|
|
99
|
+
assert "1-1" not in legend
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_render_legend_contains_all_levels():
|
|
103
|
+
"""With well-spread thresholds, all 4 block levels appear in the output."""
|
|
104
|
+
thresholds = (0, 2, 5, 10, 20)
|
|
105
|
+
scheme = ColorScheme("test", [""] * 5)
|
|
106
|
+
legend = render.render_legend(thresholds, scheme, ascii_mode=True)
|
|
107
|
+
assert ".." in legend
|
|
108
|
+
assert "::" in legend
|
|
109
|
+
assert "==" in legend
|
|
110
|
+
assert "##" in legend
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_render_grid_returns_string():
|
|
114
|
+
"""Smoke test that render_grid returns a non-empty string for a small date range."""
|
|
115
|
+
counts = {dt.date(2024, 1, 1): 0, dt.date(2024, 1, 2): 1}
|
|
116
|
+
scheme = ColorScheme("green", [""] * 5)
|
|
117
|
+
result = render.render_grid(
|
|
118
|
+
dt.date(2024, 1, 1),
|
|
119
|
+
dt.date(2024, 1, 7),
|
|
120
|
+
counts,
|
|
121
|
+
scheme,
|
|
122
|
+
"sunday",
|
|
123
|
+
"=",
|
|
124
|
+
False,
|
|
125
|
+
)
|
|
126
|
+
assert isinstance(result, str)
|
|
127
|
+
assert len(result) > 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_render_grid_month_labels():
|
|
131
|
+
"""Output contains at least one 3-letter month abbreviation."""
|
|
132
|
+
counts = {dt.date(2024, 6, 15): 1}
|
|
133
|
+
scheme = ColorScheme("green", [""] * 5)
|
|
134
|
+
result = render.render_grid(
|
|
135
|
+
dt.date(2024, 6, 1),
|
|
136
|
+
dt.date(2024, 6, 30),
|
|
137
|
+
counts,
|
|
138
|
+
scheme,
|
|
139
|
+
"sunday",
|
|
140
|
+
"=",
|
|
141
|
+
False,
|
|
142
|
+
)
|
|
143
|
+
months = [
|
|
144
|
+
"Jan",
|
|
145
|
+
"Feb",
|
|
146
|
+
"Mar",
|
|
147
|
+
"Apr",
|
|
148
|
+
"May",
|
|
149
|
+
"Jun",
|
|
150
|
+
"Jul",
|
|
151
|
+
"Aug",
|
|
152
|
+
"Sep",
|
|
153
|
+
"Oct",
|
|
154
|
+
"Nov",
|
|
155
|
+
"Dec",
|
|
156
|
+
]
|
|
157
|
+
has_month = any(m in result for m in months)
|
|
158
|
+
assert has_month, "Expected at least one month label in output"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|