Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option for printing a colored diff #1266

Merged
merged 13 commits into from May 8, 2020
1 change: 1 addition & 0 deletions CHANGES.md
Expand Up @@ -3,6 +3,7 @@
### Unreleased

- reindent docstrings when reindenting code around it (#1053)
- show colored diffs (#1266)

### 19.10b0

Expand Down
78 changes: 73 additions & 5 deletions black.py
Expand Up @@ -39,6 +39,7 @@
TypeVar,
Union,
cast,
TYPE_CHECKING,
)
from typing_extensions import Final
from mypy_extensions import mypyc_attr
Expand All @@ -59,6 +60,9 @@

from _black_version import version as __version__

if TYPE_CHECKING:
import colorama # noqa: F401

DEFAULT_LINE_LENGTH = 88
DEFAULT_EXCLUDES = r"/(\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist)/" # noqa: B950
DEFAULT_INCLUDES = r"\.pyi?$"
Expand Down Expand Up @@ -140,12 +144,18 @@ class WriteBack(Enum):
YES = 1
DIFF = 2
CHECK = 3
COLOR_DIFF = 4

@classmethod
def from_configuration(cls, *, check: bool, diff: bool) -> "WriteBack":
def from_configuration(
cls, *, check: bool, diff: bool, color: bool = False
) -> "WriteBack":
if check and not diff:
return cls.CHECK

if diff and color:
return cls.COLOR_DIFF

return cls.DIFF if diff else cls.YES


Expand Down Expand Up @@ -380,6 +390,11 @@ def target_version_option_callback(
is_flag=True,
help="Don't write the files back, just output a diff for each file on stdout.",
)
@click.option(
"--color/--no-color",
is_flag=True,
help="Show colored diff. Only applies when `--diff` is given.",
)
@click.option(
"--fast/--safe",
is_flag=True,
Expand Down Expand Up @@ -458,6 +473,7 @@ def main(
target_version: List[TargetVersion],
check: bool,
diff: bool,
color: bool,
fast: bool,
pyi: bool,
py36: bool,
Expand All @@ -470,7 +486,7 @@ def main(
config: Optional[str],
) -> None:
"""The uncompromising code formatter."""
write_back = WriteBack.from_configuration(check=check, diff=diff)
write_back = WriteBack.from_configuration(check=check, diff=diff, color=color)
if target_version:
if py36:
err("Cannot use both --target-version and --py36")
Expand Down Expand Up @@ -718,25 +734,73 @@ def format_file_in_place(
if write_back == WriteBack.YES:
with open(src, "w", encoding=encoding, newline=newline) as f:
f.write(dst_contents)
elif write_back == WriteBack.DIFF:
elif write_back in (WriteBack.DIFF, WriteBack.COLOR_DIFF):
now = datetime.utcnow()
src_name = f"{src}\t{then} +0000"
dst_name = f"{src}\t{now} +0000"
diff_contents = diff(src_contents, dst_contents, src_name, dst_name)

if write_back == write_back.COLOR_DIFF:
diff_contents = color_diff(diff_contents)

with lock or nullcontext():
f = io.TextIOWrapper(
sys.stdout.buffer,
encoding=encoding,
newline=newline,
write_through=True,
)
f = wrap_stream_for_windows(f)
f.write(diff_contents)
f.detach()

return True


def color_diff(contents: str) -> str:
"""Inject the ANSI color codes to the diff."""
lines = contents.split("\n")
for i, line in enumerate(lines):
if line.startswith("+++") or line.startswith("---"):
line = "\033[1;37m" + line + "\033[0m" # bold white, reset
if line.startswith("@@"):
line = "\033[36m" + line + "\033[0m" # cyan, reset
if line.startswith("+"):
line = "\033[32m" + line + "\033[0m" # green, reset
elif line.startswith("-"):
line = "\033[31m" + line + "\033[0m" # red, reset
lines[i] = line
return "\n".join(lines)


def wrap_stream_for_windows(
f: io.TextIOWrapper,
) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32.AnsiToWin32"]:
"""
Wrap the stream in colorama's wrap_stream so colors are shown on Windows.

If `colorama` is not found, then no change is made. If `colorama` does
exist, then it handles the logic to determine whether or not to change
things.
"""
try:
from colorama import initialise

# We set `strip=False` so that we can don't have to modify
# test_express_diff_with_color.
f = initialise.wrap_stream(
f, convert=None, strip=False, autoreset=False, wrap=True
)

# wrap_stream returns a `colorama.AnsiToWin32.AnsiToWin32` object
# which does not have a `detach()` method. So we fake one.
f.detach = lambda *args, **kwargs: None # type: ignore
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this, but I don't fully understand what detach does or why it's needed, so this is the best I could come up with.

except ImportError:
pass

return f


def format_stdin_to_stdout(
fast: bool, *, write_back: WriteBack = WriteBack.NO, mode: Mode
) -> bool:
Expand All @@ -762,11 +826,15 @@ def format_stdin_to_stdout(
)
if write_back == WriteBack.YES:
f.write(dst)
elif write_back == WriteBack.DIFF:
elif write_back in (WriteBack.DIFF, WriteBack.COLOR_DIFF):
now = datetime.utcnow()
src_name = f"STDIN\t{then} +0000"
dst_name = f"STDOUT\t{now} +0000"
f.write(diff(src, dst, src_name, dst_name))
d = diff(src, dst, src_name, dst_name)
if write_back == WriteBack.COLOR_DIFF:
d = color_diff(d)
f = wrap_stream_for_windows(f)
f.write(d)
f.detach()


Expand Down
6 changes: 5 additions & 1 deletion setup.py
Expand Up @@ -77,7 +77,11 @@ def get_long_description() -> str:
"typing_extensions>=3.7.4",
"mypy_extensions>=0.4.3",
],
extras_require={"d": ["aiohttp>=3.3.2", "aiohttp-cors"]},
extras_require={
"d": ["aiohttp>=3.3.2", "aiohttp-cors"],
"colorama": ["colorama>=0.4.3"],
},
test_suite="tests.test_black",
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Console",
Expand Down
41 changes: 41 additions & 0 deletions tests/test_black.py
Expand Up @@ -264,6 +264,28 @@ def test_piping_diff(self) -> None:
actual = actual.rstrip() + "\n" # the diff output has a trailing space
self.assertEqual(expected, actual)

def test_piping_diff_with_color(self) -> None:
source, _ = read_data("expression.py")
config = THIS_DIR / "data" / "empty_pyproject.toml"
args = [
"-",
"--fast",
f"--line-length={black.DEFAULT_LINE_LENGTH}",
"--diff",
"--color",
f"--config={config}",
]
result = BlackRunner().invoke(
black.main, args, input=BytesIO(source.encode("utf8"))
)
actual = result.output
# Again, the contents are checked in a different test, so only look for colors.
self.assertIn("\033[1;37m", actual)
self.assertIn("\033[36m", actual)
self.assertIn("\033[32m", actual)
self.assertIn("\033[31m", actual)
self.assertIn("\033[0m", actual)

@patch("black.dump_to_file", dump_to_stderr)
def test_function(self) -> None:
source, expected = read_data("function")
Expand Down Expand Up @@ -352,6 +374,25 @@ def test_expression_diff(self) -> None:
)
self.assertEqual(expected, actual, msg)

def test_expression_diff_with_color(self) -> None:
source, _ = read_data("expression.py")
expected, _ = read_data("expression.diff")
tmp_file = Path(black.dump_to_file(source))
try:
result = BlackRunner().invoke(
black.main, ["--diff", "--color", str(tmp_file)]
)
finally:
os.unlink(tmp_file)
actual = result.output
# We check the contents of the diff in `test_expression_diff`. All
# we need to check here is that color codes exist in the result.
self.assertIn("\033[1;37m", actual)
self.assertIn("\033[36m", actual)
self.assertIn("\033[32m", actual)
self.assertIn("\033[31m", actual)
self.assertIn("\033[0m", actual)

@patch("black.dump_to_file", dump_to_stderr)
def test_fstring(self) -> None:
source, expected = read_data("fstring")
Expand Down