├── data
├── ex3.dat
├── ex2.dat
├── ex9.dat
├── ex1.dat
├── single_category.dat
├── ex_pipe.dat
├── ex7.dat
├── ex4.dat
├── ex6.dat
├── test_data-116.dat
├── ex8.dat
├── ex5.dat
├── gener_rdat.py
└── cal.dat
├── tests
├── test_init.py
├── module-test3.py
├── module-test1.py
├── module-test2.py
├── test_normalize.py
├── test_charts.py
├── test_data_utils.py
├── test_read_data.py
└── README.md
├── .gitignore
├── termgraph
├── module.py
├── constants.py
├── __init__.py
├── args.py
├── utils.py
├── termgraph.py
├── data.py
└── chart.py
├── .github
└── workflows
│ └── checks.yml
├── LICENSE.txt
├── justfile
├── docs
├── assets
│ ├── barchart-stacked.svg
│ ├── barchart-multivar.svg
│ └── cal-heatmap.svg
├── index.md
├── data-class.md
├── args-class.md
├── chart-classes.md
└── README.md
├── pyproject.toml
├── README.md
└── CONTRIBUTING.md
/data/ex3.dat:
--------------------------------------------------------------------------------
1 | 1,2
2 | 2,3
3 | 3,4
4 | 4,5
5 | 5,6
6 |
--------------------------------------------------------------------------------
/data/ex2.dat:
--------------------------------------------------------------------------------
1 | label 2
2 | longlabel 3
3 | foo 4
4 | barrific 5
5 | wut 6
6 |
--------------------------------------------------------------------------------
/data/ex9.dat:
--------------------------------------------------------------------------------
1 | # Small Value Data
2 | A 1.32
3 | B 1.21
4 | C 0.50
5 | D 1.68
6 | E 1.00
7 | F 0.75
8 | G 0.00
9 | H 0.90
--------------------------------------------------------------------------------
/tests/test_init.py:
--------------------------------------------------------------------------------
1 | from termgraph import termgraph as tg
2 |
3 |
4 | def test_init_args():
5 | tg.init_args()
--------------------------------------------------------------------------------
/data/ex1.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 1
2 | 2007 183.32
3 | 2008 231.23
4 | 2009 16.43
5 | 2010 50.21
6 | 2011 508.97
7 | 2012 212.05
8 | 2014 1.0
9 |
--------------------------------------------------------------------------------
/data/single_category.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 4 with 2 Categories
2 | @ Boys
3 | 2007,183
4 | 2008,231
5 | 2009,16
6 | 2010,50
7 | 2011,508
8 | 2012,212
9 | 2014,30
10 |
--------------------------------------------------------------------------------
/data/ex_pipe.dat:
--------------------------------------------------------------------------------
1 | # Example Data with Pipe Delimiter
2 | @ Category A|Category B|Category C
3 | Label One|10.5|20.3|15.8
4 | Label Two|25.0|18.5|30.2
5 | Label Three|12.1|22.7|19.4
6 |
--------------------------------------------------------------------------------
/data/ex7.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 7 without Categories' names
2 | 2007,183.32,190.52
3 | 2008,231.23,5.0
4 | 2009,16.43,53.1
5 | 2010,50.21,7
6 | 2011,508.97,10.45
7 | 2012,212.05,20.2
8 | 2014,30.0,20.0
9 |
--------------------------------------------------------------------------------
/data/ex4.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 4 with 2 Categories
2 | @ Boys,Girls
3 | 2007,183.32,190.52
4 | 2008,231.23,5.0
5 | 2009,16.43,53.1
6 | 2010,50.21,7
7 | 2011,508.97,10.45
8 | 2012,212.05,20.2
9 | 2014,30.0,20.0
--------------------------------------------------------------------------------
/data/ex6.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 6 with Missing Values
2 | @ Boys,Girls
3 | 2007,183.32,190.52
4 | 2008,231.23,5.0
5 | 2009,16.43,53.1
6 | 2010,50.21,7
7 | 2011,508.97
8 | 2012,212.05,20.2
9 | 2014,10.0,9
10 |
--------------------------------------------------------------------------------
/data/test_data-116.dat:
--------------------------------------------------------------------------------
1 | Select
2 | UnhandledCommand
3 | Hotkey
4 | Hotkey
5 | Hotkey
6 | TargetedOrderModern
7 | Hotkey
8 | TargetedOrderModern
9 | Hotkey
10 | Select
11 | RightClick
12 | RightClick
13 | RightClick
14 | SelectRemove
--------------------------------------------------------------------------------
/data/ex8.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 8 with Categories of different scale
2 | @ People,Income
3 | 2007,100,500000
4 | 2008,450.23,760000
5 | 2009,255,1000000
6 | 2010,340,800000
7 | 2011,160,600400
8 | 2012,200,950000
9 | 2014,488,700000
10 |
--------------------------------------------------------------------------------
/data/ex5.dat:
--------------------------------------------------------------------------------
1 | # Example Data Set 5 with 3 Categories
2 | @ Boys,Girls,Babies
3 | 2007,183.32,190.52,90.0
4 | 2008,231.23,50.0,80.6
5 | 2009,16.43,53.1,76.54
6 | 2010,50.21,7,0.0
7 | 2011,508.97,10.45,-27.0
8 | 2012,212.05,20.2,-4.4
9 | 2014,30.0,9,9.8
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # OS
3 | .DS_Store
4 |
5 | # Python
6 | .coverage
7 | dist/
8 | htmlcov/
9 | .mypy_cache/
10 | *.pyc
11 | .pytest_cache/
12 | .python-version
13 | .ropeproject/
14 | .ruff_cache/
15 | termgraph.egg-info/
16 | uv.lock
17 | .venv/
18 |
19 | # Editors
20 | .claude/
21 | CLAUDE.md
22 | .gemini/
23 | .vim/
24 | .vimrc
25 | .vscode/
26 |
27 |
--------------------------------------------------------------------------------
/data/gener_rdat.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import random
3 |
4 | b = [2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018]
5 | a = [1, 40, 24, 26, 29, 80, 100, 36]
6 |
7 | BASE = 1990
8 |
9 | YEARS = 28
10 |
11 | f = open('random.dat', 'w')
12 | for offset in range(YEARS):
13 | date = BASE + offset
14 | value = random.randint(-500, 500)
15 |
16 | f.write(f'{date} {int(value)}\n')
17 | f.close()
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/module-test3.py:
--------------------------------------------------------------------------------
1 | from termgraph import Data, Args, BarChart
2 |
3 | # Create data
4 | data = Data(
5 | labels=["Q1", "Q2", "Q3", "Q4"],
6 | data=[10, 20, 40, 26],
7 | )
8 |
9 | # Configure chart options
10 | args = Args(
11 | title="Quarterly Sales",
12 | width=50,
13 | format="{:.0f}",
14 | suffix="K"
15 | )
16 |
17 | # Create and display chart
18 | chart = BarChart(data, args)
19 | chart.draw()
20 |
--------------------------------------------------------------------------------
/termgraph/module.py:
--------------------------------------------------------------------------------
1 | """This module allows drawing basic graphs in the terminal."""
2 |
3 | # termgraph.py - draw basic graphs on terminal
4 | # https://github.com/mkaz/termgraph
5 |
6 | # Import all classes from their dedicated files to maintain backward compatibility
7 | from .data import Data
8 | from .args import Args
9 | from .chart import Colors, Chart, HorizontalChart, BarChart, StackedChart, HistogramChart
10 |
11 | # Re-export everything to maintain existing API
12 | __all__ = ['Data', 'Args', 'Colors', 'Chart', 'HorizontalChart', 'BarChart', 'StackedChart', 'HistogramChart']
13 |
--------------------------------------------------------------------------------
/termgraph/constants.py:
--------------------------------------------------------------------------------
1 | """Shared constants for termgraph."""
2 |
3 | from __future__ import annotations
4 |
5 | # Calendar days
6 | DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
7 |
8 | # Units for human-readable numbers
9 | UNITS = ["", "K", "M", "B", "T"]
10 |
11 | # Default delimiter
12 | DELIM = ","
13 |
14 | # Graph characters
15 | TICK = "▇"
16 | SM_TICK = "▏"
17 |
18 | # ANSI escape SGR Parameters color codes
19 | AVAILABLE_COLORS = {
20 | "red": 91,
21 | "blue": 94,
22 | "green": 92,
23 | "magenta": 95,
24 | "yellow": 93,
25 | "black": 90,
26 | "cyan": 96,
27 | }
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | lint-and-test:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - name: Install uv
11 | uses: astral-sh/setup-uv@v3
12 | with:
13 | version: "latest"
14 | - name: Setup Python
15 | run: uv python install 3.9
16 | - name: Install dependencies
17 | run: uv sync --dev
18 | - name: Run lint
19 | run: uv run python -m ruff check .
20 | - name: Run typecheck
21 | run: uv run python -m mypy termgraph/
22 | - name: Run tests
23 | run: uv run python -m pytest
24 |
--------------------------------------------------------------------------------
/tests/module-test1.py:
--------------------------------------------------------------------------------
1 | from termgraph.data import Data
2 | from termgraph.args import Args
3 | from termgraph.chart import BarChart, StackedChart, Colors
4 |
5 | # Original Bar Chart
6 | print("--- Bar Chart ---")
7 | data = Data([[765, 787], [781, 769]], ["6th G", "7th G"], ["Boys", "Girls"])
8 | chart = BarChart(
9 | data,
10 | Args(
11 | title="Total Marks Per Class",
12 | colors=[Colors.Red, Colors.Magenta],
13 | space_between=True,
14 | ),
15 | )
16 | chart.draw()
17 |
18 | # Stacked Chart
19 | print("\n--- Stacked Chart ---")
20 | stacked_chart = StackedChart(
21 | data,
22 | Args(
23 | title="Total Marks Per Class (Stacked)",
24 | colors=[Colors.Green, Colors.Blue],
25 | space_between=True,
26 | ),
27 | )
28 | stacked_chart.draw()
--------------------------------------------------------------------------------
/data/cal.dat:
--------------------------------------------------------------------------------
1 | 2017-07-01 4.52
2 | 2017-07-06 4.81
3 | 2017-07-08 5.05
4 | 2017-07-15 2.00
5 | 2017-07-17 5.65
6 | 2017-07-19 5.15
7 | 2017-07-21 3.75
8 | 2017-08-02 3.72
9 | 2017-08-03 5.04
10 | 2017-08-05 4.60
11 | 2017-08-08 4.77
12 | 2017-08-10 5.44
13 | 2017-08-18 4.30
14 | 2017-08-24 4.84
15 | 2017-08-27 6.31
16 | 2017-08-31 4.31
17 | 2017-09-06 4.15
18 | 2017-09-08 5.19
19 | 2017-09-10 3.65
20 | 2017-09-13 4.01
21 | 2017-09-16 7.19
22 | 2017-09-21 4.21
23 | 2017-09-25 4.58
24 | 2017-09-28 8.09
25 | 2017-10-02 4.04
26 | 2017-10-09 4.29
27 | 2017-10-13 4.69
28 | 2017-10-18 4.31
29 | 2017-10-22 5.05
30 | 2017-10-28 13.10
31 | 2017-11-01 3.60
32 | 2017-11-10 4.70
33 | 2018-02-06 3.77
34 | 2018-03-05 3.80
35 | 2018-03-07 3.54
36 | 2018-05-16 3.42
37 | 2018-05-21 3.31
38 | 2018-06-11 3.55
39 | 2018-06-13 3.43
40 | 2018-06-16 3.79
41 | 2018-06-20 4.26
42 | 2018-06-21 0.00
43 | 2018-06-22 0.00
44 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Marcus Kazmierczak
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/termgraph/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ["main", "Data", "Args", "Colors", "Chart", "HorizontalChart", "BarChart", "StackedChart", "VerticalChart", "HistogramChart"]
2 |
3 | def __getattr__(name):
4 | if name == "main":
5 | from .termgraph import main
6 | return main
7 | elif name in ["Data", "Args", "Colors", "Chart", "HorizontalChart", "BarChart", "StackedChart", "VerticalChart", "HistogramChart"]:
8 | # Import from the new modular structure
9 | if name == "Data":
10 | from .data import Data
11 | return Data
12 | elif name == "Args":
13 | from .args import Args
14 | return Args
15 | elif name in ["Colors", "Chart", "HorizontalChart", "BarChart", "StackedChart", "VerticalChart", "HistogramChart"]:
16 | from .chart import Colors, Chart, HorizontalChart, BarChart, StackedChart, VerticalChart, HistogramChart
17 | return {"Colors": Colors, "Chart": Chart, "HorizontalChart": HorizontalChart, "BarChart": BarChart, "StackedChart": StackedChart, "VerticalChart": VerticalChart, "HistogramChart": HistogramChart}[name]
18 | raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
--------------------------------------------------------------------------------
/tests/module-test2.py:
--------------------------------------------------------------------------------
1 | from termgraph.data import Data
2 | from termgraph.args import Args
3 | from termgraph.chart import BarChart, HistogramChart, Colors
4 |
5 | # Original Bar Chart
6 | print("--- Simple Bar Chart ---")
7 | data = Data([[10], [50], [80], [100]], ["Label 1", "Label 2", "Label 3", "Label 4"])
8 | chart = BarChart(
9 | data,
10 | Args(
11 | colors=[Colors.Red],
12 | suffix="%",
13 | ),
14 | )
15 | chart.draw()
16 |
17 | # Different Scale Chart
18 | print("\n--- Different Scale Chart ---")
19 | diff_scale_data = Data([[10, 1000], [20, 2000], [30, 3000]], ["A", "B", "C"], ["Category 1", "Category 2"])
20 | diff_scale_chart = BarChart(
21 | diff_scale_data,
22 | Args(
23 | title="Different Scale Example",
24 | different_scale=True,
25 | colors=[Colors.Cyan, Colors.Yellow]
26 | )
27 | )
28 | diff_scale_chart.draw()
29 |
30 | # Histogram
31 | print("\n--- Histogram ---")
32 | hist_data_values = [[1], [2], [2], [3], [3], [3], [4], [4], [4], [4], [5], [5], [5], [5], [5]]
33 | hist_data = Data(hist_data_values, [str(i) for i in range(len(hist_data_values))])
34 | histogram = HistogramChart(
35 | hist_data,
36 | Args(
37 | title="Histogram Example",
38 | colors=[Colors.Green],
39 | bins=4
40 | )
41 | )
42 | histogram.draw()
43 |
--------------------------------------------------------------------------------
/tests/test_normalize.py:
--------------------------------------------------------------------------------
1 | from termgraph import termgraph as tg
2 |
3 |
4 | def test_normalize_returns_correct_results():
5 | expected = [
6 | [18.00891997563707],
7 | [22.715484213214925],
8 | [1.6140440497475292],
9 | [4.932510757019078],
10 | [50.0],
11 | [20.83128671630941],
12 | [0.09823761714835845],
13 | ]
14 | results = tg.normalize(
15 | [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]], 50
16 | )
17 | assert results == expected
18 |
19 |
20 | def test_normalize_with_all_zeros_returns_correct_results():
21 | expected = [
22 | [0],
23 | [0],
24 | [0],
25 | [0],
26 | [0],
27 | [0],
28 | [0],
29 | ]
30 | results = tg.normalize([[0], [0], [0], [0], [0], [0], [0]], 50)
31 | assert results == expected
32 |
33 |
34 | def test_normalize_with_negative_datapoint_returns_correct_results():
35 | expected = [
36 | [18.625354066709058],
37 | [23.241227816636798],
38 | [2.546389964737846],
39 | [5.8009133475923464],
40 | [50.0],
41 | [21.393336801741913],
42 | [0.0],
43 | ]
44 | results = tg.normalize(
45 | [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [-10.0]], 50
46 | )
47 | assert results == expected
48 |
49 |
50 | def test_normalize_with_larger_width_does_not_normalize():
51 | data = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]]
52 | expected = [
53 | [7203.567990254828],
54 | [9086.193685285969],
55 | [645.6176198990117],
56 | [1973.0043028076311],
57 | [20000.0],
58 | [8332.514686523764],
59 | [39.29504685934338],
60 | ]
61 | results = tg.normalize(data, 20000)
62 | assert results == expected
--------------------------------------------------------------------------------
/termgraph/args.py:
--------------------------------------------------------------------------------
1 | """Args class for termgraph - handles chart configuration options."""
2 |
3 | from __future__ import annotations
4 | from typing import Union
5 |
6 |
7 | class Args:
8 | """Class representing the arguments to modify the graph."""
9 |
10 | default = {
11 | "filename": "-",
12 | "title": None,
13 | "width": 50,
14 | "format": "{:<5.2f}",
15 | "suffix": "",
16 | "no_labels": False,
17 | "no_values": False,
18 | "space_between": False,
19 | "colors": None,
20 | "vertical": False,
21 | "stacked": False,
22 | "histogram": False,
23 | "bins": 5,
24 | "different_scale": False,
25 | "calendar": False,
26 | "start_dt": None,
27 | "custom_tick": "",
28 | "delim": "",
29 | "verbose": False,
30 | "label_before": False,
31 | "percentage": False,
32 | "no_readable": False,
33 | }
34 |
35 | def __init__(self, **kwargs):
36 | """Initialize the Args object."""
37 |
38 | self.args = dict(self.default)
39 |
40 | for arg, value in list(kwargs.items()):
41 | if arg in self.args:
42 | self.args[arg] = value
43 | else:
44 | raise Exception(f"Invalid Argument: {arg}")
45 |
46 | def get_arg(self, arg: str) -> Union[int, str, bool, None]:
47 | """Returns the value for the argument given.
48 |
49 | :arg: The name of the argument.
50 | :returns: The value of the argument.
51 |
52 | """
53 |
54 | if arg in self.args:
55 | return self.args[arg]
56 | else:
57 | raise Exception(f"Invalid Argument: {arg}")
58 |
59 | def update_args(self, **kwargs) -> None:
60 | """Updates the arguments"""
61 |
62 | for arg, value in list(kwargs.items()):
63 | if arg in self.args:
64 | self.args[arg] = value
65 | else:
66 | raise Exception(f"Invalid Argument: {arg}")
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | # Justfile for termgraph
2 | # Run `just` to see available recipes
3 |
4 | # Default recipe to display available commands
5 | default:
6 | @just --list
7 |
8 | # Install development dependencies
9 | install:
10 | uv sync --dev
11 |
12 | # Install production dependencies only
13 | install-prod:
14 | uv sync
15 |
16 | # Clean build artifacts
17 | clean:
18 | rm -rf dist/
19 | rm -rf build/
20 | rm -rf *.egg-info/
21 | rm -rf htmlcov/
22 | rm -rf .pytest_cache/
23 | rm -rf .ruff_cache/
24 | find . -type d -name __pycache__ -delete
25 | find . -type f -name "*.pyc" -delete
26 |
27 | # Run tests
28 | test:
29 | uv run python -m pytest
30 |
31 | # Run tests with verbose output
32 | test-verbose:
33 | uv run python -m pytest -v
34 |
35 | # Run specific test file
36 | test-file file:
37 | uv run python -m pytest {{file}} -v
38 |
39 | # Run module API tests (standalone executable tests)
40 | test-module:
41 | @echo "Running module-test1.py..."
42 | @uv run python tests/module-test1.py
43 | @echo "\nRunning module-test2.py..."
44 | @uv run python tests/module-test2.py
45 | @echo "\nRunning module-test3.py..."
46 | @uv run python tests/module-test3.py
47 |
48 | # Lint code with ruff
49 | lint:
50 | uv run python -m ruff check .
51 |
52 | # Format code with ruff
53 | lint-fix:
54 | uv run python -m ruff check --fix .
55 | uv run python -m ruff format .
56 |
57 | # Run mypy typecheck
58 | typecheck:
59 | uv run python -m mypy termgraph/
60 |
61 | # Run all quality checks (lint, format, typecheck)
62 | check: lint typecheck
63 |
64 | # Build distribution packages
65 | build: clean
66 | uv run python -m build
67 |
68 | # Check distribution packages
69 | check-dist: build
70 | uv run python -m twine check dist/*
71 |
72 | # Publish to PyPI
73 | publish: build check-dist
74 | uv run python -m twine upload dist/*
75 |
76 | # Run the application with sample data
77 | run-example:
78 | uv run python -m termgraph.termgraph data/ex1.dat
79 |
80 | run file *args:
81 | uv run python -m termgraph.termgraph {{file}} {{args}}
82 |
--------------------------------------------------------------------------------
/tests/test_charts.py:
--------------------------------------------------------------------------------
1 | from termgraph.data import Data
2 | from termgraph.args import Args
3 | from termgraph.chart import BarChart, VerticalChart
4 |
5 | def test_barchart_draws_correctly():
6 | labels = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"]
7 | data_values = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]]
8 |
9 | data = Data(data_values, labels)
10 | args = Args(width=50, no_labels=False, suffix="", no_values=False)
11 |
12 | chart = BarChart(data, args)
13 |
14 | # Capture the output of the draw method
15 | import io
16 | from contextlib import redirect_stdout
17 |
18 | f = io.StringIO()
19 | with redirect_stdout(f):
20 | chart.draw()
21 | output = f.getvalue()
22 |
23 | # Assert that the output contains the expected elements
24 | assert "2007: " in output
25 | assert "183.32" in output
26 | assert "2014: " in output
27 | assert "1.00" in output
28 |
29 | def test_verticalchart_draws_correctly():
30 | labels = ["A", "B"]
31 | data_values = [[10], [20]]
32 |
33 | data = Data(data_values, labels)
34 | args = Args(width=10)
35 |
36 | chart = VerticalChart(data, args)
37 |
38 | import io
39 | from contextlib import redirect_stdout
40 |
41 | f = io.StringIO()
42 | with redirect_stdout(f):
43 | chart.draw()
44 | output = f.getvalue()
45 |
46 | # Assert that the output contains the expected elements
47 | assert "▇" in output
48 | assert "A" in output
49 | assert "B" in output
50 | assert "10" in output
51 | assert "20" in output
52 |
53 | def test_custom_tick_appears_in_output():
54 | labels = ["A", "B"]
55 | data_values = [[10], [20]]
56 |
57 | data = Data(data_values, labels)
58 | args = Args(custom_tick="😀")
59 |
60 | chart = BarChart(data, args)
61 |
62 | import io
63 | from contextlib import redirect_stdout
64 |
65 | f = io.StringIO()
66 | with redirect_stdout(f):
67 | chart.draw()
68 | output = f.getvalue()
69 |
70 | # Assert that the custom tick appears in the output
71 | assert "😀" in output
72 |
--------------------------------------------------------------------------------
/docs/assets/barchart-stacked.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
50 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 |
2 | [project]
3 | name = "termgraph"
4 | version = "0.7.5"
5 | description = "A command-line tool that draws basic graphs in the terminal."
6 | readme = "README.md"
7 | license = "MIT"
8 | authors = [
9 | {name = "mkaz", email = "marcus@mkaz.com"},
10 | ]
11 | maintainers = [
12 | {name = "mkaz", email = "marcus@mkaz.com"},
13 | ]
14 | classifiers = [
15 | "Development Status :: 5 - Production/Stable",
16 | "Environment :: Console",
17 | "Intended Audience :: Developers",
18 | "License :: OSI Approved :: MIT License",
19 | "Natural Language :: English",
20 | "Operating System :: MacOS :: MacOS X",
21 | "Operating System :: POSIX",
22 | "Programming Language :: Python",
23 | "Programming Language :: Python :: 3.9",
24 | "Programming Language :: Python :: 3.10",
25 | "Programming Language :: Python :: 3.11",
26 | "Programming Language :: Python :: 3.12",
27 | "Topic :: Utilities",
28 | "Topic :: Software Development :: Libraries :: Python Modules",
29 | ]
30 | keywords = ["python", "CLI", "tool", "drawing", "graphs", "shell", "terminal", "visualization"]
31 | requires-python = ">=3.9"
32 | dependencies = [
33 | "colorama>=0.4.6",
34 | ]
35 |
36 | [dependency-groups]
37 | dev = [
38 | "pytest>=8.4.1",
39 | "ruff>=0.1.0",
40 | "build>=1.3.0",
41 | "twine>=6.1.0",
42 | "mypy>=1.17.1",
43 | "types-colorama>=0.4.15.20250801",
44 | ]
45 |
46 | [project.urls]
47 | Homepage = "https://github.com/mkaz/termgraph"
48 | Repository = "https://github.com/mkaz/termgraph"
49 | Issues = "https://github.com/mkaz/termgraph/issues"
50 | Changelog = "https://github.com/mkaz/termgraph/releases"
51 |
52 | [project.scripts]
53 | termgraph = "termgraph.termgraph:main"
54 |
55 | [build-system]
56 | requires = ["hatchling"]
57 | build-backend = "hatchling.build"
58 |
59 | # Ruff configuration
60 | [tool.ruff]
61 | target-version = "py39"
62 |
63 | # Pytest configuration
64 | [tool.pytest.ini_options]
65 | minversion = "8.4"
66 | addopts = [
67 | "--strict-markers",
68 | "--strict-config",
69 | ]
70 | testpaths = ["tests"]
71 | markers = [
72 | "slow: marks tests as slow (deselect with '-m \"not slow\"')",
73 | "integration: marks tests as integration tests",
74 | ]
75 |
76 |
--------------------------------------------------------------------------------
/termgraph/utils.py:
--------------------------------------------------------------------------------
1 | """Shared utility functions for termgraph."""
2 |
3 | from __future__ import annotations
4 | import math
5 | import sys
6 | from .constants import UNITS, TICK, SM_TICK
7 |
8 |
9 | def cvt_to_readable(num, percentage=False):
10 | """Return the number in a human readable format.
11 |
12 | Examples:
13 | 125000 -> (125.0, 'K')
14 | 12550 -> (12.55, 'K')
15 | 19561100 -> (19.561, 'M')
16 | """
17 |
18 | if percentage:
19 | return (num * 100, '%')
20 |
21 | if num >= 1 or num <= -1:
22 | neg = num < 0
23 | num = abs(num)
24 |
25 | # Find the degree of the number like if it is in thousands or millions, etc.
26 | index = math.floor(math.log(num) / math.log(1000))
27 |
28 | # Converts the number to the human readable format and returns it.
29 | newNum = round(num / (1000**index), 3)
30 | newNum *= -1 if neg else 1
31 | degree = UNITS[index]
32 |
33 | else:
34 | newNum = num
35 | degree = UNITS[0]
36 |
37 | return (newNum, degree)
38 |
39 |
40 |
41 |
42 | def print_row_core(
43 | value: float,
44 | num_blocks: int,
45 | val_min: float,
46 | color: int | None = None,
47 | label_before: bool = False,
48 | zero_as_small_tick: bool = False,
49 | tick: str = TICK,
50 | ) -> None:
51 | """Core logic for printing a row of bars in horizontal graphs.
52 |
53 | Args:
54 | value: The data value being displayed
55 | num_blocks: Number of blocks/ticks to print
56 | val_min: Minimum value in dataset
57 | color: ANSI color code (optional)
58 | label_before: Whether to use small tick for zero values with label_before
59 | zero_as_small_tick: Additional condition for using small tick on zero
60 | tick: Custom tick character to use (defaults to TICK constant)
61 | """
62 | sys.stdout.write("\033[0m") # no color
63 |
64 | if (num_blocks < 1 and (value > val_min or value > 0)) or (
65 | zero_as_small_tick and value == 0.0
66 | ):
67 | # Print something if it's not the smallest
68 | # and the normal value is less than one.
69 | sys.stdout.write(SM_TICK)
70 | else:
71 | if color:
72 | sys.stdout.write(f"\033[{color}m") # Start to write colorized.
73 | for _ in range(num_blocks):
74 | sys.stdout.write(tick)
75 |
76 | if color:
77 | sys.stdout.write("\033[0m") # Back to original.
78 |
--------------------------------------------------------------------------------
/tests/test_data_utils.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from termgraph.data import Data
3 |
4 |
5 | def test_find_min_returns_lowest_value():
6 | data_values = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]]
7 | labels = [str(i) for i in range(len(data_values))]
8 | data_obj = Data(data_values, labels)
9 | minimum = data_obj.find_min()
10 | assert minimum == 1.0
11 |
12 |
13 | def test_find_max_returns_highest_value():
14 | data_values = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]]
15 | labels = [str(i) for i in range(len(data_values))]
16 | data_obj = Data(data_values, labels)
17 | maximum = data_obj.find_max()
18 | assert maximum == 508.97
19 |
20 |
21 | def test_find_max_label_length_returns_correct_length():
22 | labels1 = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"]
23 | data_obj1 = Data([[0]] * len(labels1), labels1)
24 | length = data_obj1.find_max_label_length()
25 | assert length == 4
26 |
27 | labels2 = ["aaaaaaaa", "bbb", "cccccccccccccc", "z"]
28 | data_obj2 = Data([[0]] * len(labels2), labels2)
29 | length = data_obj2.find_max_label_length()
30 | assert length == 14
31 |
32 |
33 | # Data validation tests
34 | def test_data_empty_labels_raises_exception():
35 | """Test that Data raises exception when labels list is empty"""
36 | labels = []
37 | data = [[183.32], [231.23]]
38 | with pytest.raises(Exception, match="No labels provided"):
39 | Data(data, labels)
40 |
41 |
42 | def test_data_empty_data_raises_exception():
43 | """Test that Data raises exception when data list is empty"""
44 | labels = ["2007", "2008"]
45 | data = []
46 | with pytest.raises(Exception, match="No data provided"):
47 | Data(data, labels)
48 |
49 |
50 | def test_data_mismatching_data_and_labels_count_raises_exception():
51 | """Test that Data raises exception when data and labels have different lengths"""
52 | labels = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"]
53 | data = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05]]
54 | with pytest.raises(Exception, match="dimensions of the data and labels must be the same"):
55 | Data(data, labels)
56 |
57 |
58 | def test_data_missing_values_for_categories_raises_exception():
59 | """Test that Data raises exception when rows have inconsistent category counts"""
60 | labels = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"]
61 | data = [
62 | [183.32, 190.52],
63 | [231.23, 5.0],
64 | [16.43, 53.1],
65 | [50.21, 7.0],
66 | [508.97, 10.45],
67 | [212.05], # Missing second category
68 | [30.0, 20.0],
69 | ]
70 | with pytest.raises(Exception, match="inner dimensions of the data are different"):
71 | Data(data, labels)
--------------------------------------------------------------------------------
/docs/assets/barchart-multivar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
60 |
--------------------------------------------------------------------------------
/docs/assets/cal-heatmap.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
74 |
--------------------------------------------------------------------------------
/tests/test_read_data.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 | from unittest.mock import patch
3 | from io import StringIO
4 | from termgraph import termgraph as tg
5 |
6 |
7 | def test_read_data_returns_correct_results():
8 | args = {
9 | "filename": "data/ex4.dat",
10 | "title": None,
11 | "width": 50,
12 | "format": "{:<5.2f}",
13 | "suffix": "",
14 | "no_labels": False,
15 | "color": None,
16 | "vertical": False,
17 | "stacked": False,
18 | "different_scale": False,
19 | "calendar": False,
20 | "start_dt": None,
21 | "custom_tick": "",
22 | "delim": "",
23 | "verbose": False,
24 | "version": False,
25 | }
26 | categories, labels, data, colors = tg.read_data(args)
27 | assert categories == ["Boys", "Girls"]
28 | assert labels == ["2007", "2008", "2009", "2010", "2011", "2012", "2014"]
29 | assert data == [
30 | [183.32, 190.52],
31 | [231.23, 5.0],
32 | [16.43, 53.1],
33 | [50.21, 7.0],
34 | [508.97, 10.45],
35 | [212.05, 20.2],
36 | [30.0, 20.0],
37 | ]
38 | assert colors == []
39 |
40 |
41 | def test_concatenate_neighboring_labels():
42 | with tempfile.NamedTemporaryFile("w") as tmp:
43 | args = {
44 | "filename": tmp.name,
45 | "title": None,
46 | "width": 50,
47 | "format": "{:<5.2f}",
48 | "suffix": "",
49 | "no_labels": False,
50 | "color": None,
51 | "vertical": False,
52 | "stacked": False,
53 | "different_scale": False,
54 | "calendar": False,
55 | "start_dt": None,
56 | "custom_tick": "",
57 | "delim": "",
58 | "verbose": False,
59 | "version": False,
60 | }
61 | tmp.write(
62 | """
63 | label one 1 2 3
64 | label two 5 6 7
65 | label three 9 10 11
66 | """
67 | )
68 | tmp.seek(0)
69 | categories, labels, data, colors = tg.read_data(args)
70 | assert labels == ["label one", "label two", "label three"]
71 | assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]]
72 |
73 |
74 | def test_labels_at_end_of_row():
75 | """Check that we can identify labels that come after the data"""
76 | with tempfile.NamedTemporaryFile("w") as tmp:
77 | args = {
78 | "filename": tmp.name,
79 | "title": None,
80 | "width": 50,
81 | "format": "{:<5.2f}",
82 | "suffix": "",
83 | "no_labels": False,
84 | "color": None,
85 | "vertical": False,
86 | "stacked": False,
87 | "different_scale": False,
88 | "calendar": False,
89 | "start_dt": None,
90 | "custom_tick": "",
91 | "delim": "",
92 | "verbose": False,
93 | "version": False,
94 | }
95 | tmp.write(
96 | """
97 | 1 2 3 A
98 | 5 6 7 B
99 | 9 10 11 C
100 | """
101 | )
102 | tmp.seek(0)
103 | categories, labels, data, colors = tg.read_data(args)
104 | assert labels == ["A", "B", "C"]
105 | assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]]
106 |
107 |
108 | def test_read_data_verbose():
109 | with patch("sys.stdout", new=StringIO()) as output:
110 | args = {
111 | "filename": "data/ex1.dat",
112 | "title": None,
113 | "width": 50,
114 | "format": "{:<5.2f}",
115 | "suffix": "",
116 | "no_labels": False,
117 | "color": None,
118 | "vertical": False,
119 | "stacked": False,
120 | "different_scale": False,
121 | "calendar": False,
122 | "start_dt": None,
123 | "custom_tick": "",
124 | "delim": "",
125 | "verbose": True,
126 | "version": False,
127 | }
128 | tg.read_data(args)
129 | output = output.getvalue().strip()
130 | assert output == ">> Reading data from data/ex1.dat"
131 |
132 |
133 | def test_read_data_custom_delimiter():
134 | """Test that custom delimiter (pipe) works correctly"""
135 | args = {
136 | "filename": "data/ex_pipe.dat",
137 | "title": None,
138 | "width": 50,
139 | "format": "{:<5.2f}",
140 | "suffix": "",
141 | "no_labels": False,
142 | "color": None,
143 | "vertical": False,
144 | "stacked": False,
145 | "different_scale": False,
146 | "calendar": False,
147 | "start_dt": None,
148 | "custom_tick": "",
149 | "delim": "|",
150 | "verbose": False,
151 | "version": False,
152 | }
153 | categories, labels, data, colors = tg.read_data(args)
154 | assert categories == ["Category A", "Category B", "Category C"]
155 | assert labels == ["Label One", "Label Two", "Label Three"]
156 | assert data == [[10.5, 20.3, 15.8], [25.0, 18.5, 30.2], [12.1, 22.7, 19.4]]
157 | assert colors == []
158 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Termgraph API Documentation
2 |
3 | Welcome to the Termgraph Python API documentation. Termgraph is a command-line tool for creating terminal-based charts and graphs, and can also be used as a Python library for programmatic chart generation.
4 |
5 | ## Documentation Structure
6 |
7 | ### 📖 **[Getting Started](README.md)**
8 | Complete overview with examples and best practices for using termgraph as a Python module.
9 |
10 | ### 📊 **[Data Class](data-class.md)**
11 | Learn how to prepare and structure your data for charts:
12 | - Data validation and normalization
13 | - Working with flat and nested data
14 | - Handling categories and labels
15 | - Examples with real-world data
16 |
17 | ### 📈 **[Chart Classes](chart-classes.md)**
18 | Comprehensive guide to all available chart types:
19 | - **BarChart** - Horizontal bar charts (single and multi-series)
20 | - **StackedChart** - Stacked bar charts for part-to-whole relationships
21 | - **VerticalChart** - Column charts for time series data
22 | - **HistogramChart** - Distribution charts for continuous data
23 | - **Colors** - Color constants for styling
24 |
25 | ### ⚙️ **[Args Class](args-class.md)**
26 | Configuration options for customizing chart appearance and behavior:
27 | - Chart dimensions and layout
28 | - Value formatting and display
29 | - Color schemes and styling
30 | - Chart-specific options
31 |
32 | ## Quick Navigation
33 |
34 | ### Common Tasks
35 |
36 | | Task | Documentation |
37 | |------|---------------|
38 | | Create a simple bar chart | [README.md - Quick Start](README.md#quick-start) |
39 | | Work with multi-series data | [Data Class - Multi-Series](data-class.md#multi-series-data-with-categories) |
40 | | Customize chart colors | [Args Class - Colors](args-class.md#colors) |
41 | | Format numeric values | [Args Class - Value Formatting](args-class.md#value-formatting) |
42 | | Create stacked charts | [Chart Classes - StackedChart](chart-classes.md#stackedchart) |
43 | | Handle negative values | [Data Class - Negative Values](data-class.md#working-with-negative-values) |
44 |
45 | ### Chart Type Selection
46 |
47 | | Data Type | Recommended Chart | Documentation |
48 | |-----------|------------------|---------------|
49 | | Simple categories | BarChart | [Chart Classes - BarChart](chart-classes.md#barchart) |
50 | | Time series | VerticalChart | [Chart Classes - VerticalChart](chart-classes.md#verticalchart) |
51 | | Parts of a whole | StackedChart | [Chart Classes - StackedChart](chart-classes.md#stackedchart) |
52 | | Data distribution | HistogramChart | [Chart Classes - HistogramChart](chart-classes.md#histogramchart) |
53 | | Multi-series comparison | BarChart (multi-series) | [Chart Classes - Multi-Series](chart-classes.md#multi-series-example) |
54 |
55 | ### Example Scenarios
56 |
57 | | Scenario | Example Location |
58 | |----------|------------------|
59 | | Financial dashboard | [README.md - Financial Dashboard](README.md#financial-dashboard) |
60 | | Performance metrics | [README.md - Performance Metrics](README.md#performance-metrics-dashboard) |
61 | | Project tracking | [README.md - Project Status](README.md#project-status-tracking) |
62 | | Sales analysis | [README.md - Sales Trend](README.md#sales-trend-analysis) |
63 | | Data validation | [Data Class - Validation](data-class.md#data-validation-examples) |
64 |
65 | ## Code Examples by Complexity
66 |
67 | ### Beginner
68 | - [Simple bar chart](README.md#simple-bar-chart)
69 | - [Basic styling](README.md#styled-chart)
70 | - [Single series data](data-class.md#basic-flat-data)
71 |
72 | ### Intermediate
73 | - [Multi-series charts](chart-classes.md#multi-series-example)
74 | - [Custom formatting](args-class.md#advanced-formatting)
75 | - [Different chart types](README.md#chart-types)
76 |
77 | ### Advanced
78 | - [Financial dashboard](README.md#financial-dashboard)
79 | - [Different scales](chart-classes.md#different-scale-example)
80 | - [Complex data structures](data-class.md#multi-series-data-with-categories)
81 |
82 | ## API Classes Overview
83 |
84 | ```python
85 | from termgraph import Data, BarChart, StackedChart, VerticalChart, HistogramChart, Args, Colors
86 |
87 | # Core workflow
88 | data = Data([10, 20, 30], ["A", "B", "C"]) # Data preparation
89 | args = Args(width=50, colors=[Colors.Blue]) # Configuration
90 | chart = BarChart(data, args) # Chart creation
91 | chart.draw() # Visualization
92 | ```
93 |
94 | ## Installation and Setup
95 |
96 | ```bash
97 | # Install termgraph
98 | pip install termgraph
99 |
100 | # Basic usage in Python
101 | python -c "from termgraph import Data, BarChart; chart = BarChart(Data([1,2,3], ['A','B','C'])); chart.draw()"
102 | ```
103 |
104 | ## Support and Contributing
105 |
106 | - **Issues**: Report bugs and feature requests on [GitHub Issues](https://github.com/mkaz/termgraph/issues)
107 | - **Documentation**: This documentation is maintained alongside the codebase
108 | - **Examples**: Additional examples can be found in the repository's test files
109 |
110 | ---
111 |
112 | **Note**: This documentation covers using termgraph as a Python library. For command-line usage, see the main README in the repository root.
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | # Tests
2 |
3 | This directory contains the test suite for termgraph, organized into logical groups for better maintainability and clarity.
4 |
5 | ## Test Organization
6 |
7 | The test suite is split into focused files based on functionality:
8 |
9 | ### `test_check_data.py`
10 |
11 | Tests for the `check_data()` function that validates input data and arguments.
12 | - Data validation (empty labels, empty data)
13 | - Label/data size matching
14 | - Color validation
15 | - Category validation
16 | - Error handling and exit codes
17 |
18 | ### `test_data_utils.py`
19 |
20 | Tests for utility functions that operate on data.
21 | - `find_min()` - finding minimum values in datasets
22 | - `find_max()` - finding maximum values in datasets
23 | - `find_max_label_length()` - calculating label dimensions
24 |
25 | ### `test_normalize.py`
26 |
27 | Tests for data normalization functionality.
28 | - Basic normalization with various datasets
29 | - Edge cases (all zeros, negative values)
30 | - Different width scaling
31 | - Boundary conditions
32 |
33 | ### `test_rendering.py`
34 |
35 | Tests for chart rendering and display functions.
36 | - `horiz_rows()` - horizontal chart row generation
37 | - `vertically()` - vertical chart rendering
38 | - Chart formatting and layout
39 |
40 | ### `test_read_data.py`
41 |
42 | Tests for data input and parsing functionality.
43 | - File reading from various formats
44 | - Label parsing (beginning, end, multi-word)
45 | - Category detection
46 | - Verbose output
47 | - Data format validation
48 |
49 | ### `test_init.py`
50 |
51 | Tests for initialization and setup functions.
52 | - Argument parsing and initialization
53 |
54 | ### `test_charts.py`
55 |
56 | Tests for chart class integration.
57 | - BarChart rendering with Data and Args classes
58 | - VerticalChart rendering
59 | - Chart output validation
60 |
61 | ### Module Tests (`module-test*.py`)
62 |
63 | Standalone executable tests that demonstrate real-world module API usage:
64 |
65 | - **`module-test1.py`** - Multi-category data with BarChart and StackedChart
66 | - **`module-test2.py`** - Simple bar chart, different scale charts, and histograms
67 | - **`module-test3.py`** - Basic usage example matching README documentation
68 |
69 | These are integration tests that run as standalone Python scripts and produce visual output.
70 |
71 | ## Running Tests
72 |
73 | ### All Tests
74 |
75 | ```bash
76 | just test # Run all pytest unit tests
77 | just test-verbose # Run all pytest tests with verbose output
78 | just test-module # Run module API integration tests
79 | ```
80 |
81 | ### Individual Test Files
82 |
83 | ```bash
84 | # Run specific pytest test file
85 | just test-file tests/test_check_data.py
86 | just test-file tests/test_normalize.py
87 |
88 | # Run individual module test
89 | uv run python tests/module-test1.py
90 | uv run python tests/module-test2.py
91 | uv run python tests/module-test3.py
92 | ```
93 |
94 | ### Specific Tests
95 |
96 | ```bash
97 | uv run python -m pytest tests/test_check_data.py::test_check_data_empty_labels_exits_with_one
98 | uv run python -m pytest tests/test_normalize.py::test_normalize_with_negative_datapoint_returns_correct_results
99 | ```
100 |
101 | ## Adding New Tests
102 |
103 | ### Unit Tests (pytest)
104 |
105 | When adding new unit tests, place them in the appropriate file based on functionality:
106 |
107 | - **Data validation** → `test_check_data.py`
108 | - **Math/calculation utilities** → `test_data_utils.py`
109 | - **Data scaling/normalization** → `test_normalize.py`
110 | - **Chart class integration** → `test_charts.py`
111 | - **File parsing/input** → `test_read_data.py`
112 | - **Setup/configuration** → `test_init.py`
113 |
114 | If your test doesn't fit into any existing category, consider:
115 |
116 | 1. Whether it belongs in an existing file with a broader scope
117 | 2. Creating a new focused test file (e.g., `test_calendar.py` for calendar-specific functionality)
118 |
119 | ### Module Integration Tests
120 |
121 | Module tests (`module-test*.py`) are standalone scripts that demonstrate real-world usage:
122 |
123 | - Create a new `module-test#.py` file for end-to-end demonstrations
124 | - These should be runnable directly: `uv run python tests/module-test#.py`
125 | - Focus on realistic usage scenarios, not edge cases
126 | - Include visual output to verify charts render correctly
127 |
128 | ## Test Conventions
129 |
130 | - Use descriptive test names that explain what is being tested
131 | - Include docstrings for complex test scenarios
132 | - Use `pytest.raises(SystemExit)` for testing error conditions that call `sys.exit()`
133 | - Mock external dependencies (files, stdout) when needed
134 | - Keep test data realistic but minimal
135 |
136 | ## Dependencies
137 |
138 | Tests use the following packages:
139 | - `pytest` - Test runner and framework
140 | - `tempfile` - For creating temporary test files
141 | - `unittest.mock` - For mocking dependencies
142 | - `io.StringIO` - For capturing stdout in tests
143 |
144 | ## File Structure
145 |
146 | ```
147 | tests/
148 | ├── README.md # This file
149 | ├── test_check_data.py # Data validation tests
150 | ├── test_charts.py # Chart class integration tests
151 | ├── test_data_utils.py # Utility function tests
152 | ├── test_init.py # Initialization tests
153 | ├── test_normalize.py # Data normalization tests
154 | ├── test_read_data.py # Data reading/parsing tests
155 | ├── module-test1.py # Module API integration test (multi-category)
156 | ├── module-test2.py # Module API integration test (various chart types)
157 | ├── module-test3.py # Module API integration test (basic example)
158 | └── coverage-report.sh # Coverage report generator
159 | ```
160 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Termgraph
2 |
3 | A command-line tool and Python library that draws basic graphs in the terminal.
4 |
5 | Graph types supported:
6 | - Bar Graphs
7 | - Color charts
8 | - Multi-variable
9 | - Stacked charts
10 | - Histograms
11 | - Horizontal or Vertical
12 | - Calendar heatmaps
13 | - Emoji!
14 |
15 | ## Quick Start
16 |
17 | ### Command Line Usage
18 |
19 | ```
20 | $ termgraph data/ex1.dat
21 |
22 | # Reading data from data/ex1.dat
23 |
24 | 2007: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 183.32
25 | 2008: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 231.23
26 | 2009: ▇ 16.43
27 | 2010: ▇▇▇▇ 50.21
28 | 2011: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 508.97
29 | 2012: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 212.05
30 | 2014: ▏ 1.00
31 | ```
32 |
33 | ### Python Module Usage
34 |
35 | ```python
36 | from termgraph import Data, Args, BarChart
37 |
38 | # Create data
39 | data = Data([[10], [25], [50], [40]], ["Q1", "Q2", "Q3", "Q4"])
40 |
41 | # Configure chart options
42 | args = Args(
43 | title="Quarterly Sales",
44 | width=50,
45 | format="{:.0f}",
46 | suffix="K"
47 | )
48 |
49 | # Create and display chart
50 | chart = BarChart(data, args)
51 | chart.draw()
52 | ```
53 |
54 | Output:
55 | ```
56 | # Quarterly Sales
57 |
58 | Q1: ▇▇▇▇▇▇▇▇▇▇ 10K
59 | Q2: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25K
60 | Q3: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 50K
61 | Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 40K
62 | ```
63 |
64 | ## More Examples
65 |
66 | ### Custom Tick Marks
67 |
68 | An example using emoji as custom tick:
69 |
70 | ```
71 | termgraph data/ex1.dat --custom-tick "🏃" --width 20 --title "Running Data"
72 |
73 | # Running Data
74 |
75 | 2007: 🏃🏃🏃🏃🏃🏃🏃 183.32
76 | 2008: 🏃🏃🏃🏃🏃🏃🏃🏃🏃 231.23
77 | 2009: 16.43
78 | 2010: 🏃 50.21
79 | 2011: 🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃 508.97
80 | 2012: 🏃🏃🏃🏃🏃🏃🏃🏃 212.05
81 | 2014: 1.00
82 |
83 | ```
84 |
85 | ### Color Charts
86 |
87 | Note: Color charts use ANSI escape codes, so may not be able to copy/paste from terminal into other uses.
88 |
89 | ```bash
90 | $ termgraph data/ex4.dat --color {cyan/yellow} --space-between
91 | ```
92 |
93 | 
94 |
95 | ---
96 |
97 | ```
98 | termgraph data/ex7.dat --color {green,magenta} --stacked
99 | ```
100 |
101 | 
102 |
103 | ### Calendar Heatmap
104 |
105 | Calendar Heatmap, expects first column to be date in yyyy-mm-dd
106 |
107 | ```
108 | $ termgraph --calendar --start-dt 2017-07-01 data/cal.dat
109 | ```
110 |
111 | 
112 |
113 |
114 |
115 | ## Usage
116 |
117 | #### Command Line Interface
118 |
119 | * Create data file with two columns either comma or space separated.
120 | The first column is your labels, the second column is a numeric data
121 |
122 | * termgraph [datafile]
123 |
124 | * Help: termgraph -h
125 |
126 | #### Command Line Arguments
127 |
128 | ```
129 | usage: termgraph [-h] [options] [filename]
130 |
131 | draw basic graphs on terminal
132 |
133 | positional arguments:
134 | filename data file name (comma or space separated). Defaults to stdin.
135 |
136 | options:
137 | -h, --help show this help message and exit
138 | --title TITLE Title of graph
139 | --width WIDTH width of graph in characters default:50
140 | --format FORMAT format specifier to use.
141 | --suffix SUFFIX string to add as a suffix to all data points.
142 | --no-labels Do not print the label column
143 | --no-values Do not print the values at end
144 | --space-between Print a new line after every field
145 | --color [COLOR ...] Graph bar color( s )
146 | --vertical Vertical graph
147 | --stacked Stacked bar graph
148 | --histogram Histogram
149 | --bins BINS Bins of Histogram
150 | --different-scale Categories have different scales.
151 | --calendar Calendar Heatmap chart
152 | --start-dt START_DT Start date for Calendar chart
153 | --custom-tick CUSTOM_TICK
154 | Custom tick mark, emoji approved
155 | --delim DELIM Custom delimiter, default , or space
156 | --verbose Verbose output, helpful for debugging
157 | --label-before Display the values before the bars
158 | --no-readable Disable the readable numbers
159 | --percentage Display the number in percentage
160 | --version Display version and exit
161 | ```
162 |
163 | ### Python API
164 |
165 | All chart types are available as classes:
166 |
167 | ```python
168 | from termgraph import (
169 | Data, Args,
170 | BarChart, StackedChart, VerticalChart, HistogramChart
171 | )
172 |
173 | # Basic setup
174 | data = Data([[10], [20]], ["A", "B"])
175 | args = Args(title="My Chart")
176 |
177 | # Choose your chart type
178 | chart = BarChart(data, args) # Horizontal bars
179 | # chart = StackedChart(data, args) # Stacked bars
180 | # chart = VerticalChart(data, args) # Vertical bars
181 | # chart = HistogramChart(data, args) # Histogram
182 |
183 | chart.draw()
184 | ```
185 |
186 | **📚 [Complete Python API Documentation](docs/)**
187 |
188 | For comprehensive examples, detailed API reference, and advanced usage patterns, see the complete documentation:
189 | - **[Getting Started Guide](docs/README.md)** - Examples and best practices
190 | - **[Data Class API](docs/data-class.md)** - Data preparation and validation
191 | - **[Chart Classes API](docs/chart-classes.md)** - All chart types with examples
192 | - **[Args Configuration](docs/args-class.md)** - Complete configuration options
193 |
194 | Quick Args options:
195 | - `title`: Chart title
196 | - `width`: Width in characters (default: 50)
197 | - `format`: Number format string (default: "{:<5.2f}")
198 | - `suffix`: Add suffix to all values
199 | - `no_labels`: Don't show labels
200 | - `no_values`: Don't show values
201 | - `colors`: List of color names
202 |
203 | ## Background
204 |
205 | I wanted a quick way to visualize data stored in a simple text file. I initially created some scripts in R that generated graphs but this was a two step process of creating the graph and then opening the generated graph.
206 |
207 | After seeing [command-line sparklines](https://github.com/holman/spark) I figured I could do the same thing using block characters for bar charts.
208 |
209 | ### Contribute
210 |
211 | All contributions are welcome! For detailed information about the project structure, development workflow, and contribution guidelines, please see [CONTRIBUTING.md](CONTRIBUTING.md).
212 |
213 | **Quick Start:**
214 | - 🐛 **Bug reports** and 🚀 **feature requests**: Use [GitHub Issues](https://github.com/mkaz/termgraph/issues)
215 | - 🔧 **Code contributions**: See our [development workflow](CONTRIBUTING.md#development-workflow)
216 | - 📚 **Documentation**: Help improve our guides and examples
217 |
218 | **Code Quality:** We use `ruff` for linting and formatting, `mypy` for type checking, and maintain comprehensive test coverage.
219 |
220 | Thanks to all the [contributors](https://github.com/mkaz/termgraph/graphs/contributors)!
221 |
222 |
223 | ### License
224 |
225 | MIT License, see [LICENSE.txt](LICENSE.txt)
226 |
--------------------------------------------------------------------------------
/docs/data-class.md:
--------------------------------------------------------------------------------
1 | # Data Class API Documentation
2 |
3 | The `Data` class is the core data container for termgraph charts. It handles data validation, normalization, and provides methods for working with both flat and nested data structures.
4 |
5 | ## Constructor
6 |
7 | ```python
8 | from termgraph import Data
9 |
10 | # Basic usage
11 | data = Data(data=[10, 20, 30, 40], labels=["Q1", "Q2", "Q3", "Q4"])
12 |
13 | # With categories for multi-series data
14 | data = Data(
15 | data=[[10, 15], [20, 25], [30, 35], [40, 45]],
16 | labels=["Q1", "Q2", "Q3", "Q4"],
17 | categories=["Product A", "Product B"]
18 | )
19 | ```
20 |
21 | ### Parameters
22 |
23 | - **data** (list): Required. The numeric data to be graphed. Can be:
24 | - Flat list: `[10, 20, 30, 40]`
25 | - Nested list: `[[10, 15], [20, 25], [30, 35]]`
26 | - **labels** (list[str]): Required. Labels for each data point/row
27 | - **categories** (list[str], optional): Category names for multi-series data
28 |
29 | ### Validation
30 |
31 | The constructor validates that:
32 | - Both `data` and `labels` are provided
33 | - Data and labels have the same length
34 | - For nested data, all inner lists have consistent dimensions
35 |
36 | ## Methods
37 |
38 | ### Data Statistics
39 |
40 | #### `find_min() -> Union[int, float]`
41 | Returns the minimum value in the dataset.
42 |
43 | ```python
44 | # Flat data
45 | data = Data([10, 5, 30, 15], ["A", "B", "C", "D"])
46 | print(data.find_min()) # Output: 5
47 |
48 | # Nested data
49 | data = Data([[10, 5], [30, 15], [20, 25]], ["X", "Y", "Z"])
50 | print(data.find_min()) # Output: 5
51 | ```
52 |
53 | #### `find_max() -> Union[int, float]`
54 | Returns the maximum value in the dataset.
55 |
56 | ```python
57 | # Flat data
58 | data = Data([10, 5, 30, 15], ["A", "B", "C", "D"])
59 | print(data.find_max()) # Output: 30
60 |
61 | # Nested data
62 | data = Data([[10, 5], [30, 15], [20, 25]], ["X", "Y", "Z"])
63 | print(data.find_max()) # Output: 30
64 | ```
65 |
66 | ### Label Methods
67 |
68 | #### `find_min_label_length() -> int`
69 | Returns the length of the shortest label.
70 |
71 | ```python
72 | data = Data([10, 20, 30], ["A", "BB", "CCC"])
73 | print(data.find_min_label_length()) # Output: 1
74 | ```
75 |
76 | #### `find_max_label_length() -> int`
77 | Returns the length of the longest label.
78 |
79 | ```python
80 | data = Data([10, 20, 30], ["A", "BB", "CCC"])
81 | print(data.find_max_label_length()) # Output: 3
82 | ```
83 |
84 | ### Data Normalization
85 |
86 | #### `normalize(width: int) -> list`
87 | Normalizes data values to fit within the specified width for chart rendering.
88 |
89 | ```python
90 | # Flat data normalization
91 | data = Data([10, 20, 30, 40], ["Q1", "Q2", "Q3", "Q4"])
92 | normalized = data.normalize(50) # Normalize to 50 character width
93 | print(normalized) # [12.5, 25.0, 37.5, 50.0]
94 |
95 | # Nested data normalization
96 | data = Data([[10, 15], [20, 25], [30, 35]], ["X", "Y", "Z"])
97 | normalized = data.normalize(40)
98 | print(normalized) # [[11.43, 17.14], [22.86, 28.57], [34.29, 40.0]]
99 | ```
100 |
101 | The normalize method:
102 | - Handles negative values by offsetting all data
103 | - Scales values proportionally to the target width
104 | - Preserves relative relationships between data points
105 | - Works with both flat and nested data structures
106 |
107 | ## Properties
108 |
109 | ### `data`
110 | The raw data provided during initialization.
111 |
112 | ### `labels`
113 | The labels for each data row/point.
114 |
115 | ### `categories`
116 | Category names for multi-series data (empty list if not provided).
117 |
118 | ### `dims`
119 | Tuple representing the dimensions of the data structure.
120 |
121 | ```python
122 | # Flat data
123 | data = Data([10, 20, 30], ["A", "B", "C"])
124 | print(data.dims) # (3,)
125 |
126 | # Nested data
127 | data = Data([[10, 15], [20, 25]], ["X", "Y"])
128 | print(data.dims) # (2, 2)
129 | ```
130 |
131 | ## Examples
132 |
133 | ### Basic Flat Data
134 |
135 | ```python
136 | from termgraph import Data
137 |
138 | # Simple sales data
139 | sales_data = Data(
140 | data=[150, 230, 180, 290, 210],
141 | labels=["Jan", "Feb", "Mar", "Apr", "May"]
142 | )
143 |
144 | print(f"Min sales: {sales_data.find_min()}") # Min sales: 150
145 | print(f"Max sales: {sales_data.find_max()}") # Max sales: 290
146 | print(f"Data: {sales_data}")
147 | ```
148 |
149 | ### Multi-Series Data with Categories
150 |
151 | ```python
152 | from termgraph import Data
153 |
154 | # Quarterly sales for two products
155 | quarterly_data = Data(
156 | data=[
157 | [120, 80], # Q1: Product A, Product B
158 | [150, 95], # Q2: Product A, Product B
159 | [180, 110], # Q3: Product A, Product B
160 | [200, 125] # Q4: Product A, Product B
161 | ],
162 | labels=["Q1", "Q2", "Q3", "Q4"],
163 | categories=["Product A", "Product B"]
164 | )
165 |
166 | print(f"Dimensions: {quarterly_data.dims}") # (4, 2)
167 | print(f"Categories: {quarterly_data.categories}")
168 | ```
169 |
170 | ### Working with Negative Values
171 |
172 | ```python
173 | from termgraph import Data
174 |
175 | # Profit/Loss data with negative values
176 | pnl_data = Data(
177 | data=[-50, 30, -20, 80, 45],
178 | labels=["Jan", "Feb", "Mar", "Apr", "May"]
179 | )
180 |
181 | # Normalization handles negative values automatically
182 | normalized = pnl_data.normalize(40)
183 | print(f"Normalized data: {normalized}")
184 | ```
185 |
186 | ### Data Validation Examples
187 |
188 | ```python
189 | from termgraph import Data
190 |
191 | # These will raise exceptions:
192 | try:
193 | # Missing labels
194 | Data([10, 20, 30])
195 | except Exception as e:
196 | print(f"Error: {e}")
197 |
198 | try:
199 | # Mismatched dimensions
200 | Data([10, 20], ["A", "B", "C"])
201 | except Exception as e:
202 | print(f"Error: {e}")
203 |
204 | try:
205 | # Inconsistent nested structure
206 | Data([[10, 20], [30]], ["A", "B"])
207 | except Exception as e:
208 | print(f"Error: {e}")
209 | ```
210 |
211 | ## String Representation
212 |
213 | The `Data` class provides a tabular string representation:
214 |
215 | ```python
216 | data = Data(
217 | data=[[100, 150], [200, 250], [300, 350]],
218 | labels=["Product 1", "Product 2", "Product 3"],
219 | categories=["Sales", "Revenue"]
220 | )
221 |
222 | print(data)
223 | ```
224 |
225 | Output:
226 | ```
227 | Labels | Data
228 | -----------|---------------
229 | Product 1 | (Sales) 100
230 | | (Revenue) 150
231 | |
232 | Product 2 | (Sales) 200
233 | | (Revenue) 250
234 | |
235 | Product 3 | (Sales) 300
236 | | (Revenue) 350
237 | |
238 | ```
239 |
240 | ## Integration with Charts
241 |
242 | The `Data` class is designed to work seamlessly with all chart types:
243 |
244 | ```python
245 | from termgraph import Data, BarChart, Args
246 |
247 | # Create data
248 | data = Data([45, 32, 78, 56, 23], ["A", "B", "C", "D", "E"])
249 |
250 | # Create chart with custom arguments
251 | args = Args(width=60, colors=["red"])
252 | chart = BarChart(data, args)
253 |
254 | # Draw the chart
255 | chart.draw()
256 | ```
257 |
258 | For more examples of using Data with different chart types, see the [Chart Classes Documentation](chart-classes.md).
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Termgraph
2 |
3 | Thank you for your interest in contributing to Termgraph! This document provides guidance on the project structure and how to contribute effectively.
4 |
5 | ## Project Structure
6 |
7 | Termgraph has been designed with a clean, modular architecture that supports both command-line usage and programmatic access.
8 |
9 | ### Architecture Overview
10 |
11 | ```
12 | termgraph/
13 | ├── termgraph/
14 | │ ├── __init__.py # Package entry point with lazy imports
15 | │ ├── termgraph.py # CLI implementation and main entry point
16 | │ ├── data.py # Data class - handles all data operations
17 | │ ├── args.py # Args class - configuration management
18 | │ ├── chart.py # Chart classes - rendering and display
19 | │ ├── module.py # Backward compatibility module
20 | │ ├── utils.py # Utility functions (formatting, normalization)
21 | │ └── constants.py # Shared constants (colors, characters, units)
22 | ├── tests/ # Test suite organized by functionality
23 | ├── data/ # Sample data files for testing
24 | └── README.md # Project documentation
25 | ```
26 |
27 | ### Core Components
28 |
29 | #### **Data Class (`data.py`)**
30 | The `Data` class is responsible for:
31 | - Data validation and structure verification
32 | - Finding min/max values and label lengths
33 | - Handling categories and multi-dimensional data
34 | - Providing a clean interface for data operations
35 |
36 | ```python
37 | from termgraph import Data
38 |
39 | data = Data([[1, 2], [3, 4]], ["Label1", "Label2"])
40 | print(data.find_max()) # 4
41 | ```
42 |
43 | #### **Args Class (`args.py`)**
44 | The `Args` class manages:
45 | - Chart configuration options
46 | - Default values and validation
47 | - Type-safe access to arguments
48 |
49 | ```python
50 | from termgraph import Args
51 |
52 | args = Args(width=100, title="My Chart", percentage=True)
53 | print(args.get_arg("width")) # 100
54 | ```
55 |
56 | #### **Chart Classes (`chart.py`)**
57 | Chart classes handle:
58 | - Chart rendering and display logic
59 | - Header and legend printing
60 | - Color management
61 | - Different chart types (Bar, Horizontal, etc.)
62 |
63 | ```python
64 | from termgraph import Data, Args, BarChart
65 |
66 | data = Data([[10], [20]], ["A", "B"])
67 | args = Args(title="Test Chart")
68 | chart = BarChart(data, args)
69 | chart.draw()
70 | ```
71 |
72 | ### Design Principles
73 |
74 | 1. **Class-Based Architecture**: Everything is organized around focused classes with clear responsibilities
75 | 2. **Single Source of Truth**: No duplicate implementations - data operations live in Data class, rendering in Chart classes
76 | 3. **Backward Compatibility**: All existing APIs are maintained through import forwarding
77 | 4. **Modular Organization**: Each class has its own file for better maintainability
78 |
79 | ## Development Workflow
80 |
81 | ### Setting Up Development Environment
82 |
83 | 1. **Clone the repository:**
84 | ```bash
85 | git clone https://github.com/mkaz/termgraph.git
86 | cd termgraph
87 | ```
88 |
89 | 2. **Install development dependencies:**
90 | ```bash
91 | just install
92 | # or manually: uv sync --dev
93 | ```
94 |
95 | 3. **Run tests to verify setup:**
96 | ```bash
97 | just test
98 | ```
99 |
100 | ### Development Commands
101 |
102 | We use `just` as our command runner. Available commands:
103 |
104 | ```bash
105 | just # Show available commands
106 | just install # Install development dependencies
107 | just test # Run the test suite
108 | just test-file # Run specific test file
109 | just lint # Check code with ruff
110 | just lint-fix # Fix code formatting issues
111 | just typecheck # Run mypy type checking
112 | just check # Run all quality checks (lint + typecheck)
113 | just run-example # Run with sample data
114 | ```
115 |
116 | ### Testing
117 |
118 | Our test suite is organized by functionality:
119 |
120 | ```
121 | tests/
122 | ├── README.md # Testing documentation
123 | ├── test_check_data.py # Data validation tests
124 | ├── test_data_utils.py # Data utility function tests
125 | ├── test_normalize.py # Data normalization tests
126 | ├── test_rendering.py # Chart rendering tests
127 | ├── test_read_data.py # Data parsing tests
128 | └── test_init.py # Initialization tests
129 | ```
130 |
131 | **Adding New Tests:**
132 | - Data validation → `test_check_data.py`
133 | - Data operations → `test_data_utils.py`
134 | - Chart rendering → `test_rendering.py`
135 | - File parsing → `test_read_data.py`
136 |
137 | ### Code Quality
138 |
139 | We maintain high code quality through:
140 |
141 | - **Ruff** for linting and formatting
142 | - **MyPy** for type checking
143 | - **Comprehensive test suite** with good coverage
144 | - **Clear naming conventions** and documentation
145 |
146 | **Before submitting a PR:**
147 | 1. Run `just check` to verify code quality
148 | 2. Run `just test` to ensure all tests pass
149 | 3. Add tests for new functionality
150 | 4. Update documentation if needed
151 |
152 | ## Contributing Guidelines
153 |
154 | ### Reporting Issues
155 |
156 | When reporting bugs or requesting features:
157 | 1. Check existing [GitHub Issues](https://github.com/mkaz/termgraph/issues)
158 | 2. Provide clear reproduction steps for bugs
159 | 3. Include sample data files when relevant
160 | 4. Specify your Python version and OS
161 |
162 | ### Pull Requests
163 |
164 | 1. **Fork the repository** and create a feature branch
165 | 2. **Write tests** for new functionality
166 | 3. **Follow existing code patterns** and class structure
167 | 4. **Maintain backward compatibility** - don't break existing APIs
168 | 5. **Update documentation** if your changes affect usage
169 | 6. **Run quality checks** before submitting
170 |
171 | ### API Design Guidelines
172 |
173 | When adding new features:
174 |
175 | #### **For Data Operations:**
176 | - Add methods to the `Data` class
177 | - Ensure they work with the existing data structure
178 | - Add corresponding procedural functions in `data.py` for backward compatibility
179 |
180 | #### **For Chart Options:**
181 | - Add new arguments to `Args.default` dictionary
182 | - Update CLI argument parsing in `termgraph.py`
183 | - Ensure the option works in both CLI and programmatic usage
184 |
185 | #### **For Chart Types:**
186 | - Extend existing chart classes or create new ones inheriting from `Chart`
187 | - Follow the existing rendering patterns
188 | - Ensure compatibility with all chart options (colors, formatting, etc.)
189 |
190 | ### Examples of Good Contributions
191 |
192 | #### Adding a New Chart Option:
193 | ```python
194 | # 1. Add to Args.default in args.py
195 | "new_option": False,
196 |
197 | # 2. Add CLI argument in termgraph.py
198 | parser.add_argument("--new-option", action="store_true", help="Enable new option")
199 |
200 | # 3. Use in chart rendering
201 | if self.args.get_arg("new_option"):
202 | # implement feature
203 | ```
204 |
205 | #### Adding a Data Operation:
206 | ```python
207 | # 1. Add method to Data class in data.py
208 | def new_operation(self) -> float:
209 | return some_calculation(self.data)
210 |
211 | # 2. Add backward compatibility function
212 | def new_operation(data: list) -> float:
213 | data_obj = Data(data, [str(i) for i in range(len(data))])
214 | return data_obj.new_operation()
215 |
216 | # 3. Add tests in test_data_utils.py
217 | def test_new_operation():
218 | # test implementation
219 | ```
220 |
221 | ## Questions?
222 |
223 | - 💬 **Discussion**: Use [GitHub Issues](https://github.com/mkaz/termgraph/issues) for questions
224 | - 🐛 **Bugs**: Report via [GitHub Issues](https://github.com/mkaz/termgraph/issues)
225 | - 🚀 **Features**: Request via [GitHub Issues](https://github.com/mkaz/termgraph/issues)
226 |
227 | Thank you for contributing to Termgraph! 🎉
--------------------------------------------------------------------------------
/docs/args-class.md:
--------------------------------------------------------------------------------
1 | # Args Class API Documentation
2 |
3 | The `Args` class manages chart configuration options and arguments. It provides a centralized way to control chart appearance, behavior, and formatting.
4 |
5 | ## Constructor
6 |
7 | ```python
8 | from termgraph import Args
9 |
10 | # Default args
11 | args = Args()
12 |
13 | # Custom args
14 | args = Args(
15 | width=60,
16 | title="My Chart",
17 | colors=["red", "blue"],
18 | suffix=" units"
19 | )
20 | ```
21 |
22 | ### Parameters
23 |
24 | All parameters are optional and provided as keyword arguments. If not specified, default values are used.
25 |
26 | ## Configuration Options
27 |
28 | ### Chart Dimensions
29 |
30 | #### `width` (int, default: 50)
31 | The width of the chart in characters.
32 |
33 | ```python
34 | args = Args(width=80) # Wide chart
35 | args = Args(width=30) # Narrow chart
36 | ```
37 |
38 | ### Chart Appearance
39 |
40 | #### `title` (str, default: None)
41 | Chart title displayed at the top.
42 |
43 | ```python
44 | args = Args(title="Monthly Sales Report")
45 | ```
46 |
47 | #### `colors` (list, default: None)
48 | List of color codes for chart series. Use color names or ANSI codes.
49 |
50 | ```python
51 | from termgraph import Colors
52 |
53 | args = Args(colors=[Colors.Blue, Colors.Green, Colors.Red])
54 | # Or use color names
55 | args = Args(colors=["blue", "green", "red"])
56 | ```
57 |
58 | Available color constants:
59 | - `Colors.Black`
60 | - `Colors.Red`
61 | - `Colors.Green`
62 | - `Colors.Yellow`
63 | - `Colors.Blue`
64 | - `Colors.Magenta`
65 | - `Colors.Cyan`
66 |
67 | #### `custom_tick` (str, default: "")
68 | Custom character to use for chart bars instead of the default block character.
69 |
70 | ```python
71 | args = Args(custom_tick="*") # Use asterisks
72 | args = Args(custom_tick="=") # Use equals signs
73 | args = Args(custom_tick="█") # Use solid blocks
74 | ```
75 |
76 | ### Value Formatting
77 |
78 | #### `format` (str, default: "{:<5.2f}")
79 | Python format string for numeric values.
80 |
81 | ```python
82 | args = Args(format="{:<6.1f}") # 6 chars wide, 1 decimal
83 | args = Args(format="{:>8.0f}") # Right-aligned, no decimals
84 | args = Args(format="{:+.2f}") # Always show sign
85 | ```
86 |
87 | #### `suffix` (str, default: "")
88 | Text appended to each value.
89 |
90 | ```python
91 | args = Args(suffix=" units") # Append units
92 | args = Args(suffix="K") # Thousands
93 | args = Args(suffix="$") # Currency
94 | ```
95 |
96 | #### `percentage` (bool, default: False)
97 | Format values as percentages.
98 |
99 | ```python
100 | # Convert 0.75 to 75%
101 | args = Args(percentage=True)
102 | ```
103 |
104 | #### `no_readable` (bool, default: False)
105 | Disable automatic conversion of large numbers to readable format (e.g., 1000 → 1K).
106 |
107 | ```python
108 | args = Args(no_readable=True) # Show raw numbers
109 | ```
110 |
111 | ### Label and Value Display
112 |
113 | #### `no_labels` (bool, default: False)
114 | Hide row labels.
115 |
116 | ```python
117 | args = Args(no_labels=True) # Hide all labels
118 | ```
119 |
120 | #### `no_values` (bool, default: False)
121 | Hide numeric values next to bars.
122 |
123 | ```python
124 | args = Args(no_values=True) # Show only bars
125 | ```
126 |
127 | #### `label_before` (bool, default: False)
128 | Display labels before the bars instead of to the left with colons.
129 |
130 | ```python
131 | args = Args(label_before=True)
132 | # Changes "Label: ████" to "Label ████"
133 | ```
134 |
135 | ### Chart Layout
136 |
137 | #### `space_between` (bool, default: False)
138 | Add blank lines between chart rows.
139 |
140 | ```python
141 | args = Args(space_between=True) # More readable spacing
142 | ```
143 |
144 | #### `vertical` (bool, default: False)
145 | Create vertical/column charts instead of horizontal bars.
146 |
147 | ```python
148 | args = Args(vertical=True) # Column chart
149 | ```
150 |
151 | ### Multi-Series Options
152 |
153 | #### `different_scale` (bool, default: False)
154 | Use different scales for each data series in multi-series charts.
155 |
156 | ```python
157 | # Useful when series have very different ranges
158 | args = Args(different_scale=True)
159 | ```
160 |
161 | #### `stacked` (bool, default: False)
162 | Create stacked bar charts for multi-series data.
163 |
164 | ```python
165 | args = Args(stacked=True) # Stack series on top of each other
166 | ```
167 |
168 | ### Histogram Options
169 |
170 | #### `histogram` (bool, default: False)
171 | Enable histogram mode.
172 |
173 | ```python
174 | args = Args(histogram=True, bins=10)
175 | ```
176 |
177 | #### `bins` (int, default: 5)
178 | Number of bins for histogram charts.
179 |
180 | ```python
181 | args = Args(bins=8) # 8 histogram bins
182 | args = Args(bins=15) # Fine-grained histogram
183 | ```
184 |
185 | ### Data Input Options
186 |
187 | #### `filename` (str, default: "-")
188 | Input filename (used by CLI, usually not needed for API usage).
189 |
190 | #### `delim` (str, default: "")
191 | Custom delimiter for data parsing.
192 |
193 | ### Calendar Options
194 |
195 | #### `calendar` (bool, default: False)
196 | Enable calendar heatmap mode.
197 |
198 | #### `start_dt` (date, default: None)
199 | Start date for calendar charts.
200 |
201 | ### Debugging
202 |
203 | #### `verbose` (bool, default: False)
204 | Enable verbose output for debugging.
205 |
206 | ```python
207 | args = Args(verbose=True) # Show debug information
208 | ```
209 |
210 | ## Methods
211 |
212 | ### `get_arg(arg: str) -> Union[int, str, bool, None]`
213 | Get the value of a specific argument.
214 |
215 | ```python
216 | args = Args(width=60, title="Test")
217 |
218 | width = args.get_arg("width") # Returns 60
219 | title = args.get_arg("title") # Returns "Test"
220 | ```
221 |
222 | ### `update_args(**kwargs) -> None`
223 | Update multiple arguments after initialization.
224 |
225 | ```python
226 | args = Args(width=50)
227 | args.update_args(width=80, title="Updated Chart")
228 | ```
229 |
230 | ## Examples
231 |
232 | ### Basic Configuration
233 |
234 | ```python
235 | from termgraph import Args, Colors
236 |
237 | # Simple configuration
238 | args = Args(
239 | width=50,
240 | title="Sales Data",
241 | colors=[Colors.Blue],
242 | suffix=" units"
243 | )
244 | ```
245 |
246 | ### Advanced Formatting
247 |
248 | ```python
249 | from termgraph import Args
250 |
251 | # Detailed formatting options
252 | args = Args(
253 | width=70,
254 | title="Financial Performance Q4 2023",
255 | format="{:>8.2f}", # Right-aligned, 2 decimals
256 | suffix="K USD", # Thousands of dollars
257 | space_between=True, # Better readability
258 | no_readable=True # Show exact numbers
259 | )
260 | ```
261 |
262 | ### Multi-Series Configuration
263 |
264 | ```python
265 | from termgraph import Args, Colors
266 |
267 | # Multi-series with different colors
268 | args = Args(
269 | width=60,
270 | title="Product Comparison",
271 | colors=[Colors.Green, Colors.Blue, Colors.Red],
272 | different_scale=False, # Use same scale
273 | space_between=True
274 | )
275 | ```
276 |
277 | ### Stacked Chart Configuration
278 |
279 | ```python
280 | from termgraph import Args, Colors
281 |
282 | # Stacked chart setup
283 | args = Args(
284 | width=50,
285 | title="Market Share Evolution",
286 | colors=[Colors.Blue, Colors.Green, Colors.Yellow],
287 | stacked=True,
288 | format="{:<4.0f}",
289 | suffix="%"
290 | )
291 | ```
292 |
293 | ### Histogram Configuration
294 |
295 | ```python
296 | from termgraph import Args, Colors
297 |
298 | # Histogram settings
299 | args = Args(
300 | width=60,
301 | title="Data Distribution",
302 | histogram=True,
303 | bins=10,
304 | colors=[Colors.Cyan],
305 | format="{:<3.0f}"
306 | )
307 | ```
308 |
309 | ### Minimal Bar Chart
310 |
311 | ```python
312 | from termgraph import Args
313 |
314 | # Clean, minimal appearance
315 | args = Args(
316 | width=40,
317 | no_values=True, # Hide numbers
318 | custom_tick="▓", # Custom bar character
319 | colors=["green"]
320 | )
321 | ```
322 |
323 | ### Percentage Chart
324 |
325 | ```python
326 | from termgraph import Args, Colors
327 |
328 | # Percentage display
329 | args = Args(
330 | width=50,
331 | title="Completion Status",
332 | percentage=True,
333 | colors=[Colors.Green],
334 | format="{:<3.0f}",
335 | suffix="%"
336 | )
337 | ```
338 |
339 | ### Vertical Chart Configuration
340 |
341 | ```python
342 | from termgraph import Args, Colors
343 |
344 | # Column chart setup
345 | args = Args(
346 | width=30,
347 | title="Vertical Sales Chart",
348 | vertical=True,
349 | colors=[Colors.Blue],
350 | no_labels=False
351 | )
352 | ```
353 |
354 | ## Default Values Reference
355 |
356 | ```python
357 | # All default values
358 | default_args = {
359 | "filename": "-",
360 | "title": None,
361 | "width": 50,
362 | "format": "{:<5.2f}",
363 | "suffix": "",
364 | "no_labels": False,
365 | "no_values": False,
366 | "space_between": False,
367 | "colors": None,
368 | "vertical": False,
369 | "stacked": False,
370 | "histogram": False,
371 | "bins": 5,
372 | "different_scale": False,
373 | "calendar": False,
374 | "start_dt": None,
375 | "custom_tick": "",
376 | "delim": "",
377 | "verbose": False,
378 | "label_before": False,
379 | "percentage": False,
380 | "no_readable": False,
381 | }
382 | ```
383 |
384 | ## Error Handling
385 |
386 | The Args class validates argument names:
387 |
388 | ```python
389 | from termgraph import Args
390 |
391 | try:
392 | # Invalid argument name
393 | args = Args(invalid_option=True)
394 | except Exception as e:
395 | print(f"Error: {e}") # "Invalid Argument: invalid_option"
396 |
397 | try:
398 | # Invalid argument in get_arg
399 | args = Args()
400 | value = args.get_arg("nonexistent")
401 | except Exception as e:
402 | print(f"Error: {e}") # "Invalid Argument: nonexistent"
403 | ```
404 |
405 | ## Integration with Charts
406 |
407 | The Args object is passed to chart constructors:
408 |
409 | ```python
410 | from termgraph import Data, BarChart, Args, Colors
411 |
412 | # Create data and args
413 | data = Data([10, 20, 30], ["A", "B", "C"])
414 | args = Args(
415 | width=40,
416 | title="Sample Chart",
417 | colors=[Colors.Green]
418 | )
419 |
420 | # Create and draw chart
421 | chart = BarChart(data, args)
422 | chart.draw()
423 | ```
424 |
425 | For more information about chart types, see [Chart Classes Documentation](chart-classes.md).
426 | For data preparation, see [Data Class Documentation](data-class.md).
--------------------------------------------------------------------------------
/termgraph/termgraph.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import argparse
3 | import sys
4 | from datetime import datetime, timedelta
5 | from colorama import just_fix_windows_console
6 | import os
7 | import re
8 | import importlib.metadata
9 |
10 | from .constants import AVAILABLE_COLORS, DAYS
11 | from .data import Data
12 | from .args import Args
13 | from .chart import Chart, BarChart, StackedChart, HistogramChart, VerticalChart
14 |
15 | __version__ = importlib.metadata.version("termgraph")
16 |
17 | # colorama
18 | just_fix_windows_console()
19 |
20 |
21 | def init_args() -> dict:
22 | """Parse and return the arguments."""
23 | parser = argparse.ArgumentParser(description="draw basic graphs on terminal")
24 | parser.add_argument(
25 | "filename",
26 | nargs="?",
27 | default="-",
28 | help="data file name (comma or space separated). Defaults to stdin.",
29 | )
30 | parser.add_argument("--title", help="Title of graph")
31 | parser.add_argument(
32 | "--width", type=int, default=50, help="width of graph in characters default:50"
33 | )
34 | parser.add_argument("--format", default="{:<5.2f}", help="format specifier to use.")
35 | parser.add_argument(
36 | "--suffix", default="", help="string to add as a suffix to all data points."
37 | )
38 | parser.add_argument(
39 | "--no-labels", action="store_true", help="Do not print the label column"
40 | )
41 | parser.add_argument(
42 | "--no-values", action="store_true", help="Do not print the values at end"
43 | )
44 | parser.add_argument(
45 | "--space-between",
46 | action="store_true",
47 | help="Print a new line after every field",
48 | )
49 | parser.add_argument("--color", nargs="*", help="Graph bar color( s )")
50 | parser.add_argument("--vertical", action="store_true", help="Vertical graph")
51 | parser.add_argument("--stacked", action="store_true", help="Stacked bar graph")
52 | parser.add_argument("--histogram", action="store_true", help="Histogram")
53 | parser.add_argument("--bins", default=5, type=int, help="Bins of Histogram")
54 | parser.add_argument(
55 | "--different-scale",
56 | action="store_true",
57 | help="Categories have different scales.",
58 | )
59 | parser.add_argument(
60 | "--calendar", action="store_true", help="Calendar Heatmap chart"
61 | )
62 | parser.add_argument("--start-dt", help="Start date for Calendar chart")
63 | parser.add_argument(
64 | "--custom-tick", default="", help="Custom tick mark, emoji approved"
65 | )
66 | parser.add_argument(
67 | "--delim", default="", help="Custom delimiter, default , or space"
68 | )
69 | parser.add_argument(
70 | "--verbose", action="store_true", help="Verbose output, helpful for debugging"
71 | )
72 | parser.add_argument(
73 | "--label-before",
74 | action="store_true",
75 | default=False,
76 | help="Display the values before the bars",
77 | )
78 | parser.add_argument(
79 | "--version", action="store_true", help="Display version and exit"
80 | )
81 | parser.add_argument(
82 | "--no-readable", action="store_true", help="Disable the readable numbers"
83 | )
84 | parser.add_argument(
85 | "--percentage", action="store_true", help="Display the number in percentage"
86 | )
87 |
88 | if len(sys.argv) == 1:
89 | if sys.stdin.isatty():
90 | parser.print_usage()
91 | sys.exit(2)
92 |
93 | args = vars(parser.parse_args())
94 |
95 | return args
96 |
97 |
98 | def main():
99 | """Main function."""
100 | args = init_args()
101 |
102 | if args["version"]:
103 | print(f"termgraph v{__version__}")
104 | sys.exit()
105 |
106 | data_obj = Data.from_file(args["filename"], args)
107 | colors = _extract_colors(data_obj, args)
108 |
109 | try:
110 | if args["calendar"]:
111 | # calendar_heatmap still uses old interface
112 | calendar_heatmap(data_obj.data, data_obj.labels, args)
113 | else:
114 | chart(data_obj, args, colors)
115 | except BrokenPipeError:
116 | pass
117 |
118 |
119 | def chart(data_obj: Data, args: dict, colors: list) -> None:
120 | """Handle the normalization of data and the printing of the graph."""
121 | # Convert CLI args dict to chart Args class, mapping incompatible keys
122 | chart_args_dict = dict(args)
123 | if "color" in chart_args_dict:
124 | chart_args_dict["colors"] = chart_args_dict.pop("color")
125 |
126 | # Remove CLI-specific args that don't belong in chart Args
127 | cli_only_args = ["filename", "delim", "verbose", "version"]
128 | for cli_arg in cli_only_args:
129 | chart_args_dict.pop(cli_arg, None)
130 |
131 | chart_args = Args(**chart_args_dict)
132 | if colors:
133 | chart_args.update_args(colors=colors)
134 |
135 | # Choose chart type
136 | chart_obj: Chart
137 | if args["stacked"]:
138 | chart_obj = StackedChart(data_obj, chart_args)
139 | elif args["histogram"]:
140 | chart_obj = HistogramChart(data_obj, chart_args)
141 | elif args["vertical"]:
142 | chart_obj = VerticalChart(data_obj, chart_args)
143 | else:
144 | chart_obj = BarChart(data_obj, chart_args)
145 |
146 | chart_obj.draw()
147 |
148 |
149 | def _extract_colors(data_obj: Data, args: dict) -> list:
150 | """Extract and validate colors from args based on data dimensions.
151 |
152 | Args:
153 | data_obj: Data object containing the chart data
154 | args: Dictionary of arguments including optional "color" and "stacked"
155 |
156 | Returns:
157 | List of color codes for each category
158 | """
159 | colors = []
160 |
161 | # Determine number of categories from data dimensions
162 | if data_obj.dims and len(data_obj.dims) > 1:
163 | len_categories = data_obj.dims[1]
164 | else:
165 | len_categories = 1
166 |
167 | # If user inserts colors, they should be as many as the categories.
168 | if args.get("color") is not None:
169 | # Decompose arguments for Windows
170 | if os.name == "nt":
171 | colorargs = re.findall(r"[a-z]+", args["color"][0])
172 | if len(colorargs) != len_categories:
173 | print(">> Error: Color and category array sizes don't match")
174 | for color in colorargs:
175 | if color not in AVAILABLE_COLORS:
176 | print(
177 | ">> Error: invalid color. choose from 'red', 'blue', 'green', 'magenta', 'yellow', 'black', 'cyan'"
178 | )
179 | sys.exit(2)
180 | else:
181 | if len(args["color"]) != len_categories:
182 | print(">> Error: Color and category array sizes don't match")
183 | for color in args["color"]:
184 | if color not in AVAILABLE_COLORS:
185 | print(
186 | ">> Error: invalid color. choose from 'red', 'blue', 'green', 'magenta', 'yellow', 'black', 'cyan'"
187 | )
188 | sys.exit(2)
189 |
190 | if os.name == "nt":
191 | for color in colorargs:
192 | colors.append(AVAILABLE_COLORS.get(color))
193 | else:
194 | for color in args["color"]:
195 | colors.append(AVAILABLE_COLORS.get(color))
196 |
197 | # If user hasn't inserted colors, pick the first n colors
198 | # from the dict (n = number of categories).
199 | if args.get("stacked") and not colors:
200 | colors = [v for v in list(AVAILABLE_COLORS.values())[:len_categories]]
201 |
202 | return colors
203 |
204 | def read_data(args: dict) -> tuple[list, list, list, list]:
205 | """Read data from a file or stdin and returns it.
206 |
207 | DEPRECATED: This function is deprecated. Use Data.from_file() and _extract_colors() instead.
208 |
209 | Filename includes (categories), labels and data.
210 | We append categories and labels to lists.
211 | Data are inserted to a list of lists due to the categories.
212 |
213 | i.e.
214 | labels = ['2001', '2002', '2003', ...]
215 | categories = ['boys', 'girls']
216 | data = [ [20.4, 40.5], [30.7, 100.0], ...]"""
217 |
218 | # Use new Data.from_file() method
219 | data_obj = Data.from_file(args["filename"], args)
220 | colors = _extract_colors(data_obj, args)
221 |
222 | return data_obj.categories, data_obj.labels, data_obj.data, colors
223 |
224 |
225 | def calendar_heatmap(data: dict, labels: list, args: dict) -> None:
226 | """Print a calendar heatmap."""
227 | if args["color"]:
228 | colornum = AVAILABLE_COLORS.get(args["color"][0])
229 | else:
230 | colornum = AVAILABLE_COLORS.get("blue")
231 |
232 | dt_dict = {}
233 | for i in range(len(labels)):
234 | dt_dict[labels[i]] = data[i][0]
235 |
236 | # get max value
237 | max_val = float(max(data)[0])
238 |
239 | tick_1 = "░"
240 | tick_2 = "▒"
241 | tick_3 = "▓"
242 | tick_4 = "█"
243 |
244 | if args["custom_tick"]:
245 | tick_1 = tick_2 = tick_3 = tick_4 = args["custom_tick"]
246 |
247 | # check if start day set, otherwise use one year ago
248 | if args["start_dt"]:
249 | start_dt = datetime.strptime(args["start_dt"], "%Y-%m-%d")
250 | else:
251 | start = datetime.now()
252 | start_dt = datetime(year=start.year - 1, month=start.month, day=start.day)
253 |
254 | # modify start date to be a Monday, subtract weekday() from day
255 | start_dt = start_dt - timedelta(start_dt.weekday())
256 |
257 | # TODO: legend doesn't line up properly for all start dates/data
258 | # top legend for months
259 | sys.stdout.write(" ")
260 | for month in range(13):
261 | month_dt = datetime(
262 | year=start_dt.year, month=start_dt.month, day=1
263 | ) + timedelta(days=month * 31)
264 | sys.stdout.write(month_dt.strftime("%b") + " ")
265 | if args["custom_tick"]: # assume custom tick is emoji which is one wider
266 | sys.stdout.write(" ")
267 |
268 | sys.stdout.write("\n")
269 |
270 | for day in range(7):
271 | sys.stdout.write(DAYS[day] + ": ")
272 | for week in range(53):
273 | day_ = start_dt + timedelta(days=day + week * 7)
274 | day_str = day_.strftime("%Y-%m-%d")
275 |
276 | if day_str in dt_dict:
277 | if dt_dict[day_str] > max_val * 0.75:
278 | tick = tick_4
279 | elif dt_dict[day_str] > max_val * 0.50:
280 | tick = tick_3
281 | elif dt_dict[day_str] > max_val * 0.25:
282 | tick = tick_2
283 | # show nothing if value is zero
284 | elif dt_dict[day_str] == 0.0:
285 | tick = " "
286 | # show values for less than 0.25
287 | else:
288 | tick = tick_1
289 | else:
290 | tick = " "
291 |
292 | if colornum:
293 | sys.stdout.write(f"\033[{colornum}m")
294 |
295 | sys.stdout.write(tick)
296 | if colornum:
297 | sys.stdout.write("\033[0m")
298 |
299 | sys.stdout.write("\n")
300 |
301 |
302 | # DEPRECATED: Use Data.normalize() directly instead
303 | def normalize(data: list, width: int) -> list:
304 | """Normalize the data and return it.
305 |
306 | DEPRECATED: This function is deprecated. Use Data(data, labels).normalize(width) directly.
307 | """
308 | # Create a temporary Data object and use its normalize method
309 | temp_data = Data(data, [f"label_{i}" for i in range(len(data))])
310 | return temp_data.normalize(width)
311 |
312 |
313 | if __name__ == "__main__":
314 | main()
315 |
--------------------------------------------------------------------------------
/termgraph/data.py:
--------------------------------------------------------------------------------
1 | """Data class for termgraph - handles all data-related operations."""
2 |
3 | from __future__ import annotations
4 | from typing import Union
5 | import sys
6 | from .constants import DELIM
7 |
8 |
9 | class Data:
10 | """Class representing the data for the chart."""
11 |
12 | def __init__(
13 | self,
14 | data: list,
15 | labels: list[str],
16 | categories: Union[list[str], None] = None,
17 | ):
18 | """Initialize data
19 |
20 | :data: The data to graph on the chart
21 | :labels: The labels of the data
22 | :categories: The categories of the data
23 |
24 | Can be called with positional or keyword arguments:
25 | - Data([10, 20, 40, 26], ["Q1", "Q2", "Q3", "Q4"])
26 | - Data(data=[10, 20, 40, 26], labels=["Q1", "Q2", "Q3", "Q4"])
27 | - Data(labels=["Q1", "Q2", "Q3", "Q4"], data=[10, 20, 40, 26])
28 | """
29 |
30 | if data is None or labels is None:
31 | raise Exception("Both 'data' and 'labels' parameters are required")
32 |
33 | if not labels:
34 | raise Exception("No labels provided")
35 |
36 | if not data:
37 | raise Exception("No data provided")
38 |
39 | if len(data) != len(labels):
40 | raise Exception("The dimensions of the data and labels must be the same")
41 |
42 | self.labels = labels
43 | self.data = data
44 | self.categories = categories or []
45 | self.dims = self._find_dims(data, labels)
46 |
47 | @classmethod
48 | def from_file(cls, filename: str, args: dict) -> Data:
49 | """Read data from a file or stdin and return a Data object.
50 |
51 | This method handles reading chart data from files or stdin (when filename is "-").
52 | The file format supports:
53 | - Comments: lines starting with #
54 | - Categories: lines starting with @ followed by category names
55 | - Data rows: label followed by numeric values
56 |
57 | Args:
58 | filename: Path to data file, or "-" for stdin
59 | args: Dictionary of arguments including optional "delim" and "verbose"
60 |
61 | Returns:
62 | Data object with parsed data, labels, and categories
63 |
64 | Example file format:
65 | @ Boys Girls
66 | 2001 20.4 40.5
67 | 2002 30.7 100.0
68 | """
69 | stdin = filename == "-"
70 |
71 | # Get delimiter from args or use default
72 | delim = args.get("delim") or DELIM
73 |
74 | if args.get("verbose"):
75 | print(f">> Reading data from {('stdin' if stdin else filename)}")
76 |
77 | categories: list[str] = []
78 | labels: list[str] = []
79 | data: list = []
80 |
81 | f = None
82 |
83 | try:
84 | f = sys.stdin if stdin else open(filename, "r")
85 | for line in f:
86 | line = line.strip()
87 | if line:
88 | if not line.startswith("#"):
89 | # Line contains categories.
90 | if line.startswith("@"):
91 | cols = line.split(delim)
92 | cols[0] = cols[0].replace("@ ", "")
93 | categories = cols
94 |
95 | # Line contains label and values.
96 | else:
97 | if line.find(delim) > 0:
98 | cols = line.split(delim)
99 | row_delim = delim
100 | else:
101 | cols = line.split()
102 | row_delim = " "
103 | labeled_row = _label_row([col.strip() for col in cols], row_delim)
104 | data.append(labeled_row.data)
105 | labels.append(labeled_row.label)
106 | except FileNotFoundError:
107 | print(f">> Error: The specified file [{filename}] does not exist.")
108 | sys.exit()
109 | except IOError:
110 | print("An IOError has occurred!")
111 | sys.exit()
112 | finally:
113 | if f is not None:
114 | f.close()
115 |
116 | return cls(data, labels, categories)
117 |
118 | def _find_dims(self, data, labels, dims=None) -> Union[tuple[int], None]:
119 | if dims is None:
120 | dims = []
121 | if all([isinstance(data[i], list) for i in range(len(data))]):
122 | last = None
123 |
124 | for i in range(len(data)):
125 | curr = self._find_dims(data[i], labels[i], dims + [len(data)])
126 |
127 | if i != 0 and last != curr:
128 | raise Exception(
129 | f"The inner dimensions of the data are different\nThe dimensions of {data[i - 1]} is different than the dimensions of {data[i]}"
130 | )
131 |
132 | last = curr
133 |
134 | return last
135 |
136 | else:
137 | dims.append(len(data))
138 |
139 | return tuple(dims)
140 |
141 | def find_min(self) -> Union[int, float]:
142 | """Return the minimum value in sublist of list."""
143 | # Check if data is flat (list of numbers) or nested (list of lists)
144 | is_flat = all(not isinstance(item, list) for item in self.data)
145 |
146 | if is_flat:
147 | return min(self.data)
148 | else:
149 | return min(value for sublist in self.data for value in sublist)
150 |
151 | def find_max(self) -> Union[int, float]:
152 | """Return the maximum value in sublist of list."""
153 | # Check if data is flat (list of numbers) or nested (list of lists)
154 | is_flat = all(not isinstance(item, list) for item in self.data)
155 |
156 | if is_flat:
157 | return max(self.data)
158 | else:
159 | return max(value for sublist in self.data for value in sublist)
160 |
161 | def find_min_label_length(self) -> int:
162 | """Return the minimum length for the labels."""
163 | return min(len(label) for label in self.labels)
164 |
165 | def find_max_label_length(self) -> int:
166 | """Return the maximum length for the labels."""
167 | return max(len(label) for label in self.labels)
168 |
169 | def __str__(self):
170 | """Returns the string representation of the data.
171 | :returns: The data in a tabular format
172 | """
173 |
174 | maxlen_labels = max([len(label) for label in self.labels] + [len("Labels")]) + 1
175 |
176 | if len(self.categories) == 0:
177 | maxlen_data = max([len(str(data)) for data in self.data]) + 1
178 |
179 | else:
180 | maxlen_categories = max([len(category) for category in self.categories])
181 | maxlen_data = (
182 | max(
183 | [
184 | len(str(self.data[i][j]))
185 | for i in range(len(self.data))
186 | for j in range(len(self.categories))
187 | ]
188 | )
189 | + maxlen_categories
190 | + 4
191 | )
192 |
193 | output = [
194 | f"{' ' * (maxlen_labels - len('Labels'))}Labels | Data",
195 | f"{'-' * (maxlen_labels + 1)}|{'-' * (maxlen_data + 1)}",
196 | ]
197 |
198 | for i in range(len(self.data)):
199 | line = f"{' ' * (maxlen_labels - len(self.labels[i])) + self.labels[i]} |"
200 |
201 | if len(self.categories) == 0:
202 | line += f" {self.data[i]}"
203 |
204 | else:
205 | for j in range(len(self.categories)):
206 | if j == 0:
207 | line += f" ({self.categories[j]}) {self.data[i][0]}\n"
208 |
209 | else:
210 | line += f"{' ' * maxlen_labels} | ({self.categories[j]}) {self.data[i][j]}"
211 | line += (
212 | "\n"
213 | if j < len(self.categories) - 1
214 | else f"\n{' ' * maxlen_labels} |"
215 | )
216 |
217 | output.append(line)
218 |
219 | return "\n".join(output)
220 |
221 | def normalize(self, width: int) -> list:
222 | """Normalize the data and return it."""
223 | # Check if data is flat (list of numbers) or nested (list of lists)
224 | is_flat = all(not isinstance(item, list) for item in self.data)
225 |
226 | if is_flat:
227 | # Handle flat list data
228 | min_datum = min(self.data)
229 | if min_datum < 0:
230 | min_datum = abs(min_datum)
231 | data_offset = [d + min_datum for d in self.data]
232 | else:
233 | data_offset = self.data
234 |
235 | min_datum = min(data_offset)
236 | max_datum = max(data_offset)
237 |
238 | if min_datum == max_datum:
239 | return data_offset
240 |
241 | norm_factor = width / float(max_datum)
242 | return [v * norm_factor for v in data_offset]
243 | else:
244 | # Handle nested list data (original logic)
245 | data_offset = []
246 | min_datum = min(value for sublist in self.data for value in sublist)
247 | if min_datum < 0:
248 | min_datum = abs(min_datum)
249 | for datum in self.data:
250 | data_offset.append([d + min_datum for d in datum])
251 | else:
252 | data_offset = self.data
253 | min_datum = min(value for sublist in data_offset for value in sublist)
254 | max_datum = max(value for sublist in data_offset for value in sublist)
255 |
256 | if min_datum == max_datum:
257 | return data_offset
258 |
259 | # max_dat / width is the value for a single tick. norm_factor is the
260 | # inverse of this value
261 | # If you divide a number to the value of single tick, you will find how
262 | # many ticks it does contain basically.
263 | norm_factor = width / float(max_datum)
264 | normal_data = []
265 | for datum in data_offset:
266 | normal_data.append([v * norm_factor for v in datum])
267 |
268 | return normal_data
269 |
270 | def __repr__(self):
271 | return f"Data(data={self.data if len(str(self.data)) < 25 else str(self.data)[:25] + '...'}, labels={self.labels}, categories={self.categories})"
272 |
273 |
274 | class _LabeledRow:
275 | """Internal helper class for parsing data rows with labels."""
276 | def __init__(self, label: str, data: list[float]):
277 | self.label = label
278 | self.data = data
279 |
280 |
281 | def _label_row(row: list[str], delim: str) -> _LabeledRow:
282 | """Parse a row of data, extracting label and numeric values."""
283 | data = []
284 | labels: list[str] = []
285 | labelling = False
286 |
287 | for text in row:
288 | datum = _maybe_float(text)
289 | if datum is None and not labels:
290 | labels.append(text)
291 | labelling = True
292 | elif datum is None and labelling:
293 | labels.append(text)
294 | elif datum is not None:
295 | data.append(datum)
296 | labelling = False
297 | else:
298 | raise ValueError(f"Multiple labels not allowed: {labels}, {text}")
299 |
300 | if labels:
301 | label = delim.join(labels)
302 | else:
303 | label = row[0]
304 | data.pop(0)
305 |
306 | return _LabeledRow(label=label, data=data)
307 |
308 |
309 | def _maybe_float(text: str) -> float | None:
310 | """Try to convert text to float, return None if not possible."""
311 | try:
312 | return float(text)
313 | except ValueError:
314 | return None
315 |
--------------------------------------------------------------------------------
/docs/chart-classes.md:
--------------------------------------------------------------------------------
1 | # Chart Classes API Documentation
2 |
3 | Termgraph provides several chart classes for different visualization needs. All chart classes inherit from the base `Chart` class and work with `Data` and `Args` objects.
4 |
5 | ## Base Classes
6 |
7 | ### Chart (Abstract Base Class)
8 |
9 | The `Chart` class is the foundation for all chart types.
10 |
11 | ```python
12 | from termgraph import Chart, Data, Args
13 |
14 | # Chart is abstract - use specific chart implementations
15 | # chart = Chart(data, args) # Don't do this
16 | ```
17 |
18 | **Constructor Parameters:**
19 | - **data** (Data): Data object containing the values and labels
20 | - **args** (Args): Configuration arguments for the chart
21 |
22 | **Methods:**
23 | - `draw()`: Abstract method implemented by subclasses to render the chart
24 |
25 | ### Colors Class
26 |
27 | Provides predefined color constants for chart styling.
28 |
29 | ```python
30 | from termgraph import Colors
31 |
32 | # Available colors
33 | Colors.Black # Black color code
34 | Colors.Red # Red color code
35 | Colors.Green # Green color code
36 | Colors.Yellow # Yellow color code
37 | Colors.Blue # Blue color code
38 | Colors.Magenta # Magenta color code
39 | Colors.Cyan # Cyan color code
40 | ```
41 |
42 | ## Chart Types
43 |
44 | ### BarChart
45 |
46 | Creates horizontal bar charts. Supports both single and multi-series data.
47 |
48 | ```python
49 | from termgraph import Data, BarChart, Args
50 |
51 | # Single series bar chart
52 | data = Data([23, 45, 56, 78, 32], ["A", "B", "C", "D", "E"])
53 | chart = BarChart(data)
54 | chart.draw()
55 | ```
56 |
57 | **Features:**
58 | - Horizontal bars with customizable width
59 | - Multi-series support with categories
60 | - Different scaling options
61 | - Color support
62 | - Value formatting
63 |
64 | #### Single Series Example
65 |
66 | ```python
67 | from termgraph import Data, BarChart, Args, Colors
68 |
69 | # Simple bar chart
70 | data = Data(
71 | data=[150, 230, 180, 290, 210],
72 | labels=["Jan", "Feb", "Mar", "Apr", "May"]
73 | )
74 |
75 | args = Args(
76 | width=50,
77 | title="Monthly Sales",
78 | colors=[Colors.Green],
79 | suffix=" units"
80 | )
81 |
82 | chart = BarChart(data, args)
83 | chart.draw()
84 | ```
85 |
86 | Output:
87 | ```
88 | # Monthly Sales
89 |
90 | Jan : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150.00 units
91 | Feb : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 230.00 units
92 | Mar : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180.00 units
93 | Apr : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 290.00 units
94 | May : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 210.00 units
95 | ```
96 |
97 | #### Multi-Series Example
98 |
99 | ```python
100 | from termgraph import Data, BarChart, Args, Colors
101 |
102 | # Multi-series bar chart
103 | data = Data(
104 | data=[
105 | [120, 80], # Q1: Product A, Product B
106 | [150, 95], # Q2: Product A, Product B
107 | [180, 110], # Q3: Product A, Product B
108 | [200, 125] # Q4: Product A, Product B
109 | ],
110 | labels=["Q1", "Q2", "Q3", "Q4"],
111 | categories=["Product A", "Product B"]
112 | )
113 |
114 | args = Args(
115 | width=40,
116 | title="Quarterly Sales by Product",
117 | colors=[Colors.Blue, Colors.Red],
118 | space_between=True
119 | )
120 |
121 | chart = BarChart(data, args)
122 | chart.draw()
123 | ```
124 |
125 | Output:
126 | ```
127 | # Quarterly Sales by Product
128 |
129 | ▇ Product A ▇ Product B
130 |
131 | Q1: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 120.00
132 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 80.00
133 |
134 | Q2: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150.00
135 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 95.00
136 |
137 | Q3: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180.00
138 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 110.00
139 |
140 | Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 200.00
141 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 125.00
142 | ```
143 |
144 | #### Different Scale Example
145 |
146 | ```python
147 | from termgraph import Data, BarChart, Args, Colors
148 |
149 | # Different scales for each series
150 | data = Data(
151 | data=[
152 | [1200, 45], # Revenue (thousands), Satisfaction (%)
153 | [1500, 52],
154 | [1800, 48],
155 | [2000, 58]
156 | ],
157 | labels=["Q1", "Q2", "Q3", "Q4"],
158 | categories=["Revenue ($K)", "Satisfaction (%)"]
159 | )
160 |
161 | args = Args(
162 | width=30,
163 | different_scale=True, # Each series uses its own scale
164 | colors=[Colors.Green, Colors.Yellow],
165 | title="Revenue vs Customer Satisfaction"
166 | )
167 |
168 | chart = BarChart(data, args)
169 | chart.draw()
170 | ```
171 |
172 | ### StackedChart
173 |
174 | Creates stacked bar charts where multiple values are stacked on top of each other.
175 |
176 | ```python
177 | from termgraph import Data, StackedChart, Args, Colors
178 |
179 | # Stacked bar chart
180 | data = Data(
181 | data=[
182 | [30, 20, 10], # Desktop, Mobile, Tablet
183 | [25, 30, 15],
184 | [20, 35, 20],
185 | [15, 40, 25]
186 | ],
187 | labels=["Q1", "Q2", "Q3", "Q4"],
188 | categories=["Desktop", "Mobile", "Tablet"]
189 | )
190 |
191 | args = Args(
192 | width=50,
193 | title="Traffic Sources by Quarter",
194 | colors=[Colors.Blue, Colors.Green, Colors.Yellow]
195 | )
196 |
197 | chart = StackedChart(data, args)
198 | chart.draw()
199 | ```
200 |
201 | Output:
202 | ```
203 | # Traffic Sources by Quarter
204 |
205 | ▇ Desktop ▇ Mobile ▇ Tablet
206 |
207 | Q1: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 60.00
208 | Q2: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 70.00
209 | Q3: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 75.00
210 | Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 80.00
211 | ```
212 |
213 | ### VerticalChart
214 |
215 | Creates vertical bar charts (column charts).
216 |
217 | ```python
218 | from termgraph import Data, VerticalChart, Args, Colors
219 |
220 | # Vertical bar chart
221 | data = Data(
222 | data=[23, 45, 56, 78, 32],
223 | labels=["A", "B", "C", "D", "E"]
224 | )
225 |
226 | args = Args(
227 | width=40,
228 | title="Vertical Chart Example",
229 | colors=[Colors.Cyan]
230 | )
231 |
232 | chart = VerticalChart(data, args)
233 | chart.draw()
234 | ```
235 |
236 | Output:
237 | ```
238 | # Vertical Chart Example
239 |
240 | ▇
241 | ▇
242 | ▇ ▇
243 | ▇ ▇
244 | ▇ ▇ ▇ ▇
245 | ▇ ▇ ▇ ▇
246 | ▇ ▇ ▇ ▇ ▇
247 | ▇ ▇ ▇ ▇ ▇
248 | ▇ ▇ ▇ ▇ ▇
249 | ----------------
250 | 23.00 45.00 56.00 78.00 32.00
251 | ----------------
252 | A B C D E
253 | ```
254 |
255 | ### HistogramChart
256 |
257 | Creates histogram charts that bin continuous data into ranges.
258 |
259 | ```python
260 | from termgraph import Data, HistogramChart, Args, Colors
261 |
262 | # Histogram chart
263 | # Note: For histograms, data should be the raw values you want to bin
264 | data = Data(
265 | data=[[12.5, 15.3, 18.7, 22.1, 25.6, 28.9, 32.4, 35.8, 38.2, 41.7]],
266 | labels=["Temperature Readings"]
267 | )
268 |
269 | args = Args(
270 | width=40,
271 | bins=5, # Number of bins
272 | title="Temperature Distribution",
273 | colors=[Colors.Red]
274 | )
275 |
276 | chart = HistogramChart(data, args)
277 | chart.draw()
278 | ```
279 |
280 | Output:
281 | ```
282 | # Temperature Distribution
283 |
284 | 12.0 – 18.0: ▇▇▇▇▇▇▇▇▇▇▇▇ 3.00
285 | 18.0 – 24.0: ▇▇▇▇▇▇▇▇ 2.00
286 | 24.0 – 30.0: ▇▇▇▇▇▇▇▇ 2.00
287 | 30.0 – 36.0: ▇▇▇▇▇▇▇▇ 2.00
288 | 36.0 – 42.0: ▇▇▇▇▇▇▇▇▇▇▇▇ 3.00
289 | ```
290 |
291 | ## Configuration Options
292 |
293 | All chart classes accept an `Args` object for configuration. See [Args Class Documentation](args-class.md) for complete details.
294 |
295 | ### Common Options
296 |
297 | ```python
298 | from termgraph import Args, Colors
299 |
300 | args = Args(
301 | width=50, # Chart width in characters
302 | title="My Chart", # Chart title
303 | colors=[Colors.Blue], # Colors for series
304 | suffix=" units", # Value suffix
305 | format="{:<6.1f}", # Value formatting
306 | no_labels=False, # Hide labels
307 | no_values=False, # Hide values
308 | space_between=True # Add space between bars
309 | )
310 | ```
311 |
312 | ### Chart-Specific Options
313 |
314 | ```python
315 | # Bar chart specific
316 | args = Args(
317 | different_scale=True, # Use different scales for multi-series
318 | label_before=True # Show labels before bars
319 | )
320 |
321 | # Histogram specific
322 | args = Args(
323 | bins=10 # Number of histogram bins
324 | )
325 |
326 | # Vertical chart specific
327 | args = Args(
328 | vertical=True # Enable vertical mode
329 | )
330 | ```
331 |
332 | ## Advanced Examples
333 |
334 | ### Complex Multi-Series Chart
335 |
336 | ```python
337 | from termgraph import Data, BarChart, Args, Colors
338 |
339 | # Sales data across multiple regions and quarters
340 | data = Data(
341 | data=[
342 | [150, 120, 90], # Q1: North, South, West
343 | [180, 140, 110], # Q2: North, South, West
344 | [200, 160, 130], # Q3: North, South, West
345 | [220, 180, 150] # Q4: North, South, West
346 | ],
347 | labels=["Q1 2023", "Q2 2023", "Q3 2023", "Q4 2023"],
348 | categories=["North Region", "South Region", "West Region"]
349 | )
350 |
351 | args = Args(
352 | width=60,
353 | title="Regional Sales Performance",
354 | colors=[Colors.Blue, Colors.Green, Colors.Yellow],
355 | space_between=True,
356 | suffix="K",
357 | format="{:<5.0f}"
358 | )
359 |
360 | chart = BarChart(data, args)
361 | chart.draw()
362 | ```
363 |
364 | ### Percentage Data with Custom Formatting
365 |
366 | ```python
367 | from termgraph import Data, BarChart, Args, Colors
368 |
369 | # Percentage completion data
370 | data = Data(
371 | data=[0.65, 0.80, 0.45, 0.92, 0.73],
372 | labels=["Project A", "Project B", "Project C", "Project D", "Project E"]
373 | )
374 |
375 | args = Args(
376 | width=40,
377 | title="Project Completion Status",
378 | colors=[Colors.Green],
379 | percentage=True, # Format as percentages
380 | format="{:<4.0f}",
381 | suffix="%"
382 | )
383 |
384 | chart = BarChart(data, args)
385 | chart.draw()
386 | ```
387 |
388 | ### Negative Values Handling
389 |
390 | ```python
391 | from termgraph import Data, BarChart, Args, Colors
392 |
393 | # Profit/Loss data with negative values
394 | data = Data(
395 | data=[-50, 30, -20, 80, 45, -15],
396 | labels=["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
397 | )
398 |
399 | args = Args(
400 | width=50,
401 | title="Monthly P&L",
402 | colors=[Colors.Red], # Single color for all bars
403 | suffix="K",
404 | format="{:<+6.0f}" # Show + for positive values
405 | )
406 |
407 | chart = BarChart(data, args)
408 | chart.draw()
409 | ```
410 |
411 | ## Error Handling
412 |
413 | Charts will handle common data issues gracefully:
414 |
415 | ```python
416 | from termgraph import Data, BarChart
417 |
418 | try:
419 | # Empty data
420 | data = Data([], [])
421 | chart = BarChart(data)
422 | chart.draw()
423 | except Exception as e:
424 | print(f"Error: {e}")
425 |
426 | try:
427 | # Mismatched dimensions
428 | data = Data([10, 20], ["A"])
429 | chart = BarChart(data)
430 | chart.draw()
431 | except Exception as e:
432 | print(f"Error: {e}")
433 | ```
434 |
435 | ## Integration Example
436 |
437 | Complete example showing how to use multiple chart types with the same data:
438 |
439 | ```python
440 | from termgraph import Data, BarChart, StackedChart, VerticalChart, Args, Colors
441 |
442 | # Sample data
443 | data = Data(
444 | data=[[30, 45], [25, 50], [40, 35], [35, 40]],
445 | labels=["Q1", "Q2", "Q3", "Q4"],
446 | categories=["Revenue", "Expenses"]
447 | )
448 |
449 | args = Args(
450 | width=40,
451 | title="Financial Data",
452 | colors=[Colors.Green, Colors.Red]
453 | )
454 |
455 | # Different chart types with same data
456 | print("=== Bar Chart ===")
457 | bar_chart = BarChart(data, args)
458 | bar_chart.draw()
459 |
460 | print("\n=== Stacked Chart ===")
461 | stacked_chart = StackedChart(data, args)
462 | stacked_chart.draw()
463 |
464 | print("\n=== Vertical Chart ===")
465 | vertical_chart = VerticalChart(data, args)
466 | vertical_chart.draw()
467 | ```
468 |
469 | For more information about data preparation, see [Data Class Documentation](data-class.md).
470 | For configuration options, see [Args Class Documentation](args-class.md).
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Termgraph Python API Documentation
2 |
3 | Termgraph is a Python library for creating terminal-based charts and graphs. This documentation covers using termgraph as a Python module for programmatic chart generation.
4 |
5 | ## Quick Start
6 |
7 | ```python
8 | from termgraph import Data, BarChart, Args
9 |
10 | # Create data
11 | data = Data([23, 45, 56, 78, 32], ["A", "B", "C", "D", "E"])
12 |
13 | # Create and display chart
14 | chart = BarChart(data)
15 | chart.draw()
16 | ```
17 |
18 | ## Installation
19 |
20 | ```bash
21 | pip install termgraph
22 | ```
23 |
24 | ## Core Components
25 |
26 | Termgraph consists of three main classes:
27 |
28 | 1. **[Data](data-class.md)** - Handles data storage, validation, and normalization
29 | 2. **[Chart Classes](chart-classes.md)** - Various chart types (Bar, Stacked, Vertical, Histogram)
30 | 3. **[Args](args-class.md)** - Configuration and styling options
31 |
32 | ## Basic Usage
33 |
34 | ### Simple Bar Chart
35 |
36 | ```python
37 | from termgraph import Data, BarChart
38 |
39 | # Sales data
40 | data = Data(
41 | data=[150, 230, 180, 290, 210],
42 | labels=["Jan", "Feb", "Mar", "Apr", "May"]
43 | )
44 |
45 | chart = BarChart(data)
46 | chart.draw()
47 | ```
48 |
49 | Output:
50 | ```
51 | Jan: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150.00
52 | Feb: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 230.00
53 | Mar: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180.00
54 | Apr: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 290.00
55 | May: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 210.00
56 | ```
57 |
58 | ### Styled Chart
59 |
60 | ```python
61 | from termgraph import Data, BarChart, Args, Colors
62 |
63 | data = Data(
64 | data=[150, 230, 180, 290, 210],
65 | labels=["Jan", "Feb", "Mar", "Apr", "May"]
66 | )
67 |
68 | args = Args(
69 | width=60,
70 | title="Monthly Sales Report",
71 | colors=[Colors.Green],
72 | suffix=" units",
73 | format="{:<6.0f}"
74 | )
75 |
76 | chart = BarChart(data, args)
77 | chart.draw()
78 | ```
79 |
80 | Output:
81 | ```
82 | # Monthly Sales Report
83 |
84 | Jan: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150 units
85 | Feb: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 230 units
86 | Mar: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180 units
87 | Apr: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 290 units
88 | May: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 210 units
89 | ```
90 |
91 | ## Chart Types
92 |
93 | ### Bar Chart (Horizontal)
94 |
95 | Best for comparing values across categories.
96 |
97 | ```python
98 | from termgraph import Data, BarChart, Args, Colors
99 |
100 | # Single series
101 | data = Data([45, 32, 78, 56, 23], ["Product A", "Product B", "Product C", "Product D", "Product E"])
102 | chart = BarChart(data, Args(colors=[Colors.Blue]))
103 | chart.draw()
104 | ```
105 |
106 | ### Multi-Series Bar Chart
107 |
108 | Compare multiple data series side by side.
109 |
110 | ```python
111 | from termgraph import Data, BarChart, Args, Colors
112 |
113 | # Quarterly sales for two products
114 | data = Data(
115 | data=[
116 | [120, 80], # Q1: Product A, Product B
117 | [150, 95], # Q2: Product A, Product B
118 | [180, 110], # Q3: Product A, Product B
119 | [200, 125] # Q4: Product A, Product B
120 | ],
121 | labels=["Q1", "Q2", "Q3", "Q4"],
122 | categories=["Product A", "Product B"]
123 | )
124 |
125 | args = Args(
126 | title="Quarterly Sales Comparison",
127 | colors=[Colors.Blue, Colors.Red],
128 | width=50
129 | )
130 |
131 | chart = BarChart(data, args)
132 | chart.draw()
133 | ```
134 |
135 | ### Stacked Bar Chart
136 |
137 | Show parts of a whole.
138 |
139 | ```python
140 | from termgraph import Data, StackedChart, Args, Colors
141 |
142 | # Budget breakdown
143 | data = Data(
144 | data=[
145 | [30, 20, 10], # Q1: Marketing, Development, Operations
146 | [35, 25, 15], # Q2: Marketing, Development, Operations
147 | [40, 30, 20], # Q3: Marketing, Development, Operations
148 | [45, 35, 25] # Q4: Marketing, Development, Operations
149 | ],
150 | labels=["Q1", "Q2", "Q3", "Q4"],
151 | categories=["Marketing", "Development", "Operations"]
152 | )
153 |
154 | args = Args(
155 | title="Budget Allocation by Quarter",
156 | colors=[Colors.Green, Colors.Blue, Colors.Yellow],
157 | suffix="K"
158 | )
159 |
160 | chart = StackedChart(data, args)
161 | chart.draw()
162 | ```
163 |
164 | ### Vertical Chart (Column Chart)
165 |
166 | Good for time series or when you have many categories.
167 |
168 | ```python
169 | from termgraph import Data, VerticalChart, Args, Colors
170 |
171 | data = Data([23, 45, 56, 78, 32, 67, 45], ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
172 |
173 | args = Args(
174 | title="Daily Website Visits",
175 | colors=[Colors.Cyan],
176 | width=40
177 | )
178 |
179 | chart = VerticalChart(data, args)
180 | chart.draw()
181 | ```
182 |
183 | ### Histogram
184 |
185 | Show distribution of continuous data.
186 |
187 | ```python
188 | from termgraph import Data, HistogramChart, Args, Colors
189 |
190 | # Temperature readings that will be binned
191 | data = Data(
192 | data=[[15.2, 18.7, 22.1, 19.5, 25.3, 28.9, 24.4, 21.8, 26.2, 23.7, 20.1, 17.6]],
193 | labels=["Temperature Data"]
194 | )
195 |
196 | args = Args(
197 | title="Temperature Distribution",
198 | bins=6,
199 | colors=[Colors.Red],
200 | width=50
201 | )
202 |
203 | chart = HistogramChart(data, args)
204 | chart.draw()
205 | ```
206 |
207 | ## Advanced Examples
208 |
209 | ### Financial Dashboard
210 |
211 | ```python
212 | from termgraph import Data, BarChart, StackedChart, Args, Colors
213 |
214 | # Revenue data
215 | revenue_data = Data(
216 | data=[
217 | [450, 380, 290], # Q1: Americas, EMEA, APAC
218 | [520, 420, 340], # Q2: Americas, EMEA, APAC
219 | [480, 450, 380], # Q3: Americas, EMEA, APAC
220 | [600, 480, 420] # Q4: Americas, EMEA, APAC
221 | ],
222 | labels=["Q1 2023", "Q2 2023", "Q3 2023", "Q4 2023"],
223 | categories=["Americas", "EMEA", "APAC"]
224 | )
225 |
226 | # Revenue by region (stacked)
227 | print("=== Revenue by Region (Stacked) ===")
228 | stacked_args = Args(
229 | title="Quarterly Revenue by Region",
230 | colors=[Colors.Blue, Colors.Green, Colors.Yellow],
231 | width=60,
232 | suffix="K USD",
233 | format="{:<5.0f}"
234 | )
235 | stacked_chart = StackedChart(revenue_data, stacked_args)
236 | stacked_chart.draw()
237 |
238 | # Regional comparison (side by side)
239 | print("\n=== Regional Performance Comparison ===")
240 | bar_args = Args(
241 | title="Regional Performance by Quarter",
242 | colors=[Colors.Blue, Colors.Green, Colors.Yellow],
243 | width=60,
244 | suffix="K USD",
245 | space_between=True
246 | )
247 | bar_chart = BarChart(revenue_data, bar_args)
248 | bar_chart.draw()
249 | ```
250 |
251 | ### Performance Metrics Dashboard
252 |
253 | ```python
254 | from termgraph import Data, BarChart, Args, Colors
255 |
256 | # Performance metrics with different scales
257 | performance_data = Data(
258 | data=[
259 | [95.5, 1250], # Uptime %, Response Time (ms)
260 | [98.2, 980],
261 | [97.1, 1100],
262 | [99.1, 850],
263 | [96.8, 1300]
264 | ],
265 | labels=["Week 1", "Week 2", "Week 3", "Week 4", "Week 5"],
266 | categories=["Uptime (%)", "Response Time (ms)"]
267 | )
268 |
269 | args = Args(
270 | title="System Performance Metrics",
271 | colors=[Colors.Green, Colors.Red],
272 | different_scale=True, # Use different scales for each metric
273 | width=50,
274 | space_between=True
275 | )
276 |
277 | chart = BarChart(performance_data, args)
278 | chart.draw()
279 | ```
280 |
281 | ### Project Status Tracking
282 |
283 | ```python
284 | from termgraph import Data, BarChart, Args, Colors
285 |
286 | # Project completion percentages
287 | projects_data = Data(
288 | data=[0.85, 0.62, 0.93, 0.78, 0.45, 0.91],
289 | labels=["Website Redesign", "Mobile App", "Database Migration", "API v2", "Documentation", "Testing Suite"]
290 | )
291 |
292 | args = Args(
293 | title="Project Completion Status",
294 | colors=[Colors.Green],
295 | percentage=True,
296 | width=50,
297 | format="{:<3.0f}",
298 | suffix="%"
299 | )
300 |
301 | chart = BarChart(projects_data, args)
302 | chart.draw()
303 | ```
304 |
305 | ### Sales Trend Analysis
306 |
307 | ```python
308 | from termgraph import Data, VerticalChart, BarChart, Args, Colors
309 |
310 | # Monthly sales trend
311 | monthly_sales = Data(
312 | data=[120, 135, 150, 145, 160, 175, 185, 170, 190, 200, 195, 210],
313 | labels=["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
314 | )
315 |
316 | # Vertical chart for trend visualization
317 | print("=== Monthly Sales Trend ===")
318 | vertical_args = Args(
319 | title="2023 Sales Trend",
320 | colors=[Colors.Blue],
321 | width=60
322 | )
323 | vertical_chart = VerticalChart(monthly_sales, vertical_args)
324 | vertical_chart.draw()
325 |
326 | # Horizontal chart for precise values
327 | print("\n=== Detailed Monthly Sales ===")
328 | bar_args = Args(
329 | title="2023 Monthly Sales Details",
330 | colors=[Colors.Green],
331 | width=50,
332 | suffix="K USD",
333 | format="{:<5.0f}"
334 | )
335 | bar_chart = BarChart(monthly_sales, bar_args)
336 | bar_chart.draw()
337 | ```
338 |
339 | ## Data Formats
340 |
341 | ### Flat Data (Single Series)
342 |
343 | ```python
344 | # Simple list of numbers
345 | data = Data([10, 20, 30, 40], ["A", "B", "C", "D"])
346 | ```
347 |
348 | ### Nested Data (Multi-Series)
349 |
350 | ```python
351 | # List of lists for multiple data series
352 | data = Data(
353 | data=[
354 | [10, 15, 20], # Row 1: three values
355 | [25, 30, 35], # Row 2: three values
356 | [40, 45, 50] # Row 3: three values
357 | ],
358 | labels=["Category 1", "Category 2", "Category 3"],
359 | categories=["Series A", "Series B", "Series C"]
360 | )
361 | ```
362 |
363 | ### Working with Real Data
364 |
365 | ```python
366 | import csv
367 | from termgraph import Data, BarChart, Args, Colors
368 |
369 | # Reading from CSV (example)
370 | def load_csv_data(filename):
371 | """Load data from CSV file."""
372 | data = []
373 | labels = []
374 |
375 | with open(filename, 'r') as f:
376 | reader = csv.reader(f)
377 | next(reader) # Skip header
378 |
379 | for row in reader:
380 | labels.append(row[0])
381 | data.append(float(row[1]))
382 |
383 | return Data(data, labels)
384 |
385 | # Usage
386 | # data = load_csv_data('sales_data.csv')
387 | # chart = BarChart(data, Args(title="Sales Data", colors=[Colors.Blue]))
388 | # chart.draw()
389 | ```
390 |
391 | ## Error Handling
392 |
393 | ```python
394 | from termgraph import Data, BarChart
395 |
396 | try:
397 | # This will raise an exception - mismatched dimensions
398 | data = Data([10, 20, 30], ["A", "B"])
399 | chart = BarChart(data)
400 | chart.draw()
401 | except Exception as e:
402 | print(f"Error creating chart: {e}")
403 |
404 | try:
405 | # This will raise an exception - empty data
406 | data = Data([], [])
407 | chart = BarChart(data)
408 | chart.draw()
409 | except Exception as e:
410 | print(f"Error with empty data: {e}")
411 | ```
412 |
413 | ## Best Practices
414 |
415 | ### 1. Choose the Right Chart Type
416 |
417 | - **Bar Chart**: Comparing categories or values
418 | - **Stacked Chart**: Showing parts of a whole
419 | - **Vertical Chart**: Time series or many categories
420 | - **Histogram**: Distribution of continuous data
421 |
422 | ### 2. Use Appropriate Colors
423 |
424 | ```python
425 | # Good: Different colors for different series
426 | args = Args(colors=[Colors.Blue, Colors.Green, Colors.Red])
427 |
428 | # Good: Single color for single series
429 | args = Args(colors=[Colors.Blue])
430 |
431 | # Avoid: Too many similar colors
432 | ```
433 |
434 | ### 3. Format Values Appropriately
435 |
436 | ```python
437 | # For percentages
438 | args = Args(percentage=True, format="{:<3.0f}", suffix="%")
439 |
440 | # For currency
441 | args = Args(format="{:<6.2f}", suffix=" USD")
442 |
443 | # For large numbers
444 | args = Args(suffix="K", format="{:<5.0f}") # Use K, M, etc.
445 | ```
446 |
447 | ### 4. Set Appropriate Width
448 |
449 | ```python
450 | # Narrow for simple data
451 | args = Args(width=30)
452 |
453 | # Wide for detailed data
454 | args = Args(width=80)
455 |
456 | # Consider terminal width limitations
457 | ```
458 |
459 | ## API Reference
460 |
461 | - **[Data Class](data-class.md)** - Data handling and normalization
462 | - **[Chart Classes](chart-classes.md)** - All available chart types
463 | - **[Args Class](args-class.md)** - Configuration options
464 |
465 | ## Examples Repository
466 |
467 | For more examples and use cases, see the test files in the repository:
468 | - Basic usage examples
469 | - Complex multi-series charts
470 | - Real-world data scenarios
471 | - Integration patterns
472 |
473 | ## Contributing
474 |
475 | If you find issues or want to contribute improvements to the API documentation, please visit the [GitHub repository](https://github.com/mkaz/termgraph).
--------------------------------------------------------------------------------
/termgraph/chart.py:
--------------------------------------------------------------------------------
1 | """Chart classes for termgraph - handles chart rendering and display."""
2 |
3 | from __future__ import annotations
4 | import math
5 | import sys
6 | from typing import Union, List, Tuple
7 | from itertools import zip_longest
8 | from colorama import just_fix_windows_console
9 | from .constants import TICK, SM_TICK, AVAILABLE_COLORS
10 | from .utils import cvt_to_readable, print_row_core
11 | from .data import Data
12 | from .args import Args
13 |
14 | # colorama
15 | just_fix_windows_console()
16 |
17 | def format_value(
18 | value: Union[int, float], format_str_arg, percentage_arg, suffix_arg
19 | ) -> str:
20 | """Format a value consistently across chart types."""
21 | # Handle type conversions and defaults
22 | if format_str_arg is None or not isinstance(format_str_arg, str):
23 | format_str = "{:<5.2f}"
24 | else:
25 | format_str = format_str_arg
26 |
27 | if percentage_arg is None or not isinstance(percentage_arg, bool):
28 | percentage = False
29 | else:
30 | percentage = percentage_arg
31 |
32 | if suffix_arg is None or not isinstance(suffix_arg, str):
33 | suffix = ""
34 | else:
35 | suffix = suffix_arg
36 |
37 | formatted_val = format_str.format(value)
38 |
39 | if percentage and "%" not in formatted_val:
40 | try:
41 | # Convert to percentage
42 | numeric_value = float(formatted_val)
43 | formatted_val = f"{numeric_value * 100:.0f}%"
44 | except ValueError:
45 | # If conversion fails, just add % suffix
46 | formatted_val += "%"
47 |
48 | return f" {formatted_val}{suffix}"
49 |
50 |
51 | class Colors:
52 | """Class representing available color values for graphs."""
53 |
54 | Black = AVAILABLE_COLORS["black"]
55 | Red = AVAILABLE_COLORS["red"]
56 | Green = AVAILABLE_COLORS["green"]
57 | Yellow = AVAILABLE_COLORS["yellow"]
58 | Blue = AVAILABLE_COLORS["blue"]
59 | Magenta = AVAILABLE_COLORS["magenta"]
60 | Cyan = AVAILABLE_COLORS["cyan"]
61 |
62 |
63 | class Chart:
64 | """Class representing a chart"""
65 |
66 | def __init__(self, data: Data, args: Args):
67 | """Initialize the chart
68 |
69 | :data: The data to be displayed on the chart
70 | :args: The arguments for the chart
71 |
72 | """
73 |
74 | self.data = data
75 | self.args = args
76 | self.normal_data = self._normalize()
77 |
78 | def draw(self) -> None:
79 | """Draw the chart with the given data"""
80 |
81 | raise NotImplementedError()
82 |
83 | def _print_header(self) -> None:
84 | title = self.args.get_arg("title")
85 |
86 | custom_tick = self.args.get_arg("custom_tick")
87 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK
88 |
89 | if title is not None:
90 | print("")
91 | print(f"# {title}\n")
92 |
93 | if len(self.data.categories) > 0:
94 | colors = self.args.get_arg("colors")
95 |
96 | # Print categories' names above the graph.
97 | for i in range(len(self.data.categories)):
98 | if colors is not None and isinstance(colors, list):
99 | sys.stdout.write(f"\033[{colors[i]}m") # Start to write colorized.
100 |
101 | sys.stdout.write(f"{tick} {self.data.categories[i]} ")
102 | if colors:
103 | sys.stdout.write("\033[0m") # Back to original.
104 |
105 | print("\n")
106 |
107 | def _normalize(self) -> list[list[float]]:
108 | """Normalize the data and return it."""
109 | width = self.args.get_arg("width")
110 | if not isinstance(width, int):
111 | width = 50 # Default width
112 | return self.data.normalize(width)
113 |
114 |
115 | class HorizontalChart(Chart):
116 | """Class representing a horizontal chart"""
117 |
118 | def __init__(self, data: Data, args: Args = Args()):
119 | """Initialize the chart
120 |
121 | :data: The data to be displayed on the chart
122 | :args: The arguments for the chart
123 |
124 | """
125 |
126 | super().__init__(data, args)
127 |
128 | def print_row(
129 | self,
130 | value: Union[int, float],
131 | num_blocks: Union[int, float],
132 | val_min: Union[int, float],
133 | color: Union[int, None],
134 | label: str = "",
135 | tail: str = "",
136 | ) -> None:
137 | """A method to print a row for a horizontal graphs.
138 | i.e:
139 | 1: ▇▇ 2
140 | 2: ▇▇▇ 3
141 | 3: ▇▇▇▇ 4
142 | """
143 | doprint = self.args.get_arg("label_before") and not self.args.get_arg(
144 | "vertical"
145 | )
146 |
147 | if doprint:
148 | print(label, tail, " ", end="")
149 |
150 | # Get custom tick if set, otherwise use default TICK
151 | custom_tick = self.args.get_arg("custom_tick")
152 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK
153 |
154 | print_row_core(
155 | value=float(value),
156 | num_blocks=int(num_blocks),
157 | val_min=float(val_min),
158 | color=color,
159 | zero_as_small_tick=bool(self.args.get_arg("label_before")),
160 | tick=tick,
161 | )
162 |
163 | if doprint:
164 | print()
165 |
166 |
167 | class BarChart(HorizontalChart):
168 | """Class representing a bar chart"""
169 |
170 | def __init__(self, data: Data, args: Args = Args()):
171 | """Initialize the bar chart
172 |
173 | :data: The data to be displayed on the chart
174 | :args: The arguments for the chart
175 |
176 | """
177 |
178 | super().__init__(data, args)
179 |
180 | def _normalize(self) -> list[list[float]]:
181 | """Normalize the data and return it."""
182 | if self.args.get_arg("different_scale"):
183 | # Normalization per category
184 | normal_data: List[List[float]] = [[] for _ in range(len(self.data.data))]
185 | width = self.args.get_arg("width")
186 | if not isinstance(width, int):
187 | width = 50 # Default width
188 |
189 | if self.data.dims and len(self.data.dims) > 1:
190 | for i in range(self.data.dims[1]):
191 | cat_data = [[dat[i]] for dat in self.data.data]
192 |
193 | # Create temporary Data object for category data
194 | from .data import Data
195 | temp_data = Data(cat_data, [f"cat_{j}" for j in range(len(cat_data))])
196 | normal_cat_data = temp_data.normalize(width)
197 |
198 | for row_idx, norm_val in enumerate(normal_cat_data):
199 | normal_data[row_idx].append(norm_val[0])
200 | return normal_data
201 | else:
202 | return super()._normalize()
203 |
204 | def draw(self) -> None:
205 | """Draws the chart"""
206 | self._print_header()
207 |
208 | colors = (
209 | self.args.get_arg("colors")
210 | if self.args.get_arg("colors") is not None
211 | else [None]
212 | * (self.data.dims[1] if self.data.dims and len(self.data.dims) > 1 else 1)
213 | )
214 |
215 | val_min = self.data.find_min()
216 |
217 | for i in range(len(self.data.labels)):
218 | if self.args.get_arg("no_labels"):
219 | # Hide the labels.
220 | label = ""
221 | else:
222 | if self.args.get_arg("label_before"):
223 | fmt = "{:<{x}}"
224 | else:
225 | fmt = "{:<{x}}: "
226 |
227 | label = fmt.format(
228 | self.data.labels[i], x=self.data.find_max_label_length()
229 | )
230 |
231 | values = self.data.data[i]
232 | num_blocks = self.normal_data[i]
233 |
234 | # Handle both flat data (numbers) and nested data (lists)
235 | if not isinstance(values, list):
236 | # Flat data: convert single value to list
237 | values = [values]
238 | if isinstance(num_blocks, list):
239 | num_blocks = num_blocks
240 | else:
241 | num_blocks = [num_blocks]
242 |
243 | if self.args.get_arg("space_between") and i != 0:
244 | print()
245 |
246 | for j in range(len(values)):
247 | # In Multiple series graph 1st category has label at the beginning,
248 | # whereas the rest categories have only spaces.
249 | if j > 0:
250 | len_label = len(label)
251 | label = " " * len_label
252 |
253 | if self.args.get_arg("label_before"):
254 | fmt = "{}{}{}"
255 |
256 | else:
257 | fmt = " {}{}{}"
258 |
259 | if self.args.get_arg("no_values"):
260 | tail = self.args.get_arg("suffix")
261 |
262 | else:
263 | val, deg = cvt_to_readable(
264 | values[j], self.args.get_arg("percentage")
265 | )
266 | format_str = self.args.get_arg("format")
267 | if isinstance(format_str, str):
268 | formatted_val = format_str.format(val)
269 | else:
270 | formatted_val = f"{val:<5.2f}" # Default format
271 | tail = fmt.format(
272 | formatted_val,
273 | deg,
274 | self.args.get_arg("suffix"),
275 | )
276 |
277 | if colors and isinstance(colors, list) and j < len(colors):
278 | color = colors[j]
279 | else:
280 | color = None
281 |
282 | if not self.args.get_arg("label_before") and not self.args.get_arg(
283 | "vertical"
284 | ):
285 | print(label, end="")
286 |
287 | self.print_row(
288 | value=values[j],
289 | num_blocks=int(num_blocks[j]),
290 | val_min=val_min,
291 | color=color,
292 | label=label,
293 | tail=str(tail) if tail is not None else "",
294 | )
295 |
296 | if not self.args.get_arg("label_before") and not self.args.get_arg(
297 | "vertical"
298 | ):
299 | print(tail)
300 |
301 |
302 | class StackedChart(HorizontalChart):
303 | """Class representing a stacked bar chart"""
304 |
305 | def __init__(self, data: Data, args: Args = Args()):
306 | """Initialize the stacked chart
307 |
308 | :data: The data to be displayed on the chart
309 | :args: The arguments for the chart
310 | """
311 | super().__init__(data, args)
312 |
313 | def draw(self) -> None:
314 | """Draws the stacked chart"""
315 | self._print_header()
316 |
317 | colors_arg = self.args.get_arg("colors")
318 | if isinstance(colors_arg, list):
319 | colors = colors_arg
320 | else:
321 | colors = [None] * (
322 | self.data.dims[1] if self.data.dims and len(self.data.dims) > 1 else 1
323 | )
324 |
325 | val_min = self.data.find_min()
326 | normal_data = self._normalize()
327 |
328 | # Get custom tick if set, otherwise use default TICK
329 | custom_tick = self.args.get_arg("custom_tick")
330 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK
331 |
332 | for i in range(len(self.data.labels)):
333 | if self.args.get_arg("no_labels"):
334 | # Hide the labels.
335 | label = ""
336 | else:
337 | label = f"{self.data.labels[i]:<{self.data.find_max_label_length()}}: "
338 |
339 | if self.args.get_arg("space_between") and i != 0:
340 | print()
341 |
342 | print(label, end="")
343 |
344 | values = self.data.data[i]
345 | num_blocks = normal_data[i]
346 |
347 | for j in range(len(values)):
348 | print_row_core(
349 | value=values[j],
350 | num_blocks=int(num_blocks[j]),
351 | val_min=val_min,
352 | color=colors[j] if j < len(colors) else None,
353 | zero_as_small_tick=False,
354 | tick=tick,
355 | )
356 |
357 | if self.args.get_arg("no_values"):
358 | # Hide the values.
359 | tail = ""
360 | else:
361 | tail = format_value(
362 | sum(values),
363 | self.args.get_arg("format"),
364 | self.args.get_arg("percentage"),
365 | self.args.get_arg("suffix"),
366 | )
367 |
368 | print(tail)
369 |
370 |
371 | class VerticalChart(Chart):
372 | """Class representing a vertical chart"""
373 |
374 | def __init__(self, data: Data, args: Args = Args()):
375 | """Initialize the vertical chart"""
376 | super().__init__(data, args)
377 | self.value_list: list[str] = []
378 | self.zipped_list: list[tuple[str, ...]] = []
379 | self.vertical_list: list[str] = []
380 | self.maxi = 0
381 |
382 | def _prepare_vertical(self, value: float, num_blocks: int):
383 | """Prepare the vertical graph data."""
384 | self.value_list.append(str(value))
385 |
386 | if self.maxi < num_blocks:
387 | self.maxi = num_blocks
388 |
389 | if num_blocks > 0:
390 | self.vertical_list.append((TICK * num_blocks))
391 | else:
392 | self.vertical_list.append(SM_TICK)
393 |
394 | def draw(self) -> None:
395 | """Draws the vertical chart"""
396 | self._print_header()
397 |
398 | colors = self.args.get_arg("colors")
399 | color = colors[0] if colors and isinstance(colors, list) else None
400 |
401 | for i in range(len(self.data.labels)):
402 | values = self.data.data[i]
403 | num_blocks = self.normal_data[i]
404 | for j in range(len(values)):
405 | self._prepare_vertical(values[j], int(num_blocks[j]))
406 |
407 | # Zip_longest method in order to turn them vertically.
408 | for row in zip_longest(*self.vertical_list, fillvalue=" "):
409 | self.zipped_list.append(row)
410 |
411 | result_list: List[Tuple[str, ...]] = []
412 |
413 | if self.zipped_list:
414 | counter = 0
415 | width = self.args.get_arg("width")
416 | if not isinstance(width, int):
417 | width = 50 # Default width
418 |
419 | # Combined with the maxi variable, escapes the appending method at
420 | # the correct point or the default one (width).
421 | for row in reversed(self.zipped_list):
422 | result_list.append(row)
423 | counter += 1
424 |
425 | if self.maxi == width:
426 | if counter == width:
427 | break
428 | else:
429 | if counter == self.maxi:
430 | break
431 |
432 | if color:
433 | sys.stdout.write(f"\033[{color}m")
434 |
435 | for row in result_list:
436 | print(*row)
437 |
438 | sys.stdout.write("\033[0m")
439 |
440 | if result_list and not self.args.get_arg("no_values"):
441 | print("-" * len(result_list[0]) * 2)
442 | print(" ".join(self.value_list))
443 |
444 | if result_list and not self.args.get_arg("no_labels"):
445 | print("-" * len(result_list[0]) * 2)
446 | # Print Labels
447 | labels = self.data.labels
448 | if labels:
449 | print(" ".join(labels))
450 |
451 |
452 | class HistogramChart(Chart):
453 | """Class representing a histogram chart"""
454 |
455 | def __init__(self, data: Data, args: Args = Args()):
456 | """Initialize the histogram chart
457 |
458 | :data: The data to be displayed on the chart
459 | :args: The arguments for the chart
460 | """
461 | super().__init__(data, args)
462 |
463 | def draw(self) -> None:
464 | """Draws the histogram chart"""
465 | self._print_header()
466 |
467 | colors_arg = self.args.get_arg("colors")
468 | if isinstance(colors_arg, list):
469 | colors = colors_arg
470 | else:
471 | colors = [None]
472 |
473 | val_min = self.data.find_min()
474 | val_max = self.data.find_max()
475 |
476 | # Calculate borders
477 | class_min = math.floor(val_min)
478 | class_max = math.ceil(val_max)
479 | class_range = class_max - class_min
480 | bins_arg = self.args.get_arg("bins")
481 | if isinstance(bins_arg, int):
482 | bins_count = bins_arg
483 | else:
484 | bins_count = 5 # default
485 | class_width = class_range / bins_count
486 |
487 | border = float(class_min)
488 | borders = []
489 | max_len = len(str(border))
490 |
491 | for b in range(bins_count + 1):
492 | borders.append(border)
493 | len_border = len(str(border))
494 | if len_border > max_len:
495 | max_len = len_border
496 | border += class_width
497 | border = round(border, 1)
498 |
499 | # Count num of data via border
500 | count_list = []
501 |
502 | for start, end in zip(borders[:-1], borders[1:]):
503 | count = 0
504 | # Count values in this bin range
505 | for row in self.data.data:
506 | for v in row: # Handle multi-dimensional data
507 | if start <= v < end:
508 | count += 1
509 |
510 | count_list.append([count])
511 |
512 | # Handle the case where the maximum value is exactly equal to the upper border
513 | # Calculate total number of max values and add them to the last bin
514 | count = sum(1 for row in self.data.data for v in row if v == class_max)
515 | count_list[-1][0] += count
516 |
517 | width_arg = self.args.get_arg("width")
518 | if isinstance(width_arg, int):
519 | width = width_arg
520 | else:
521 | width = 50 # default
522 |
523 | # Create temporary Data object for count data
524 | from .data import Data
525 | temp_data = Data(count_list, [f"bin_{i}" for i in range(len(count_list))])
526 | normal_counts = temp_data.normalize(width)
527 |
528 | # Get custom tick if set, otherwise use default TICK
529 | custom_tick = self.args.get_arg("custom_tick")
530 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK
531 |
532 | for i, (start_border, end_border) in enumerate(zip(borders[:-1], borders[1:])):
533 | if colors and colors[0]:
534 | color = colors[0]
535 | else:
536 | color = None
537 |
538 | if not self.args.get_arg("no_labels"):
539 | print(f"{start_border:{max_len}} – {end_border:{max_len}}: ", end="")
540 |
541 | num_blocks = normal_counts[i]
542 |
543 | print_row_core(
544 | value=count_list[i][0],
545 | num_blocks=int(num_blocks[0]),
546 | val_min=0, # Histogram always starts from 0
547 | color=color,
548 | zero_as_small_tick=False,
549 | tick=tick,
550 | )
551 |
552 | if self.args.get_arg("no_values"):
553 | tail = ""
554 | else:
555 | tail = format_value(
556 | count_list[i][0],
557 | self.args.get_arg("format"),
558 | self.args.get_arg("percentage"),
559 | self.args.get_arg("suffix"),
560 | )
561 | print(tail)
562 |
--------------------------------------------------------------------------------