Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
----------------------------

Expand Down
4 changes: 4 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------

Expand Down
16 changes: 11 additions & 5 deletions humanfriendly/terminal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand Down
93 changes: 92 additions & 1 deletion humanfriendly/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""Test suite for the `humanfriendly` package."""

# Standard library modules.
import ctypes
import datetime
import math
import os
Expand Down Expand Up @@ -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):
Expand Down