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.
- {heatmap_cli-0.25.0/src/heatmap_cli.egg-info → heatmap_cli-0.25.2}/PKG-INFO +1 -1
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/pyproject.toml +1 -1
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/__init__.py +13 -5
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/cli.py +65 -14
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/heatmap.py +62 -11
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2/src/heatmap_cli.egg-info}/PKG-INFO +1 -1
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/SOURCES.txt +1 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_animate_by_week.py +13 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_demo_option.py +16 -3
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_errors.py +18 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_utils.py +28 -0
- heatmap_cli-0.25.2/tests/test_iso_53_week_year.py +83 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_start_date_option.py +20 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_week_option.py +32 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/LICENSE.md +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/README.md +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/setup.cfg +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli/__main__.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/dependency_links.txt +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/entry_points.txt +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/requires.txt +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/src/heatmap_cli.egg-info/top_level.txt +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_animate_no_frames.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_animate_open.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_annotate_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_annotation_truncation.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cbar_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cmap_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cmax_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_cmin_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_configure_cbar.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_debug_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_end_date_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_env_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_direct.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_open.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_heatmap_wrapper.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_help_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_no_annotate.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_open_heatmap.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_output_dir_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_purge_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_quiet_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_refresh_output_dir.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_setup_logging.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_title_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_verbose_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_version_flag.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/tests/test_year_option.py +0 -0
- {heatmap_cli-0.25.0 → heatmap_cli-0.25.2}/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.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) ->
|
|
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
|
-
|
|
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) /
|
|
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
|
-
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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=
|
|
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
|
-
#
|
|
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",
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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/
|
|
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 /
|
|
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
|
|
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
|