Repository: jquast/blessed

Issue:
Conditionally set default esc_delay of inkey() from environment
from https://github.com/jquast/blessed/issues/156#issuecomment-600790544

- We should honor this lesser known `ESCDELAY` environment value as the default value of `Terminal.inkey(esc_delay)` if set, https://stackoverflow.com/a/28020568 and https://invisible-island.net/ncurses/man/ncurses.3x.html

> Specifies the total time, in milliseconds, for which ncurses will await a character sequence, e.g., a function key.  The  default  value,  1000

Discussion:
codecov[bot]: We're currently processing your upload.  This comment will be updated when the results are available.

Patch:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 51cc31b..eb3a5cc 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -1,10 +1,17 @@
 # https://docs.readthedocs.io/en/stable/config-file/v2.html
 version: 2
+
 sphinx:
   configuration: docs/conf.py
+
 formats: all
+
+build:
+  os: ubuntu-22.04
+  tools:
+    python: '3'
+
 python:
-  version: 3.7
   install:
     - method: setuptools
       path: .
diff --git a/blessed/keyboard.py b/blessed/keyboard.py
index 31cc98c..401ec45 100644
--- a/blessed/keyboard.py
+++ b/blessed/keyboard.py
@@ -1,6 +1,7 @@
 """Sub-module providing 'keyboard awareness'."""
 
 # std imports
+import os
 import re
 import time
 import platform
@@ -448,4 +449,28 @@ CURSES_KEYCODE_OVERRIDE_MIXIN = (
     ('KEY_BEGIN', curses.KEY_BEG),
 )
 
+#: Default delay, in seconds, of Escape key detection in
+#: :meth:`Terminal.inkey`.` curses has a default delay of 1000ms (1 second) for
+#: escape sequences.  This is too long for modern applications, so we set it to
+#: 350ms, or 0.35 seconds. It is still a bit conservative, for remote telnet or
+#: ssh servers, for example.
+DEFAULT_ESCDELAY = 0.35
+
+
+def _reinit_escdelay():
+    # pylint: disable=W0603
+    # Using the global statement: this is necessary to
+    # allow test coverage without complex module reload
+    global DEFAULT_ESCDELAY
+    if os.environ.get('ESCDELAY'):
+        try:
+            DEFAULT_ESCDELAY = int(os.environ['ESCDELAY']) / 1000.0
+        except ValueError:
+            # invalid values of 'ESCDELAY' are ignored
+            pass
+
+
+_reinit_escdelay()
+
+
 __all__ = ('Keystroke', 'get_keyboard_codes', 'get_keyboard_sequences',)
diff --git a/blessed/terminal.py b/blessed/terminal.py
index 76214e9..fc0b0c4 100644
--- a/blessed/terminal.py
+++ b/blessed/terminal.py
@@ -18,7 +18,8 @@ import collections
 
 # local
 from .color import COLOR_DISTANCE_ALGORITHMS
-from .keyboard import (_time_left,
+from .keyboard import (DEFAULT_ESCDELAY,
+                       _time_left,
                        _read_until,
                        resolve_sequence,
                        get_keyboard_codes,
@@ -1425,8 +1426,8 @@ class Terminal(object):
             self.stream.write(self.rmkx)
             self.stream.flush()
 
-    def inkey(self, timeout=None, esc_delay=0.35):
-        """
+    def inkey(self, timeout=None, esc_delay=DEFAULT_ESCDELAY):
+        r"""
         Read and return the next keyboard event within given timeout.
 
         Generally, this should be used inside the :meth:`raw` context manager.
@@ -1434,12 +1435,16 @@ class Terminal(object):
         :arg float timeout: Number of seconds to wait for a keystroke before
             returning.  When ``None`` (default), this method may block
             indefinitely.
-        :arg float esc_delay: To distinguish between the keystroke of
-           ``KEY_ESCAPE``, and sequences beginning with escape, the parameter
-           ``esc_delay`` specifies the amount of time after receiving escape
-           (``chr(27)``) to seek for the completion of an application key
-           before returning a :class:`~.Keystroke` instance for
-           ``KEY_ESCAPE``.
+        :arg float esc_delay: Time in seconds to block after Escape key
+           is received to await another key sequence beginning with
+           escape such as *KEY_LEFT*, sequence ``'\x1b[D'``], before returning a
+           :class:`~.Keystroke` instance for ``KEY_ESCAPE``.
+
+           Users may override the default value of ``esc_delay`` in seconds,
+           using environment value of ``ESCDELAY`` as milliseconds, see
+           `ncurses(3)`_ section labeled *ESCDELAY* for details.  Setting
+           the value as an argument to this function will override any
+           such preference.
         :rtype: :class:`~.Keystroke`.
         :returns: :class:`~.Keystroke`, which may be empty (``u''``) if
            ``timeout`` is specified and keystroke is not received.
@@ -1454,11 +1459,12 @@ class Terminal(object):
             <https://docs.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod>`_.
             Decreasing the time resolution will reduce this to 10 ms, while increasing it, which
             is rarely done, will have a perceptable impact on the behavior.
+
+        _`ncurses(3)`: https://www.man7.org/linux/man-pages/man3/ncurses.3x.html
         """
         resolve = functools.partial(resolve_sequence,
                                     mapper=self._keymap,
                                     codes=self._keycodes)
-
         stime = time.time()
 
         # re-buffer previously received keystrokes,
diff --git a/tox.ini b/tox.ini
index 017f354..d3a4405 100644
--- a/tox.ini
+++ b/tox.ini
@@ -67,8 +67,9 @@ commands =
     autopep8 --in-place  --recursive --aggressive --aggressive blessed/ bin/ setup.py
 
 [testenv:docformatter]
+# docformatter pinned due to https://github.com/PyCQA/docformatter/issues/264
 deps =
-    docformatter
+    docformatter<1.7.4
     untokenize
 commands =
     docformatter \
@@ -83,8 +84,9 @@ commands =
         {toxinidir}/docs/conf.py
 
 [testenv:docformatter_check]
+# docformatter pinned due to https://github.com/PyCQA/docformatter/issues/264
 deps =
-    docformatter
+    docformatter<1.7.4
     untokenize
 commands =
     docformatter \


Test patch:
diff --git a/tests/accessories.py b/tests/accessories.py
index 9d04d5a..54eec45 100644
--- a/tests/accessories.py
+++ b/tests/accessories.py
@@ -61,7 +61,7 @@ class as_subprocess(object):  # pylint: disable=too-few-public-methods
     def __init__(self, func):
         self.func = func
 
-    def __call__(self, *args, **kwargs):  # pylint: disable=too-many-locals, too-complex
+    def __call__(self, *args, **kwargs):  # pylint: disable=too-many-locals,too-complex
         if IS_WINDOWS:
             self.func(*args, **kwargs)
             return
diff --git a/tests/test_core.py b/tests/test_core.py
index d5039dc..cb6cf6f 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -94,6 +94,9 @@ def test_null_fileno():
 @pytest.mark.skipif(IS_WINDOWS, reason="requires more than 1 tty")
 def test_number_of_colors_without_tty():
     """``number_of_colors`` should return 0 when there's no tty."""
+    if 'COLORTERM' in os.environ:
+        del os.environ['COLORTERM']
+
     @as_subprocess
     def child_256_nostyle():
         t = TestTerminal(stream=six.StringIO())
@@ -118,6 +121,13 @@ def test_number_of_colors_without_tty():
                          force_styling=True)
         assert (t.number_of_colors == 0)
 
+    @as_subprocess
+    def child_24bit_forcestyle_with_colorterm():
+        os.environ['COLORTERM'] = 'truecolor'
+        t = TestTerminal(kind='vt220', stream=six.StringIO(),
+                         force_styling=True)
+        assert (t.number_of_colors == 1 << 24)
+
     child_0_forcestyle()
     child_8_forcestyle()
     child_256_forcestyle()
diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py
index 6622f4c..8cd50e0 100644
--- a/tests/test_keyboard.py
+++ b/tests/test_keyboard.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 """Tests for keyboard support."""
 # std imports
+import os
 import sys
 import platform
 import tempfile
@@ -363,3 +364,32 @@ def test_keypad_mixins_and_aliases():  # pylint: disable=too-many-statements
         assert resolve(u"\x1bOS").name == "KEY_F4"
 
     child('xterm')
+
+
+def test_ESCDELAY_unset_unchanged():
+    """Unset ESCDELAY leaves DEFAULT_ESCDELAY unchanged in _reinit_escdelay()."""
+    if 'ESCDELAY' in os.environ:
+        del os.environ['ESCDELAY']
+    import blessed.keyboard
+    prev_value = blessed.keyboard.DEFAULT_ESCDELAY
+    blessed.keyboard._reinit_escdelay()
+    assert blessed.keyboard.DEFAULT_ESCDELAY == prev_value
+
+
+def test_ESCDELAY_bad_value_unchanged():
+    """Invalid ESCDELAY leaves DEFAULT_ESCDELAY unchanged in _reinit_escdelay()."""
+    os.environ['ESCDELAY'] = 'XYZ123!'
+    import blessed.keyboard
+    prev_value = blessed.keyboard.DEFAULT_ESCDELAY
+    blessed.keyboard._reinit_escdelay()
+    assert blessed.keyboard.DEFAULT_ESCDELAY == prev_value
+    del os.environ['ESCDELAY']
+
+
+def test_ESCDELAY_10ms():
+    """Verify ESCDELAY modifies DEFAULT_ESCDELAY in _reinit_escdelay()."""
+    os.environ['ESCDELAY'] = '1234'
+    import blessed.keyboard
+    blessed.keyboard._reinit_escdelay()
+    assert blessed.keyboard.DEFAULT_ESCDELAY == 1.234
+    del os.environ['ESCDELAY']