heatmap_cli 0.25.2__tar.gz → 0.25.4__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.4}/PKG-INFO +1 -1
  2. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/pyproject.toml +1 -1
  3. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/__init__.py +6 -4
  4. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/cli.py +70 -10
  5. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/heatmap.py +45 -22
  6. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4/src/heatmap_cli.egg-info}/PKG-INFO +1 -1
  7. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_demo_option.py +42 -0
  8. heatmap_cli-0.25.4/tests/test_heatmap_open.py +83 -0
  9. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_wrapper.py +20 -4
  10. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_iso_53_week_year.py +27 -0
  11. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_start_date_option.py +22 -0
  12. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_title_option.py +14 -0
  13. heatmap_cli-0.25.2/tests/test_heatmap_open.py +0 -46
  14. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/LICENSE.md +0 -0
  15. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/README.md +0 -0
  16. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/setup.cfg +0 -0
  17. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/__main__.py +0 -0
  18. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/SOURCES.txt +0 -0
  19. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/dependency_links.txt +0 -0
  20. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/entry_points.txt +0 -0
  21. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/requires.txt +0 -0
  22. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/top_level.txt +0 -0
  23. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_animate_by_week.py +0 -0
  24. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_animate_no_frames.py +0 -0
  25. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_animate_open.py +0 -0
  26. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_annotate_flag.py +0 -0
  27. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_annotation_truncation.py +0 -0
  28. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cbar_flag.py +0 -0
  29. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cmap_option.py +0 -0
  30. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cmax_option.py +0 -0
  31. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cmin_option.py +0 -0
  32. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_configure_cbar.py +0 -0
  33. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_debug_flag.py +0 -0
  34. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_end_date_option.py +0 -0
  35. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_env_flag.py +0 -0
  36. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_direct.py +0 -0
  37. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_errors.py +0 -0
  38. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_utils.py +0 -0
  39. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_help_flag.py +0 -0
  40. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_no_annotate.py +0 -0
  41. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_open_heatmap.py +0 -0
  42. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_output_dir_option.py +0 -0
  43. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_purge_flag.py +0 -0
  44. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_quiet_flag.py +0 -0
  45. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_refresh_output_dir.py +0 -0
  46. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_setup_logging.py +0 -0
  47. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_verbose_flag.py +0 -0
  48. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_version_flag.py +0 -0
  49. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_week_option.py +0 -0
  50. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_year_option.py +0 -0
  51. {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/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.4
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.4"
29
29
  keywords = ["heatmap", "cli", "visualization", "calendar", "data-viz"]
30
30
  dependencies = [
31
31
  "matplotlib",
@@ -31,8 +31,8 @@ import matplotlib as mpl
31
31
  import matplotlib.pyplot as plt
32
32
  import pandas as pd
33
33
  import seaborn as sns
34
- from PIL import Image
35
34
 
35
+ from heatmap_cli.heatmap import _get_output_dir
36
36
  from heatmap_cli.heatmap import run as generate_heatmaps
37
37
 
38
38
  __version__ = metadata.version("heatmap_cli")
@@ -135,7 +135,9 @@ def generate_demo(config: argparse.Namespace) -> None:
135
135
  """
136
136
  random.seed(config.seed if config.seed is not None else DEFAULT_DEMO_SEED)
137
137
  config.cmap = random.sample(CMAPS, config.demo)
138
- config.input_filename = str(_generate_sample_csv(config))
138
+ config.input_filename = str(
139
+ Path(config.output_dir) / DEMO_SAMPLE_FILENAME,
140
+ )
139
141
  generate_heatmaps(config)
140
142
 
141
143
 
@@ -162,7 +164,7 @@ def _generate_sample_csv(config: argparse.Namespace) -> Path:
162
164
  )
163
165
  df_dates["count"] = random.sample(range(DEMO_MAX_COUNT), len(df_dates))
164
166
 
165
- csv_filename = Path(config.output_dir) / DEMO_SAMPLE_FILENAME
167
+ csv_filename = _get_output_dir(config) / DEMO_SAMPLE_FILENAME
166
168
  csv_filename.parent.mkdir(parents=True, exist_ok=True)
167
169
  df_dates.to_csv(csv_filename, sep=",", index=False, header=False)
168
170
  logger.debug("Generate sample CSV file: %s", csv_filename)
@@ -191,7 +193,7 @@ class EnvironmentAction(argparse.Action):
191
193
  for name, version in (
192
194
  ("matplotlib", mpl.__version__),
193
195
  ("pandas", pd.__version__),
194
- ("pillow", Image.__version__),
196
+ ("pillow", metadata.version("pillow")),
195
197
  ("seaborn", sns.__version__),
196
198
  )
197
199
  )
@@ -43,6 +43,7 @@ from heatmap_cli import (
43
43
  setup_logging,
44
44
  )
45
45
  from heatmap_cli.heatmap import (
46
+ _sanitize_string_for_filename,
46
47
  _weeks_in_iso_year,
47
48
  )
48
49
  from heatmap_cli.heatmap import (
@@ -208,8 +209,8 @@ def build_parser() -> argparse.ArgumentParser:
208
209
  type=_valid_date,
209
210
  default=None,
210
211
  help=(
211
- "filter from the date of the year from the CSV file "
212
- "(default: %(default)s)"
212
+ "filter from the date of the year from the CSV file and "
213
+ "this will overwrite -y and -w option (default: %(default)s)"
213
214
  ),
214
215
  metavar="START_DATE",
215
216
  )
@@ -424,6 +425,49 @@ def _generate_cmap_help() -> str:
424
425
  return cmap_help + cmap_default + "\n" + cmap_choices.rstrip()
425
426
 
426
427
 
428
+ def _resolve_date_filter_overrides(
429
+ parsed_args: argparse.Namespace,
430
+ ) -> None:
431
+ """Apply --start-date/--end-date overrides to the year and week filters.
432
+
433
+ A --start-date/--end-date pair defines a self-contained calendar date
434
+ range that overrides the -y/--year and -w/--week cutoff, avoiding a
435
+ double filter between the ISO-week year column and the raw date column.
436
+ When only one endpoint is given it derives a partial-year view; when
437
+ both are given --end-date wins for the year/week since it caps the range.
438
+
439
+ Args:
440
+ parsed_args (argparse.Namespace): The newly parsed arguments.
441
+
442
+ Returns:
443
+ None
444
+ """
445
+ # Capture the user-supplied filters before --end-date/--start-date clobber
446
+ # them, so the overriding of -y/-w is reported instead of happening
447
+ # silently.
448
+ explicit_year = parsed_args.year
449
+ explicit_week = parsed_args.week
450
+
451
+ if parsed_args.end_date:
452
+ (year, week, _day) = parsed_args.end_date.isocalendar()
453
+ parsed_args.year = [year]
454
+ parsed_args.week = week
455
+ elif parsed_args.start_date:
456
+ if explicit_year is not None:
457
+ logger.warning(
458
+ "argument --start-date: --year is ignored when "
459
+ "--start-date is used.",
460
+ )
461
+ if explicit_week is not None:
462
+ logger.warning(
463
+ "argument --start-date: --week is ignored when "
464
+ "--start-date is used.",
465
+ )
466
+ (year, _week, _day) = parsed_args.start_date.isocalendar()
467
+ parsed_args.year = [year]
468
+ parsed_args.week = _weeks_in_iso_year(year)
469
+
470
+
427
471
  def _process_args(
428
472
  parser: argparse.ArgumentParser,
429
473
  parsed_args: argparse.Namespace,
@@ -445,14 +489,7 @@ def _process_args(
445
489
  # double filter between the ISO-week year column and the raw date column.
446
490
  # When only one endpoint is given it derives a partial-year view; when
447
491
  # both are given --end-date wins for the year/week since it caps the range.
448
- if parsed_args.end_date:
449
- (year, week, _day) = parsed_args.end_date.isocalendar()
450
- parsed_args.year = [year]
451
- parsed_args.week = week
452
- elif parsed_args.start_date:
453
- (year, _week, _day) = parsed_args.start_date.isocalendar()
454
- parsed_args.year = [year]
455
- parsed_args.week = _weeks_in_iso_year(year)
492
+ _resolve_date_filter_overrides(parsed_args)
456
493
 
457
494
  if (
458
495
  parsed_args.start_date
@@ -479,6 +516,10 @@ def _process_args(
479
516
  if parsed_args.cmap is None:
480
517
  parsed_args.cmap = [DEFAULT_CMAP]
481
518
 
519
+ # Reject titles that sanitize to an empty string, which would otherwise
520
+ # yield a degenerate filename like 001__<cmap>.png.
521
+ _validate_title(parser, parsed_args.title)
522
+
482
523
  # A GIF output is only ever produced by the animation path, so asking for
483
524
  # gif implies --animate-by-week; otherwise fig.savefig(format="gif") would
484
525
  # emit a single-frame still instead of an animation.
@@ -488,6 +529,23 @@ def _process_args(
488
529
  return parsed_args
489
530
 
490
531
 
532
+ def _validate_title(
533
+ parser: argparse.ArgumentParser,
534
+ title: str | None,
535
+ ) -> None:
536
+ """Reject titles that would produce a degenerate filename.
537
+
538
+ Args:
539
+ parser (argparse.ArgumentParser): The argument parser instance.
540
+ title (str | None): The title value from the command line.
541
+
542
+ Returns:
543
+ None
544
+ """
545
+ if title is not None and not _sanitize_string_for_filename(title):
546
+ parser.error("argument --title: cannot be empty or symbolic only.")
547
+
548
+
491
549
  def main(args: Sequence[str] | None = None) -> None:
492
550
  """Run the main program flow.
493
551
 
@@ -510,6 +568,8 @@ def main(args: Sequence[str] | None = None) -> None:
510
568
 
511
569
  processed_args = _process_args(parser, parsed_args)
512
570
  setup_logging(processed_args)
571
+ if processed_args.seed is not None and not processed_args.demo:
572
+ logger.warning("--seed is ignored unless --demo is used")
513
573
  if processed_args.demo:
514
574
  generate_demo(processed_args)
515
575
  return
@@ -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,14 +300,21 @@ def _generate_heatmap_wrapper(
286
300
  cmap: str,
287
301
  config: argparse.Namespace,
288
302
  dataframe: pd.DataFrame,
289
- ) -> Image.Image | None:
290
- """Wrapper for _generate_heatmap to handle args in Pool."""
291
- return _generate_heatmap(
292
- seq,
293
- cmap,
294
- config,
295
- dataframe,
296
- return_image=False,
303
+ ) -> Path | None:
304
+ """Wrapper for _generate_heatmap to handle args in Pool.
305
+
306
+ Workers always save to disk (``return_image=False``), so the result is a
307
+ file Path, never an in-memory image.
308
+ """
309
+ return cast(
310
+ "Path | None",
311
+ _generate_heatmap(
312
+ seq,
313
+ cmap,
314
+ config,
315
+ dataframe,
316
+ return_image=False,
317
+ ),
297
318
  )
298
319
 
299
320
 
@@ -400,10 +421,15 @@ def _filter_and_pivot_data(
400
421
 
401
422
  logger.debug("Last date in filtered data: %s", max(steps["date"]).date())
402
423
 
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
424
+ # The ISO year spans from the Monday of its first week, which may fall in
425
+ # the previous calendar year, through the Sunday before week 1 of the next
426
+ # ISO year. Using these boundaries keeps every date inside a single ISO
427
+ # year, so each (weekday, week) cell maps to exactly one date. Late
428
+ # December days that already belong to next year's ISO week 1 are excluded,
429
+ # otherwise they would be averaged with the real count in the same cell and
430
+ # silently halve it (pivot_table default aggfunc is "mean").
405
431
  start_date = _week_start_date(config.year, 1)
406
- end_date = pd.to_datetime(f"{config.year}-12-31")
432
+ end_date = _week_start_date(config.year + 1, 1) - timedelta(days=1)
407
433
  logger.debug("start date: %s", start_date)
408
434
  logger.debug("end date: %s", end_date)
409
435
  full_year_dates = pd.date_range(start=start_date, end=end_date, freq="D")
@@ -507,7 +533,7 @@ def _generate_heatmap(
507
533
  dataframe: pd.DataFrame,
508
534
  *,
509
535
  return_image: bool = False,
510
- ) -> Image.Image | None:
536
+ ) -> Image.Image | Path | None:
511
537
  """Generate a heatmap.
512
538
 
513
539
  Args:
@@ -520,8 +546,8 @@ def _generate_heatmap(
520
546
  saving to disk.
521
547
 
522
548
  Returns:
523
- Optional[Image.Image]: The PIL Image object if return_image is True,
524
- otherwise None.
549
+ Optional[Image.Image | Path]: The PIL Image object if return_image is
550
+ True, otherwise the Path of the saved heatmap file.
525
551
  """
526
552
  fig, axis = _setup_plot_artists(cmap, config, dataframe)
527
553
 
@@ -534,10 +560,7 @@ def _generate_heatmap(
534
560
  img_filename = _save_heatmap_figure(fig, config, seq, cmap, title)
535
561
  logger.info("generate heatmap: %s", img_filename)
536
562
 
537
- if config.open:
538
- _open_heatmap(img_filename)
539
-
540
- return None
563
+ return img_filename
541
564
 
542
565
 
543
566
  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.4
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,45 @@ 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_seed_without_demo_warns(cli_runner, csv_file):
113
+ csv = csv_file("sample.csv")
114
+ ret = cli_runner(
115
+ csv,
116
+ "-y",
117
+ "2024",
118
+ "-w",
119
+ "52",
120
+ "--seed",
121
+ "42",
122
+ expect_error=False,
123
+ )
124
+ assert ret.returncode == 0
125
+ assert "--seed is ignored unless --demo is used" in ret.stderr
126
+
127
+
128
+ def test_seed_with_demo_no_warning(cli_runner):
129
+ ret = cli_runner("--demo", 1, "--seed", "42", expect_error=False)
130
+ assert ret.returncode == 0
131
+ assert "--seed is ignored unless --demo is used" not in ret.stderr
132
+
133
+
134
+ def test_demo_with_tilde_output_dir(cli_runner, monkeypatch, tmpdir):
135
+ """--demo must expand ~ in --output-dir like the rest of the pipeline."""
136
+ home = Path(tmpdir, "home")
137
+ home.mkdir()
138
+ monkeypatch.setenv("HOME", str(home))
139
+
140
+ ret = cli_runner("--demo", 1, "-O", "~/demoout", expect_error=False)
141
+
142
+ assert ret.returncode == 0
143
+ assert (home / "demoout" / DEMO_SAMPLE_FILENAME).exists()
144
+ 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
@@ -76,3 +76,25 @@ def test_start_date_overrides_year_cutoff(cli_runner, csv_file):
76
76
  csv = csv_file("sample.csv")
77
77
  ret = cli_runner(csv, "-d", "-s", "2023-06-01", "--year", "2024")
78
78
  assert "year=[2023]" in ret.stderr
79
+
80
+
81
+ def test_start_date_warns_when_year_overridden(cli_runner, csv_file):
82
+ """Passing -y with --start-date must warn that -y is ignored."""
83
+ csv = csv_file("sample.csv")
84
+ ret = cli_runner(csv, "-s", "2023-06-01", "--year", "2024")
85
+ assert "argument --start-date: --year is ignored when" in ret.stderr
86
+
87
+
88
+ @pytest.mark.parametrize("option", ["-w", "--week"])
89
+ def test_start_date_warns_when_week_overridden(cli_runner, csv_file, option):
90
+ """Passing -w with --start-date must warn that -w is ignored."""
91
+ csv = csv_file("sample.csv")
92
+ ret = cli_runner(csv, "-s", "2023-06-01", option, "20")
93
+ assert "argument --start-date: --week is ignored when" in ret.stderr
94
+
95
+
96
+ def test_start_date_alone_no_override_warning(cli_runner, csv_file):
97
+ """--start-date used alone must not emit an override warning."""
98
+ csv = csv_file("sample.csv")
99
+ ret = cli_runner(csv, "-s", "2023-06-01")
100
+ assert "is ignored when --start-date" not in ret.stderr
@@ -25,6 +25,20 @@ def test_debug_logs(cli_runner, csv_file, option):
25
25
  assert "my title is foobar" in ret.stderr
26
26
 
27
27
 
28
+ @pytest.mark.parametrize("option", ["-t", "--title"])
29
+ def test_empty_title_rejected(cli_runner, csv_file, option):
30
+ csv = csv_file("sample.csv")
31
+ ret = cli_runner(csv, "-y", "2024", option, "")
32
+ assert "argument --title: cannot be empty or symbolic only." in ret.stderr
33
+
34
+
35
+ @pytest.mark.parametrize("option", ["-t", "--title"])
36
+ def test_symbols_only_title_rejected(cli_runner, csv_file, option):
37
+ csv = csv_file("sample.csv")
38
+ ret = cli_runner(csv, "-y", "2024", option, "!!!")
39
+ assert "argument --title: cannot be empty or symbolic only." in ret.stderr
40
+
41
+
28
42
  def test_default_title(cli_runner, csv_file):
29
43
  csv = csv_file("sample.csv")
30
44
  ret = cli_runner(csv, "-y", "2024", "-d", "-w", "42")
@@ -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