heatmap_cli 0.25.1__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.1/src/heatmap_cli.egg-info → heatmap_cli-0.25.2}/PKG-INFO +1 -1
  2. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/pyproject.toml +1 -1
  3. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli/__init__.py +13 -5
  4. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli/cli.py +38 -13
  5. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli/heatmap.py +11 -2
  6. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2/src/heatmap_cli.egg-info}/PKG-INFO +1 -1
  7. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_animate_by_week.py +13 -0
  8. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_demo_option.py +16 -3
  9. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_heatmap_errors.py +18 -0
  10. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_heatmap_utils.py +28 -0
  11. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_start_date_option.py +20 -0
  12. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/LICENSE.md +0 -0
  13. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/README.md +0 -0
  14. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/setup.cfg +0 -0
  15. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli/__main__.py +0 -0
  16. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/SOURCES.txt +0 -0
  17. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/dependency_links.txt +0 -0
  18. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/entry_points.txt +0 -0
  19. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/requires.txt +0 -0
  20. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/top_level.txt +0 -0
  21. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_animate_no_frames.py +0 -0
  22. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_animate_open.py +0 -0
  23. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_annotate_flag.py +0 -0
  24. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_annotation_truncation.py +0 -0
  25. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_cbar_flag.py +0 -0
  26. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_cmap_option.py +0 -0
  27. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_cmax_option.py +0 -0
  28. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_cmin_option.py +0 -0
  29. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_configure_cbar.py +0 -0
  30. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_debug_flag.py +0 -0
  31. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_end_date_option.py +0 -0
  32. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_env_flag.py +0 -0
  33. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_heatmap_direct.py +0 -0
  34. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_heatmap_open.py +0 -0
  35. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_heatmap_wrapper.py +0 -0
  36. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_help_flag.py +0 -0
  37. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_iso_53_week_year.py +0 -0
  38. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_no_annotate.py +0 -0
  39. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_open_heatmap.py +0 -0
  40. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_output_dir_option.py +0 -0
  41. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_purge_flag.py +0 -0
  42. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_quiet_flag.py +0 -0
  43. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_refresh_output_dir.py +0 -0
  44. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_setup_logging.py +0 -0
  45. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_title_option.py +0 -0
  46. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_verbose_flag.py +0 -0
  47. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_version_flag.py +0 -0
  48. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_week_option.py +0 -0
  49. {heatmap_cli-0.25.1 → heatmap_cli-0.25.2}/tests/test_year_option.py +0 -0
  50. {heatmap_cli-0.25.1 → 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.1
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.1"
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()
@@ -429,11 +440,19 @@ def _process_args(
429
440
  Raises:
430
441
  SystemExit: If validation fails, typically via parser.error().
431
442
  """
432
- # 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.
433
448
  if parsed_args.end_date:
434
449
  (year, week, _day) = parsed_args.end_date.isocalendar()
435
450
  parsed_args.year = [year]
436
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)
437
456
 
438
457
  if (
439
458
  parsed_args.start_date
@@ -460,6 +479,12 @@ def _process_args(
460
479
  if parsed_args.cmap is None:
461
480
  parsed_args.cmap = [DEFAULT_CMAP]
462
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
+
463
488
  return parsed_args
464
489
 
465
490
 
@@ -311,7 +311,8 @@ def _read_and_prepare_data(config: argparse.Namespace) -> pd.DataFrame:
311
311
 
312
312
  Raises:
313
313
  FileNotFoundError: If the input CSV file does not exist.
314
- 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.
315
316
  """
316
317
  try:
317
318
  dataframe = pd.read_csv(
@@ -323,6 +324,10 @@ def _read_and_prepare_data(config: argparse.Namespace) -> pd.DataFrame:
323
324
  logger.error("Input file not found: %s", config.input_filename)
324
325
  raise
325
326
 
327
+ if dataframe.empty:
328
+ msg = f"Input CSV file is empty: {config.input_filename}"
329
+ raise ValueError(msg)
330
+
326
331
  dataframe["date"] = pd.to_datetime(dataframe["date"])
327
332
 
328
333
  negative_counts = dataframe[dataframe["count"] < 0]
@@ -814,13 +819,17 @@ def _refresh_output_dir(config: argparse.Namespace) -> None:
814
819
  def _get_output_dir(config: argparse.Namespace) -> Path:
815
820
  """Get the output directory path, resolved relative to CWD.
816
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
+
817
826
  Args:
818
827
  config (argparse.Namespace): Config from command line arguments.
819
828
 
820
829
  Returns:
821
830
  Path: The output directory path.
822
831
  """
823
- output_dir = Path(config.output_dir)
832
+ output_dir = Path(os.path.expandvars(config.output_dir)).expanduser()
824
833
  if output_dir.is_absolute():
825
834
  return output_dir
826
835
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: heatmap_cli
3
- Version: 0.25.1
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
@@ -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"
@@ -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
File without changes
File without changes
File without changes