From 489322161c44bc9ecb833831d8124c4eac68d306 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:55:08 +0300 Subject: [PATCH] Add colour to vulture CLI output --- CHANGELOG.md | 4 ++++ README.md | 11 ++++++++++ pyproject.toml | 1 + requirements.txt | 1 + tests/test_config.py | 1 + tests/test_report.py | 9 ++++++++- vulture/config.py | 14 +++++++++++++ vulture/core.py | 48 ++++++++++++++++++++++++++++++++++++++------ 8 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e3c695..66376761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.17 (unreleased) + +* Colour output and add `-c`/`--color {yes,no,auto}` option (#415, Hugo van Kemenade). + # 2.16 (2026-03-25) * Fix false positives for dead code after while loops (#412, #413, Jendrik Seipp). diff --git a/README.md b/README.md index 7c504f77..b9f38cd2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ tool for higher code quality. * tested: tests itself and has complete test coverage * complements pyflakes and has the same output syntax * sorts unused classes and functions by size with `--sort-by-size` +* colour output, with confidence shown via a temperature gradient ## Installation @@ -164,6 +165,16 @@ def foo(arg: Sequence): ``` +## Colour output + +Output is in colour by default when stdout is a terminal. The +confidence percentage uses a temperature gradient: 60% dark grey, 90% +yellow, 100% red. + +Use `-c`/`--color {yes,no,auto}` to force colour on or off, or set the +[`NO_COLOR`](https://no-color.org/) or +[`FORCE_COLOR`](https://force-color.org/) environment variables. + ## Configuration You can also store command line arguments in `pyproject.toml` under the diff --git a/pyproject.toml b/pyproject.toml index 1cafea11..2fd2a189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ "Topic :: Software Development :: Quality Assurance", ] dependencies = [ + "termcolor >= 2.3.0", "tomli >= 1.1.0; python_version < '3.11'", ] diff --git a/requirements.txt b/requirements.txt index 542c64e6..7b4bc7da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ +termcolor >= 2.3.0 tomli >= 2.4.1; python_version < '3.11' diff --git a/tests/test_config.py b/tests/test_config.py index 6209fa11..85b32a12 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -166,6 +166,7 @@ def test_config_merging(): exclude=["cli_exclude"], ignore_decorators=["cli_deco"], ignore_names=["cli_name"], + color="auto", config="pyproject.toml", make_whitelist=True, min_confidence=20, diff --git a/tests/test_report.py b/tests/test_report.py index 55f8ba7c..f29560a9 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -33,7 +33,7 @@ def test_report(code, expected, make_whitelist=False): filename = "foo.py" v.scan(code, filename=filename) capsys.readouterr() - ret = v.report(make_whitelist=make_whitelist) + ret = v.report(make_whitelist=make_whitelist, color="no") assert ret assert capsys.readouterr().out == expected.format(filename=filename) @@ -61,6 +61,13 @@ def test_item_report(check_report): check_report(mock_code, expected) +def test_color(v): + v.scan("import os\n") + [item] = v.get_unused_code() + assert "\x1b[" in item.get_report(color="yes") + assert "\x1b[" not in item.get_report(color="no") + + def test_make_whitelist(check_report): expected = """\ foo # unused import ({filename}:1) diff --git a/vulture/config.py b/vulture/config.py index 28f0ee57..d657a79b 100644 --- a/vulture/config.py +++ b/vulture/config.py @@ -15,6 +15,7 @@ #: Possible configuration options and their respective defaults DEFAULTS = { + "color": "auto", "config": "pyproject.toml", "min_confidence": 0, "paths": [], @@ -26,6 +27,8 @@ "verbose": False, } +COLOR_CHOICES = ("yes", "no", "auto") + class InputError(Exception): def __init__(self, message): @@ -56,6 +59,10 @@ def _check_output_config(config): """ if not config["paths"]: raise InputError("Please pass at least one file or directory") + if config["color"] not in COLOR_CHOICES: + raise InputError( + f"color must be one of {COLOR_CHOICES}, got {config['color']!r}" + ) def _parse_toml(infile): @@ -160,6 +167,13 @@ def csv(exclude): default=missing, help="Sort unused functions and classes by their lines of code.", ) + parser.add_argument( + "-c", + "--color", + choices=COLOR_CHOICES, + default=missing, + help="Colour the output (default: auto).", + ) parser.add_argument( "--config", type=str, diff --git a/vulture/core.py b/vulture/core.py index ff354d41..af58ce2f 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -7,6 +7,8 @@ from functools import partial from pathlib import Path +from termcolor import colored + from vulture import lines, noqa, utils from vulture.config import InputError, make_config from vulture.reachability import Reachability @@ -151,16 +153,45 @@ def size(self): assert self.last_lineno >= self.first_lineno return self.last_lineno - self.first_lineno + 1 - def get_report(self, add_size=False): + def get_report(self, add_size=False, *, color="auto"): if add_size: line_format = "line" if self.size == 1 else "lines" size_report = f", {self.size:d} {line_format}" else: size_report = "" - return ( - f"{utils.format_path(self.filename)}:{self.first_lineno:d}: " - f"{self.message} ({self.confidence}% confidence{size_report})" + + no_color = color == "no" + force_color = color == "yes" + + def c(text, *args, **kwargs): + return colored( + text, + *args, + no_color=no_color, + force_color=force_color, + **kwargs, + ) + + path = c(utils.format_path(self.filename), attrs=["bold"]) + colon = c(":", "cyan") + lineno = f"{self.first_lineno:d}" + if self.confidence >= 100: + confidence_color = "red" + elif self.confidence >= 90: + confidence_color = "yellow" + else: + confidence_color = "dark_grey" + confidence = c( + f"({self.confidence}% confidence{size_report})", confidence_color ) + if self.typ == "unreachable_code": + message = c(self.message, "red", attrs=["bold"]) + else: + message = ( + f"unused {c(self.typ, 'red', attrs=['bold'])} " + f"{c(repr(self.name), attrs=['bold'])}" + ) + return f"{path}{colon}{lineno}{colon} {message} {confidence}" def get_whitelist_string(self): filename = utils.format_path(self.filename) @@ -347,7 +378,11 @@ def by_size(item): ) def report( - self, min_confidence=0, sort_by_size=False, make_whitelist=False + self, + min_confidence=0, + sort_by_size=False, + make_whitelist=False, + color="auto", ): """ Print ordered list of Item objects to stdout. @@ -358,7 +393,7 @@ def report( self._log( item.get_whitelist_string() if make_whitelist - else item.get_report(add_size=sort_by_size), + else item.get_report(add_size=sort_by_size, color=color), force=True, ) self.exit_code = ExitCode.DeadCode @@ -679,5 +714,6 @@ def main(): min_confidence=config["min_confidence"], sort_by_size=config["sort_by_size"], make_whitelist=config["make_whitelist"], + color=config["color"], ) )