diff --git a/README.md b/README.md index 8313417..96b3b7e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,30 @@ Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ ## More Examples +### Fixed Scale + +Use `--scale` to compare charts across multiple runs without changing the +number of units represented by each tick: + +```bash +printf 'A 6\nB 12\n' | termgraph --scale 4 --format '{:g}' +``` + +```text +A: ▇▇ 6 +B: ▇▇▇ 12 +``` + +Each tick represents up to four units. Positive values round up to a whole +tick and zero draws no bar. The scale must be positive and finite, and the +data must be finite and non-negative. `--scale` overrides `--width` without +clipping bars. It supports horizontal, stacked, and vertical bar charts; +stacked segments round independently. It cannot be combined with +`--different-scale`, `--histogram`, or `--calendar`. + +The same option is available in Python: `BarChart(data, Args(scale=4)).draw()`. +Omitting it keeps the existing automatic scaling. + ### Custom Tick Marks An example using emoji as custom tick: @@ -137,6 +161,7 @@ options: -h, --help show this help message and exit --title TITLE Title of graph --width WIDTH width of graph in characters default:50 + --scale SCALE fixed units per tick for non-negative bar charts; overrides --width --format FORMAT format specifier to use. --suffix SUFFIX string to add as a suffix to all data points. --no-labels Do not print the label column @@ -194,6 +219,7 @@ For comprehensive examples, detailed API reference, and advanced usage patterns, Quick Args options: - `title`: Chart title - `width`: Width in characters (default: 50) +- `scale`: Fixed units per tick for non-negative bar charts (default: None) - `format`: Number format string (default: "{:<5.2f}") - `suffix`: Add suffix to all values - `no_labels`: Don't show labels diff --git a/docs/args-class.md b/docs/args-class.md index 70ef135..d210065 100644 --- a/docs/args-class.md +++ b/docs/args-class.md @@ -35,6 +35,21 @@ args = Args(width=80) # Wide chart args = Args(width=30) # Narrow chart ``` +#### `scale` (float, default: None) +Fixed units represented by one tick in horizontal, stacked, and vertical bar +charts. Unlike automatic scaling, this does not depend on the dataset maximum. +It overrides `width` without clipping bars, rounds positive values up to whole +ticks, and leaves zero as zero. Stacked segments round independently. + +```python +args = Args(scale=4) # 6 -> 2 ticks, 12 -> 3 ticks +``` + +The scale must be positive and finite. The data must be finite and non-negative; +negative values are not offset because that would change the baseline between +runs. `different_scale`, histograms, and calendar heatmaps are not supported +with a fixed scale. Omitting `scale` preserves automatic scaling. + ### Chart Appearance #### `title` (str, default: None) @@ -209,7 +224,7 @@ args = Args(verbose=True) # Show debug information ## Methods -### `get_arg(arg: str) -> Union[int, str, bool, None]` +### `get_arg(arg: str) -> Union[int, float, str, bool, None]` Get the value of a specific argument. ```python @@ -359,6 +374,7 @@ default_args = { "filename": "-", "title": None, "width": 50, + "scale": None, "format": "{:<5.2f}", "suffix": "", "no_labels": False, @@ -423,4 +439,4 @@ chart.draw() ``` For more information about chart types, see [Chart Classes Documentation](chart-classes.md). -For data preparation, see [Data Class Documentation](data-class.md). \ No newline at end of file +For data preparation, see [Data Class Documentation](data-class.md). diff --git a/docs/data-class.md b/docs/data-class.md index 97e6399..c00684c 100644 --- a/docs/data-class.md +++ b/docs/data-class.md @@ -83,8 +83,9 @@ print(data.find_max_label_length()) # Output: 3 ### Data Normalization -#### `normalize(width: int) -> list` -Normalizes data values to fit within the specified width for chart rendering. +#### `normalize(width: int, scale: float | None = None) -> list` +Normalizes data values to fit within the specified width for chart rendering, +or uses a fixed number of units per tick when `scale` is provided. ```python # Flat data normalization @@ -99,11 +100,20 @@ print(normalized) # [[11.43, 17.14], [22.86, 28.57], [34.29, 40.0]] ``` The normalize method: -- Handles negative values by offsetting all data -- Scales values proportionally to the target width +- Without a fixed scale, handles negative values by offsetting all data +- Without a fixed scale, scales values proportionally to the target width - Preserves relative relationships between data points - Works with both flat and nested data structures +For fixed scaling, `width` is ignored and positive values round up to whole +ticks. `scale` must be a positive finite number; data must be finite and +non-negative. Neither mode mutates the input data. + +```python +data = Data([[6], [12]], ["A", "B"]) +print(data.normalize(50, scale=4)) # [[2], [3]] +``` + ## Properties ### `data` @@ -255,4 +265,4 @@ chart = BarChart(data, args) chart.draw() ``` -For more examples of using Data with different chart types, see the [Chart Classes Documentation](chart-classes.md). \ No newline at end of file +For more examples of using Data with different chart types, see the [Chart Classes Documentation](chart-classes.md). diff --git a/pyproject.toml b/pyproject.toml index 3ed33f9..6c013c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,10 @@ build-backend = "hatchling.build" [tool.ruff] target-version = "py39" +[tool.ruff.lint] +# Preserve the rule set used before Ruff expanded its defaults. +select = ["E4", "E7", "E9", "F"] + # Pytest configuration [tool.pytest.ini_options] minversion = "8.4" @@ -72,4 +76,3 @@ markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", ] - diff --git a/termgraph/args.py b/termgraph/args.py index 9ba4571..93e4c3f 100644 --- a/termgraph/args.py +++ b/termgraph/args.py @@ -11,6 +11,7 @@ class Args: "filename": "-", "title": None, "width": 50, + "scale": None, "format": "{:<5.2f}", "suffix": "", "no_labels": False, @@ -43,7 +44,7 @@ def __init__(self, **kwargs): else: raise Exception(f"Invalid Argument: {arg}") - def get_arg(self, arg: str) -> Union[int, str, bool, None]: + def get_arg(self, arg: str) -> Union[int, float, str, bool, None]: """Returns the value for the argument given. :arg: The name of the argument. @@ -63,4 +64,4 @@ def update_args(self, **kwargs) -> None: if arg in self.args: self.args[arg] = value else: - raise Exception(f"Invalid Argument: {arg}") \ No newline at end of file + raise Exception(f"Invalid Argument: {arg}") diff --git a/termgraph/chart.py b/termgraph/chart.py index 5adba1a..218b77d 100644 --- a/termgraph/chart.py +++ b/termgraph/chart.py @@ -103,7 +103,14 @@ def _normalize(self) -> list[list[float]]: width = self.args.get_arg("width") if not isinstance(width, int): width = 50 # Default width - return self.data.normalize(width) + scale = self.args.get_arg("scale") + if scale is None: + return self.data.normalize(width) + if self.args.get_arg("different_scale"): + raise ValueError("scale cannot be used with different_scale") + if scale is not None and not isinstance(scale, (int, float)): + raise ValueError("scale must be a positive finite number") + return self.data.normalize(width, scale=scale) class HorizontalChart(Chart): @@ -170,6 +177,8 @@ def __init__(self, data: Data, args: Args = Args()): def _normalize(self) -> list[list[float]]: """Normalize the data and return it.""" + if self.args.get_arg("scale") is not None: + return super()._normalize() if self.args.get_arg("different_scale"): # Normalization per category normal_data: List[List[float]] = [[] for _ in range(len(self.data.data))] @@ -426,6 +435,11 @@ def __init__(self, data: Data, args: Args = Args()): """ super().__init__(data, args) + def _normalize(self) -> list[list[float]]: + if self.args.get_arg("scale") is not None: + raise ValueError("scale cannot be used with histogram") + return super()._normalize() + def draw(self) -> None: """Draws the histogram chart""" self._print_header() diff --git a/termgraph/data.py b/termgraph/data.py index 5bddc4f..08cf898 100644 --- a/termgraph/data.py +++ b/termgraph/data.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Union import sys +import math from .constants import DELIM @@ -218,11 +219,39 @@ def __str__(self): return "\n".join(output) - def normalize(self, width: int) -> list: - """Normalize the data and return it.""" + def normalize(self, width: int, scale: float | None = None) -> list: + """Normalize data to width, or to a fixed number of units per tick. + + A fixed scale ignores width and rounds positive values up to a whole + tick. It requires finite, non-negative data and does not offset values. + """ # Check if data is flat (list of numbers) or nested (list of lists) is_flat = all(not isinstance(item, list) for item in self.data) + if scale is not None: + if ( + isinstance(scale, bool) + or not isinstance(scale, (int, float)) + or (isinstance(scale, float) and not math.isfinite(scale)) + or scale <= 0 + ): + raise ValueError("scale must be a positive finite number") + + def scaled_value(value: float) -> int: + if (isinstance(value, float) and not math.isfinite(value)) or value < 0: + raise ValueError("scale requires finite non-negative data") + try: + blocks = value / scale + except OverflowError: + raise ValueError("scaled value is too large") from None + if not math.isfinite(blocks): + raise ValueError("scaled value is too large") + return max(1, math.ceil(blocks)) if value > 0 else 0 + + if is_flat: + return [scaled_value(value) for value in self.data] + return [[scaled_value(value) for value in row] for row in self.data] + if is_flat: # Handle flat list data min_datum = min(self.data) diff --git a/termgraph/termgraph.py b/termgraph/termgraph.py index c8898f5..ab383bc 100644 --- a/termgraph/termgraph.py +++ b/termgraph/termgraph.py @@ -6,6 +6,7 @@ import os import re import importlib.metadata +import math from .constants import AVAILABLE_COLORS, DAYS from .data import Data @@ -18,6 +19,17 @@ just_fix_windows_console() +def _positive_scale(value: str) -> float: + """Parse the fixed number of units represented by one tick.""" + try: + scale = float(value) + except ValueError: + raise argparse.ArgumentTypeError("scale must be a positive finite number") from None + if not math.isfinite(scale) or scale <= 0: + raise argparse.ArgumentTypeError("scale must be a positive finite number") + return scale + + def init_args() -> dict: """Parse and return the arguments.""" parser = argparse.ArgumentParser(description="draw basic graphs on terminal") @@ -31,6 +43,10 @@ def init_args() -> dict: parser.add_argument( "--width", type=int, default=50, help="width of graph in characters default:50" ) + parser.add_argument( + "--scale", type=_positive_scale, + help="fixed units per tick for non-negative bar charts; overrides --width", + ) parser.add_argument("--format", default="{:<5.2f}", help="format specifier to use.") parser.add_argument( "--suffix", default="", help="string to add as a suffix to all data points." @@ -92,6 +108,11 @@ def init_args() -> dict: args = vars(parser.parse_args()) + if args["scale"] is not None: + for option in ("different_scale", "histogram", "calendar"): + if args[option]: + parser.error(f"--scale cannot be used with --{option.replace('_', '-')}") + return args @@ -114,6 +135,11 @@ def main(): chart(data_obj, args, colors) except BrokenPipeError: pass + except ValueError as exc: + if args.get("scale") is None: + raise + print(f">> Error: {exc}", file=sys.stderr) + sys.exit(2) def chart(data_obj: Data, args: dict, colors: list) -> None: diff --git a/tests/test_init.py b/tests/test_init.py index 9e5b125..06662fb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,5 +1,10 @@ from termgraph import termgraph as tg +import sys -def test_init_args(): - tg.init_args() \ No newline at end of file +def test_init_args(monkeypatch): + monkeypatch.setattr(sys, "argv", ["termgraph", "input.dat"]) + args = tg.init_args() + assert args["filename"] == "input.dat" + assert args["width"] == 50 + assert args["scale"] is None diff --git a/tests/test_read_data.py b/tests/test_read_data.py index 5ef9faa..1c6b01e 100644 --- a/tests/test_read_data.py +++ b/tests/test_read_data.py @@ -1,4 +1,3 @@ -import tempfile from unittest.mock import patch from io import StringIO from termgraph import termgraph as tg @@ -38,71 +37,71 @@ def test_read_data_returns_correct_results(): assert colors == [] -def test_concatenate_neighboring_labels(): - with tempfile.NamedTemporaryFile("w") as tmp: - args = { - "filename": tmp.name, - "title": None, - "width": 50, - "format": "{:<5.2f}", - "suffix": "", - "no_labels": False, - "color": None, - "vertical": False, - "stacked": False, - "different_scale": False, - "calendar": False, - "start_dt": None, - "custom_tick": "", - "delim": "", - "verbose": False, - "version": False, - } - tmp.write( - """ +def test_concatenate_neighboring_labels(tmp_path): + input_file = tmp_path / "data.txt" + input_file.write_text( + """ label one 1 2 3 label two 5 6 7 label three 9 10 11 - """ - ) - tmp.seek(0) - categories, labels, data, colors = tg.read_data(args) - assert labels == ["label one", "label two", "label three"] - assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]] + """, + encoding="utf-8", + ) + args = { + "filename": str(input_file), + "title": None, + "width": 50, + "format": "{:<5.2f}", + "suffix": "", + "no_labels": False, + "color": None, + "vertical": False, + "stacked": False, + "different_scale": False, + "calendar": False, + "start_dt": None, + "custom_tick": "", + "delim": "", + "verbose": False, + "version": False, + } + categories, labels, data, colors = tg.read_data(args) + assert labels == ["label one", "label two", "label three"] + assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]] -def test_labels_at_end_of_row(): +def test_labels_at_end_of_row(tmp_path): """Check that we can identify labels that come after the data""" - with tempfile.NamedTemporaryFile("w") as tmp: - args = { - "filename": tmp.name, - "title": None, - "width": 50, - "format": "{:<5.2f}", - "suffix": "", - "no_labels": False, - "color": None, - "vertical": False, - "stacked": False, - "different_scale": False, - "calendar": False, - "start_dt": None, - "custom_tick": "", - "delim": "", - "verbose": False, - "version": False, - } - tmp.write( - """ + input_file = tmp_path / "data.txt" + input_file.write_text( + """ 1 2 3 A 5 6 7 B 9 10 11 C - """ - ) - tmp.seek(0) - categories, labels, data, colors = tg.read_data(args) - assert labels == ["A", "B", "C"] - assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]] + """, + encoding="utf-8", + ) + args = { + "filename": str(input_file), + "title": None, + "width": 50, + "format": "{:<5.2f}", + "suffix": "", + "no_labels": False, + "color": None, + "vertical": False, + "stacked": False, + "different_scale": False, + "calendar": False, + "start_dt": None, + "custom_tick": "", + "delim": "", + "verbose": False, + "version": False, + } + categories, labels, data, colors = tg.read_data(args) + assert labels == ["A", "B", "C"] + assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]] def test_read_data_verbose(): diff --git a/tests/test_scale.py b/tests/test_scale.py new file mode 100644 index 0000000..b4e1643 --- /dev/null +++ b/tests/test_scale.py @@ -0,0 +1,198 @@ +"""Fixed-scale normalization, rendering, and command-line regression tests.""" + +import math +import os +import re +import subprocess +import sys +from copy import deepcopy + +import pytest + +from termgraph import Args, BarChart, Data, HistogramChart, StackedChart, VerticalChart + + +def plain_output(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def run_cli(*options, data="A 6\nB 12\n"): + env = dict(os.environ, PYTHONIOENCODING="utf-8") + return subprocess.run( + [sys.executable, "-m", "termgraph.termgraph", *options], + input=data, + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=15, + check=False, + ) + + +@pytest.mark.parametrize( + ("values", "expected"), + [ + ([0, 1, 4, 5, 8], [0, 1, 1, 2, 2]), + ([[0, 1], [4, 5], [8, 12]], [[0, 1], [1, 2], [2, 3]]), + ([[4], [4]], [[1], [1]]), + ([[0], [0]], [[0], [0]]), + ], +) +def test_fixed_scale_normalization(values, expected): + original = deepcopy(values) + data = Data(values, [str(i) for i in range(len(values))]) + assert data.normalize(50, scale=4) == expected + assert data.data == original + + +def test_fixed_scale_does_not_depend_on_width_or_other_values(): + first = Data([[26], [37], [6]], ["A", "B", "C"]) + second = Data([[12], [70], [6]], ["A", "B", "C"]) + assert first.normalize(1, scale=4) == [[7], [10], [2]] + assert second.normalize(100, scale=4) == [[3], [18], [2]] + + +def test_fractional_scale(): + data = Data([[0.1], [0.5], [0.75]], ["A", "B", "C"]) + assert data.normalize(50, scale=0.25) == [[1], [2], [3]] + + +@pytest.mark.parametrize("scale", [0, -1, math.nan, math.inf, -math.inf, True, "4"]) +def test_invalid_api_scale(scale): + data = Data([[1]], ["A"]) + with pytest.raises(ValueError, match="scale must be a positive finite number"): + data.normalize(50, scale=scale) + + +@pytest.mark.parametrize("value", [-1, math.nan, math.inf, -math.inf]) +def test_fixed_scale_rejects_negative_or_nonfinite_values(value): + data = Data([[value]], ["A"]) + with pytest.raises(ValueError, match="finite non-negative data"): + data.normalize(50, scale=4) + + +def test_fixed_scale_rejects_overflow(): + data = Data([[1e308]], ["A"]) + with pytest.raises(ValueError, match="scaled value is too large"): + data.normalize(50, scale=1e-308) + + +def test_very_large_integers_do_not_raise_overflow_error(): + large = 10**400 + data = Data([[large]], ["A"]) + assert data.normalize(50, scale=large) == [[1]] + with pytest.raises(ValueError, match="scaled value is too large"): + data.normalize(50, scale=4) + + +@pytest.mark.parametrize("values", [[0, 1, 4, 5], [[0], [1], [4], [5]]]) +def test_fixed_scale_bar_rendering(values, capsys): + chart = BarChart( + Data(values, ["A", "B", "C", "D"]), + Args(scale=4, width=1, custom_tick="#", format="{:g}", suffix=" units"), + ) + chart.draw() + assert plain_output(capsys.readouterr().out).splitlines() == [ + "A: 0 units", + "B: # 1 units", + "C: # 4 units", + "D: ## 5 units", + ] + + +def test_fixed_scale_stacked_rendering(capsys): + chart = StackedChart( + Data([[4, 8], [8, 12]], ["A", "B"]), + Args(scale=4, custom_tick="#", format="{:g}"), + ) + chart.draw() + assert plain_output(capsys.readouterr().out).splitlines() == [ + "A: ### 12", + "B: ##### 20", + ] + + +def test_fixed_scale_vertical_rendering(capsys): + chart = VerticalChart( + Data([[4], [12]], ["A", "B"]), + Args(scale=4, width=1, custom_tick="#", no_values=True, no_labels=True), + ) + chart.draw() + assert plain_output(capsys.readouterr().out).splitlines() == [" #", " #", "# #"] + + +@pytest.mark.parametrize("chart_type", [BarChart, StackedChart, VerticalChart]) +def test_fixed_scale_rejects_different_scale_api(chart_type): + with pytest.raises(ValueError, match="different_scale"): + chart_type(Data([[1, 2]], ["A"]), Args(scale=4, different_scale=True)) + + +def test_fixed_scale_rejects_histogram_api(): + with pytest.raises(ValueError, match="histogram"): + HistogramChart(Data([[1], [2]], ["A", "B"]), Args(scale=4)) + + +def test_cli_fixed_scale(): + result = run_cli( + "--scale", "4", "--width", "1", "--custom-tick", "#", "--format", "{:g}" + ) + assert result.returncode == 0, result.stderr + assert plain_output(result.stdout).splitlines() == ["A: ## 6", "B: ### 12"] + + +@pytest.mark.parametrize("option", ["--stacked", "--vertical"]) +def test_cli_fixed_scale_other_bar_charts(option): + result = run_cli("--scale", "4", option, "--custom-tick", "#") + assert result.returncode == 0, result.stderr + assert "#" in result.stdout + + +@pytest.mark.parametrize("scale", ["0", "-1", "nan", "inf", "-inf", "text"]) +def test_cli_rejects_invalid_scale(scale): + result = run_cli(f"--scale={scale}") + assert result.returncode == 2 + assert "positive finite number" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" + + +@pytest.mark.parametrize("option", ["--different-scale", "--histogram", "--calendar"]) +def test_cli_rejects_incompatible_modes(option): + result = run_cli("--scale", "4", option) + assert result.returncode == 2 + assert f"--scale cannot be used with {option}" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" + + +def test_cli_rejects_negative_data_without_traceback(): + result = run_cli("--scale", "4", data="A -1\nB 2\n") + assert result.returncode == 2 + assert "finite non-negative data" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" + + +def test_scale_none_preserves_automatic_normalization(): + data = Data([[1], [2]], ["A", "B"]) + assert data.normalize(50, scale=None) == data.normalize(50) + assert BarChart(data, Args()).normal_data == data.normalize(50) + + +def test_automatic_scaling_preserves_legacy_normalize_override(): + class CustomData(Data): + def normalize(self, width): + return [[width]] + + assert BarChart(CustomData([[1]], ["A"]), Args(width=3)).normal_data == [[3]] + + +def test_positive_values_remain_visible_after_division_underflow(): + assert Data([[1e-300]], ["A"]).normalize(50, scale=1e300) == [[1]] + + +def test_args_scale_can_be_updated(): + args = Args() + args.update_args(scale=4) + assert BarChart(Data([[6]], ["A"]), args).normal_data == [[2]]