heatmap_cli 0.25.2__tar.gz → 0.25.3__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.
Files changed (51) hide show
  1. {heatmap_cli-0.25.2/src/heatmap_cli.egg-info → heatmap_cli-0.25.3}/PKG-INFO +1 -1
  2. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/pyproject.toml +1 -1
  3. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli/__init__.py +5 -2
  4. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli/heatmap.py +31 -15
  5. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3/src/heatmap_cli.egg-info}/PKG-INFO +1 -1
  6. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_demo_option.py +20 -0
  7. heatmap_cli-0.25.3/tests/test_heatmap_open.py +83 -0
  8. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_heatmap_wrapper.py +20 -4
  9. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_iso_53_week_year.py +27 -0
  10. heatmap_cli-0.25.2/tests/test_heatmap_open.py +0 -46
  11. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/LICENSE.md +0 -0
  12. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/README.md +0 -0
  13. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/setup.cfg +0 -0
  14. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli/__main__.py +0 -0
  15. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli/cli.py +0 -0
  16. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli.egg-info/SOURCES.txt +0 -0
  17. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli.egg-info/dependency_links.txt +0 -0
  18. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli.egg-info/entry_points.txt +0 -0
  19. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli.egg-info/requires.txt +0 -0
  20. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/src/heatmap_cli.egg-info/top_level.txt +0 -0
  21. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_animate_by_week.py +0 -0
  22. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_animate_no_frames.py +0 -0
  23. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_animate_open.py +0 -0
  24. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_annotate_flag.py +0 -0
  25. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_annotation_truncation.py +0 -0
  26. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_cbar_flag.py +0 -0
  27. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_cmap_option.py +0 -0
  28. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_cmax_option.py +0 -0
  29. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_cmin_option.py +0 -0
  30. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_configure_cbar.py +0 -0
  31. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_debug_flag.py +0 -0
  32. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_end_date_option.py +0 -0
  33. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_env_flag.py +0 -0
  34. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_heatmap_direct.py +0 -0
  35. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_heatmap_errors.py +0 -0
  36. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_heatmap_utils.py +0 -0
  37. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_help_flag.py +0 -0
  38. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_no_annotate.py +0 -0
  39. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_open_heatmap.py +0 -0
  40. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_output_dir_option.py +0 -0
  41. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_purge_flag.py +0 -0
  42. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_quiet_flag.py +0 -0
  43. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_refresh_output_dir.py +0 -0
  44. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_setup_logging.py +0 -0
  45. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_start_date_option.py +0 -0
  46. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_title_option.py +0 -0
  47. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_verbose_flag.py +0 -0
  48. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_version_flag.py +0 -0
  49. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_week_option.py +0 -0
  50. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_year_option.py +0 -0
  51. {heatmap_cli-0.25.2 → heatmap_cli-0.25.3}/tests/test_yes_flag.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: heatmap_cli
3
- Version: 0.25.2
3
+ Version: 0.25.3
4
4
  Summary: A console program that generates a yearly calendar heatmap.
5
5
  Author-email: Kian-Meng Ang <kianmeng@cpan.org>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Programming Language :: Python",
26
26
  "Topic :: Scientific/Engineering :: Visualization",
27
27
  ]
28
- version = "0.25.2"
28
+ version = "0.25.3"
29
29
  keywords = ["heatmap", "cli", "visualization", "calendar", "data-viz"]
30
30
  dependencies = [
31
31
  "matplotlib",
@@ -33,6 +33,7 @@ import pandas as pd
33
33
  import seaborn as sns
34
34
  from PIL import Image
35
35
 
36
+ from heatmap_cli.heatmap import _get_output_dir
36
37
  from heatmap_cli.heatmap import run as generate_heatmaps
37
38
 
38
39
  __version__ = metadata.version("heatmap_cli")
@@ -135,7 +136,9 @@ def generate_demo(config: argparse.Namespace) -> None:
135
136
  """
136
137
  random.seed(config.seed if config.seed is not None else DEFAULT_DEMO_SEED)
137
138
  config.cmap = random.sample(CMAPS, config.demo)
138
- config.input_filename = str(_generate_sample_csv(config))
139
+ config.input_filename = str(
140
+ Path(config.output_dir) / DEMO_SAMPLE_FILENAME,
141
+ )
139
142
  generate_heatmaps(config)
140
143
 
141
144
 
@@ -162,7 +165,7 @@ def _generate_sample_csv(config: argparse.Namespace) -> Path:
162
165
  )
163
166
  df_dates["count"] = random.sample(range(DEMO_MAX_COUNT), len(df_dates))
164
167
 
165
- csv_filename = Path(config.output_dir) / DEMO_SAMPLE_FILENAME
168
+ csv_filename = _get_output_dir(config) / DEMO_SAMPLE_FILENAME
166
169
  csv_filename.parent.mkdir(parents=True, exist_ok=True)
167
170
  df_dates.to_csv(csv_filename, sep=",", index=False, header=False)
168
171
  logger.debug("Generate sample CSV file: %s", csv_filename)
@@ -27,7 +27,7 @@ import shutil
27
27
  import webbrowser
28
28
  from datetime import date, datetime, timedelta, timezone
29
29
  from pathlib import Path
30
- from typing import TYPE_CHECKING, Any
30
+ from typing import TYPE_CHECKING, Any, cast
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  import argparse
@@ -106,6 +106,13 @@ def run(config: argparse.Namespace) -> None:
106
106
 
107
107
  _refresh_output_dir(config)
108
108
 
109
+ # For a demo the sample CSV is generated after the output directory is
110
+ # refreshed, so that --purge does not delete the freshly written data file.
111
+ if config.demo:
112
+ from heatmap_cli import _generate_sample_csv # noqa: PLC0415
113
+
114
+ config.input_filename = str(_generate_sample_csv(config))
115
+
109
116
  base_dataframe = _read_and_prepare_data(config)
110
117
 
111
118
  for year in config.year:
@@ -183,7 +190,7 @@ def _generate_animated_heatmap(
183
190
  start_of_week = _week_start_date(config.year, config.week)
184
191
  dates_in_week = [start_of_week + timedelta(days=i) for i in range(7)]
185
192
 
186
- frames = []
193
+ frames: list[Image.Image] = []
187
194
  for seq, day in enumerate(dates_in_week, 1):
188
195
  frame_pivoted_df = final_pivoted_df.copy()
189
196
 
@@ -210,7 +217,7 @@ def _generate_animated_heatmap(
210
217
  return_image=True,
211
218
  )
212
219
  if frame:
213
- frames.append(frame)
220
+ frames.append(cast("Image.Image", frame))
214
221
 
215
222
  if not frames:
216
223
  logger.warning("no frames generated to create GIF animation.")
@@ -278,7 +285,14 @@ def _generate_static_heatmaps(
278
285
  initializer=_init_worker_logging,
279
286
  initargs=(config,),
280
287
  ) as pool:
281
- pool.starmap(_generate_heatmap_wrapper, args)
288
+ generated_files = pool.starmap(_generate_heatmap_wrapper, args)
289
+
290
+ # Workers never open browsers; open the finished heatmaps from the main
291
+ # process instead, so -o does not spawn a tab per background worker.
292
+ if config.open:
293
+ for img_filename in generated_files:
294
+ if img_filename:
295
+ _open_heatmap(img_filename)
282
296
 
283
297
 
284
298
  def _generate_heatmap_wrapper(
@@ -286,7 +300,7 @@ def _generate_heatmap_wrapper(
286
300
  cmap: str,
287
301
  config: argparse.Namespace,
288
302
  dataframe: pd.DataFrame,
289
- ) -> Image.Image | None:
303
+ ) -> Image.Image | Path | None:
290
304
  """Wrapper for _generate_heatmap to handle args in Pool."""
291
305
  return _generate_heatmap(
292
306
  seq,
@@ -400,10 +414,15 @@ def _filter_and_pivot_data(
400
414
 
401
415
  logger.debug("Last date in filtered data: %s", max(steps["date"]).date())
402
416
 
403
- # the start date of the year may span across two years
404
- # for example, the first monday of 2026 week 1 is 2025-12-29
417
+ # The ISO year spans from the Monday of its first week, which may fall in
418
+ # the previous calendar year, through the Sunday before week 1 of the next
419
+ # ISO year. Using these boundaries keeps every date inside a single ISO
420
+ # year, so each (weekday, week) cell maps to exactly one date. Late
421
+ # December days that already belong to next year's ISO week 1 are excluded,
422
+ # otherwise they would be averaged with the real count in the same cell and
423
+ # silently halve it (pivot_table default aggfunc is "mean").
405
424
  start_date = _week_start_date(config.year, 1)
406
- end_date = pd.to_datetime(f"{config.year}-12-31")
425
+ end_date = _week_start_date(config.year + 1, 1) - timedelta(days=1)
407
426
  logger.debug("start date: %s", start_date)
408
427
  logger.debug("end date: %s", end_date)
409
428
  full_year_dates = pd.date_range(start=start_date, end=end_date, freq="D")
@@ -507,7 +526,7 @@ def _generate_heatmap(
507
526
  dataframe: pd.DataFrame,
508
527
  *,
509
528
  return_image: bool = False,
510
- ) -> Image.Image | None:
529
+ ) -> Image.Image | Path | None:
511
530
  """Generate a heatmap.
512
531
 
513
532
  Args:
@@ -520,8 +539,8 @@ def _generate_heatmap(
520
539
  saving to disk.
521
540
 
522
541
  Returns:
523
- Optional[Image.Image]: The PIL Image object if return_image is True,
524
- otherwise None.
542
+ Optional[Image.Image | Path]: The PIL Image object if return_image is
543
+ True, otherwise the Path of the saved heatmap file.
525
544
  """
526
545
  fig, axis = _setup_plot_artists(cmap, config, dataframe)
527
546
 
@@ -534,10 +553,7 @@ def _generate_heatmap(
534
553
  img_filename = _save_heatmap_figure(fig, config, seq, cmap, title)
535
554
  logger.info("generate heatmap: %s", img_filename)
536
555
 
537
- if config.open:
538
- _open_heatmap(img_filename)
539
-
540
- return None
556
+ return img_filename
541
557
 
542
558
 
543
559
  def _configure_heatmap_options(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: heatmap_cli
3
- Version: 0.25.2
3
+ Version: 0.25.3
4
4
  Summary: A console program that generates a yearly calendar heatmap.
5
5
  Author-email: Kian-Meng Ang <kianmeng@cpan.org>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -100,3 +100,23 @@ def test_demo_does_not_clobber_user_sample(tmpdir, cli_runner):
100
100
 
101
101
  assert user_sample.read_text() == "user,data\n"
102
102
  assert (output_dir / DEMO_SAMPLE_FILENAME).exists()
103
+
104
+
105
+ def test_demo_with_purge_survives(cli_runner):
106
+ """--demo --purge must not delete the sample CSV it just generated."""
107
+ ret = cli_runner("--demo", 1, "-p", "-Y", expect_error=False)
108
+ assert ret.returncode == 0
109
+ assert "Input file not found" not in ret.stderr
110
+
111
+
112
+ def test_demo_with_tilde_output_dir(cli_runner, monkeypatch, tmpdir):
113
+ """--demo must expand ~ in --output-dir like the rest of the pipeline."""
114
+ home = Path(tmpdir, "home")
115
+ home.mkdir()
116
+ monkeypatch.setenv("HOME", str(home))
117
+
118
+ ret = cli_runner("--demo", 1, "-O", "~/demoout", expect_error=False)
119
+
120
+ assert ret.returncode == 0
121
+ assert (home / "demoout" / DEMO_SAMPLE_FILENAME).exists()
122
+ assert not Path("~/demoout").is_dir()
@@ -0,0 +1,83 @@
1
+ # Copyright (C) 2026 Kian-Meng Ang
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU Affero General Public License as published
5
+ # by the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU Affero General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU Affero General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ import argparse
17
+ from pathlib import Path
18
+ from unittest.mock import patch
19
+
20
+ import pandas as pd
21
+
22
+ from heatmap_cli.heatmap import _generate_heatmap, _generate_static_heatmaps
23
+
24
+
25
+ def make_heatmap_config(tmpdir, *, should_open: bool):
26
+ return argparse.Namespace(
27
+ annotate=False,
28
+ cbar=False,
29
+ cmap_min=None,
30
+ cmap_max=None,
31
+ author="Test Author",
32
+ title="Test Title",
33
+ output_dir=str(tmpdir),
34
+ format="png",
35
+ open=should_open,
36
+ )
37
+
38
+
39
+ def test_generate_heatmap_returns_saved_path(tmpdir):
40
+ config = make_heatmap_config(tmpdir, should_open=True)
41
+
42
+ df = pd.DataFrame([[10, 20], [30, 40]], index=[1, 2], columns=["01", "02"])
43
+
44
+ result = _generate_heatmap(1, "vlag", config, df)
45
+ assert isinstance(result, Path)
46
+ assert result.exists()
47
+
48
+ output_files = list(Path(str(tmpdir)).glob("*.png"))
49
+ assert len(output_files) >= 1
50
+
51
+
52
+ def test_generate_heatmap_does_not_open(tmpdir):
53
+ """Opening is the caller's job, so _generate_heatmap never opens."""
54
+ config = make_heatmap_config(tmpdir, should_open=True)
55
+
56
+ df = pd.DataFrame([[10, 20], [30, 40]], index=[1, 2], columns=["01", "02"])
57
+
58
+ with patch("heatmap_cli.heatmap._open_heatmap") as mocked_open:
59
+ _generate_heatmap(1, "vlag", config, df)
60
+ mocked_open.assert_not_called()
61
+
62
+
63
+ def test_static_heatmaps_open_once_after_pool(tmpdir):
64
+ """-o opens the finished heatmaps from the main process after the pool."""
65
+ config = argparse.Namespace(
66
+ annotate=False,
67
+ cbar=False,
68
+ cmap_min=None,
69
+ cmap_max=None,
70
+ author="Test Author",
71
+ title="Test Title",
72
+ output_dir=str(tmpdir),
73
+ format="png",
74
+ open=True,
75
+ cmap=["vlag"],
76
+ )
77
+
78
+ df = pd.DataFrame([[10, 20], [30, 40]], index=[1, 2], columns=["01", "02"])
79
+
80
+ with patch("heatmap_cli.heatmap._open_heatmap") as mocked_open:
81
+ _generate_static_heatmaps(df, config)
82
+ assert mocked_open.call_count == 1
83
+ assert mocked_open.call_args.args[0].exists()
@@ -21,8 +21,8 @@ import pandas as pd
21
21
  from heatmap_cli.heatmap import _generate_heatmap_wrapper
22
22
 
23
23
 
24
- def test_generate_heatmap_wrapper(tmpdir):
25
- config = argparse.Namespace(
24
+ def _make_config(tmpdir, *, should_open: bool):
25
+ return argparse.Namespace(
26
26
  annotate=False,
27
27
  cbar=False,
28
28
  cmap_min=None,
@@ -31,13 +31,29 @@ def test_generate_heatmap_wrapper(tmpdir):
31
31
  title="Test Title",
32
32
  output_dir=str(tmpdir),
33
33
  format="png",
34
- open=False,
34
+ open=should_open,
35
35
  )
36
36
 
37
+
38
+ def test_generate_heatmap_wrapper(tmpdir):
39
+ config = _make_config(tmpdir, should_open=False)
40
+
37
41
  df = pd.DataFrame([[10, 20], [30, 40]], index=[1, 2], columns=["01", "02"])
38
42
 
39
43
  result = _generate_heatmap_wrapper(1, "vlag", config, df)
40
- assert result is None
44
+ assert isinstance(result, Path)
45
+ assert result.exists()
41
46
 
42
47
  output_files = list(Path(str(tmpdir)).glob("*.png"))
43
48
  assert len(output_files) >= 1
49
+
50
+
51
+ def test_wrapper_uses_opened_heatmap_path(tmpdir):
52
+ """The pool wrapper returns the saved filename for the caller to open."""
53
+ config = _make_config(tmpdir, should_open=True)
54
+
55
+ df = pd.DataFrame([[10, 20], [30, 40]], index=[1, 2], columns=["01", "02"])
56
+
57
+ result = _generate_heatmap_wrapper(1, "vlag", config, df)
58
+ assert isinstance(result, Path)
59
+ assert result.exists()
@@ -81,3 +81,30 @@ def test_week_53_dropped_when_partial_year_requested():
81
81
  pivoted = _filter_and_pivot_data(df, config)
82
82
 
83
83
  assert pivoted["53"].sum() == 0
84
+
85
+
86
+ def test_iso_year_start_counts_kept_intact():
87
+ """Late December days of the prior ISO week 1 must not halve counts."""
88
+ # ISO year 2025 runs from 2024-12-30 through 2025-12-28. The Monday of
89
+ # ISO week 1 (2024-12-30) used to be averaged with 2025-12-29, which
90
+ # already belongs to ISO 2026 week 1 and landed in the same cell as an
91
+ # implied zero, halving the real count.
92
+ df = _base_dataframe(
93
+ [
94
+ ("2024-12-30", 5000),
95
+ ("2025-01-06", 4000),
96
+ ("2025-12-28", 3000),
97
+ ],
98
+ )
99
+ config = argparse.Namespace(
100
+ year=2025,
101
+ week=WEEKS_IN_YEAR,
102
+ start_date=None,
103
+ end_date=None,
104
+ )
105
+
106
+ pivoted = _filter_and_pivot_data(df, config)
107
+
108
+ assert pivoted.loc[1, "01"] == 5000
109
+ assert pivoted.loc[1, "02"] == 4000
110
+ assert pivoted.loc[7, "52"] == 3000
@@ -1,46 +0,0 @@
1
- # Copyright (C) 2026 Kian-Meng Ang
2
- #
3
- # This program is free software: you can redistribute it and/or modify
4
- # it under the terms of the GNU Affero General Public License as published
5
- # by the Free Software Foundation, either version 3 of the License, or
6
- # (at your option) any later version.
7
- #
8
- # This program is distributed in the hope that it will be useful,
9
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
- # GNU Affero General Public License for more details.
12
- #
13
- # You should have received a copy of the GNU Affero General Public License
14
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
-
16
- import argparse
17
- from pathlib import Path
18
- from unittest.mock import patch
19
-
20
- import pandas as pd
21
-
22
- from heatmap_cli.heatmap import _generate_heatmap
23
-
24
-
25
- def test_generate_heatmap_with_open(tmpdir):
26
- config = argparse.Namespace(
27
- annotate=False,
28
- cbar=False,
29
- cmap_min=None,
30
- cmap_max=None,
31
- author="Test Author",
32
- title="Test Title",
33
- output_dir=str(tmpdir),
34
- format="png",
35
- open=True,
36
- )
37
-
38
- df = pd.DataFrame([[10, 20], [30, 40]], index=[1, 2], columns=["01", "02"])
39
-
40
- with patch("heatmap_cli.heatmap._open_heatmap") as mocked_open:
41
- result = _generate_heatmap(1, "vlag", config, df)
42
- assert result is None
43
- mocked_open.assert_called_once()
44
-
45
- output_files = list(Path(str(tmpdir)).glob("*.png"))
46
- assert len(output_files) >= 1
File without changes
File without changes
File without changes