refactor: prepare the interactive shell in its own module
driver / standard (pull_request) Successful in 40s
driver / lint (pull_request) Successful in 27s
driver / test (3.10) (pull_request) Successful in 3m57s
driver / test (3.11) (pull_request) Successful in 1m7s
driver / test (3.12) (pull_request) Successful in 1m9s
driver / types (pull_request) Successful in 19m18s
driver / lowest (pull_request) Successful in 43s
driver / security (pull_request) Successful in 1m3s
driver / build (pull_request) Successful in 58s
driver / test (3.13) (pull_request) Successful in 16m1s
CI / driver (pull_request) Successful in 23m9s
driver / publish (pull_request) Skipped

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.
This commit is contained in:
Christian Manivong committed 2026-10-23 22:46:36 +02:00
1 parent 4159612a33
commit 42e93f02dd
5 files changed
+66 -48

No files matched your search

+54
View File
@@ -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)
+4 -41
View File
@@ -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; "
@@ -419,32 +407,7 @@ class LinuxDriver(
raise ConnectionException(str(exc)) from exc
self._host_key.observe(self._device.remote_conn_pre.get_transport())
# No history before anything else is typed (NetOrk/netork#1039).
try:
self._device.send_command(_HISTORY_OFF, expect_string=_PROMPT_RE)
except Exception:
logger.debug(
"Could not switch the shell history off on %s", self.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:
self._device.send_command(_PROMPT_HOOKS_OFF, expect_string=_PROMPT_RE)
self._device.set_base_prompt()
except Exception:
logger.debug(
"Could not switch the prompt hooks off on %s", self.hostname, exc_info=True
)
# Prevent PTY from wrapping long output lines (e.g. docker JSON).
try:
self._device.send_command(
"stty cols 10000 2>/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)
+1 -1
View File
@@ -22,7 +22,7 @@
"napalm_linux.linux"
],
"python_file_lines": {
"napalm_linux/linux.py": 2307
"napalm_linux/linux.py": 2288
},
"untyped_public": []
}
+1 -1
View File
@@ -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"
+6 -5
View File
@@ -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):