Skip to content

Commit fd36947

Browse files
authored
Init colorama only in Windows legacy terminal (#238)
1 parent 5c34bb6 commit fd36947

File tree

7 files changed

+86
-48
lines changed

7 files changed

+86
-48
lines changed

knack/cli.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .completion import CLICompletion
1212
from .output import OutputProducer
1313
from .log import CLILogging, get_logger
14-
from .util import CLIError
14+
from .util import CLIError, is_modern_terminal
1515
from .config import CLIConfig
1616
from .query import CLIQuery
1717
from .events import EVENT_CLI_PRE_EXECUTE, EVENT_CLI_SUCCESSFUL_EXECUTE, EVENT_CLI_POST_EXECUTE
@@ -21,10 +21,6 @@
2121

2222
logger = get_logger(__name__)
2323

24-
# Temporarily force color to be enabled even when out_file is not stdout.
25-
# This is only intended for testing purpose.
26-
_KNACK_TEST_FORCE_ENABLE_COLOR = False
27-
2824

2925
class CLI(object): # pylint: disable=too-many-instance-attributes
3026
""" The main driver for the CLI """
@@ -101,6 +97,8 @@ def __init__(self,
10197

10298
self.only_show_errors = self.config.getboolean('core', 'only_show_errors', fallback=False)
10399
self.enable_color = self._should_enable_color()
100+
# Init colorama only in Windows legacy terminal
101+
self._should_init_colorama = self.enable_color and os.name == 'nt' and not is_modern_terminal()
104102

105103
@staticmethod
106104
def _should_show_version(args):
@@ -207,7 +205,8 @@ def invoke(self, args, initial_invocation_data=None, out_file=None):
207205
exit_code = 0
208206
try:
209207
out_file = out_file or self.out_file
210-
if out_file is sys.stdout and self.enable_color or _KNACK_TEST_FORCE_ENABLE_COLOR:
208+
if out_file is sys.stdout and self._should_init_colorama:
209+
self.init_debug_log.append("Init colorama.")
211210
import colorama
212211
colorama.init()
213212
# point out_file to the new sys.stdout which is overwritten by colorama
@@ -250,7 +249,7 @@ def invoke(self, args, initial_invocation_data=None, out_file=None):
250249
finally:
251250
self.raise_event(EVENT_CLI_POST_EXECUTE)
252251

253-
if self.enable_color or _KNACK_TEST_FORCE_ENABLE_COLOR:
252+
if self._should_init_colorama:
254253
import colorama
255254
colorama.deinit()
256255

@@ -274,14 +273,13 @@ def _should_enable_color(self):
274273
self.init_debug_log.append("Color is disabled by config.")
275274
return False
276275

277-
if 'PYCHARM_HOSTED' in os.environ:
278-
if sys.stdout == sys.__stdout__ and sys.stderr == sys.__stderr__:
279-
self.init_debug_log.append("Enable color in PyCharm.")
280-
return True
281-
else:
282-
if sys.stdout.isatty() and sys.stderr.isatty() and self.out_file is sys.stdout:
283-
self.init_debug_log.append("Enable color in terminal.")
284-
return True
276+
if sys.stdout.isatty() and sys.stderr.isatty() and self.out_file is sys.stdout:
277+
self.init_debug_log.append("Enable color in terminal.")
278+
return True
279+
280+
if 'PYCHARM_HOSTED' in os.environ and sys.stdout == sys.__stdout__ and sys.stderr == sys.__stderr__:
281+
self.init_debug_log.append("Enable color in PyCharm.")
282+
return True
285283

286284
self.init_debug_log.append("Cannot enable color.")
287285
return False

knack/util.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,24 @@ def todict(obj, post_processor=None): # pylint: disable=too-many-return-stateme
142142
if not callable(v) and not k.startswith('_')}
143143
return post_processor(obj, result) if post_processor else result
144144
return obj
145+
146+
147+
def is_modern_terminal():
148+
"""Detect whether the current terminal is a modern terminal that supports Unicode and
149+
Console Virtual Terminal Sequences.
150+
151+
Currently, these terminals can be detected:
152+
- VS Code terminal
153+
- PyCharm
154+
- Windows Terminal
155+
"""
156+
# VS Code: https://github.com/microsoft/vscode/pull/30346
157+
if os.environ.get('TERM_PROGRAM', '').lower() == 'vscode':
158+
return True
159+
# PyCharm: https://youtrack.jetbrains.com/issue/PY-4853
160+
if 'PYCHARM_HOSTED' in os.environ:
161+
return True
162+
# Windows Terminal: https://github.com/microsoft/terminal/issues/1040
163+
if 'WT_SESSION' in os.environ:
164+
return True
165+
return False

tests/test_deprecation.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@
88
import mock
99
except ImportError:
1010
from unittest import mock
11-
from threading import Lock
1211

1312
from knack.arguments import ArgumentsContext
14-
from knack.commands import CLICommand, CLICommandsLoader, CommandGroup
13+
from knack.commands import CLICommandsLoader, CommandGroup
1514

16-
from tests.util import DummyCLI, redirect_io, disable_color
15+
from tests.util import DummyCLI, redirect_io, assert_in_multi_line, disable_color
1716

1817

1918
def example_handler(arg1, arg2=None, arg3=None):
@@ -80,7 +79,7 @@ def test_deprecate_command_group_help(self):
8079
cmd4 [Deprecated] : Short summary here.
8180
8281
""".format(self.cli_ctx.name)
83-
self.assertEqual(expected, actual)
82+
assert_in_multi_line(expected, actual)
8483

8584
@redirect_io
8685
def test_deprecate_command_help_hidden(self):
@@ -100,7 +99,7 @@ def test_deprecate_command_help_hidden(self):
10099
--arg -a : Allowed values: 1, 2, 3.
101100
--arg3
102101
""".format(self.cli_ctx.name)
103-
self.assertIn(expected, actual)
102+
assert_in_multi_line(expected, actual)
104103

105104
@redirect_io
106105
def test_deprecate_command_plain_execute(self):
@@ -211,7 +210,7 @@ def test_deprecate_command_group_help_plain(self):
211210
cmd1 : Short summary here.
212211
213212
""".format(self.cli_ctx.name)
214-
self.assertEqual(expected, actual)
213+
assert_in_multi_line(expected, actual)
215214

216215
@redirect_io
217216
def test_deprecate_command_group_help_hidden(self):
@@ -229,7 +228,7 @@ def test_deprecate_command_group_help_hidden(self):
229228
cmd1 : Short summary here.
230229
231230
""".format(self.cli_ctx.name)
232-
self.assertIn(expected, actual)
231+
assert_in_multi_line(expected, actual)
233232

234233
@redirect_io
235234
def test_deprecate_command_group_help_expiring(self):
@@ -243,7 +242,7 @@ def test_deprecate_command_group_help_expiring(self):
243242
This command group has been deprecated and will be removed in version '1.0.0'. Use
244243
'alt-group4' instead.
245244
""".format(self.cli_ctx.name)
246-
self.assertIn(expected, actual)
245+
assert_in_multi_line(expected, actual)
247246

248247
@redirect_io
249248
@disable_color
@@ -282,7 +281,7 @@ def test_deprecate_command_implicitly(self):
282281
This command is implicitly deprecated because command group 'group1' is deprecated and
283282
will be removed in a future release. Use 'alt-group1' instead.
284283
""".format(self.cli_ctx.name)
285-
self.assertIn(expected, actual)
284+
assert_in_multi_line(expected, actual)
286285

287286

288287
class TestArgumentDeprecation(unittest.TestCase):
@@ -352,7 +351,7 @@ def test_deprecate_arguments_command_help(self):
352351
--opt4
353352
--opt5
354353
""".format(self.cli_ctx.name)
355-
self.assertTrue(actual.startswith(expected))
354+
assert_in_multi_line(expected, actual)
356355

357356
@redirect_io
358357
def test_deprecate_arguments_execute(self):

tests/test_experimental.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from knack.arguments import ArgumentsContext
1616
from knack.commands import CLICommandsLoader, CommandGroup
1717

18-
from tests.util import DummyCLI, redirect_io, remove_space
18+
from tests.util import DummyCLI, redirect_io, assert_in_multi_line
1919

2020

2121
def example_handler(arg1, arg2=None, arg3=None):
@@ -64,7 +64,7 @@ def test_experimental_command_implicitly_execute(self):
6464
self.cli_ctx.invoke('grp1 cmd1 -b b'.split())
6565
actual = self.io.getvalue()
6666
expected = "Command group 'grp1' is experimental and under development."
67-
self.assertIn(remove_space(expected), remove_space(actual))
67+
self.assertIn(expected, actual)
6868

6969
@redirect_io
7070
def test_experimental_command_group_help(self):
@@ -83,15 +83,15 @@ def test_experimental_command_group_help(self):
8383
cmd1 [Experimental] : Short summary here.
8484
8585
""".format(self.cli_ctx.name)
86-
self.assertEqual(expected, actual)
86+
assert_in_multi_line(expected, actual)
8787

8888
@redirect_io
8989
def test_experimental_command_plain_execute(self):
9090
""" Ensure general warning displayed when running experimental command. """
9191
self.cli_ctx.invoke('cmd1 -b b'.split())
9292
actual = self.io.getvalue()
9393
expected = "This command is experimental and under development."
94-
self.assertIn(remove_space(expected), remove_space(actual))
94+
self.assertIn(expected, actual)
9595

9696

9797
class TestCommandGroupExperimental(unittest.TestCase):
@@ -136,7 +136,7 @@ def test_experimental_command_group_help_plain(self):
136136
cmd1 : Short summary here.
137137
138138
""".format(self.cli_ctx.name)
139-
self.assertIn(remove_space(expected), remove_space(actual))
139+
assert_in_multi_line(expected, actual)
140140

141141
@redirect_io
142142
def test_experimental_command_implicitly(self):
@@ -150,7 +150,7 @@ def test_experimental_command_implicitly(self):
150150
Long summary here. Still long summary.
151151
Command group 'group1' is experimental and under development.
152152
""".format(self.cli_ctx.name)
153-
self.assertIn(remove_space(expected), remove_space(actual))
153+
assert_in_multi_line(expected, actual)
154154

155155

156156
class TestArgumentExperimental(unittest.TestCase):
@@ -193,7 +193,7 @@ def test_experimental_arguments_command_help(self):
193193
--arg1 [Experimental] [Required] : Arg1.
194194
Argument '--arg1' is experimental and under development.
195195
""".format(self.cli_ctx.name)
196-
self.assertIn(remove_space(expected), remove_space(actual))
196+
assert_in_multi_line(expected, actual)
197197

198198
@redirect_io
199199
def test_experimental_arguments_execute(self):

tests/test_preview.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from knack.arguments import ArgumentsContext
1616
from knack.commands import CLICommandsLoader, CommandGroup
1717

18-
from tests.util import DummyCLI, redirect_io, disable_color
18+
from tests.util import DummyCLI, redirect_io, assert_in_multi_line, disable_color
1919

2020

2121
def example_handler(arg1, arg2=None, arg3=None):
@@ -75,7 +75,7 @@ def test_preview_command_group_help(self):
7575
cmd1 [Preview] : Short summary here.
7676
7777
""".format(self.cli_ctx.name)
78-
self.assertEqual(expected, actual)
78+
assert_in_multi_line(expected, actual)
7979

8080
@redirect_io
8181
def test_preview_command_plain_execute(self):
@@ -170,7 +170,7 @@ def test_preview_command_group_help_plain(self):
170170
cmd1 : Short summary here.
171171
172172
""".format(self.cli_ctx.name)
173-
self.assertEqual(expected, actual)
173+
assert_in_multi_line(expected, actual)
174174

175175
@redirect_io
176176
@disable_color
@@ -202,7 +202,7 @@ def test_preview_command_implicitly(self):
202202
Command group 'group1' is in preview. It may be changed/removed in a future
203203
release.
204204
""".format(self.cli_ctx.name)
205-
self.assertIn(expected, actual)
205+
assert_in_multi_line(expected, actual)
206206

207207

208208
class TestArgumentPreview(unittest.TestCase):
@@ -245,7 +245,7 @@ def test_preview_arguments_command_help(self):
245245
--arg1 [Preview] [Required] : Arg1.
246246
Argument '--arg1' is in preview. It may be changed/removed in a future release.
247247
""".format(self.cli_ctx.name)
248-
self.assertIn(expected, actual)
248+
assert_in_multi_line(expected, actual)
249249

250250
@redirect_io
251251
@disable_color

tests/test_util.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55

6-
from collections import namedtuple
76
import unittest
7+
from collections import namedtuple
88
from datetime import date, time, datetime
9+
from unittest import mock
910

10-
from knack.util import todict, to_snake_case
11+
from knack.util import todict, to_snake_case, is_modern_terminal
1112

1213

1314
class TestUtils(unittest.TestCase):
@@ -87,6 +88,16 @@ def test_to_snake_case_already_snake(self):
8788
actual = to_snake_case(the_input)
8889
self.assertEqual(expected, actual)
8990

91+
def test_is_modern_terminal(self):
92+
with mock.patch.dict("os.environ", clear=True):
93+
self.assertEqual(is_modern_terminal(), False)
94+
with mock.patch.dict("os.environ", TERM_PROGRAM='vscode'):
95+
self.assertEqual(is_modern_terminal(), True)
96+
with mock.patch.dict("os.environ", PYCHARM_HOSTED='1'):
97+
self.assertEqual(is_modern_terminal(), True)
98+
with mock.patch.dict("os.environ", WT_SESSION='c25cb945-246a-49e5-b37a-1e4b6671b916'):
99+
self.assertEqual(is_modern_terminal(), True)
100+
90101

91102
if __name__ == '__main__':
92103
unittest.main()

tests/util.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@
77
import mock
88
except ImportError:
99
from unittest import mock
10+
import logging
11+
import os
12+
import re
13+
import shutil
1014
import sys
1115
import tempfile
12-
import shutil
13-
import os
1416
from io import StringIO
15-
import logging
16-
from knack.log import CLI_LOGGER_NAME
1717

1818
from knack.cli import CLI, CLICommandsLoader, CommandInvoker
19+
from knack.log import CLI_LOGGER_NAME
1920

2021
TEMP_FOLDER_NAME = "knack_temp"
2122

@@ -33,8 +34,7 @@ def wrapper(self):
3334
cli_logger.handlers.clear()
3435

3536
sys.stdout = sys.stderr = self.io = StringIO()
36-
with mock.patch("knack.cli._KNACK_TEST_FORCE_ENABLE_COLOR", True):
37-
func(self)
37+
func(self)
3838
self.io.close()
3939
sys.stdout = original_stdout
4040
sys.stderr = original_stderr
@@ -54,8 +54,17 @@ def wrapper(self):
5454
return wrapper
5555

5656

57-
def remove_space(str):
58-
return str.replace(' ', '').replace('\n', '')
57+
def _remove_control_sequence(string):
58+
return re.sub(r'\x1b[^m]+m', '', string)
59+
60+
61+
def _remove_whitespace(string):
62+
return re.sub(r'\s', '', string)
63+
64+
65+
def assert_in_multi_line(sub_string, string):
66+
# assert sub_string is in string, with all whitespaces, line breaks and control sequences ignored
67+
assert _remove_whitespace(sub_string) in _remove_control_sequence(_remove_whitespace(string))
5968

6069

6170
class MockContext(CLI):
@@ -77,7 +86,7 @@ def get_cli_version(self):
7786
def __init__(self, **kwargs):
7887
kwargs['config_dir'] = new_temp_folder()
7988
super().__init__(**kwargs)
80-
# Force colorama to initialize
89+
# Force to enable color
8190
self.enable_color = True
8291

8392

0 commit comments

Comments
 (0)