Coverage for yuio / term.py: 74%
305 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9Querying terminal info and working with ANSI terminals.
11This is a low-level module upon which :mod:`yuio.io` builds
12its higher-level abstraction.
15Detecting terminal capabilities
16-------------------------------
18Terminal capabilities are stored in a :class:`Term` object.
20Usually, you don't need to query terminal capabilities yourself,
21as you can use Yuio's global configuration from :mod:`yuio.io`
22(see :func:`yuio.io.get_term`).
24However, you can get a :class:`Term` object by using :func:`get_term_from_stream`:
26.. autofunction:: get_term_from_stream
28.. autoclass:: Term
29 :members:
31.. autoclass:: TerminalTheme
32 :members:
34.. autoclass:: Lightness
35 :members:
37.. autoclass:: InteractiveSupport
38 :members:
41Utilities
42---------
44.. autofunction:: detect_ci
46.. autofunction:: detect_ci_color_support
48"""
50from __future__ import annotations
52import contextlib
53import dataclasses
54import enum
55import locale
56import os
57import re
58import shutil
59import sys
60from dataclasses import dataclass
62import yuio
63import yuio.color
64from yuio import _typing as _t
65from yuio.color import ColorSupport
67__all__ = [
68 "ColorSupport",
69 "InteractiveSupport",
70 "Lightness",
71 "Term",
72 "TerminalTheme",
73 "detect_ci",
74 "detect_ci_color_support",
75 "get_term_from_stream",
76]
78T = _t.TypeVar("T")
81class Lightness(enum.Enum):
82 """
83 Overall color theme of a terminal.
85 Can help with deciding which colors to use when printing output.
87 """
89 UNKNOWN = enum.auto()
90 """
91 We couldn't determine terminal background, or it wasn't dark
92 or bright enough to fall in one category or another.
94 """
96 DARK = enum.auto()
97 """
98 Terminal background is dark.
100 """
102 LIGHT = enum.auto()
103 """
104 Terminal background is light.
106 """
109class InteractiveSupport(enum.IntEnum):
110 """
111 Terminal's capability for rendering interactive widgets.
113 """
115 NONE = 0
116 """
117 Terminal can't render anything interactive.
119 """
121 MOVE_CURSOR = 1
122 """
123 Terminal can move cursor and erase lines.
125 """
127 FULL = 2
128 """
129 Terminal can process queries, enter ``CBREAK`` mode, etc.
131 """
134@dataclass(frozen=True, slots=True)
135class TerminalTheme:
136 """
137 Colors and theme of the attached terminal.
139 """
141 background: yuio.color.ColorValue
142 """
143 Background color of a terminal.
145 """
147 foreground: yuio.color.ColorValue
148 """
149 Foreground color of a terminal.
151 """
153 black: yuio.color.ColorValue
154 """
155 Color value for the default "black" color.
157 """
159 red: yuio.color.ColorValue
160 """
161 Color value for the default "red" color.
163 """
165 green: yuio.color.ColorValue
166 """
167 Color value for the default "green" color.
169 """
171 yellow: yuio.color.ColorValue
172 """
173 Color value for the default "yellow" color.
175 """
177 blue: yuio.color.ColorValue
178 """
179 Color value for the default "blue" color.
181 """
183 magenta: yuio.color.ColorValue
184 """
185 Color value for the default "magenta" color.
187 """
189 cyan: yuio.color.ColorValue
190 """
191 Color value for the default "cyan" color.
193 """
195 white: yuio.color.ColorValue
196 """
197 Color value for the default "white" color.
199 """
201 lightness: Lightness
202 """
203 Overall color theme of a terminal, i.e. dark or light.
205 """
208@dataclass(frozen=True, slots=True)
209class Term:
210 """
211 This class contains all info about what kinds of things the terminal
212 supports. If available, it will also have info about terminal's theme,
213 i.e. dark or light background, etc.
215 """
217 ostream: _t.TextIO
218 """
219 Terminal's output stream.
221 """
223 istream: _t.TextIO
224 """
225 Terminal's input stream.
227 """
229 ostream_is_tty: bool = dataclasses.field(default=False, kw_only=True)
230 """
231 Output stream is connected to a TTY device, meaning that is goes to a user.
233 Output stream being a TTY doesn't necessarily mean that it's interactive.
234 For example, the process can run in background, but still be attached
235 to a terminal.
237 Use :attr:`~Term.interactive_support` to check for interactivity level.
239 """
241 istream_is_tty: bool = dataclasses.field(default=False, kw_only=True)
242 """
243 Input stream is connected to a TTY device, meaning that is goes to a user.
245 """
247 color_support: ColorSupport = dataclasses.field(
248 default=ColorSupport.NONE, kw_only=True
249 )
250 """
251 Terminal's capability for coloring output.
253 """
255 interactive_support: InteractiveSupport = dataclasses.field(
256 default=InteractiveSupport.NONE, kw_only=True
257 )
258 """
259 Terminal's capability for rendering interactive widgets.
261 """
263 terminal_theme: TerminalTheme | None = dataclasses.field(default=None, kw_only=True)
264 """
265 Terminal's default foreground, background, and text colors.
267 """
269 @property
270 def supports_colors(self) -> bool:
271 """
272 Return :data:`True` if terminal supports simple 8-bit color codes.
274 """
276 return self.color_support >= ColorSupport.ANSI
278 @property
279 def supports_colors_256(self) -> bool:
280 """
281 Return :data:`True` if terminal supports 256-encoded colors.
283 """
285 return self.color_support >= ColorSupport.ANSI_256
287 @property
288 def supports_colors_true(self) -> bool:
289 """
290 Return :data:`True` if terminal supports true colors.
292 """
294 return self.color_support >= ColorSupport.ANSI_TRUE
296 @property
297 def can_move_cursor(self) -> bool:
298 """
299 Return :data:`True` if terminal can move cursor and erase lines.
301 """
303 return (
304 self.supports_colors
305 and self.interactive_support >= InteractiveSupport.MOVE_CURSOR
306 )
308 @property
309 def can_query_terminal(self) -> bool:
310 """
311 Return :data:`True` if terminal can process queries, enter ``CBREAK`` mode, etc.
313 This is an alias to :attr:`~Term.is_fully_interactive`.
315 """
317 return self.is_fully_interactive
319 @property
320 def is_fully_interactive(self) -> bool:
321 """
322 Return :data:`True` if we're in a fully interactive environment.
324 """
326 return (
327 self.supports_colors and self.interactive_support >= InteractiveSupport.FULL
328 )
330 @property
331 def is_unicode(self) -> bool:
332 encoding = (
333 getattr(self.ostream, "encoding", None) or locale.getpreferredencoding()
334 )
335 return "utf" in encoding or "unicode" in encoding
338_CI_ENV_VARS = [
339 "TRAVIS",
340 "CIRCLECI",
341 "APPVEYOR",
342 "GITLAB_CI",
343 "BUILDKITE",
344 "DRONE",
345 "TEAMCITY_VERSION",
346 "GITHUB_ACTIONS",
347]
350def get_term_from_stream(
351 ostream: _t.TextIO, istream: _t.TextIO, /, *, query_terminal_theme: bool = True
352) -> Term:
353 """
354 Query info about a terminal attached to the given stream.
356 :param ostream:
357 output stream.
358 :param istream:
359 input stream.
360 :param query_terminal_theme:
361 By default, this function queries background, foreground, and text colors
362 of the terminal if ``ostream`` and ``istream`` are connected to a TTY.
364 Set this parameter to :data:`False` to disable querying.
366 """
368 if "__YUIO_FORCE_FULL_TERM_SUPPORT" in os.environ: # pragma: no cover
369 # For building docs in github
370 return Term(
371 ostream=ostream,
372 istream=istream,
373 ostream_is_tty=True,
374 istream_is_tty=True,
375 color_support=ColorSupport.ANSI_TRUE,
376 interactive_support=InteractiveSupport.FULL,
377 )
379 has_interactive_output = _is_interactive_output(ostream)
380 has_interactive_input = _is_interactive_input(istream)
382 # Note: we don't rely on argparse to parse out flags and send them to us
383 # because these functions can be called before parsing arguments.
384 if (
385 "--no-color" in sys.argv
386 or "--no-colors" in sys.argv
387 or "--force-no-color" in sys.argv
388 or "--force-no-colors" in sys.argv
389 or "NO_COLOR" in os.environ
390 or "FORCE_NO_COLOR" in os.environ
391 or "FORCE_NO_COLORS" in os.environ
392 ):
393 return Term(
394 ostream,
395 istream,
396 ostream_is_tty=has_interactive_output,
397 istream_is_tty=has_interactive_input,
398 )
400 term = os.environ.get("TERM", "").lower()
401 colorterm = os.environ.get("COLORTERM", "").lower()
402 is_foreground = _is_foreground(ostream) and _is_foreground(istream)
403 in_ci = detect_ci()
404 color_support = ColorSupport.NONE
405 if (
406 "--force-color" in sys.argv
407 or "--force-colors" in sys.argv
408 or "FORCE_COLOR" in os.environ
409 or "FORCE_COLORS" in os.environ
410 ):
411 color_support = ColorSupport.ANSI
412 if has_interactive_output:
413 if in_ci:
414 color_support = detect_ci_color_support()
415 elif os.name == "nt":
416 if _enable_vt_processing(ostream, istream):
417 color_support = ColorSupport.ANSI_TRUE
418 elif colorterm in ("truecolor", "24bit") or term == "xterm-kitty":
419 color_support = ColorSupport.ANSI_TRUE
420 elif colorterm in ("yes", "true") or "256color" in term or term == "screen":
421 if (
422 os.name == "posix"
423 and term == "xterm-256color"
424 and shutil.which("wslinfo")
425 ):
426 color_support = ColorSupport.ANSI_TRUE
427 else:
428 color_support = ColorSupport.ANSI_256
429 elif "linux" in term or "color" in term or "ansi" in term or "xterm" in term:
430 color_support = ColorSupport.ANSI
432 interactive_support = InteractiveSupport.NONE
433 theme = None
434 if is_foreground and color_support >= ColorSupport.ANSI and not in_ci:
435 if has_interactive_output and has_interactive_input:
436 interactive_support = InteractiveSupport.FULL
437 if (
438 query_terminal_theme
439 and color_support >= ColorSupport.ANSI_256
440 and "YUIO_DISABLE_OSC_QUERIES" not in os.environ
441 ):
442 theme = _get_standard_colors(ostream, istream)
443 else:
444 interactive_support = InteractiveSupport.MOVE_CURSOR
446 return Term(
447 ostream=ostream,
448 istream=istream,
449 ostream_is_tty=has_interactive_output,
450 istream_is_tty=has_interactive_input,
451 color_support=color_support,
452 interactive_support=interactive_support,
453 terminal_theme=theme,
454 )
457def detect_ci() -> bool:
458 """
459 Scan environment variables to detect if we're in a known CI environment.
461 """
463 return "CI" in os.environ or any(ci in os.environ for ci in _CI_ENV_VARS)
466def detect_ci_color_support() -> ColorSupport:
467 """
468 Scan environment variables to detect an appropriate level of color support
469 of a CI environment.
471 If we're not in CI, return :attr:`ColorSupport.NONE`.
473 """
475 if "GITHUB_ACTIONS" in os.environ:
476 return ColorSupport.ANSI_TRUE
477 elif any(ci in os.environ for ci in _CI_ENV_VARS):
478 return ColorSupport.ANSI
479 else:
480 return ColorSupport.NONE
483def _get_standard_colors(
484 ostream: _t.TextIO, istream: _t.TextIO
485) -> TerminalTheme | None:
486 try:
487 query = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\" + "".join(
488 [f"\x1b]4;{i};?\x1b\\" for i in range(8)]
489 )
490 response = _query_term(ostream, istream, query)
491 if not response:
492 return None
494 # Deal with foreground color.
496 match = re.match(
497 r"^\x1b]10;rgb:([0-9a-f]{2,4})/([0-9a-f]{2,4})/([0-9a-f]{2,4})(?:\x1b\\|\a)",
498 response,
499 re.IGNORECASE,
500 )
501 if match is None: # pragma: no cover
502 return None
504 r, g, b = (int(v, 16) // 16 ** (len(v) - 2) for v in match.groups())
505 foreground = yuio.color.ColorValue.from_rgb(r, g, b)
507 response = response[match.end() :]
509 # Deal with background color.
511 match = re.match(
512 r"^\x1b]11;rgb:([0-9a-f]{2,4})/([0-9a-f]{2,4})/([0-9a-f]{2,4})(?:\x1b\\|\a)",
513 response,
514 re.IGNORECASE,
515 )
516 if match is None: # pragma: no cover
517 return None
519 r, g, b = (int(v, 16) // 16 ** (len(v) - 2) for v in match.groups())
520 background = yuio.color.ColorValue.from_rgb(r, g, b)
521 luma = (0.2627 * r + 0.6780 * g + 0.0593 * b) / 256
523 if luma <= 0.2:
524 lightness = Lightness.DARK
525 elif luma >= 0.85:
526 lightness = Lightness.LIGHT
527 else:
528 lightness = Lightness.UNKNOWN
530 response = response[match.end() :]
532 # Deal with other colors
534 colors = {}
536 while response:
537 match = re.match(
538 r"^\x1b]4;(\d+);rgb:([0-9a-f]{2,4})/([0-9a-f]{2,4})/([0-9a-f]{2,4})(?:\x1b\\|\a)",
539 response,
540 re.IGNORECASE,
541 )
542 if match is None: # pragma: no cover
543 return None
545 c = int(match.group(1))
546 r, g, b = (int(v, 16) // 16 ** (len(v) - 2) for v in match.groups()[1:])
547 colors[c] = yuio.color.ColorValue.from_rgb(r, g, b)
549 response = response[match.end() :]
551 if set(colors.keys()) != {0, 1, 2, 3, 4, 5, 6, 7}: # pragma: no cover
552 return None
554 # return colors
555 return TerminalTheme(
556 background=background,
557 foreground=foreground,
558 black=colors[0],
559 red=colors[1],
560 green=colors[2],
561 yellow=colors[3],
562 blue=colors[4],
563 magenta=colors[5],
564 cyan=colors[6],
565 white=colors[7],
566 lightness=lightness,
567 )
569 except Exception: # pragma: no cover
570 return None
573def _query_term(ostream: _t.TextIO, istream: _t.TextIO, query: str) -> str | None:
574 try:
575 with _enter_raw_mode(ostream, istream):
576 # Lock the keyboard.
577 ostream.write("\x1b[2h")
578 ostream.flush()
579 _flush_input_buffer(ostream, istream)
581 # It is important that we unlock keyboard before exiting `cbreak`,
582 # hence the nested `try`.
583 try:
584 # Append a DA1 query, as virtually all terminals support it.
585 ostream.write(query + "\x1b[c")
586 ostream.flush()
588 buf = _read_keycode(ostream, istream)
589 if not buf.startswith("\x1b"):
590 yuio._logger.warning("_query_term invalid response: %r", buf)
591 return None
593 # Read till we find a DA1 response.
594 while not re.search(r"\x1b\[\?.*?c", buf):
595 buf += _read_keycode(ostream, istream)
597 return buf[: buf.index("\x1b[?")]
598 finally:
599 _flush_input_buffer(ostream, istream)
601 # Release the keyboard.
602 ostream.write("\x1b[2i")
603 ostream.flush()
604 except Exception: # pragma: no cover
605 yuio._logger.warning("_query_term error", exc_info=True)
606 return None
609def _is_tty(stream: _t.TextIO | None) -> bool:
610 try:
611 return stream is not None and stream.isatty()
612 except Exception: # pragma: no cover
613 return False
616if os.name == "posix":
618 def _is_foreground(stream: _t.TextIO | None) -> bool:
619 try:
620 return stream is not None and os.getpgrp() == os.tcgetpgrp(stream.fileno())
621 except Exception: # pragma: no cover
622 return False
624elif os.name == "nt":
626 def _is_foreground(stream: _t.TextIO | None) -> bool:
627 return True
629else:
631 def _is_foreground(stream: _t.TextIO | None) -> bool:
632 return False
635def _is_interactive_input(stream: _t.TextIO | None) -> bool:
636 try:
637 return stream is not None and _is_tty(stream) and stream.readable()
638 except Exception: # pragma: no cover
639 return False
642def _is_interactive_output(stream: _t.TextIO | None) -> bool:
643 try:
644 return stream is not None and _is_tty(stream) and stream.writable()
645 except Exception: # pragma: no cover
646 return False
649# Platform-specific code for working with terminals.
650if os.name == "posix":
651 import select
652 import termios
653 import tty
655 @contextlib.contextmanager
656 def _enter_raw_mode(
657 ostream: _t.TextIO,
658 istream: _t.TextIO,
659 bracketed_paste: bool = False,
660 modify_keyboard: bool = False,
661 ):
662 prev_mode = termios.tcgetattr(istream)
663 tty.setcbreak(istream, termios.TCSAFLUSH)
665 prologue = []
666 if bracketed_paste:
667 prologue.append("\x1b[?2004h")
668 if modify_keyboard:
669 prologue.append("\033[>4;2m")
670 if prologue:
671 ostream.write("".join(prologue))
672 ostream.flush()
674 try:
675 yield
676 finally:
677 epilogue = []
678 if bracketed_paste:
679 epilogue.append("\x1b[?2004l")
680 if modify_keyboard:
681 epilogue.append("\033[>4m")
682 if epilogue:
683 ostream.write("".join(epilogue))
684 ostream.flush()
685 termios.tcsetattr(istream, termios.TCSAFLUSH, prev_mode)
687 def _read_keycode(ostream: _t.TextIO, istream: _t.TextIO) -> str:
688 key = os.read(istream.fileno(), 128)
689 while bool(select.select([istream], [], [], 0)[0]):
690 key += os.read(istream.fileno(), 128)
692 return key.decode(istream.encoding, errors="replace")
694 def _flush_input_buffer(ostream: _t.TextIO, istream: _t.TextIO):
695 pass
697 def _enable_vt_processing(ostream: _t.TextIO, istream: _t.TextIO) -> bool:
698 return False # This is a windows functionality
700elif os.name == "nt":
701 import ctypes
702 import ctypes.wintypes
703 import msvcrt
705 _FlushConsoleInputBuffer = ctypes.windll.kernel32.FlushConsoleInputBuffer
706 _FlushConsoleInputBuffer.argtypes = [ctypes.wintypes.HANDLE]
707 _FlushConsoleInputBuffer.restype = ctypes.wintypes.BOOL
709 _GetConsoleMode = ctypes.windll.kernel32.GetConsoleMode
710 _GetConsoleMode.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.LPDWORD]
711 _GetConsoleMode.restype = ctypes.wintypes.BOOL
713 _SetConsoleMode = ctypes.windll.kernel32.SetConsoleMode
714 _SetConsoleMode.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD]
715 _SetConsoleMode.restype = ctypes.wintypes.BOOL
717 _ReadConsoleW = ctypes.windll.kernel32.ReadConsoleW
718 _ReadConsoleW.argtypes = [
719 ctypes.wintypes.HANDLE,
720 ctypes.wintypes.LPVOID,
721 ctypes.wintypes.DWORD,
722 ctypes.wintypes.LPDWORD,
723 ctypes.wintypes.LPVOID,
724 ]
725 _ReadConsoleW.restype = ctypes.wintypes.BOOL
727 _ENABLE_PROCESSED_OUTPUT = 0x0001
728 _ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
729 _ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
730 _ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
732 if sys.__stdin__ is not None: # TODO: don't rely on sys.__stdin__?
733 _ISTREAM_HANDLE = msvcrt.get_osfhandle(sys.__stdin__.fileno())
734 else:
735 _ISTREAM_HANDLE = None
737 @contextlib.contextmanager
738 def _enter_raw_mode(
739 ostream: _t.TextIO,
740 istream: _t.TextIO,
741 bracketed_paste: bool = False,
742 modify_keyboard: bool = False,
743 ):
744 assert _ISTREAM_HANDLE is not None
746 mode = ctypes.wintypes.DWORD()
747 success = _GetConsoleMode(_ISTREAM_HANDLE, ctypes.byref(mode))
748 if not success:
749 raise ctypes.WinError()
750 success = _SetConsoleMode(_ISTREAM_HANDLE, _ENABLE_VIRTUAL_TERMINAL_INPUT)
751 if not success:
752 raise ctypes.WinError()
754 try:
755 yield
756 finally:
757 success = _SetConsoleMode(_ISTREAM_HANDLE, mode)
758 if not success:
759 raise ctypes.WinError()
761 def _read_keycode(ostream: _t.TextIO, istream: _t.TextIO) -> str:
762 assert _ISTREAM_HANDLE is not None
764 CHAR16 = ctypes.wintypes.WCHAR * 16
766 n_read = ctypes.wintypes.DWORD()
767 buffer = CHAR16()
769 success = _ReadConsoleW(
770 _ISTREAM_HANDLE,
771 ctypes.byref(buffer),
772 16,
773 ctypes.byref(n_read),
774 0,
775 )
776 if not success:
777 raise ctypes.WinError()
779 return buffer.value
781 def _flush_input_buffer(ostream: _t.TextIO, istream: _t.TextIO):
782 assert _ISTREAM_HANDLE is not None
784 success = _FlushConsoleInputBuffer(_ISTREAM_HANDLE)
785 if not success:
786 raise ctypes.WinError()
788 def _enable_vt_processing(ostream: _t.TextIO, istream: _t.TextIO) -> bool:
789 try:
790 version = sys.getwindowsversion()
791 if version.major < 10 or version.build < 14931:
792 return False
794 handle = msvcrt.get_osfhandle(ostream.fileno())
795 return bool(
796 _SetConsoleMode(
797 handle,
798 _ENABLE_PROCESSED_OUTPUT
799 | _ENABLE_WRAP_AT_EOL_OUTPUT
800 | _ENABLE_VIRTUAL_TERMINAL_PROCESSING,
801 )
802 )
803 except Exception: # pragma: no cover
804 return False
806else:
808 @contextlib.contextmanager
809 def _enter_raw_mode(
810 ostream: _t.TextIO,
811 istream: _t.TextIO,
812 bracketed_paste: bool = False,
813 modify_keyboard: bool = False,
814 ):
815 raise OSError("not supported")
816 yield
818 def _read_keycode(ostream: _t.TextIO, istream: _t.TextIO) -> str:
819 raise OSError("not supported")
821 def _flush_input_buffer(ostream: _t.TextIO, istream: _t.TextIO):
822 raise OSError("not supported")
824 def _enable_vt_processing(ostream: _t.TextIO, istream: _t.TextIO) -> bool:
825 raise OSError("not supported")