Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions docs/args-class.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -359,6 +374,7 @@ default_args = {
"filename": "-",
"title": None,
"width": 50,
"scale": None,
"format": "{:<5.2f}",
"suffix": "",
"no_labels": False,
Expand Down Expand Up @@ -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).
For data preparation, see [Data Class Documentation](data-class.md).
20 changes: 15 additions & 5 deletions docs/data-class.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -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).
For more examples of using Data with different chart types, see the [Chart Classes Documentation](chart-classes.md).
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -72,4 +76,3 @@ markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]

5 changes: 3 additions & 2 deletions termgraph/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Args:
"filename": "-",
"title": None,
"width": 50,
"scale": None,
"format": "{:<5.2f}",
"suffix": "",
"no_labels": False,
Expand Down Expand Up @@ -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.
Expand All @@ -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}")
raise Exception(f"Invalid Argument: {arg}")
16 changes: 15 additions & 1 deletion termgraph/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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))]
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 31 additions & 2 deletions termgraph/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations
from typing import Union
import sys
import math
from .constants import DELIM


Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions termgraph/termgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import re
import importlib.metadata
import math

from .constants import AVAILABLE_COLORS, DAYS
from .data import Data
Expand All @@ -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")
Expand All @@ -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."
Expand Down Expand Up @@ -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


Expand All @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from termgraph import termgraph as tg
import sys


def test_init_args():
tg.init_args()
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
Loading
Loading