Skip to content
Merged
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
87 changes: 60 additions & 27 deletions blessed/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,9 @@ def __init__(self,

#: Normalized terminal software name for python wcwidth correction tables.
#: ``True`` when unknown, so wcwidth reads ``TERM_PROGRAM`` and ``TERM`` itself.
self.term_program = self.__init_term_program() or True
self.term_program: Union[bool, str] = True
self._term_program_probed: bool = False
self.detect_term_program()

def _software_version_from_env(self) -> Optional[SoftwareVersion]:
"""
Expand All @@ -347,30 +349,6 @@ def _software_version_from_env(self) -> Optional[SoftwareVersion]:
raw = ' '.join(filter(None, (term_program, term_version)))
return SoftwareVersion(raw=raw, name=term_program, version=term_version)

def __init_term_program(self) -> str:
"""
Return a normalized terminal software name for use with wcwidth correction tables.

A non-empty ``TERM_PROGRAM`` takes precedence, and a ``TERM`` that names its terminal
is left to wcwidth's own environment lookup; both skip the inquiry. XTVERSION is
queried otherwise, when the terminal can answer on the keyboard fd. Returns an empty
string when unknown.
"""
version = self._software_version_from_env()
if version is not None:
return version.name
term = os.environ.get('TERM', '').strip().lower()

# TERM=xterm identification is non-authoritative for terminal identification. subsequent
# XTVERSION query can identify 'xterm' or its imposters, 'mlterm' and 'putty'.
if term != 'xterm' and term in list_term_programs():
return ''
if self.is_a_tty and self.does_styling and self._keyboard_fd is not None:
version = self.get_software_version()
if version is not None and version.name:
return version.name
return ''

def __init_ambiguous_width(self) -> int:
"""Return ``AMBIGUOUS_WIDE`` when it is 1 or 2, otherwise 1 (default)."""
# Although it is possible to detect "ambiguous width as wide" on class initialization,
Expand Down Expand Up @@ -1519,6 +1497,61 @@ def _probe_ambiguous_width(self, timeout: float, fallback: int) -> int:

return width if width in {1, 2} else fallback

def detect_term_program(
self,
timeout: float = TERMINAL_QUERY_TIMEOUT_SECONDS,
force: bool = False) -> Union[bool, str]:
r"""
Detect the terminal software name applied to python wcwidth correction tables.

:arg float timeout: Timeout in seconds to await an XTVERSION response.
:arg bool force: Detect again, bypassing the cache.
:rtype: bool or str
:returns: Normalized terminal name, or ``True`` when unknown, so wcwidth reads the
``TERM_PROGRAM`` and ``TERM`` environment values itself.

A non-empty ``TERM_PROGRAM`` environment value takes precedence, and a ``TERM`` that
names its terminal is left to wcwidth's own environment lookup, neither makes an
inquiry. An XTVERSION query is made otherwise, identifying terminals that report an
uninformative ``TERM=xterm``, such as 'mlterm' and 'putty'.

The result is cached, assigned to :attr:`term_program`, and returned, and used
automatically by subsequent alignment methods, :meth:`length`, :meth:`wrap`,
:meth:`ljust`, :meth:`rjust`, and :meth:`center`.

When :attr:`is_a_tty` is False, no sequences are transmitted or response awaited, and
the current value is returned without inquiry, and without caching, so that a terminal
attached after initialization, such as a remote stream, may be inquired by a later call.
"""
if not force and self._term_program_probed:
return self.term_program

version = self._software_version_from_env()
if version is not None:
self.term_program = version.name
self._term_program_probed = True
return self.term_program

term = os.environ.get('TERM', '').strip().lower()

# TERM=xterm identification is non-authoritative for terminal identification. subsequent
# XTVERSION query can identify 'xterm' or its imposters, 'mlterm' and 'putty'.
if term != 'xterm' and term in list_term_programs():
self.term_program = True
self._term_program_probed = True
return self.term_program

if not self.is_a_tty or not self.does_styling or self._keyboard_fd is None:
return self.term_program

version = self.get_software_version(timeout=timeout)
if version is not None and version.name:
self.term_program = version.name
else:
self.term_program = True
self._term_program_probed = True
return self.term_program

def _is_apple_terminal(self, timeout: Optional[float]) -> bool:
"""Whether the terminal is Apple's Terminal.app."""
if os.environ.get('TERM_PROGRAM') == 'Apple_Terminal':
Expand Down Expand Up @@ -4443,8 +4476,8 @@ def _apply_errant_response(self, ks: Keystroke) -> bool:
:rtype: bool
:returns: True when *ks* was a delayed reply; it is never returned as a keystroke,
and is recorded in :attr:`errors`. A delayed XTGETTCAP response is also
available to later :meth:`get_xtgettcap` calls; a delayed XTVERSION response is
discarded, as :attr:`term_program` is fixed at class initialization.
available to later :meth:`get_xtgettcap` calls, a delayed XTVERSION response
is discarded.
"""
if ks.name not in {'XTGETTCAP_RESPONSE', 'XTVERSION_RESPONSE'}:
return False
Expand Down
3 changes: 3 additions & 0 deletions docs/history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Version History
:meth:`~Terminal.detect_ambiguous_width` to measure it, which assigns and caches
:attr:`~Terminal.ambiguous_width`, or set ``AMBIGUOUS_WIDE`` to ``1`` or ``2`` to supply the
value without measurement, :ghpull:`420`.
* introduced: :meth:`~Terminal.detect_term_program` to detect the terminal software name, or to
detect it again after initialization, as needed by a terminal attached later, such as a remote
stream, :ghpull:`420`.
* change: :meth:`~Terminal.get_software_version` returns the ``TERM_PROGRAM`` and
``TERM_PROGRAM_VERSION`` environment values when defined, without inquiry, and these values also
override the Corrections_ detection and the XTVERSION query otherwise made for
Expand Down
5 changes: 5 additions & 0 deletions docs/measuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ the terminal and before any measurement:
from blessed import Terminal
term = Terminal()
term.detect_ambiguous_width()
term.detect_term_program()

print(term.term_program, term.ambiguous_width)

Expand All @@ -158,6 +159,10 @@ such as for automated tests. When no terminal answers, such as when output is r
terminal software is ``True`` and ambiguous width remains narrow. Blank ``TERM_PROGRAM`` to unset
it and make the XTVERSION query.

:meth:`~.Terminal.detect_term_program` applies the same detection and assigns
:attr:`~.Terminal.term_program`, which is useful for a terminal attached after construction, such as
a remote stream, when the XTVERSION query could not be made at class-initialization.

Text Sizing
-----------

Expand Down
45 changes: 41 additions & 4 deletions tests/test_software_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ def test_empty_term_program_still_queries(monkeypatch):
response = SoftwareVersion('\x1bP>|kitty(0.24.2)\x1b\\', 'kitty', '0.24.2')

with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query:
assert term._Terminal__init_term_program() == 'kitty'
assert term.detect_term_program() == 'kitty'

assert query.called

Expand All @@ -360,7 +360,7 @@ def test_resolvable_term_skips_the_query(monkeypatch, term):
terminal._keyboard_fd = 0 # pylint: disable=protected-access

with mock.patch.object(Terminal, 'get_software_version') as query:
assert terminal._Terminal__init_term_program() == ''
assert terminal.detect_term_program() is True

assert not query.called

Expand All @@ -375,7 +375,7 @@ def test_claimed_term_still_queries(monkeypatch):
response = SoftwareVersion('\x1bP>|mlterm(3.9.4)\x1b\\', 'mlterm', '3.9.4')

with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query:
assert terminal._Terminal__init_term_program() == 'mlterm'
assert terminal.detect_term_program() == 'mlterm'

assert query.called

Expand All @@ -390,6 +390,43 @@ def test_generic_term_still_queries(monkeypatch):
response = SoftwareVersion('\x1bP>|foot(1.20.1)\x1b\\', 'foot', '1.20.1')

with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query:
assert terminal._Terminal__init_term_program() == 'foot'
assert terminal.detect_term_program() == 'foot'

assert query.called


def test_detect_term_program_without_tty_defers(monkeypatch):
"""Without a tty no inquiry is made, and a later call after wiring makes one."""
monkeypatch.setenv('TERM_PROGRAM', '')
monkeypatch.setenv('TERM', 'xterm-256color')
terminal = TestTerminal(force_styling=True)
response = SoftwareVersion('\x1bP>|foot(1.20.1)\x1b\\', 'foot', '1.20.1')

with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query:
assert terminal.detect_term_program() is True
assert not query.called
terminal._is_a_tty = True # pylint: disable=protected-access
terminal._keyboard_fd = 0 # pylint: disable=protected-access
assert terminal.detect_term_program() == 'foot'

assert query.call_count == 1


def test_detect_term_program_caches_until_forced(monkeypatch):
"""The detected name is cached, force=True detects again."""
monkeypatch.setenv('TERM_PROGRAM', '')
monkeypatch.setenv('TERM', 'xterm-256color')
terminal = TestTerminal(force_styling=True)
terminal._is_a_tty = True # pylint: disable=protected-access
terminal._keyboard_fd = 0 # pylint: disable=protected-access
versions = [SoftwareVersion('\x1bP>|foot(1.20.1)\x1b\\', 'foot', '1.20.1'),
SoftwareVersion('\x1bP>|mlterm(3.9.4)\x1b\\', 'mlterm', '3.9.4')]

with mock.patch.object(Terminal, 'get_software_version',
side_effect=versions) as query:
assert terminal.detect_term_program() == 'foot'
assert terminal.detect_term_program() == 'foot'
assert query.call_count == 1
assert terminal.detect_term_program(force=True) == 'mlterm'

assert query.call_count == 2
Loading