diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1b51c9c..eadd87f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,13 @@ Changelog`_. This project adheres to `semantic versioning`_. .. _Keep a Changelog: http://keepachangelog.com/ .. _semantic versioning: http://semver.org/ +Unreleased +---------- + +- Restore native Windows ANSI support on repeated ``enable_ansi_support()`` + calls after another process changes the console mode (issue #59). Other + support checks and colorama initialization remain cached. + `Release 10.0`_ (2021-09-17) ---------------------------- diff --git a/README.rst b/README.rst index 80145d5..4901658 100644 --- a/README.rst +++ b/README.rst @@ -139,6 +139,10 @@ like ``humanfriendly --demo`` should work out of the box (if your system is up-to-date enough). If this doesn't work then you can install the colorama_ package, it will be used automatically once installed. +Calling ``enable_ansi_support()`` again restores native Windows ANSI support +if a child process has changed the console mode. Colorama initialization and +non-Windows terminal detection remain cached. + Contact ------- diff --git a/humanfriendly/terminal/__init__.py b/humanfriendly/terminal/__init__.py index ba9739c..6638d86 100644 --- a/humanfriendly/terminal/__init__.py +++ b/humanfriendly/terminal/__init__.py @@ -417,7 +417,6 @@ def connected_to_terminal(stream=None): return False -@cached def enable_ansi_support(): """ Try to enable support for ANSI escape sequences (required on Windows). @@ -447,16 +446,23 @@ def enable_ansi_support(): This makes it possible to call :func:`enable_ansi_support()` unconditionally without checking the current platform. - The :func:`~humanfriendly.decorators.cached` decorator is used to ensure - that this function is only executed once, but its return value remains - available on later calls. + Native Windows console support is enabled on every call, so it can be + restored after a child process changes the console mode. The other + configurations remain cached to avoid initializing :pypi:`colorama` + multiple times. """ if have_windows_native_ansi_support(): import ctypes ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7) ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-12), 7) return True - elif on_windows(): + return _enable_ansi_support() + + +@cached +def _enable_ansi_support(): + """Cache ANSI support for configurations without native Windows support.""" + if on_windows(): if 'ANSICON' in os.environ: return True try: diff --git a/humanfriendly/tests.py b/humanfriendly/tests.py index 72dad99..386b298 100644 --- a/humanfriendly/tests.py +++ b/humanfriendly/tests.py @@ -10,6 +10,7 @@ """Test suite for the `humanfriendly` package.""" # Standard library modules. +import ctypes import datetime import math import os @@ -123,7 +124,97 @@ ) # Test dependencies. -from mock import MagicMock +from mock import MagicMock, patch + +import humanfriendly.terminal as terminal + +# Patch the function globals, rather than the public deprecation proxy. +terminal = terminal.module + + +class AnsiSupportTestCase(unittest.TestCase): + + """Check native console reenabling without changing a real console.""" + + def setUp(self): + """Clear cached results so each test starts with a fresh configuration.""" + self.clear_ansi_cache() + + def tearDown(self): + """Leave subsequent tests with a fresh configuration.""" + self.clear_ansi_cache() + + def clear_ansi_cache(self): + """Reset caches in both the original and split implementations.""" + for name in ('enable_ansi_support', '_enable_ansi_support'): + function = getattr(terminal, name, None) + if hasattr(function, 'cached_results'): + del function.cached_results + + def test_native_ansi_reenabled_after_console_mode_changes(self): + """Reenable stdout and stderr after an external console mode change.""" + modes = {} + kernel32 = MagicMock() + kernel32.GetStdHandle.side_effect = lambda handle: handle + kernel32.SetConsoleMode.side_effect = lambda handle, mode: modes.update({handle: mode}) + with patch.object(terminal, 'have_windows_native_ansi_support', return_value=True), \ + patch.object(ctypes, 'windll', MagicMock(kernel32=kernel32), create=True): + assert terminal.enable_ansi_support() is True + assert modes == {-11: 7, -12: 7} + # Model a child program disabling ANSI on the shared handles. + modes.update({-11: 0, -12: 0}) + assert terminal.enable_ansi_support() is True + assert modes == {-11: 7, -12: 7} + assert kernel32.SetConsoleMode.call_count == 4 + + def test_colorama_fallback_stays_cached(self): + """Initialize colorama only once when native support is unavailable.""" + colorama = MagicMock() + with patch.object(terminal, 'have_windows_native_ansi_support', return_value=False), \ + patch.object(terminal, 'on_windows', return_value=True), \ + patch.dict(os.environ, {}, clear=True), \ + patch.dict(sys.modules, {'colorama': colorama}): + assert terminal.enable_ansi_support() is True + assert terminal.enable_ansi_support() is True + assert colorama.init.call_count == 1 + + def test_non_windows_fallback_stays_cached(self): + """Preserve cached terminal detection on non-Windows platforms.""" + with patch.object(terminal, 'have_windows_native_ansi_support', return_value=False), \ + patch.object(terminal, 'on_windows', return_value=False), \ + patch.object(terminal, 'connected_to_terminal', side_effect=[True, False]) as connected: + assert terminal.enable_ansi_support() is True + assert terminal.enable_ansi_support() is True + assert connected.call_count == 1 + + def test_ansicon_fallback_stays_cached(self): + """Keep ANSICON support cached without initializing colorama.""" + colorama = MagicMock() + with patch.object(terminal, 'have_windows_native_ansi_support', return_value=False), \ + patch.object(terminal, 'on_windows', return_value=True), \ + patch.dict(os.environ, {'ANSICON': '1'}, clear=True), \ + patch.dict(sys.modules, {'colorama': colorama}): + assert terminal.enable_ansi_support() is True + assert terminal.enable_ansi_support() is True + assert colorama.init.call_count == 0 + + def test_missing_colorama_fallback_stays_false(self): + """Keep unsupported Windows configurations cached as false.""" + with patch.object(terminal, 'have_windows_native_ansi_support', return_value=False), \ + patch.object(terminal, 'on_windows', return_value=True), \ + patch.dict(os.environ, {}, clear=True), \ + patch.dict(sys.modules, {'colorama': None}): + assert terminal.enable_ansi_support() is False + assert terminal.enable_ansi_support() is False + + def test_non_terminal_fallback_stays_false(self): + """Keep a failed non-Windows terminal detection cached as well.""" + with patch.object(terminal, 'have_windows_native_ansi_support', return_value=False), \ + patch.object(terminal, 'on_windows', return_value=False), \ + patch.object(terminal, 'connected_to_terminal', return_value=False) as connected: + assert terminal.enable_ansi_support() is False + assert terminal.enable_ansi_support() is False + assert connected.call_count == 1 class HumanFriendlyTestCase(TestCase):