diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ebe7978..77d98af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: additional_dependencies: [ "numpy>=1.20", "asyncssh>=2.9", - "blessed>=1.37.0", + "blessed>=1.42.0", "types-setuptools", "pytest", ] diff --git a/README.md b/README.md index 9e1f29c..9a7eb50 100644 --- a/README.md +++ b/README.md @@ -182,30 +182,30 @@ The table below sums up my findings when I tried the most common terminal emulat | Ghostty | Excellent | 24-bit colors | Good | Yes | 60 FPS | | | Kitty | Excellent | 24-bit colors | Good | Yes | 60 FPS | | | foot | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| Alacritty | Excellent | 24-bit colors | Good | Yes | 60 FPS | | | Rio | Excellent | 24-bit colors | Good | Yes | 60 FPS | | +| Contour | Excellent | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support](https://github.com/contour-terminal/contour/releases) | +| Alacritty | Good | 24-bit colors | Good | No* | 60 FPS | *Fails kitty detection [due to reported bug](https://github.com/alacritty/alacritty/pull/8953) | | Konsole | Good | 24-bit colors | Good | No | 60 FPS | | | Gnome terminal | Good | 24-bit colors | Good | No | 60 FPS | | | Terminator | Good | 24-bit colors | Good | No | 60 FPS | | -| XTerm | Good | 24-bit colors | Good | No | 60 FPS | No resize shortcuts, launch as ``xterm -tn xterm-256color`` | +| XTerm | Good | 24-bit colors | Good | No | 60 FPS | Ctrl+Right click to resize, "Unreadable" locks up XTerm | | Rxvt | Good | 24-bit colors | Good | No | 60 FPS | No resize shortcuts | +| Terminology | Good | 24-bit colors | Light misalignments | No | 60 FPS | Font sizes under ~9pt create horizontal line artifacts | | Termit | Ok | 24-bit colors | Good | No | 60 FPS | No window title | | Mlterm | Ok | 24-bit colors | Light misalignments | No | 60 FPS | No resize shortcuts | -| Terminology | Ok | 24-bit colors | Possible misalignments | No | 30 FPS | Weird colors | -| Contour | Bad | 24-bit colors | Good | Broken | 60 FPS | [Bug (no release event!)](https://github.com/contour-terminal/contour/pull/1924) | About MacOS: | MacOS | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | |------------------|------------|---------------|---------------------------|-------------------------|-------------|--------------------------| | iTerm2 | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| Terminal | Bad | 24-bit colors | Bad--adjust font spacing! | No | 30 FPS | A bit jittery | +| Terminal.app | Bad | 24-bit colors | Bad--adjust font spacing! | No | 30 FPS | A bit jittery | About Windows: | Windows | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | |--------------------|------------|---------------|------------------------|-------------------------|-------------|--------------------------| -| Windows terminal | Good | 24-bit colors | Good | Coming Soon | 60 FPS | [Download Preview for kitty support)](https://github.com/microsoft/terminal/releases) | +| Windows terminal | Excellent | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support)](https://github.com/microsoft/terminal/releases) | | Cmder | Unplayable | 24-bit colors | Good | Yes | 2 FPS | No window title | | Terminus | Unplayable | 24-bit colors | Misalignments | No | 10 FPS | | | Command prompt | Bad | 24-bit colors | Good | No | 1 FPS | Slow/Unresponsive | diff --git a/gambaterm/colors.py b/gambaterm/colors.py index 6c88869..7f7529a 100644 --- a/gambaterm/colors.py +++ b/gambaterm/colors.py @@ -24,14 +24,23 @@ def report(self) -> str: """Return a human-readable report of the color mode.""" if self == ColorMode.COULD_NOT_DETECT: return "Could not detect color mode" + if self == ColorMode.HAS_24_BIT_COLOR: + return "True color" + return f"{self.number_of_colors} colors" + + @property + def number_of_colors(self) -> int: + """Return a human-readable report of the color mode.""" + if self == ColorMode.COULD_NOT_DETECT: + return 0 if self == ColorMode.HAS_2_BIT_COLOR: - return "4 colors" + return 4 if self == ColorMode.HAS_4_BIT_COLOR: - return "16 colors" + return 16 if self == ColorMode.HAS_8_BIT_COLOR: - return "256 colors" + return 256 if self == ColorMode.HAS_24_BIT_COLOR: - return "True color" + return 1 << 24 assert False diff --git a/gambaterm/keyboard_input.py b/gambaterm/keyboard_input.py index db717d9..d5eb5ce 100755 --- a/gambaterm/keyboard_input.py +++ b/gambaterm/keyboard_input.py @@ -22,7 +22,6 @@ Here is a list of terminals known to support this protocol: - kitty https://sw.kovidgoyal.net/kitty/ -- alacritty https://alacritty.org/ - ghostty https://ghostty.org/ - foot https://codeberg.org/dnkl/foot - iTerm2 https://iterm2.com/ @@ -134,11 +133,20 @@ def pop_keystrokes(self) -> list[Keystroke]: def is_kitty_keyboard_protocol_supported( term: Terminal, timeout: float | None = None ) -> bool: - """Check if the terminal supports the kitty keyboard protocol. - - Some terminals (e.g. last release of Contour) responds to the kitty keyboard query but ignore - the flags we set, so we verify that report_events is actually enabled after requesting it. - """ + """Check if the terminal supports the kitty keyboard protocol.""" + # Some terminals (eg. last release of Contour) responds to the kitty keyboard query but ignores + # the modes that we set, it is not fully implementing them. And so we verify that report_events + # is actually enabled after requesting it. + # + # Other terminals (eg. last release of Alacritty), *do* support the kitty keyboard protocol + # flags that set, but fail to accurately report their state! + # https://github.com/alacritty/alacritty/pull/8953 -- it's too bad alacritty also doesn't + # support XTVERSION either, or we could conditionally return True for a version range! + # + # In a sense, we tradeoff: "ensure contour is not wrongly detected" (report_events not + # implemented) for Alacritty is wrongly detected as missing support for kitty (report_events not + # reported due to bug). I hope that Alacritty will accept the reported bug and next release will + # be OK. state = term.get_kitty_keyboard_state(timeout=timeout) if state is None: return False diff --git a/gambaterm/remote_terminal.py b/gambaterm/remote_terminal.py index 7edc24c..25275e4 100644 --- a/gambaterm/remote_terminal.py +++ b/gambaterm/remote_terminal.py @@ -11,22 +11,16 @@ from blessed import Terminal as BlessedTerminal from blessed.terminal import WINSZ -# Python's curses.setupterm() can only be called once per process — subsequent -# calls with a different terminal type are silently ignored. Since the SSH -# server handles multiple concurrent connections in threads, all RemoteTerminal -# instances share whatever terminal type was initialized first by the local -# Terminal(). We hardcode 'xterm-256color' as the kind since: -# 1. It's universally compatible with modern terminals -# 2. We use standard VT100/ANSI escape codes directly, not terminfo caps -# 3. It avoids issues where the first client's TERM value differs from subsequent -REMOTE_TERMINAL_TYPE = "xterm-256color" - class RemoteTerminal(BlessedTerminal): """A blessed Terminal subclass for remote streams (SSH, telnet). Stubs raw/cbreak mode (the remote connection is already raw) and - overrides size detection to use values provided by the server. + overrides size detection to use server protocol-negotiated values. + + Callers should invoke ``get_xtgettcap()`` after initialization + to probe the terminal's true capabilities once the connection + is fully established. """ def __init__( @@ -35,18 +29,41 @@ def __init__( keyboard_fd: int, rows: int, columns: int, + kind: str | None = None, ) -> None: self._rows = rows self._columns = columns - super().__init__(kind=REMOTE_TERMINAL_TYPE, stream=stream, force_styling=True) - # Blessed only sets _keyboard_fd when stream is sys.__stdout__, so - # for remote pipes we must set it and initialize the decoder manually - self._keyboard_fd = keyboard_fd # type: ignore[assignment] + self._remote_keyboard_fd = keyboard_fd + super().__init__( + kind=kind, + stream=stream, + force_styling=True, + kind_fallback="xterm-256color", + ) + # wire `_keyboard_fd` and enable `_is_a_tty` *after* class initialization. + self._keyboard_fd = self._remote_keyboard_fd # type: ignore[assignment] + self._is_a_tty = True self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")() - @property - def is_a_tty(self) -> bool: - return True + def probe_xtgettcap(self, timeout: float = 1.0) -> None: + """ + Probe terminal capabilities via XTGETTCAP and apply results. + + This allows to improved 'number_of_colors' detection, and, to "overlay" capabilities not + found in jinxed terminfo database but detected by XTGETTCAP: 'blink', 'sitm', 'ritm', + 'cvvis', 'Smulx', 'Setulc', 'Ms', the same way that blessed.Terminal() would have but we + is_a_tty was detected False when we initialized it. + + This method is not called or used by gambaterm-ssh or gambaterm-telnet, because the above + capabilities are not used and kitty keyboard support pretty reliably suggests 24-bit color + support. + """ + self._xtgettcap_cache = self._Terminal__init__xtgettcap() # type: ignore[assignment] + self.number_of_colors = self._Terminal__init__color_capabilities() # type: ignore[assignment] + if self._xtgettcap_cache.supported and self.does_styling: + self._jinxed_term.overlay_capabilities( + **self._xtgettcap_cache.make_jinxed_capabilities() + ) @contextlib.contextmanager def raw(self) -> Generator[None, None, None]: diff --git a/gambaterm/run.py b/gambaterm/run.py index 4f8c0fd..b7d0e69 100644 --- a/gambaterm/run.py +++ b/gambaterm/run.py @@ -162,6 +162,7 @@ def run( height, width = new_height, new_width refx, refy = get_ref(width, height, console) color_mode = new_color_mode + term.number_of_colors = new_color_mode.number_of_colors last_frame.fill(0) # Render frame with synchronized output mode (DEC 2026) to prevent flickering diff --git a/gambaterm/ssh.py b/gambaterm/ssh.py index a392731..52c322e 100644 --- a/gambaterm/ssh.py +++ b/gambaterm/ssh.py @@ -152,6 +152,7 @@ async def ssh_process_handler(process: SSHServerProcess[str]) -> int: terminal_type, executor, ), + terminal_type=terminal_type, ) @@ -207,11 +208,16 @@ def ssh_terminal_handler( else: assert False - # Kitty keyboard protocol implies 24-bit color support + # It is possible, here, to probe XTGETTCAP which helps correct terminal.number_of_colors using + # 'RGB' and 'colors', and some special attributes like blink, underline et al., but since they + # are not used by gambaterm, it is not called unless we find better reason otherwise. + # terminal.probe_xtgettcap(timeout=1.0) + + # In practice kitty keyboard protocol pretty well implies 24-bit color support already, color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR print( - f"[Terminal Info] {username}: {terminal_type}, {input_source}, {terminal.width}x{terminal.height}" + f"[Terminal Info] {username}: term={terminal_type}, {input_source}, {terminal.width}x{terminal.height}" ) try: diff --git a/gambaterm/ssh_app_session.py b/gambaterm/ssh_app_session.py index 31d634d..f10469d 100644 --- a/gambaterm/ssh_app_session.py +++ b/gambaterm/ssh_app_session.py @@ -77,11 +77,19 @@ async def process_to_terminal( process: SSHServerProcess[str], executor: ThreadPoolExecutor, target: Callable[[RemoteTerminal], T], + terminal_type: str | None = None, ) -> T: """Create a blessed RemoteTerminal from an SSH process. Once the redirections are set up, I/O become synchronous, so we run the target function in a thread executor to avoid blocking the event loop + + :param process: SSHServerProcess string + :param executor: ThreadPoolExecutor for running the game thread + :param target: callable receiving the RemoteTerminal, run in executor + :param terminal_type: terminal type from SSH SendEnv/AcceptEnv, `TERM` value + :returns: return value of *target* + """ width, height, _, _ = process.get_terminal_size() if width == height == 0: @@ -94,6 +102,7 @@ def _target() -> T: keyboard_fd=keyboard_fd, rows=height, columns=width, + kind=terminal_type, ) with _bind_resize(process, ssh_term): return target(ssh_term) diff --git a/gambaterm/telnet.py b/gambaterm/telnet.py index 8b1f424..f603693 100644 --- a/gambaterm/telnet.py +++ b/gambaterm/telnet.py @@ -33,9 +33,7 @@ is_kitty_keyboard_protocol_supported, ) from .remote_terminal import RemoteTerminal -from .telnet_app_session import ( - telnet_to_terminal, -) +from .telnet_app_session import telnet_to_terminal def _save_dir_name(username: str | None) -> str: @@ -53,7 +51,6 @@ def thread_target( terminal: RemoteTerminal, console_callback: Callable[[], Console], app_config: AppConfig, - color_mode: ColorMode, username: str | None, ) -> int: """Run the emulator in a thread with the given RemoteTerminal.""" @@ -76,6 +73,14 @@ def thread_target( print(f"< User `{username}` did not support keyboard protocol") return 1 + # It is possible, here, to probe XTGETTCAP which helps correct terminal.number_of_colors using + # 'RGB' and 'colors', and some special attributes like blink, underline et al., but since they + # are not used by gambaterm, it is not called unless we find better reason otherwise. + # terminal.probe_xtgettcap(timeout=1.0) + + # In practice kitty keyboard protocol pretty well implies 24-bit color support already, + color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR + try: terminal.stream.write( terminal.enter_fullscreen + terminal.clear + terminal.hide_cursor @@ -90,6 +95,7 @@ def thread_target( color_mode=color_mode, break_after=app_config.break_after, speed=app_config.speed, + use_cpr_sync=app_config.cpr_sync, ) except (KeyboardInterrupt, EOFError): return 0 @@ -244,20 +250,13 @@ async def _telnet_shell( except (asyncio.TimeoutError, KeyError): pass - terminal_type = writer.get_extra_info("TERM") or "unknown" + terminal_type = writer.get_extra_info("TERM") or None username = writer.get_extra_info("USER") or None print( - f"> Telnet client connected ({peer_host}:{peer_port})" + f"> Telnet client connected ({peer_host}:{peer_port} term={terminal_type})" + (f" user={username}" if username else "") ) - if terminal_type == "unknown": - print("Warning: terminal type not negotiated, assuming xterm-256color.") - terminal_type = "xterm-256color" - - # Kitty keyboard protocol implies 24-bit color support - color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR - if idle_timeout is not None: stats_task = asyncio.create_task( _log_connection_stats(reader, writer, peer_host, peer_port, idle_timeout) @@ -267,10 +266,7 @@ async def _telnet_shell( cols = writer.get_extra_info("cols") or 80 rows = writer.get_extra_info("rows") or 24 - print( - f"[Terminal Info] {peer_host}: {terminal_type}, " - f"{color_mode.name}, {cols}x{rows}" - ) + print(f"[Terminal Info] {peer_host}: ttype={terminal_type}, {cols}x{rows}") try: # Copy namespace and set telnet-specific save directory @@ -289,13 +285,14 @@ async def _telnet_shell( config = AppConfig(**vars(namespace)) def target(term: RemoteTerminal) -> int: - return thread_target(term, console_callback, config, color_mode, username) + return thread_target(term, console_callback, config, username) return await telnet_to_terminal( reader, writer, executor, target, + terminal_type=terminal_type, ) finally: if stats_task is not None: diff --git a/gambaterm/telnet_app_session.py b/gambaterm/telnet_app_session.py index ba12d4d..4fe0d3e 100644 --- a/gambaterm/telnet_app_session.py +++ b/gambaterm/telnet_app_session.py @@ -103,15 +103,17 @@ async def telnet_to_terminal( writer: TelnetWriter, executor: ThreadPoolExecutor, target: Callable[[RemoteTerminal], T], + terminal_type: str | None = None, ) -> T: """Create a RemoteTerminal and run *target* in a thread executor. - Sets up a pipe for output forwarding with paced delivery at 60 pps. + Sets up a pipe for i/o forwarding :param writer: telnetlib3 writer :param executor: ThreadPoolExecutor for running the game thread :param target: callable receiving the RemoteTerminal, run in executor :param input_read_fd: read end of the input pipe (keyboard_fd for blessed) + :param terminal_type: negotiated terminal type from telnet TTYPE or NEW-ENVIRON TERM :returns: return value of *target* """ cols = writer.get_extra_info("cols") or 80 @@ -128,8 +130,8 @@ async def telnet_to_terminal( def _target() -> T: try: - # The output stream is created so that the remote terminal can be instanciated. - # However, it's not used in practice since the `run` function writes bytes directly to the file decriptor. + # The output stream is created so that the remote terminal can be instantiated. + # However, it's not used in practice since the `run` function writes bytes directly to the file descriptor. # Still, this context manager is responsible for closing the `output_write_fd` file descriptor. with open(output_write_fd, "w", newline="\r\n") as stream: telnet_term = RemoteTerminal( @@ -137,6 +139,7 @@ def _target() -> T: keyboard_fd=input_read_fd, rows=rows, columns=cols, + kind=terminal_type, ) with bind_resize_telnet(writer, telnet_term): return target(telnet_term) diff --git a/pyproject.toml b/pyproject.toml index c40b53f..bb6e7e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,12 +29,12 @@ classifiers = [ dependencies = [ "numpy>=2.0.0,<3.0.0", "asyncssh>=2.9,<3.0.0", - "blessed>=1.38.0,<2", + "blessed>=1.43.0,<2", "miniaudio>=1.2", "samplerate>=0.1.0,<0.2.0", "python-xlib; sys_platform == 'linux'", "pynput; sys_platform != 'linux'", - "telnetlib3>=4.0.1,<5", + "telnetlib3>=4.0.4,<5", ] [project.optional-dependencies] @@ -89,7 +89,7 @@ test-command = "gambaterm --help" [tool.cibuildwheel.macos] test-requires = "pytest" -test-command = "pytest {project}/tests -v -k 'test_gambaterm[non-interactive]'" +test-command = "pytest {project}/tests -v -k 'non-interactive'" [tool.cibuildwheel.linux] test-requires = "pytest" diff --git a/tests/test_gambaterm.py b/tests/test_gambaterm.py index 219c735..58c6f46 100644 --- a/tests/test_gambaterm.py +++ b/tests/test_gambaterm.py @@ -9,6 +9,14 @@ TEST_ROM = Path(__file__).parent / "test_rom.gb" +# Color argument variants for parametrization: +# "forced" -- explicit --color-mode 4, bypasses auto-detection +# "auto" -- no --color-mode, exercises detect_local_color_mode +COLOR_ARG_VARIANTS = ( + pytest.param("--color-mode 4", id="forced-color"), + pytest.param("", id="auto-color"), +) + @pytest.fixture def ssh_config(tmp_path: Path) -> Iterator[Path]: @@ -29,12 +37,17 @@ def gambaterm_config(tmp_path: Path) -> Iterator[Path]: del os.environ["GAMBATERM_CONFIG_DIR"] +@pytest.mark.parametrize("color_arg", COLOR_ARG_VARIANTS) @pytest.mark.parametrize( "interactive", (False, True), ids=("non-interactive", "interactive") ) -def test_gambaterm(interactive: bool) -> None: +def test_gambaterm(interactive: bool, color_arg: str) -> None: assert TEST_ROM.exists() - command = f"gambaterm {TEST_ROM} --break-after 10 --input-file /dev/null --disable-audio --color-mode 4" + command = ( + f"gambaterm {TEST_ROM} --break-after 10" + f" --input-file /dev/null --disable-audio" + + (f" {color_arg}" if color_arg else "") + ) result = run( f"script -e -q -c '{command}' /dev/null" if interactive else command, shell=True, @@ -49,9 +62,14 @@ def test_gambaterm(interactive: bool) -> None: assert "▀ ▄▄ ▀" in result.stdout -def test_gambaterm_ssh(ssh_config: Path, gambaterm_config: Path) -> None: +@pytest.mark.parametrize("color_arg", COLOR_ARG_VARIANTS) +def test_gambaterm_ssh( + ssh_config: Path, gambaterm_config: Path, color_arg: str +) -> None: assert TEST_ROM.exists() - command = f"gambaterm-ssh {TEST_ROM} --break-after 10 --input-file /dev/null --color-mode 4" + command = f"gambaterm-ssh {TEST_ROM} --break-after 10 --input-file /dev/null" + ( + f" {color_arg}" if color_arg else "" + ) env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" server = Popen( @@ -90,8 +108,7 @@ async def ssh_client() -> str: client_stdout = asyncio.run(ssh_client()) assert "| test_rom.gb |" in client_stdout - if sys.platform == "linux": - assert "▀ ▄▄ ▀" in client_stdout + assert "▀ ▄▄ ▀" in client_stdout finally: server.terminate() server.wait() @@ -101,11 +118,12 @@ async def ssh_client() -> str: server.stderr.close() -def test_gambaterm_telnet() -> None: +@pytest.mark.parametrize("color_arg", COLOR_ARG_VARIANTS) +def test_gambaterm_telnet(color_arg: str) -> None: assert TEST_ROM.exists() command = ( f"{sys.executable} -m gambaterm.telnet {TEST_ROM} --break-after 10" - f" --input-file /dev/null --color-mode 4" + f" --input-file /dev/null" + (f" {color_arg}" if color_arg else "") ) env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" @@ -148,8 +166,70 @@ async def telnet_client() -> str: result = asyncio.run(telnet_client()) assert "| test_rom.gb |" in result - if sys.platform == "linux": - assert "\u2580" in result or "\u2584" in result + assert "\u2580" in result or "\u2584" in result + finally: + server.terminate() + server.wait() + print(server.stdout.read(), end="", file=sys.stdout) + print(server.stderr.read(), end="", file=sys.stderr) + server.stdout.close() + server.stderr.close() + + +def test_gambaterm_telnet_unknown_term() -> None: + """Telnet client connects with an unknown terminal type. + + Exercises the ``kind=None`` fallback path in RemoteTerminal, where blessed's + ``__init_termcap_kind`` resolves the terminal type through protocol-negotiated TERM and + fallsback to ``kind_fallback='xterm-256color'``. + """ + assert TEST_ROM.exists() + command = ( + f"{sys.executable} -m gambaterm.telnet {TEST_ROM} --break-after 10" + f" --input-file /dev/null" + ) + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + server = Popen( + command.split(), stdout=PIPE, stderr=PIPE, bufsize=0, text=True, env=env + ) + assert server.stdout is not None + assert server.stderr is not None + try: + assert ( + server.stdout.readline() == "Running telnet server on 127.0.0.1:8023...\n" + ) + + async def telnet_client() -> str: + import telnetlib3 + + reader, writer = await telnetlib3.open_connection( + host="127.0.0.1", + port=8023, + encoding=False, + force_binary=True, + term="unknown", + cols=80, + rows=24, + ) + output = b"" + try: + while True: + chunk = await asyncio.wait_for(reader.read(65536), timeout=5) + if not chunk: + break + if isinstance(chunk, bytes): + output += chunk + except (asyncio.TimeoutError, EOFError): + pass + finally: + if not writer.is_closing(): + writer.close() + return output.decode("utf-8", errors="replace") + + result = asyncio.run(telnet_client()) + assert "| test_rom.gb |" in result + assert "\u2580" in result or "\u2584" in result finally: server.terminate() server.wait() diff --git a/uv.lock b/uv.lock index 0705e5d..e8e8706 100644 --- a/uv.lock +++ b/uv.lock @@ -30,15 +30,15 @@ wheels = [ [[package]] name = "blessed" -version = "1.38.0" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinxed", marker = "sys_platform == 'win32'" }, + { name = "jinxed" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/1f/f2535d0eb1fb8af7915f96b4d42810345c255bbbca39939a23e59c0695d8/blessed-1.38.0.tar.gz", hash = "sha256:89ce6ec6567f7aced0716b73577b7a1702eb23c667838bb46d7d9bd48c36d1b3", size = 14008103, upload-time = "2026-03-30T22:47:36.787Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/b0/7add3e9172d2f9f9ccf05d3bdc3868037e48d07101fce1970188d08b28c9/blessed-1.43.0.tar.gz", hash = "sha256:acf55b31a10bb53dac0f7b42899233814bc4c726e8a1550df684cd7e558eb09f", size = 14030733, upload-time = "2026-05-23T08:25:11.002Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/81/26113a258b8b4068a7ae528cb1c13f1af2acfa0702368312183013ddc4f4/blessed-1.38.0-py3-none-any.whl", hash = "sha256:905884ae650e41284fa4fd7d0c3eed5e5b4a42be8c2bfb24c90d79fbf26a1490", size = 121251, upload-time = "2026-03-30T22:47:34.138Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2a/a6acd38161c28f7606e9c8e90e0f3dcb8f0523ab1ea41d05282869cac7da/blessed-1.43.0-py3-none-any.whl", hash = "sha256:2b4bff47c6d08a5e5b7684f419ab6e5a00235e7c87d08d65242eb796af85153a", size = 129978, upload-time = "2026-05-23T08:25:08.554Z" }, ] [[package]] @@ -239,14 +239,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "asyncssh", specifier = ">=2.9,<3.0.0" }, - { name = "blessed", specifier = ">=1.38.0,<2" }, + { name = "blessed", specifier = ">=1.43.0,<2" }, { name = "miniaudio", specifier = ">=1.2" }, { name = "numpy", specifier = ">=2.0.0,<3.0.0" }, { name = "pygame", marker = "extra == 'controller-support'", specifier = ">=1.9.5,<2.0.0" }, { name = "pynput", marker = "sys_platform != 'linux'" }, { name = "python-xlib", marker = "sys_platform == 'linux'" }, { name = "samplerate", specifier = ">=0.1.0,<0.2.0" }, - { name = "telnetlib3", specifier = ">=4.0.1,<5" }, + { name = "telnetlib3", specifier = ">=4.0.4,<5" }, ] provides-extras = ["controller-support"] @@ -264,14 +264,14 @@ wheels = [ [[package]] name = "jinxed" -version = "1.4.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ansicon", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/e9/96633f12b6829eb1e91e70e5846704c0b1293ec47bd65a7b681e19c8eeff/jinxed-1.4.0.tar.gz", hash = "sha256:8f7801a10799de39e509eb5abc6d131ee169c1ce4fd5d568aa85b5f56ed58068", size = 37169, upload-time = "2026-03-26T01:49:38.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/eb/8821ce6e7386e96355f2c6be83944925b4a0870572896fd33a4e61b8aa5a/jinxed-2.0.0.tar.gz", hash = "sha256:64b960b8f8d9966e1b2e7cb57ea2b1aa0a8d7e68045b96dc7ef58201e43a1209", size = 127118, upload-time = "2026-05-08T21:25:25.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/b7/9ab2b79bcbcc53cf8772a19d26713dd9574d4d81ee4fea29678d8cadcec7/jinxed-1.4.0-py2.py3-none-any.whl", hash = "sha256:95876a8b270081b8e28a9bbcbabe4fa98327faa91102526f724ed1904f9a55ac", size = 34522, upload-time = "2026-03-26T01:49:36.762Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/b61668fd3b1e43b445979ec9a9e0af4781bf06884937d1e906f6a1be6dff/jinxed-2.0.0-py2.py3-none-any.whl", hash = "sha256:b3df1be5262a37145ef42875a8bbf918f1a563fbd035359650dd9fc0bb2b9294", size = 95364, upload-time = "2026-05-08T21:25:24.536Z" }, ] [[package]] @@ -670,15 +670,15 @@ wheels = [ [[package]] name = "telnetlib3" -version = "4.0.2" +version = "4.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blessed", marker = "sys_platform == 'win32'" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1f/a5de9f0f86976d299a439619417019a9674fa8662377659734dd6e067235/telnetlib3-4.0.2.tar.gz", hash = "sha256:76726d7a7a34163b5dbef461e9cbf8fb4bda8f4fe7fcb225f56cbb4dcf5fd16e", size = 355495, upload-time = "2026-04-10T19:16:46.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/a6/87fae7b6e1e602d4ff0f239a96d7b58b9a6b4a0ae59546790f99830673e3/telnetlib3-4.0.4.tar.gz", hash = "sha256:6ab9ce0b9e8663b002d7e10513deb0c17bddb982d520681e0788511d99695a7a", size = 352646, upload-time = "2026-05-23T20:24:37.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/0c/7f710109880afddcb407ce2dc21354a01c3332bc7f4494fd56214f067f9b/telnetlib3-4.0.2-py3-none-any.whl", hash = "sha256:7dfc9cf86a6ace90655689d4828aeda41c4c278ec8da1597756b4fd6df15d5d1", size = 361970, upload-time = "2026-04-10T19:16:45.302Z" }, + { url = "https://files.pythonhosted.org/packages/f9/99/b3352fc3708d101c77948df010d942be2770cdf7e5918f87ac0c1a4eca0d/telnetlib3-4.0.4-py3-none-any.whl", hash = "sha256:41fc6860faeba0f4b02a839f6c0e1a7cfb5ca5be17f2dda2be2f4377d1999aba", size = 359378, upload-time = "2026-05-23T20:24:36.233Z" }, ] [[package]] @@ -746,9 +746,9 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ]