From 5f1794d1aac9f2f5be4893bcc8ba8a061b9aeb08 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 19:19:18 -0700 Subject: [PATCH 1/9] Add colorama.just_fix_windows_console() --- README.rst | 63 ++++++++++++++++++++++++++++++++--------- colorama/__init__.py | 2 +- colorama/ansitowin32.py | 4 +++ colorama/initialise.py | 24 ++++++++++++++++ demos/demo01.py | 4 +-- 5 files changed, 81 insertions(+), 16 deletions(-) diff --git a/README.rst b/README.rst index fb0b75f..7b173d8 100644 --- a/README.rst +++ b/README.rst @@ -55,7 +55,8 @@ This has the upshot of providing a simple cross-platform API for printing colored terminal text from Python, and has the happy side-effect that existing applications or libraries which use ANSI sequences to produce colored output on Linux or Macs can now also work on Windows, simply by calling -``colorama.init()``. +``colorama.just_fix_windows_console()`` (since v0.4.6) or ``colorama.init()`` +(all versions, but may have other side-effects – see below). An alternative approach is to install ``ansi.sys`` on Windows machines, which provides the same behaviour for all applications running in terminals. Colorama @@ -85,30 +86,66 @@ Usage Initialisation .............. -Applications should initialise Colorama using: +If the only thing you want from Colorama is to get ANSI escapes to work on +Windows, then run: + +.. code-block:: python + + from colorama import just_fix_windows_console + just_fix_windows_console() + +If you're on a recent version of Windows 10 or better, and your stdout/stderr +are pointing to a Windows console, then this will flip the magic configuration +switch to enable Windows' built-in ANSI support. + +If you're on an older version of Windows, and your stdout/stderr are pointing to +a Windows console, then this will wrap ``sys.stdout`` and/or ``sys.stderr`` in a +magic file object that intercepts ANSI escape sequences and issues the +appropriate Win32 calls to emulate them. + +In all other circumstances, it does nothing whatsoever. Basically the idea is +that this makes Windows act like Unix with respect to ANSI escape handling. + +It's safe to call this function multiple times. It's safe to call this function +on non-Windows platforms, but it won't do anything. It's safe to call this +function when one or both of your stdout/stderr are redirected to a file – it +won't do anything to those streams. + +Alternatively, you can use the older interface with more features (but also more +potential footguns): .. code-block:: python from colorama import init init() -On Windows, calling ``init()`` will filter ANSI escape sequences out of any -text sent to ``stdout`` or ``stderr``, and replace them with equivalent Win32 -calls. +This does the same thing as ``just_fix_windows_console``, except for the +following differences: + +- It's not safe to call ``init`` multiple times; you can end up with multiple + layers of wrapping and broken ANSI support. -On other platforms, calling ``init()`` has no effect (unless you request other -optional functionality, see "Init Keyword Args" below; or if output -is redirected). By design, this permits applications to call ``init()`` -unconditionally on all platforms, after which ANSI output should just work. +- Colorama will apply a heuristic to guess whether stdout/stderr support ANSI, + and if it thinks they don't, then it will wrap ``sys.stdout`` and + ``sys.stderr`` in a magic file object that strips out ANSI escape sequences + before printing them. This happens on all platforms, and can be convenient if + you want to write your code to emit ANSI escape sequences unconditionally, and + let Colorama decide whether they should actually be output. But note that + Colorama's heuristic is not particularly clever. -On all platforms, if output is redirected, ANSI escape sequences are completely -stripped out. +- ``init`` also accepts explicit keyword args to enable/disable various + functionality – see below. To stop using Colorama before your program exits, simply call ``deinit()``. This will restore ``stdout`` and ``stderr`` to their original values, so that Colorama is disabled. To resume using Colorama again, call ``reinit()``; it is cheaper than calling ``init()`` again (but does the same thing). +Most users should depend on ``colorama >= 0.4.6``, and use +``just_fix_windows_console``. The old ``init`` interface will be supported +indefinitely for backwards compatibility, but we don't plan to fix any issues +with it, also for backwards compatibility. + Colored Output .............. @@ -145,11 +182,11 @@ those ANSI sequences to also work on Windows: .. code-block:: python - from colorama import init + from colorama import just_fix_windows_console from termcolor import colored # use Colorama to make Termcolor work on Windows too - init() + just_fix_windows_console() # then use Termcolor for all colored text output print(colored('Hello, World!', 'green', 'on_red')) diff --git a/colorama/__init__.py b/colorama/__init__.py index 518ac80..f5cdfbe 100644 --- a/colorama/__init__.py +++ b/colorama/__init__.py @@ -1,5 +1,5 @@ # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. -from .initialise import init, deinit, reinit, colorama_text +from .initialise import init, deinit, reinit, colorama_text, just_fix_windows_console from .ansi import Fore, Back, Style, Cursor from .ansitowin32 import AnsiToWin32 diff --git a/colorama/ansitowin32.py b/colorama/ansitowin32.py index 2060311..abf209e 100644 --- a/colorama/ansitowin32.py +++ b/colorama/ansitowin32.py @@ -271,3 +271,7 @@ def convert_osc(self, text): if params[0] in '02': winterm.set_title(params[1]) return text + + + def flush(self): + self.wrapped.flush() diff --git a/colorama/initialise.py b/colorama/initialise.py index 430d066..4b45ba1 100644 --- a/colorama/initialise.py +++ b/colorama/initialise.py @@ -14,6 +14,7 @@ atexit_done = False +fixed_windows_console = False def reset_all(): if AnsiToWin32 is not None: # Issue #74: objects might become None at exit @@ -55,6 +56,29 @@ def deinit(): sys.stderr = orig_stderr +def just_fix_windows_console(): + global fixed_windows_console + + if sys.platform != "win32": + return + if fixed_windows_console: + return + if wrapped_stdout is not None or wrapped_stderr is not None: + # Someone already ran init() and it did stuff, so we won't second-guess them + return + + # On newer versions of Windows, AnsiToWin32.__init__ will implicitly enable the + # native ANSI support in the console as a side-effect. We only need to actually + # replace sys.stdout/stderr if we're in the old-style conversion mode. + new_stdout = AnsiToWin32(sys.stdout, convert=None, strip=None, autoreset=False) + if new_stdout.convert: + sys.stdout = new_stdout + new_stderr = AnsiToWin32(sys.stderr, convert=None, strip=None, autoreset=False) + if new_stderr.convert: + sys.stderr = new_stderr + + fixed_windows_console = True + @contextlib.contextmanager def colorama_text(*args, **kwargs): init(*args, **kwargs) diff --git a/demos/demo01.py b/demos/demo01.py index 99d896a..c367024 100644 --- a/demos/demo01.py +++ b/demos/demo01.py @@ -10,9 +10,9 @@ # Add parent dir to sys path, so the following 'import colorama' always finds # the local source in preference to any installed version of colorama. import fixpath -from colorama import init, Fore, Back, Style +from colorama import just_fix_windows_console, Fore, Back, Style -init() +just_fix_windows_console() # Fore, Back and Style are convenience classes for the constant ANSI strings that set # the foreground, background and style. The don't have any magic of their own. From bab6f9391a23bd005fc3602709b5ab09296a5578 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 19:33:48 -0700 Subject: [PATCH 2/9] init->just_fix_windows_console in all demos that call bare init() --- demos/demo02.py | 4 ++-- demos/demo06.py | 2 +- demos/demo07.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/demos/demo02.py b/demos/demo02.py index ea96d87..8ca7d4b 100644 --- a/demos/demo02.py +++ b/demos/demo02.py @@ -5,9 +5,9 @@ from __future__ import print_function import fixpath -from colorama import init, Fore, Back, Style +from colorama import just_fix_windows_console, Fore, Back, Style -init() +just_fix_windows_console() print(Fore.GREEN + 'green, ' + Fore.RED + 'red, ' diff --git a/demos/demo06.py b/demos/demo06.py index f9125d8..21f7acc 100644 --- a/demos/demo06.py +++ b/demos/demo06.py @@ -24,7 +24,7 @@ PASSES = 1000 def main(): - colorama.init() + colorama.just_fix_windows_console() pos = lambda y, x: Cursor.POS(x, y) # draw a white border. print(Back.WHITE, end='') diff --git a/demos/demo07.py b/demos/demo07.py index f569580..0d28a1e 100644 --- a/demos/demo07.py +++ b/demos/demo07.py @@ -16,7 +16,7 @@ def main(): aba 3a4 """ - colorama.init() + colorama.just_fix_windows_console() print("aaa") print("aaa") print("aaa") From 66b76eda602c87973fa25a776476d79be7d7fab7 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 20:56:13 -0700 Subject: [PATCH 3/9] Remove broken test This test never actually tested what it claimed to, and it's broken on Win10, so just remove it. --- colorama/tests/initialise_test.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/colorama/tests/initialise_test.py b/colorama/tests/initialise_test.py index 7bbd18f..62ede38 100644 --- a/colorama/tests/initialise_test.py +++ b/colorama/tests/initialise_test.py @@ -78,14 +78,6 @@ def testInitWrapOffDoesntWrapOnWindows(self): def testInitWrapOffIncompatibleWithAutoresetOn(self): self.assertRaises(ValueError, lambda: init(autoreset=True, wrap=False)) - @patch('colorama.ansitowin32.winterm', None) - @patch('colorama.ansitowin32.winapi_test', lambda *_: True) - def testInitOnlyWrapsOnce(self): - with osname("nt"): - init() - init() - self.assertWrapped() - @patch('colorama.win32.SetConsoleTextAttribute') @patch('colorama.initialise.AnsiToWin32') def testAutoResetPassedOn(self, mockATW32, _): From 3f3c06a02262e5bf8932099a3dc29ae238f233ca Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 20:57:11 -0700 Subject: [PATCH 4/9] Fix test on Win10 --- colorama/tests/initialise_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/colorama/tests/initialise_test.py b/colorama/tests/initialise_test.py index 62ede38..d689b4d 100644 --- a/colorama/tests/initialise_test.py +++ b/colorama/tests/initialise_test.py @@ -40,6 +40,7 @@ def assertNotWrapped(self): @patch('colorama.initialise.reset_all') @patch('colorama.ansitowin32.winapi_test', lambda *_: True) + @patch('colorama.ansitowin32.enable_vt_processing', lambda *_: False) def testInitWrapsOnWindows(self, _): with osname("nt"): init() From 3c5553fa2b96a0dc17852e93e9f83bc4dec7cc38 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 20:57:50 -0700 Subject: [PATCH 5/9] Be more thorough about cleaning up state between tests --- colorama/initialise.py | 25 +++++++++++++++++++------ colorama/tests/initialise_test.py | 3 ++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/colorama/initialise.py b/colorama/initialise.py index 4b45ba1..6e01026 100644 --- a/colorama/initialise.py +++ b/colorama/initialise.py @@ -6,15 +6,24 @@ from .ansitowin32 import AnsiToWin32 -orig_stdout = None -orig_stderr = None +def _wipe_internal_state_for_tests(): + global orig_stdout, orig_stderr + orig_stdout = None + orig_stderr = None + + global wrapped_stdout, wrapped_stderr + wrapped_stdout = None + wrapped_stderr = None + + global atexit_done + atexit_done = False -wrapped_stdout = None -wrapped_stderr = None + global fixed_windows_console + fixed_windows_console = False -atexit_done = False + # no-op if it wasn't registered + atexit.unregister(reset_all) -fixed_windows_console = False def reset_all(): if AnsiToWin32 is not None: # Issue #74: objects might become None at exit @@ -102,3 +111,7 @@ def wrap_stream(stream, convert, strip, autoreset, wrap): if wrapper.should_wrap(): stream = wrapper.stream return stream + + +# Use this for initial setup as well, to reduce code duplication +_wipe_internal_state_for_tests() diff --git a/colorama/tests/initialise_test.py b/colorama/tests/initialise_test.py index d689b4d..563985a 100644 --- a/colorama/tests/initialise_test.py +++ b/colorama/tests/initialise_test.py @@ -8,7 +8,7 @@ from mock import patch from ..ansitowin32 import StreamWrapper -from ..initialise import init +from ..initialise import init, just_fix_windows_console, _wipe_internal_state_for_tests from .utils import osname, replace_by orig_stdout = sys.stdout @@ -23,6 +23,7 @@ def setUp(self): self.assertNotWrapped() def tearDown(self): + _wipe_internal_state_for_tests() sys.stdout = orig_stdout sys.stderr = orig_stderr From 479da74614d1bb669dd90f81c9e19c8b01767526 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 20:58:37 -0700 Subject: [PATCH 6/9] Work around a bunch of spurious test failures --- colorama/winterm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/colorama/winterm.py b/colorama/winterm.py index fd7202c..aad867e 100644 --- a/colorama/winterm.py +++ b/colorama/winterm.py @@ -190,5 +190,6 @@ def enable_vt_processing(fd): mode = win32.GetConsoleMode(handle) if mode & win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING: return True - except OSError: + # Can get TypeError in testsuite where 'fd' is a Mock() + except (OSError, TypeError): return False From 38b498a7f094b54e7e8babec60f477252112db64 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 21:06:20 -0700 Subject: [PATCH 7/9] Add test for just_fix_windows_console --- colorama/tests/initialise_test.py | 67 ++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/colorama/tests/initialise_test.py b/colorama/tests/initialise_test.py index 563985a..62dbfe0 100644 --- a/colorama/tests/initialise_test.py +++ b/colorama/tests/initialise_test.py @@ -3,9 +3,9 @@ from unittest import TestCase, main, skipUnless try: - from unittest.mock import patch + from unittest.mock import patch, Mock except ImportError: - from mock import patch + from mock import patch, Mock from ..ansitowin32 import StreamWrapper from ..initialise import init, just_fix_windows_console, _wipe_internal_state_for_tests @@ -116,5 +116,68 @@ def testAtexitRegisteredOnlyOnce(self, mockRegister): self.assertFalse(mockRegister.called) +class JustFixWindowsConsoleTest(TestCase): + def _reset(self): + _wipe_internal_state_for_tests() + sys.stdout = orig_stdout + sys.stderr = orig_stderr + + def tearDown(self): + self._reset() + + def testJustFixWindowsConsole(self): + if sys.platform != "win32": + # just_fix_windows_console should be a no-op + just_fix_windows_console() + self.assertIs(sys.stdout, orig_stdout) + self.assertIs(sys.stderr, orig_stderr) + else: + for native_ansi in [False, True]: + # Emulate stdout=not a tty, stderr=tty + # to check that we handle both cases correctly + stdout = Mock() + stdout.closed = False + stdout.isatty.return_value = False + stdout.fileno.return_value = 1 + sys.stdout = stdout + + stderr = Mock() + stderr.closed = False + stderr.isatty.return_value = True + stderr.fileno.return_value = 2 + sys.stderr = stderr + + with patch( + 'colorama.ansitowin32.enable_vt_processing', + lambda *_: native_ansi + ): + # Regular single-call test + just_fix_windows_console() + self.assertIs(sys.stdout, stdout) + if native_ansi: + self.assertIs(sys.stderr, stderr) + else: + self.assertIsNot(sys.stderr, stderr) + + # second call without resetting is always a no-op + prev_stdout = sys.stdout + prev_stderr = sys.stderr + just_fix_windows_console() + self.assertIs(sys.stdout, prev_stdout) + self.assertIs(sys.stderr, prev_stderr) + + self._reset() + + # If init() runs first, just_fix_windows_console should be a no-op + init() + prev_stdout = sys.stdout + prev_stderr = sys.stderr + just_fix_windows_console() + self.assertIs(prev_stdout, sys.stdout) + self.assertIs(prev_stderr, sys.stderr) + + self._reset() + + if __name__ == '__main__': main() From 1a82924e382dfa29db3aade22e41484692db9e70 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 21:10:40 -0700 Subject: [PATCH 8/9] Workaround py2's lack of atexit.unregister --- colorama/initialise.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/colorama/initialise.py b/colorama/initialise.py index 6e01026..d5fd4b7 100644 --- a/colorama/initialise.py +++ b/colorama/initialise.py @@ -21,8 +21,12 @@ def _wipe_internal_state_for_tests(): global fixed_windows_console fixed_windows_console = False - # no-op if it wasn't registered - atexit.unregister(reset_all) + try: + # no-op if it wasn't registered + atexit.unregister(reset_all) + except AttributeError: + # python 2: no atexit.unregister. Oh well, we did our best. + pass def reset_all(): From 78bce1870957c747227200ffa986982aad2e5a4b Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Sun, 16 Oct 2022 21:15:00 -0700 Subject: [PATCH 9/9] Refactor test and make it work in CI (no console) --- colorama/tests/initialise_test.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/colorama/tests/initialise_test.py b/colorama/tests/initialise_test.py index 62dbfe0..89f9b07 100644 --- a/colorama/tests/initialise_test.py +++ b/colorama/tests/initialise_test.py @@ -125,6 +125,7 @@ def _reset(self): def tearDown(self): self._reset() + @patch("colorama.ansitowin32.winapi_test", lambda: True) def testJustFixWindowsConsole(self): if sys.platform != "win32": # just_fix_windows_console should be a no-op @@ -132,7 +133,7 @@ def testJustFixWindowsConsole(self): self.assertIs(sys.stdout, orig_stdout) self.assertIs(sys.stderr, orig_stderr) else: - for native_ansi in [False, True]: + def fake_std(): # Emulate stdout=not a tty, stderr=tty # to check that we handle both cases correctly stdout = Mock() @@ -147,17 +148,23 @@ def testJustFixWindowsConsole(self): stderr.fileno.return_value = 2 sys.stderr = stderr + for native_ansi in [False, True]: with patch( 'colorama.ansitowin32.enable_vt_processing', lambda *_: native_ansi ): + self._reset() + fake_std() + # Regular single-call test + prev_stdout = sys.stdout + prev_stderr = sys.stderr just_fix_windows_console() - self.assertIs(sys.stdout, stdout) + self.assertIs(sys.stdout, prev_stdout) if native_ansi: - self.assertIs(sys.stderr, stderr) + self.assertIs(sys.stderr, prev_stderr) else: - self.assertIsNot(sys.stderr, stderr) + self.assertIsNot(sys.stderr, prev_stderr) # second call without resetting is always a no-op prev_stdout = sys.stdout @@ -167,6 +174,7 @@ def testJustFixWindowsConsole(self): self.assertIs(sys.stderr, prev_stderr) self._reset() + fake_std() # If init() runs first, just_fix_windows_console should be a no-op init() @@ -176,8 +184,6 @@ def testJustFixWindowsConsole(self): self.assertIs(prev_stdout, sys.stdout) self.assertIs(prev_stderr, sys.stderr) - self._reset() - if __name__ == '__main__': main()