heatmap_cli 0.25.0__tar.gz → 0.25.2__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 (50) hide show
  1. {heatmap_cli-0.25.0/src/heatmap_cli.egg-info → heatmap_cli-0.25.2}/PKG-INFO +1 -1
  2. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/pyproject.toml +1 -1
  3. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/__init__.py +13 -5
  4. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/cli.py +65 -14
  5. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/heatmap.py +62 -11
  6. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2/src/heatmap_cli.egg-info}/PKG-INFO +1 -1
  7. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/SOURCES.txt +1 -0
  8. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_animate_by_week.py +13 -0
  9. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_demo_option.py +16 -3
  10. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_errors.py +18 -0
  11. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_utils.py +28 -0
  12. heatmap_cli-0.25.2/tests/test_iso_53_week_year.py +83 -0
  13. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_start_date_option.py +20 -0
  14. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_week_option.py +32 -0
  15. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/LICENSE.md +0 -0
  16. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/README.md +0 -0
  17. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/setup.cfg +0 -0
  18. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/__main__.py +0 -0
  19. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/dependency_links.txt +0 -0
  20. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/entry_points.txt +0 -0
  21. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/requires.txt +0 -0
  22. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/top_level.txt +0 -0
  23. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_animate_no_frames.py +0 -0
  24. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_animate_open.py +0 -0
  25. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_annotate_flag.py +0 -0
  26. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_annotation_truncation.py +0 -0
  27. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cbar_flag.py +0 -0
  28. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cmap_option.py +0 -0
  29. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cmax_option.py +0 -0
  30. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cmin_option.py +0 -0
  31. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_configure_cbar.py +0 -0
  32. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_debug_flag.py +0 -0
  33. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_end_date_option.py +0 -0
  34. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_env_flag.py +0 -0
  35. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_direct.py +0 -0
  36. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_open.py +0 -0
  37. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_wrapper.py +0 -0
  38. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_help_flag.py +0 -0
  39. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_no_annotate.py +0 -0
  40. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_open_heatmap.py +0 -0
  41. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_output_dir_option.py +0 -0
  42. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_purge_flag.py +0 -0
  43. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_quiet_flag.py +0 -0
  44. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_refresh_output_dir.py +0 -0
  45. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_setup_logging.py +0 -0
  46. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_title_option.py +0 -0
  47. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_verbose_flag.py +0 -0
  48. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_version_flag.py +0 -0
  49. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_year_option.py +0 -0
  50. {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/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.0
3
+ Version: 0.25.2
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.0"
28
+ version = "0.25.2"
29
29
  keywords = ["heatmap", "cli", "visualization", "calendar", "data-viz"]
30
30
  dependencies = [
31
31
  "matplotlib",
@@ -43,6 +43,10 @@ DEMO_MAX_COUNT = 12000
43
43
  # Fixed default seed so --demo output is deterministic for docs and tests.
44
44
  DEFAULT_DEMO_SEED = 42
45
45
 
46
+ # The demo sample CSV uses a dedicated, unlikely-to-collide name so --demo
47
+ # does not silently overwrite a user's file in the output directory.
48
+ DEMO_SAMPLE_FILENAME = "heatmap_demo_sample.csv"
49
+
46
50
  # Sort colormaps in a case-insensitive manner.
47
51
  CMAPS = sorted(plt.colormaps, key=str.casefold)
48
52
 
@@ -129,21 +133,24 @@ def generate_demo(config: argparse.Namespace) -> None:
129
133
  Returns:
130
134
  None
131
135
  """
132
- config.input_filename = f"{config.output_dir}/sample.csv"
133
136
  random.seed(config.seed if config.seed is not None else DEFAULT_DEMO_SEED)
134
137
  config.cmap = random.sample(CMAPS, config.demo)
135
- _generate_sample_csv(config)
138
+ config.input_filename = str(_generate_sample_csv(config))
136
139
  generate_heatmaps(config)
137
140
 
138
141
 
139
- def _generate_sample_csv(config: argparse.Namespace) -> None:
142
+ def _generate_sample_csv(config: argparse.Namespace) -> Path:
140
143
  """Generate a sample CSV data file.
141
144
 
145
+ The file uses the dedicated demo name so a user's own ``sample.csv`` in
146
+ the output directory is never touched. Re-running ``--demo`` into the same
147
+ directory may overwrite this demo's own previous artifact.
148
+
142
149
  Args:
143
150
  config (argparse.Namespace): Config from command line arguments.
144
151
 
145
152
  Returns:
146
- None
153
+ Path: The path the sample CSV was written to.
147
154
  """
148
155
  df_dates = pd.DataFrame(
149
156
  {
@@ -155,10 +162,11 @@ def _generate_sample_csv(config: argparse.Namespace) -> None:
155
162
  )
156
163
  df_dates["count"] = random.sample(range(DEMO_MAX_COUNT), len(df_dates))
157
164
 
158
- csv_filename = Path(config.output_dir) / "sample.csv"
165
+ csv_filename = Path(config.output_dir) / DEMO_SAMPLE_FILENAME
159
166
  csv_filename.parent.mkdir(parents=True, exist_ok=True)
160
167
  df_dates.to_csv(csv_filename, sep=",", index=False, header=False)
161
168
  logger.debug("Generate sample CSV file: %s", csv_filename)
169
+ return csv_filename
162
170
 
163
171
 
164
172
  class EnvironmentAction(argparse.Action):
@@ -32,9 +32,7 @@ from typing import TYPE_CHECKING
32
32
  if TYPE_CHECKING:
33
33
  from collections.abc import Sequence
34
34
 
35
- import matplotlib as mpl
36
- from matplotlib.backend_bases import FigureCanvasBase
37
- from matplotlib.backends import backend_registry
35
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
38
36
 
39
37
  from heatmap_cli import (
40
38
  CMAPS,
@@ -44,18 +42,31 @@ from heatmap_cli import (
44
42
  generate_demo,
45
43
  setup_logging,
46
44
  )
47
- from heatmap_cli.heatmap import run as generate_heatmaps
45
+ from heatmap_cli.heatmap import (
46
+ _weeks_in_iso_year,
47
+ )
48
+ from heatmap_cli.heatmap import (
49
+ run as generate_heatmaps,
50
+ )
48
51
 
49
52
 
50
53
  def _get_supported_formats() -> list[str]:
51
- """Retrieve supported image formats dynamically from matplotlib."""
52
- try:
53
- backend_name = mpl.get_backend()
54
- backend_mod = backend_registry.load_backend_module(backend_name)
55
- canvas_cls = backend_mod.FigureCanvas
56
- return sorted(canvas_cls.get_supported_filetypes().keys())
57
- except (AttributeError, ImportError, KeyError, TypeError):
58
- return sorted(FigureCanvasBase.get_supported_filetypes().keys())
54
+ """Retrieve image formats supported by the Agg rendering backend.
55
+
56
+ All heatmaps are rendered with the Agg backend (see
57
+ ``heatmap.run`` and ``_setup_plot_artists``), so the CLI choices are
58
+ derived from `FigureCanvasAgg` rather than the backend active at import
59
+ time, keeping ``--format`` choices in sync with what can actually be
60
+ saved.
61
+
62
+ ``gif`` is added explicitly: it is not produced by matplotlib's
63
+ ``savefig`` (Agg does not list it) but by the PIL encoder in the
64
+ ``--animate-by-week`` path, which implies that format.
65
+ """
66
+ formats = sorted(FigureCanvasAgg.get_supported_filetypes().keys())
67
+ if "gif" not in formats:
68
+ formats.append("gif")
69
+ return formats
59
70
 
60
71
 
61
72
  IMAGE_FORMATS = _get_supported_formats()
@@ -65,6 +76,7 @@ logger = logging.getLogger(__name__)
65
76
 
66
77
  DEFAULT_CMAP = "RdYlGn_r"
67
78
  CMAP_ITEMS_PER_ROW = 6
79
+ MAX_ISO_WEEK = 53
68
80
 
69
81
 
70
82
  def _valid_date(s: str) -> datetime.date:
@@ -86,6 +98,31 @@ def _valid_date(s: str) -> datetime.date:
86
98
  raise argparse.ArgumentTypeError(msg) from error
87
99
 
88
100
 
101
+ def _valid_week(s: str) -> int:
102
+ """Validate the ISO week number.
103
+
104
+ Args:
105
+ s (str): Week number string to validate.
106
+
107
+ Returns:
108
+ int: The validated week number.
109
+
110
+ Raises:
111
+ argparse.ArgumentTypeError: If the week number is invalid.
112
+ """
113
+ try:
114
+ week = int(s)
115
+ except ValueError as error:
116
+ msg = f"invalid week number: '{s}', expected an integer."
117
+ raise argparse.ArgumentTypeError(msg) from error
118
+
119
+ if not 1 <= week <= MAX_ISO_WEEK:
120
+ msg = f"invalid week number: '{s}', expected between 1 and 53."
121
+ raise argparse.ArgumentTypeError(msg)
122
+
123
+ return week
124
+
125
+
89
126
  def build_parser() -> argparse.ArgumentParser:
90
127
  """Build the argument parser for the CLI.
91
128
 
@@ -142,7 +179,7 @@ def build_parser() -> argparse.ArgumentParser:
142
179
  "-w",
143
180
  "--week",
144
181
  dest="week",
145
- type=int,
182
+ type=_valid_week,
146
183
  default=None,
147
184
  help=(
148
185
  "filter until week of the year from the CSV file "
@@ -403,11 +440,19 @@ def _process_args(
403
440
  Raises:
404
441
  SystemExit: If validation fails, typically via parser.error().
405
442
  """
406
- # Handle --end-date overriding --year and --week
443
+ # A --start-date/--end-date pair defines a self-contained calendar date
444
+ # range that overrides the -y/--year and -w/--week cutoff, avoiding a
445
+ # double filter between the ISO-week year column and the raw date column.
446
+ # When only one endpoint is given it derives a partial-year view; when
447
+ # both are given --end-date wins for the year/week since it caps the range.
407
448
  if parsed_args.end_date:
408
449
  (year, week, _day) = parsed_args.end_date.isocalendar()
409
450
  parsed_args.year = [year]
410
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)
411
456
 
412
457
  if (
413
458
  parsed_args.start_date
@@ -434,6 +479,12 @@ def _process_args(
434
479
  if parsed_args.cmap is None:
435
480
  parsed_args.cmap = [DEFAULT_CMAP]
436
481
 
482
+ # A GIF output is only ever produced by the animation path, so asking for
483
+ # gif implies --animate-by-week; otherwise fig.savefig(format="gif") would
484
+ # emit a single-frame still instead of an animation.
485
+ if parsed_args.format == "gif":
486
+ parsed_args.animate_by_week = True
487
+
437
488
  return parsed_args
438
489
 
439
490
 
@@ -21,6 +21,7 @@ import copy
21
21
  import io
22
22
  import logging
23
23
  import multiprocessing
24
+ import os
24
25
  import re
25
26
  import shutil
26
27
  import webbrowser
@@ -40,10 +41,6 @@ from matplotlib.backends.backend_agg import FigureCanvasAgg
40
41
  from matplotlib.figure import Figure
41
42
  from PIL import Image
42
43
 
43
- # Generate matplotlib graphs without an X server.
44
- # See http://stackoverflow.com/a/4935945
45
- mpl.use("agg")
46
-
47
44
  # Define constants for figure and image settings
48
45
  DEFAULT_FIGURE_SIZE = (8, 5)
49
46
  DEFAULT_DPI = 76
@@ -99,7 +96,13 @@ def run(config: argparse.Namespace) -> None:
99
96
  None
100
97
  """
101
98
  logger.debug(config)
102
- logger.debug("number of cpu: %d", multiprocessing.cpu_count())
99
+ logger.debug("number of cpu: %d", os.cpu_count())
100
+
101
+ # Generate matplotlib graphs without an X server. Applied at run time so
102
+ # merely importing this module does not switch the global backend.
103
+ # See http://stackoverflow.com/a/4935945
104
+ if mpl.get_backend().lower() != "agg":
105
+ mpl.use("agg")
103
106
 
104
107
  _refresh_output_dir(config)
105
108
 
@@ -120,6 +123,22 @@ def run(config: argparse.Namespace) -> None:
120
123
  _generate_static_heatmaps(pivoted_dataframe, config_for_year)
121
124
 
122
125
 
126
+ def _weeks_in_iso_year(year: int) -> int:
127
+ """Return the number of ISO weeks (52 or 53) in the given year.
128
+
129
+ December 28 is always in the last ISO week of its year, so its week
130
+ number gives the total count of weeks for that year. For example,
131
+ 2026 is a 53-week ISO year while 2025 has 52 weeks.
132
+
133
+ Args:
134
+ year (int): The ISO year.
135
+
136
+ Returns:
137
+ int: Number of ISO weeks in the year.
138
+ """
139
+ return date(year, 12, 28).isocalendar().week
140
+
141
+
123
142
  def _week_start_date(year: int, week: int) -> date:
124
143
  """Return the Monday of the given ISO week.
125
144
 
@@ -222,6 +241,22 @@ def _generate_animated_heatmap(
222
241
  _open_heatmap(img_filename)
223
242
 
224
243
 
244
+ def _init_worker_logging(config: argparse.Namespace) -> None:
245
+ """Configure logging in a pool worker process.
246
+
247
+ Non-fork platforms spawn fresh worker processes that do not inherit
248
+ the parent logger handlers and level, so reapply the logging
249
+ configuration in every worker.
250
+
251
+ Args:
252
+ config (argparse.Namespace): Config from command line arguments.
253
+ """
254
+ # Imported here to avoid a circular import with the package __init__.
255
+ from heatmap_cli import setup_logging # noqa: PLC0415
256
+
257
+ setup_logging(config)
258
+
259
+
225
260
  def _generate_static_heatmaps(
226
261
  pivoted_dataframe: pd.DataFrame,
227
262
  config: argparse.Namespace,
@@ -239,9 +274,10 @@ def _generate_static_heatmaps(
239
274
  ]
240
275
  logger.debug(args)
241
276
 
242
- # Fork, instead of spawn process (child) inherit parent logger config.
243
- # See https://stackoverflow.com/q/14643568
244
- with multiprocessing.get_context("fork").Pool() as pool:
277
+ with multiprocessing.Pool(
278
+ initializer=_init_worker_logging,
279
+ initargs=(config,),
280
+ ) as pool:
245
281
  pool.starmap(_generate_heatmap_wrapper, args)
246
282
 
247
283
 
@@ -275,7 +311,8 @@ def _read_and_prepare_data(config: argparse.Namespace) -> pd.DataFrame:
275
311
 
276
312
  Raises:
277
313
  FileNotFoundError: If the input CSV file does not exist.
278
- ValueError: If duplicate dates or negative counts are found.
314
+ ValueError: If the input is empty, or duplicate dates or negative
315
+ counts are found.
279
316
  """
280
317
  try:
281
318
  dataframe = pd.read_csv(
@@ -287,6 +324,10 @@ def _read_and_prepare_data(config: argparse.Namespace) -> pd.DataFrame:
287
324
  logger.error("Input file not found: %s", config.input_filename)
288
325
  raise
289
326
 
327
+ if dataframe.empty:
328
+ msg = f"Input CSV file is empty: {config.input_filename}"
329
+ raise ValueError(msg)
330
+
290
331
  dataframe["date"] = pd.to_datetime(dataframe["date"])
291
332
 
292
333
  negative_counts = dataframe[dataframe["count"] < 0]
@@ -337,7 +378,13 @@ def _filter_and_pivot_data(
337
378
  ValueError: If no data is extracted for the specified period.
338
379
  """
339
380
  date_filter = dataframe["year"] == config.year
340
- date_filter &= dataframe["week"] <= str(config.week).zfill(2)
381
+
382
+ # A week of 52 or above means the whole year is requested, so include
383
+ # week 53 for ISO 53-week years instead of silently dropping its data.
384
+ max_week = config.week
385
+ if max_week >= WEEKS_IN_YEAR:
386
+ max_week = _weeks_in_iso_year(config.year)
387
+ date_filter &= dataframe["week"] <= str(max_week).zfill(2)
341
388
 
342
389
  if config.start_date:
343
390
  date_filter &= dataframe["date"] >= pd.Timestamp(config.start_date)
@@ -772,13 +819,17 @@ def _refresh_output_dir(config: argparse.Namespace) -> None:
772
819
  def _get_output_dir(config: argparse.Namespace) -> Path:
773
820
  """Get the output directory path, resolved relative to CWD.
774
821
 
822
+ Expands a leading ``~``/``~user`` (user home) and environment variables
823
+ such as ``$HOME`` or ``${OUTPUT_DIR}`` before resolving relative paths
824
+ against the current working directory.
825
+
775
826
  Args:
776
827
  config (argparse.Namespace): Config from command line arguments.
777
828
 
778
829
  Returns:
779
830
  Path: The output directory path.
780
831
  """
781
- output_dir = Path(config.output_dir)
832
+ output_dir = Path(os.path.expandvars(config.output_dir)).expanduser()
782
833
  if output_dir.is_absolute():
783
834
  return output_dir
784
835
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: heatmap_cli
3
- Version: 0.25.0
3
+ Version: 0.25.2
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
@@ -31,6 +31,7 @@ tests/test_heatmap_open.py
31
31
  tests/test_heatmap_utils.py
32
32
  tests/test_heatmap_wrapper.py
33
33
  tests/test_help_flag.py
34
+ tests/test_iso_53_week_year.py
34
35
  tests/test_no_annotate.py
35
36
  tests/test_open_heatmap.py
36
37
  tests/test_output_dir_option.py
@@ -52,3 +52,16 @@ def test_animate_by_week_with_start_date(cli_runner, csv_file):
52
52
  "2023-01-01",
53
53
  )
54
54
  assert ret.returncode == 0
55
+
56
+
57
+ def test_gif_format_implies_animate_by_week(cli_runner, csv_file, tmpdir):
58
+ """-f gif should route into the animation path via --animate-by-week."""
59
+ csv = csv_file("sample.csv")
60
+ ret = cli_runner(csv, "-f", "gif", "-y", "2023", "-w", "1", "-d")
61
+ assert ret.returncode == 0
62
+ assert "animate_by_week=True" in ret.stderr
63
+
64
+ output_dir = Path(str(tmpdir.join("scripttest", "output")))
65
+ gif_files = list(output_dir.glob("*.gif"))
66
+ assert len(gif_files) == 1
67
+ assert "animated" in gif_files[0].name
@@ -17,13 +17,13 @@ import argparse
17
17
  from pathlib import Path
18
18
  from unittest.mock import MagicMock
19
19
 
20
- from heatmap_cli import CMAPS, DemoAction
20
+ from heatmap_cli import CMAPS, DEMO_SAMPLE_FILENAME, DemoAction
21
21
 
22
22
 
23
23
  def test_debug_logs(cli_runner):
24
24
  ret = cli_runner("-d", "--demo", 1)
25
25
  assert "demo=1" in ret.stderr
26
- assert "input_filename='output/sample.csv'" in ret.stderr
26
+ assert f"input_filename='output/{DEMO_SAMPLE_FILENAME}'" in ret.stderr
27
27
 
28
28
 
29
29
  def test_no_debug_logs_without_debug_flag(cli_runner):
@@ -64,7 +64,7 @@ def test_demo_clamps_to_minimum_of_one():
64
64
  def _demo_output(tmpdir):
65
65
  output_dir = Path(tmpdir, "scripttest", "output")
66
66
  files = sorted(path.name for path in output_dir.iterdir())
67
- csv_bytes = (output_dir / "sample.csv").read_bytes()
67
+ csv_bytes = (output_dir / DEMO_SAMPLE_FILENAME).read_bytes()
68
68
  return files, csv_bytes
69
69
 
70
70
 
@@ -87,3 +87,16 @@ def test_demo_changes_with_different_seed(cli_runner, tmpdir):
87
87
  _, csv2 = _demo_output(tmpdir)
88
88
 
89
89
  assert csv1 != csv2
90
+
91
+
92
+ def test_demo_does_not_clobber_user_sample(tmpdir, cli_runner):
93
+ """A user's own sample.csv in the output dir must survive --demo."""
94
+ output_dir = Path(tmpdir, "scripttest", "output")
95
+ output_dir.mkdir(parents=True)
96
+ user_sample = output_dir / "sample.csv"
97
+ user_sample.write_text("user,data\n")
98
+
99
+ cli_runner("--demo", 1)
100
+
101
+ assert user_sample.read_text() == "user,data\n"
102
+ assert (output_dir / DEMO_SAMPLE_FILENAME).exists()
@@ -56,6 +56,24 @@ def test_negative_count_error_direct(tmpdir):
56
56
  _read_and_prepare_data(config)
57
57
 
58
58
 
59
+ def test_empty_csv_error_direct(tmpdir):
60
+ csv_path = Path(tmpdir) / "empty.csv"
61
+ csv_path.write_text("")
62
+
63
+ config = argparse.Namespace(input_filename=str(csv_path), annotate=False)
64
+ with pytest.raises(ValueError, match="Input CSV file is empty"):
65
+ _read_and_prepare_data(config)
66
+
67
+
68
+ def test_empty_csv_error_cli(cli_runner, tmpdir):
69
+ csv_path = Path(tmpdir) / "empty.csv"
70
+ csv_path.write_text("")
71
+
72
+ ret = cli_runner(str(csv_path))
73
+ assert "Input CSV file is empty" in ret.stderr
74
+ assert "No data extracted" not in ret.stderr
75
+
76
+
59
77
  def test_refresh_output_dir_purge_os_error(tmpdir):
60
78
  output_dir = Path(tmpdir) / "output"
61
79
  output_dir.mkdir()
@@ -15,10 +15,12 @@
15
15
 
16
16
  import argparse
17
17
  import datetime
18
+ from pathlib import Path
18
19
 
19
20
  from heatmap_cli.heatmap import (
20
21
  _generate_filename,
21
22
  _generate_title,
23
+ _get_output_dir,
22
24
  _sanitize_string_for_filename,
23
25
  _week_start_date,
24
26
  )
@@ -82,3 +84,29 @@ def test_generate_title():
82
84
 
83
85
  config.title = "Custom Title"
84
86
  assert _generate_title(config) == "Custom Title"
87
+
88
+
89
+ def test_get_output_dir_relative(tmpdir, monkeypatch):
90
+ monkeypatch.chdir(tmpdir)
91
+ config = argparse.Namespace(output_dir="out")
92
+ assert _get_output_dir(config) == Path.cwd() / "out"
93
+
94
+
95
+ def test_get_output_dir_absolute(tmpdir):
96
+ absolute = Path(str(tmpdir)) / "abs"
97
+ config = argparse.Namespace(output_dir=str(absolute))
98
+ assert _get_output_dir(config) == absolute
99
+
100
+
101
+ def test_get_output_dir_expands_tilde(tmpdir, monkeypatch):
102
+ home = Path(str(tmpdir))
103
+ monkeypatch.setenv("HOME", str(home))
104
+ config = argparse.Namespace(output_dir="~/out")
105
+ assert _get_output_dir(config) == home / "out"
106
+
107
+
108
+ def test_get_output_dir_expands_env_var(tmpdir, monkeypatch):
109
+ fromenv = Path(str(tmpdir)) / "fromenv"
110
+ monkeypatch.setenv("OUTPUT_DIR", str(fromenv))
111
+ config = argparse.Namespace(output_dir="$OUTPUT_DIR/sub")
112
+ assert _get_output_dir(config) == fromenv / "sub"
@@ -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
+ """Tests for ISO 53-week year handling."""
17
+
18
+ import argparse
19
+
20
+ import pandas as pd
21
+
22
+ from heatmap_cli.heatmap import (
23
+ WEEKS_IN_YEAR,
24
+ _filter_and_pivot_data,
25
+ _weeks_in_iso_year,
26
+ )
27
+
28
+
29
+ def test_weeks_in_iso_year():
30
+ assert _weeks_in_iso_year(2026) == 53
31
+ assert _weeks_in_iso_year(2020) == 53
32
+ assert _weeks_in_iso_year(2025) == 52
33
+ assert _weeks_in_iso_year(2024) == 52
34
+
35
+
36
+ def _base_dataframe(rows: list[tuple[str, int]]) -> pd.DataFrame:
37
+ """Build a prepared DataFrame as done by _read_and_prepare_data."""
38
+ df = pd.DataFrame(rows, columns=["date", "count"])
39
+ df["date"] = pd.to_datetime(df["date"])
40
+ df["weekday"] = df["date"].dt.weekday + 1
41
+ df["year"] = df["date"].dt.isocalendar().year
42
+ df["week"] = df["date"].dt.isocalendar().week.astype(str).str.zfill(2)
43
+ return df
44
+
45
+
46
+ def test_week_53_kept_when_full_year_requested():
47
+ # Week 53 of the 53-week ISO year 2026 starts on 2026-12-28.
48
+ df = _base_dataframe(
49
+ [
50
+ ("2026-01-05", 100),
51
+ ("2026-06-15", 200),
52
+ ("2026-12-28", 300),
53
+ ],
54
+ )
55
+ config = argparse.Namespace(
56
+ year=2026,
57
+ week=WEEKS_IN_YEAR,
58
+ start_date=None,
59
+ end_date=None,
60
+ )
61
+
62
+ pivoted = _filter_and_pivot_data(df, config)
63
+
64
+ assert pivoted.loc[1, "53"] == 300
65
+
66
+
67
+ def test_week_53_dropped_when_partial_year_requested():
68
+ df = _base_dataframe(
69
+ [
70
+ ("2026-01-05", 100),
71
+ ("2026-12-28", 300),
72
+ ],
73
+ )
74
+ config = argparse.Namespace(
75
+ year=2026,
76
+ week=10,
77
+ start_date=None,
78
+ end_date=None,
79
+ )
80
+
81
+ pivoted = _filter_and_pivot_data(df, config)
82
+
83
+ assert pivoted["53"].sum() == 0
@@ -56,3 +56,23 @@ def test_title_with_start_and_end_date(cli_runner, csv_file):
56
56
  "Year 2023: Total Daily Walking Steps From 2023-01-01 to 2023-01-31"
57
57
  )
58
58
  assert f"DEBUG: heatmap_cli.heatmap: {expected_title}" in ret.stderr
59
+
60
+
61
+ def test_start_date_alone_derives_year(cli_runner, csv_file):
62
+ """--start-date alone should derive year/week instead of double-filtering.
63
+
64
+ Without this, -s 2023-06-01 on 2023 data would default year to the
65
+ current year and raise "No data extracted". The date now drives a
66
+ partial-year view.
67
+ """
68
+ csv = csv_file("sample.csv")
69
+ ret = cli_runner(csv, "-d", "-s", "2023-06-01")
70
+ assert "year=[2023], week=52" in ret.stderr
71
+ assert ret.returncode == 0
72
+
73
+
74
+ def test_start_date_overrides_year_cutoff(cli_runner, csv_file):
75
+ """An explicit -y conflicting with --start-date is resolved by the date."""
76
+ csv = csv_file("sample.csv")
77
+ ret = cli_runner(csv, "-d", "-s", "2023-06-01", "--year", "2024")
78
+ assert "year=[2023]" in ret.stderr
@@ -63,3 +63,35 @@ def test_end_date_option_invalid_format(cli_runner, csv_file):
63
63
  assert (
64
64
  "invalid date format: '2025-13-01', expected YYYY-MM-DD." in ret.stderr
65
65
  )
66
+
67
+
68
+ def test_last_valid_week(cli_runner, csv_file):
69
+ """Test that --week 53 is accepted."""
70
+ csv = csv_file("sample.csv")
71
+ ret = cli_runner(csv, "-y", "2024", "-d", "-w", "53")
72
+
73
+ assert "week=53" in ret.stderr
74
+
75
+
76
+ @pytest.mark.parametrize("option", ["-w", "--week"])
77
+ @pytest.mark.parametrize("value", ["0", "-3", "54", "99"])
78
+ def test_week_out_of_range(cli_runner, csv_file, option, value):
79
+ """Test that --week rejects values outside the ISO week range."""
80
+ csv = csv_file("sample.csv")
81
+ ret = cli_runner(csv, "-y", "2024", option, value, expect_error=True)
82
+
83
+ assert ret.returncode == 2
84
+ assert (
85
+ f"invalid week number: '{value}', expected between 1 and 53."
86
+ in ret.stderr
87
+ )
88
+
89
+
90
+ @pytest.mark.parametrize("option", ["-w", "--week"])
91
+ def test_week_not_a_number(cli_runner, csv_file, option):
92
+ """Test that --week rejects non-integer values."""
93
+ csv = csv_file("sample.csv")
94
+ ret = cli_runner(csv, "-y", "2024", option, "abc", expect_error=True)
95
+
96
+ assert ret.returncode == 2
97
+ assert "invalid week number: 'abc', expected an integer." in ret.stderr
File without changes
File without changes
File without changes