Skip to content

Commit 8af4d76

Browse files
authored
Merge pull request #396 from networktocode/release-3.0.2
Release v3.0.2
2 parents bcad7f7 + 07e1fb1 commit 8af4d76

8 files changed

Lines changed: 389 additions & 51 deletions

File tree

docs/admin/release_notes/version_3.0.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ This document describes all new features and changes in the release. The format
77
- Pyntc now requires the `PYNTC_LOG_FILE` environment variable to output logging to a file. The new default behavior is to only log to stderr.
88

99
<!-- towncrier release notes start -->
10+
## [v3.0.2 (2026-05-28)](https://github.com/networktocode/pyntc/releases/tag/v3.0.2)
11+
12+
### Added
13+
14+
- [#395](https://github.com/networktocode/pyntc/issues/395) - Added an ``install_mode`` property to ``BaseDevice`` (abstract) and ``IOSDevice``; the IOS implementation returns ``True`` when the device boots from ``packages.conf``.
15+
16+
### Deprecated
17+
18+
- [#395](https://github.com/networktocode/pyntc/issues/395) - Deprecated the ``install_mode`` argument to ``IOSDevice.install_os``; install mode is now derived from the device's ``boot_options`` via the new ``install_mode`` property and will be removed in a future release.
19+
20+
### Fixed
21+
22+
- [#394](https://github.com/networktocode/pyntc/issues/394) - Fixed Junos reboots not being detected when waiting for the device to reload.
23+
- [#394](https://github.com/networktocode/pyntc/issues/394) - Increased the default Junos reboot wait timeout from 1 hour to 2 hours.
1024

1125
## [v3.0.1 (2026-05-26)](https://github.com/networktocode/pyntc/releases/tag/v3.0.1)
1226

pyntc/devices/base_device.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,20 @@ def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs):
450450
"""
451451
raise NotImplementedError
452452

453+
@property
454+
def install_mode(self):
455+
"""Indicate whether the device is operating in install mode.
456+
457+
Drivers override this to derive the value from the device's current boot
458+
configuration. Used by ``install_os`` to choose between install-mode and
459+
legacy upgrade procedures.
460+
461+
Returns:
462+
(bool): True when the device boots from an install-mode bundle
463+
(e.g., ``packages.conf`` on IOS-XE), False otherwise.
464+
"""
465+
raise NotImplementedError
466+
453467
def install_os(self, image_name, reboot=True, **vendor_specifics):
454468
"""Install the OS from specified image_name.
455469

pyntc/devices/ios_device.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import re
55
import time
6+
import warnings
67

78
from netmiko import ConnectHandler, FileTransfer
89
from netmiko.exceptions import ReadTimeout
@@ -319,6 +320,17 @@ def boot_options(self):
319320
log.debug("Host %s: the boot options are {dict(sys=boot_image)}", self.host)
320321
return {"sys": boot_image}
321322

323+
@property
324+
def install_mode(self):
325+
"""Return whether the device is currently booted in install mode.
326+
327+
Returns:
328+
(bool): True when the current boot image equals
329+
:data:`INSTALL_MODE_FILE_NAME` (i.e., ``packages.conf``),
330+
False otherwise.
331+
"""
332+
return self.boot_options.get("sys") == INSTALL_MODE_FILE_NAME
333+
322334
def checkpoint(self, checkpoint_file):
323335
"""Create checkpoint file.
324336
@@ -877,13 +889,28 @@ def file_copy_remote_exists(self, src, dest=None, file_system=None):
877889
log.debug("Host %s: File %s does not already exist on remote.", self.host, src)
878890
return False
879891

880-
def install_os(self, image_name, reboot=True, install_mode=False, read_timeout=2000, **vendor_specifics):
892+
def _resolve_install_mode(self, install_mode):
893+
"""Return the effective install_mode flag, warning if the caller passed it explicitly."""
894+
if install_mode is None:
895+
return self.install_mode
896+
warnings.warn(
897+
"The install_mode argument to install_os is deprecated; install mode is now "
898+
"derived from the device's boot_options via the install_mode property.",
899+
DeprecationWarning,
900+
)
901+
return install_mode
902+
903+
def install_os(self, image_name, reboot=True, install_mode=None, read_timeout=2000, **vendor_specifics):
881904
"""Installs the prescribed Network OS, which must be present before issuing this command.
882905
883906
Args:
884907
image_name (str): Name of the IOS image to boot into
885908
reboot (bool): Whether to reboot the device after setting the boot options. Defaults to true.
886-
install_mode (bool, optional): Uses newer install method on devices. Defaults to False.
909+
install_mode (bool, optional): **Deprecated.** Whether to use the newer install-mode
910+
upgrade procedure. When omitted (the default), the value is derived from
911+
:attr:`install_mode`, which reads the device's current boot configuration.
912+
Passing the argument explicitly still works but emits a ``DeprecationWarning``
913+
and will be removed in a future release.
887914
read_timeout (int, optional): Netmiko timeout when waiting for device prompt. Default 2000.
888915
vendor_specifics (dict, optional): Vendor specific arguments to pass to the install command.
889916
@@ -893,14 +920,15 @@ def install_os(self, image_name, reboot=True, install_mode=False, read_timeout=2
893920
Returns:
894921
(bool): False if no install is needed, true if the install completes successfully
895922
"""
923+
use_install_mode = self._resolve_install_mode(install_mode)
896924
timeout = vendor_specifics.get("timeout", 3600)
897925
if not self._image_booted(image_name):
898-
if install_mode and not reboot:
926+
if use_install_mode and not reboot:
899927
raise ValueError(
900928
"IOS devices automatically reboot after installation when using install mode but the reboot argument was set to false."
901929
)
902930

903-
if install_mode:
931+
if use_install_mode:
904932
# Change boot statement to be boot system <flash>:packages.conf
905933
self.set_boot_options(INSTALL_MODE_FILE_NAME, **vendor_specifics)
906934

@@ -942,7 +970,7 @@ def install_os(self, image_name, reboot=True, install_mode=False, read_timeout=2
942970
self._wait_for_device_reboot(timeout=timeout)
943971

944972
# Set FastCLI back to originally set when using install mode
945-
if install_mode:
973+
if use_install_mode:
946974
image_name = INSTALL_MODE_FILE_NAME
947975
# Verify the OS level
948976
if not self._image_booted(image_name):

pyntc/devices/jnpr_device.py

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -228,18 +228,53 @@ def _uptime_to_string(self, uptime_full_string):
228228
days, hours, minutes, seconds = self._uptime_components(uptime_full_string)
229229
return f"{days:02d}:{hours:02d}:{minutes:02d}:{seconds:02d}"
230230

231-
def _wait_for_device_reboot(self, timeout=3600):
231+
def _wait_for_device_reboot(self, original_uptime, timeout=7200):
232+
"""Block until the device reboots and accepts a fresh connection.
233+
234+
Drops the existing NETCONF session and polls for the device to come back.
235+
The reboot is considered complete when a new connection succeeds and the
236+
device reports an uptime lower than ``original_uptime`` (i.e., it has
237+
booted since the reboot was issued).
238+
239+
The pre-reboot session must be discarded first: once the device restarts,
240+
PyEZ still reports it as connected even though the transport is dead, so it
241+
is closed here to force each probe to establish a fresh connection.
242+
243+
Args:
244+
original_uptime (int): Device uptime in seconds captured before the reboot.
245+
timeout (int, optional): Max seconds to wait for the device to return. Defaults to 2 hours.
246+
"""
232247
start = time.time()
233-
disconnected = False
248+
249+
# Drop the pre-reboot NETCONF session so subsequent probes can't read from
250+
# a stale connection PyEZ still reports as connected.
251+
try:
252+
self.close()
253+
except Exception as close_exc: # pylint: disable=broad-exception-caught
254+
log.debug("Host %s: Pre-reboot disconnect raised %s (ignored).", self.host, close_exc)
255+
234256
while time.time() - start < timeout:
235-
if disconnected:
236-
try:
237-
self.open()
257+
try:
258+
self.open()
259+
self._uptime = None
260+
current_uptime = self.uptime
261+
if current_uptime is not None and current_uptime < original_uptime:
262+
log.info(
263+
"Host %s: Device rebooted (uptime %ss < pre-reboot %ss).",
264+
self.host,
265+
current_uptime,
266+
original_uptime,
267+
)
238268
return
239-
except: # noqa E722 # nosec # pylint: disable=bare-except
240-
pass
241-
elif not self.connected:
242-
disconnected = True
269+
log.debug(
270+
"Host %s: Reachable but uptime %ss >= pre-reboot %ss; still waiting.",
271+
self.host,
272+
current_uptime,
273+
original_uptime,
274+
)
275+
except Exception as exc: # pylint: disable=broad-exception-caught
276+
log.debug("Host %s: Reboot probe failed (%s); will retry.", self.host, exc)
277+
self.native.connected = False
243278
time.sleep(10)
244279

245280
raise RebootTimeoutError(hostname=self.hostname, wait_time=timeout)
@@ -318,12 +353,14 @@ def uptime(self):
318353
Returns:
319354
(int): Device uptime in seconds.
320355
"""
321-
try:
322-
native_uptime_string = self.native.facts["RE0"]["up_time"]
323-
except (AttributeError, TypeError):
324-
native_uptime_string = None
325-
326356
if self._uptime is None:
357+
try:
358+
# Bust PyEZ's cached facts so a cold cache always reflects the live device.
359+
self.native.facts_refresh(keys="RE0")
360+
native_uptime_string = self.native.facts["RE0"]["up_time"]
361+
except (AttributeError, TypeError, KeyError):
362+
native_uptime_string = None
363+
327364
if native_uptime_string is not None:
328365
self._uptime = self._uptime_to_seconds(native_uptime_string)
329366

@@ -337,13 +374,16 @@ def uptime_string(self):
337374
Returns:
338375
(str): Device uptime.
339376
"""
340-
try:
341-
native_uptime_string = self.native.facts["RE0"]["up_time"]
342-
except (AttributeError, TypeError):
343-
native_uptime_string = None
344-
345377
if self._uptime_string is None:
346-
self._uptime_string = self._uptime_to_string(native_uptime_string)
378+
try:
379+
# Bust PyEZ's cached facts so a cold cache always reflects the live device.
380+
self.native.facts_refresh(keys="RE0")
381+
native_uptime_string = self.native.facts["RE0"]["up_time"]
382+
except (AttributeError, TypeError, KeyError):
383+
native_uptime_string = None
384+
385+
if native_uptime_string is not None:
386+
self._uptime_string = self._uptime_to_string(native_uptime_string)
347387

348388
return self._uptime_string
349389

@@ -505,13 +545,13 @@ def open(self):
505545
if not self.connected:
506546
self.native.open()
507547

508-
def reboot(self, wait_for_reload=False, timeout=3600, confirm=None):
548+
def reboot(self, wait_for_reload=False, timeout=7200, confirm=None):
509549
"""
510550
Reload the controller or controller pair.
511551
512552
Args:
513553
wait_for_reload (bool): Whether the reboot method should wait for the device to come back up before returning. Defaults to False.
514-
timeout (int, optional): Time in seconds to wait for the device to return after reboot. Defaults to 1 hour.
554+
timeout (int, optional): Time in seconds to wait for the device to return after reboot. Defaults to 2 hours.
515555
confirm (None): Not used. Deprecated since v0.17.0.
516556
517557
Example:
@@ -522,9 +562,16 @@ def reboot(self, wait_for_reload=False, timeout=3600, confirm=None):
522562
if confirm is not None:
523563
warnings.warn("Passing 'confirm' to reboot method is deprecated.", DeprecationWarning)
524564

565+
self._uptime = None
566+
original_uptime = self.uptime
567+
if original_uptime is None:
568+
raise CommandError(
569+
command="reboot",
570+
message="Could not determine pre-reboot uptime; refusing to wait for reload.",
571+
)
525572
self.sw.reboot(in_min=0)
526573
if wait_for_reload:
527-
self._wait_for_device_reboot(timeout=timeout)
574+
self._wait_for_device_reboot(original_uptime, timeout=timeout)
528575

529576
def rollback(self, filename):
530577
"""Rollback to a specific configuration file.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pyntc"
3-
version = "3.0.1"
3+
version = "3.0.2"
44
description = "Python library focused on tasks related to device level and OS management."
55
authors = ["Network to Code, LLC <opensource@networktocode.com>"]
66
readme = "README.md"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Tests for the abstract :class:`pyntc.devices.base_device.BaseDevice` contract."""
2+
3+
import pytest
4+
5+
from pyntc.devices.base_device import BaseDevice
6+
7+
8+
@pytest.fixture
9+
def base_device():
10+
return BaseDevice(host="host", username="user", password="pass")
11+
12+
13+
def test_install_mode_raises_not_implemented(base_device):
14+
with pytest.raises(NotImplementedError):
15+
_ = base_device.install_mode

0 commit comments

Comments
 (0)