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.
- {heatmap_cli-0.25.2/src/heatmap_cli.egg-info → heatmap_cli-0.25.4}/PKG-INFO +1 -1
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/pyproject.toml +1 -1
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/__init__.py +6 -4
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/cli.py +70 -10
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/heatmap.py +45 -22
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4/src/heatmap_cli.egg-info}/PKG-INFO +1 -1
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_demo_option.py +42 -0
- heatmap_cli-0.25.4/tests/test_heatmap_open.py +83 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_wrapper.py +20 -4
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_iso_53_week_year.py +27 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_start_date_option.py +22 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_title_option.py +14 -0
- heatmap_cli-0.25.2/tests/test_heatmap_open.py +0 -46
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/LICENSE.md +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/README.md +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/setup.cfg +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli/__main__.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/SOURCES.txt +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/dependency_links.txt +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/entry_points.txt +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/requires.txt +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/src/heatmap_cli.egg-info/top_level.txt +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_animate_by_week.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_animate_no_frames.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_animate_open.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_annotate_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_annotation_truncation.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cbar_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cmap_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cmax_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_cmin_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_configure_cbar.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_debug_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_end_date_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_env_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_direct.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_errors.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_heatmap_utils.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_help_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_no_annotate.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_open_heatmap.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_output_dir_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_purge_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_quiet_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_refresh_output_dir.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_setup_logging.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_verbose_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_version_flag.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_week_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_year_option.py +0 -0
- {heatmap_cli-0.25.2 → heatmap_cli-0.25.4}/tests/test_yes_flag.py +0 -0
|
@@ -25,7 +25,7 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python",
|
|
26
26
|
"Topic :: Scientific/Engineering :: Visualization",
|
|
27
27
|
]
|
|
28
|
-
version = "0.25.
|
|
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(
|
|
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 =
|
|
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",
|
|
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
|
-
|
|
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
|
-
) ->
|
|
290
|
-
"""Wrapper for _generate_heatmap to handle args in Pool.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
#
|
|
404
|
-
#
|
|
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 =
|
|
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
|
|
524
|
-
|
|
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
|
-
|
|
538
|
-
_open_heatmap(img_filename)
|
|
539
|
-
|
|
540
|
-
return None
|
|
563
|
+
return img_filename
|
|
541
564
|
|
|
542
565
|
|
|
543
566
|
def _configure_heatmap_options(
|
|
@@ -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
|
|
25
|
-
|
|
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=
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|