Skip to content

Commit b2400aa

Browse files
committed
ansi improvements and tty detection
1 parent 881b04d commit b2400aa

File tree

3 files changed

+77
-26
lines changed

3 files changed

+77
-26
lines changed

devtools/ansi.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
import sys
12
from enum import IntEnum
23

34
_ansi_template = '\033[{}m'
45

56

7+
def isatty(stream):
8+
try:
9+
return stream.isatty()
10+
except Exception:
11+
return False
12+
13+
614
class Style(IntEnum):
715
"""
816
Heavily borrowed from https://github.com/pallets/click/blob/6.7/click/termui.py
917
10-
Italic added and generally modernised improved.
18+
Italic added, multiple ansi codes condensed into one block and generally modernised.
1119
"""
1220
reset = 0
1321

@@ -85,18 +93,23 @@ def __call__(self, text: str, *styles, reset: bool=True):
8593
string which means that styles do not carry over. This
8694
can be disabled to compose styles.
8795
"""
88-
parts = []
96+
codes = []
8997
for s in styles:
9098
if not isinstance(s, self.__class__):
9199
try:
92100
s = self.styles[s]
93101
except KeyError:
94102
raise ValueError('invalid style "{}"'.format(s))
95-
parts.append(_ansi_template.format(s))
96-
parts.append(text)
103+
codes.append(str(s.value))
104+
105+
if codes:
106+
r = _ansi_template.format(';'.join(codes)) + text
107+
else:
108+
r = text
109+
97110
if reset:
98-
parts.append(_ansi_template.format(self.reset))
99-
return ''.join(parts)
111+
r += _ansi_template.format(self.reset)
112+
return r
100113

101114
@property
102115
def styles(self):
@@ -118,5 +131,7 @@ def __str__(self):
118131
style = Style(-1)
119132

120133

121-
def sprint(text, *styles, reset=True, flush=True, **print_kwargs):
122-
print(style(text, *styles, reset=reset), flush=flush, **print_kwargs)
134+
def sprint(text, *styles, reset=True, flush=True, file=None, **print_kwargs):
135+
if isatty(file or sys.stdout):
136+
text = style(text, *styles, reset=reset)
137+
print(text, flush=flush, file=file, **print_kwargs)

devtools/debug.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,8 @@ def __init__(self, *, filename: str, lineno: int, frame: str, arguments: List[De
5656
self.arguments = arguments
5757

5858
def __str__(self) -> str:
59-
template = '{s.filename}:{s.lineno} {s.frame}'
60-
# turns out single line output is ugly
61-
# if len(self.arguments) == 1:
62-
# return (template + ': {a}').format(s=self, a=self.arguments[0])
63-
# else:
64-
return (template + '\n {a}').format(s=self, a='\n '.join(str(a) for a in self.arguments))
59+
template = '{s.filename}:{s.lineno} {s.frame}\n {a}'
60+
return template.format(s=self, a='\n '.join(str(a) for a in self.arguments))
6561

6662
def __repr__(self) -> str:
6763
arguments = ' '.join(str(a) for a in self.arguments)
@@ -78,14 +74,20 @@ class Debug:
7874

7975
def __init__(self, *,
8076
warnings: Optional[bool]=None,
77+
colours: Optional[bool]=None,
8178
frame_context_length: int=50):
82-
if warnings is None:
83-
self._warnings = env_true('PY_DEVTOOLS_WARNINGS', 'TRUE')
84-
else:
85-
self._warnings = warnings
79+
self._warnings = self._env_bool(warnings, 'PY_DEVTOOLS_WARNINGS')
80+
self._colours = self._env_bool(colours, 'PY_DEVTOOLS_COLOURS')
8681
# 50 lines should be enough to make sure we always get the entire function definition
8782
self._frame_context_length = frame_context_length
8883

84+
@classmethod
85+
def _env_bool(cls, value, env_name, env_default='TRUE'):
86+
if value is None:
87+
return env_true(env_name, env_default)
88+
else:
89+
return value
90+
8991
def __call__(self, *args, **kwargs):
9092
print(self._process(args, kwargs, r'debug *\('), flush=True)
9193

tests/test_ansi.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
1+
import io
2+
13
import pytest
24

35
from devtools.ansi import sprint, style
46

57

68
def test_colorize():
79
v = style('hello', style.red)
8-
assert v == '\x1b[31mhello\x1b[0m', repr(v)
10+
assert v == '\x1b[31mhello\x1b[0m'
911

1012

1113
def test_no_reset():
1214
v = style('hello', style.bold, reset=False)
13-
assert v == '\x1b[1mhello', repr(v)
15+
assert v == '\x1b[1mhello'
16+
17+
18+
def test_combine_styles():
19+
v = style('hello', style.red, style.bold)
20+
assert v == '\x1b[31;1mhello\x1b[0m'
21+
22+
23+
def test_no_styles():
24+
v = style('hello')
25+
assert v == 'hello\x1b[0m'
1426

1527

1628
def test_style_str():
1729
v = style('hello', 'red')
18-
assert v == '\x1b[31mhello\x1b[0m', repr(v)
30+
assert v == '\x1b[31mhello\x1b[0m'
1931

2032

2133
def test_invalid_style_str():
@@ -24,11 +36,33 @@ def test_invalid_style_str():
2436
assert exc_info.value.args[0] == 'invalid style "mauve"'
2537

2638

27-
def test_print(capsys):
28-
sprint('hello', style.green)
29-
stdout, stderr = capsys.readouterr()
30-
assert stdout == '\x1b[32mhello\x1b[0m\n', repr(stdout)
31-
assert stderr == ''
39+
def test_print_not_tty():
40+
stream = io.StringIO()
41+
sprint('hello', style.green, file=stream)
42+
out = stream.getvalue()
43+
assert out == 'hello\n'
44+
45+
46+
def test_print_is_tty():
47+
class TTYStream(io.StringIO):
48+
def isatty(self):
49+
return True
50+
51+
stream = TTYStream()
52+
sprint('hello', style.green, file=stream)
53+
out = stream.getvalue()
54+
assert out == '\x1b[32mhello\x1b[0m\n', repr(out)
55+
56+
57+
def test_print_tty_error():
58+
class TTYStream(io.StringIO):
59+
def isatty(self):
60+
raise RuntimeError('boom')
61+
62+
stream = TTYStream()
63+
sprint('hello', style.green, file=stream)
64+
out = stream.getvalue()
65+
assert out == 'hello\n'
3266

3367

3468
def test_get_styles():

0 commit comments

Comments
 (0)