From 42e93f02dd46ad31be6d44f51db5631c6d6b0a1d Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 22:46:36 +0200 Subject: [PATCH] refactor: prepare the interactive shell in its own module The prompt-hook step (#41) pushed linux.py past its line count in the quality baseline. The three steps that make netmiko's fresh shell fit to read - history off, prompt hooks off, no line wrap - and the prompt pattern move to napalm_linux/_shell.py, under strict mypy. linux.py shrinks below where it was; the baseline follows. --- napalm_linux/_shell.py | 54 +++++++++++++++++++++++++ napalm_linux/linux.py | 45 ++------------------- tests/quality_baseline.json | 2 +- tests/test_sudo_off_the_command_line.py | 2 +- tests/test_systemd_osc_context.py | 11 ++--- 5 files changed, 66 insertions(+), 48 deletions(-) create mode 100644 napalm_linux/_shell.py diff --git a/napalm_linux/_shell.py b/napalm_linux/_shell.py new file mode 100644 index 0000000..a780953 --- /dev/null +++ b/napalm_linux/_shell.py @@ -0,0 +1,54 @@ +"""The interactive shell netmiko opens, made fit to be read before the first command.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +#: The end of a command's output when nothing better is known: a line that looks +#: like a shell prompt. +PROMPT_RE = r"[#$\>]\s*$" + +#: Sent before anything else in the interactive session (NetOrk/netork#1039): +#: the shell writes no history file and records nothing more. The leading space +#: keeps this line itself out of a history under ``HISTCONTROL=ignorespace``; +#: a shell without ``set -o history`` (dash) only complains, to /dev/null. +HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true" + +#: Sent right after ``HISTORY_OFF`` (#41): systemd 258's +#: ``/etc/profile.d/80-systemd-osc-context.sh`` wraps every command in OSC 3008 +#: sequences from ``PS0`` and ``PROMPT_COMMAND``, each with a fresh UUID. It appends +#: to ``PROMPT_COMMAND`` as an array, so only ``unset`` removes its hook. +PROMPT_HOOKS_OFF = " unset PS0 PROMPT_COMMAND; true" + +#: Keeps the PTY from wrapping long output lines (e.g. docker JSON). +NO_LINE_WRAP = "stty cols 10000 2>/dev/null || true" + + +def prepare_shell(device: Any, hostname: str) -> None: + """Make netmiko's fresh shell *device* fit to read, one step at a time. + + A step the shell refuses is logged and skipped: none of them is worth + failing the connection for. + """ + # No history before anything else is typed (NetOrk/netork#1039). + try: + device.send_command(HISTORY_OFF, expect_string=PROMPT_RE) + except Exception: + logger.debug("Could not switch the shell history off on %s", hostname, exc_info=True) + + # No systemd OSC 3008 context around every command (#41). netmiko learned + # the prompt with those sequences in front, and a prompt carrying a fresh + # UUID never matches again, so it reads the prompt anew once they are gone. + try: + device.send_command(PROMPT_HOOKS_OFF, expect_string=PROMPT_RE) + device.set_base_prompt() + except Exception: + logger.debug("Could not switch the prompt hooks off on %s", hostname, exc_info=True) + + try: + device.send_command(NO_LINE_WRAP, expect_string=PROMPT_RE) + except Exception: + logger.debug("%s: ignored", hostname, exc_info=True) diff --git a/napalm_linux/linux.py b/napalm_linux/linux.py index e383d36..ba781c2 100644 --- a/napalm_linux/linux.py +++ b/napalm_linux/linux.py @@ -74,6 +74,9 @@ from netmiko.exceptions import ( NetmikoTimeoutException, ) +from napalm_linux._shell import PROMPT_RE as _PROMPT_RE +from napalm_linux._shell import prepare_shell + logger = logging.getLogger("napalm_linux") # Package managers in detection order @@ -86,9 +89,6 @@ _RC_MARKER = "__NETORK_RC=" _RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE) -#: The end of a command's output when nothing better is known: a line that looks -#: like a shell prompt. -_PROMPT_RE = r"[#$\>]\s*$" #: A command ending in ``echo __NAME=$?`` reports its exit status on a line of its #: own: ``_RC_MARKER`` here, ``__APT_RC=`` and ``__SVC_RC=`` in napalm-device-types. _STATUS_ECHO_RE = re.compile(r"echo\s+(__[A-Z_]+=)\$\?") @@ -159,18 +159,6 @@ _PRIVILEGED_STDIN = ( "mktemp, cat) and have root install that file." ) -#: Sent before anything else in the interactive session (NetOrk/netork#1039): -#: the shell writes no history file and records nothing more. The leading space -#: keeps this line itself out of a history under ``HISTCONTROL=ignorespace``; -#: a shell without ``set -o history`` (dash) only complains, to /dev/null. -_HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true" - -#: Sent right after ``_HISTORY_OFF`` (#41): systemd 258's -#: ``/etc/profile.d/80-systemd-osc-context.sh`` wraps every command in OSC 3008 -#: sequences from ``PS0`` and ``PROMPT_COMMAND``, each with a fresh UUID. It appends -#: to ``PROMPT_COMMAND`` as an array, so only ``unset`` removes its hook. -_PROMPT_HOOKS_OFF = " unset PS0 PROMPT_COMMAND; true" - #: First in every script ``_sudo`` runs: a sudo that does not prompt (NOPASSWD, #: a cached timestamp) leaves the password on stdin, and nothing may read it. _NO_STDIN = "exec /dev/null || true", expect_string=r"[#$>]\s*$" - ) - except Exception: - logger.debug("%s: ignored", self.hostname, exc_info=True) + prepare_shell(self._device, self.hostname) self._pkg_manager = self._forced_pkg_manager or self._detect_pkg_manager() logger.debug("Connected to %s, pkg_manager=%s", self.hostname, self._pkg_manager) diff --git a/tests/quality_baseline.json b/tests/quality_baseline.json index 3b05b92..cc50fe9 100644 --- a/tests/quality_baseline.json +++ b/tests/quality_baseline.json @@ -22,7 +22,7 @@ "napalm_linux.linux" ], "python_file_lines": { - "napalm_linux/linux.py": 2307 + "napalm_linux/linux.py": 2288 }, "untyped_public": [] } diff --git a/tests/test_sudo_off_the_command_line.py b/tests/test_sudo_off_the_command_line.py index 06aaeee..c45bc54 100644 --- a/tests/test_sudo_off_the_command_line.py +++ b/tests/test_sudo_off_the_command_line.py @@ -21,7 +21,7 @@ from unittest.mock import MagicMock, patch import pytest from napalm_linux import LinuxDriver -from napalm_linux.linux import _HISTORY_OFF +from napalm_linux._shell import HISTORY_OFF as _HISTORY_OFF _PW = "s3cr3t 'q' $x" diff --git a/tests/test_systemd_osc_context.py b/tests/test_systemd_osc_context.py index 0ed9bb3..dc6bb21 100644 --- a/tests/test_systemd_osc_context.py +++ b/tests/test_systemd_osc_context.py @@ -11,7 +11,8 @@ characters and netOrk could not store the poll. from unittest.mock import MagicMock, patch -from napalm_linux.linux import _HISTORY_OFF, _PROMPT_HOOKS_OFF, LinuxDriver +from napalm_linux._shell import HISTORY_OFF, PROMPT_HOOKS_OFF +from napalm_linux.linux import LinuxDriver #: What ``cat /etc/os-release | grep PRETTY_NAME ...`` returned through netmiko on #: an Ubuntu 26.04.1 host (2026-10-23), the prompt line already gone. @@ -39,13 +40,13 @@ class TestPromptHooksOff: _opened(device) sent = [c.args[0] for c in device.send_command.call_args_list] - assert sent[:2] == [_HISTORY_OFF, _PROMPT_HOOKS_OFF] + assert sent[:2] == [HISTORY_OFF, PROMPT_HOOKS_OFF] def test_it_unsets_both_hooks_including_an_array_prompt_command(self): # The script appends to PROMPT_COMMAND as an array; assigning the empty # string would only clear element 0 and leave systemd's hook running. - assert _PROMPT_HOOKS_OFF.startswith(" ") - assert "unset PS0 PROMPT_COMMAND" in _PROMPT_HOOKS_OFF + assert PROMPT_HOOKS_OFF.startswith(" ") + assert "unset PS0 PROMPT_COMMAND" in PROMPT_HOOKS_OFF def test_the_prompt_is_read_again_once_the_hooks_are_gone(self): # netmiko learned the prompt with the sequences in front of it; a prompt @@ -56,7 +57,7 @@ class TestPromptHooksOff: _opened(device) calls = [(c[0], c.args[0] if c.args else None) for c in device.mock_calls] - hooks_off = calls.index(("send_command", _PROMPT_HOOKS_OFF)) + hooks_off = calls.index(("send_command", PROMPT_HOOKS_OFF)) assert "set_base_prompt" in [name for name, _ in calls[hooks_off + 1 :]] def test_a_shell_that_refuses_it_does_not_stop_the_connection(self):