Skip to content

Commit 5afcdb2

Browse files
authored
Merge pull request #378 from networktocode/release-v2.4.0
Release v2.4.0
2 parents 9934b2f + 477c3c6 commit 5afcdb2

46 files changed

Lines changed: 2880 additions & 483 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,6 @@ public
309309
/compose.yaml
310310
/dump.sql
311311
/pyntc/static/pyntc/docs
312+
313+
# Exception for pynxos/lib
314+
!/pyntc/devices/pynxos/lib/
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
2+
# v2.4 Release Notes
3+
4+
This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5+
6+
## Release Overview
7+
8+
- Add ability to check for sufficient free space before copying files to devices, with support for EOS, IOS, ASA, and JunOS platforms.
9+
- Added reboot flag to Device.install_os for supported platforms.
10+
11+
## [v2.4.0 (2026-04-29)](https://github.com/networktocode/pyntc/releases/tag/v2.4.0)
12+
13+
### Added
14+
15+
- [#370](https://github.com/networktocode/pyntc/issues/370) - Added a pre-transfer free-space check to EOS ``file_copy`` and ``remote_file_copy`` that raises ``NotEnoughFreeSpaceError`` when the target filesystem lacks room for the image.
16+
- [#370](https://github.com/networktocode/pyntc/issues/370) - Added ``file_size_unit`` (``bytes``, ``megabytes``, or ``gigabytes``; default ``bytes``) and a computed ``file_size_bytes`` to ``FileCopyModel`` so ``remote_file_copy`` can verify free space against a caller-supplied size; when ``file_size`` is omitted the pre-transfer check is skipped.
17+
- [#371](https://github.com/networktocode/pyntc/issues/371) - Added free space validation for file copy operations on IOS devices.
18+
- [#372](https://github.com/networktocode/pyntc/issues/372) - Added a pre-transfer free-space check to Cisco ASA ``file_copy`` and ``remote_file_copy`` that raises ``NotEnoughFreeSpaceError`` when the target filesystem lacks room for the image.
19+
- [#373](https://github.com/networktocode/pyntc/issues/373) - Added a pre-transfer free-space check to Juniper JunOS ``file_copy`` and ``remote_file_copy`` that raises ``NotEnoughFreeSpaceError`` when the target filesystem lacks room for the image.
20+
- [#375](https://github.com/networktocode/pyntc/issues/375) - Added free space validation for file copy operations on NXOS devices.
21+
- [#376](https://github.com/networktocode/pyntc/issues/376) - Added reboot flag to Device.install_os for supported platforms.
22+
- [#376](https://github.com/networktocode/pyntc/issues/376) - Vendored pynxos library and added reboot flag to Device.set_boot_options.
23+
24+
### Changed
25+
26+
- [#356](https://github.com/networktocode/pyntc/issues/356) - Bump dependencies

docs/generate_code_reference_pages.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44

55
import mkdocs_gen_files
66

7+
EXCLUDE_PATHS = {
8+
"pyntc/devices/pynxos", # Exclude vendored package pynxos
9+
}
10+
711
for file_path in Path("pyntc").rglob("*.py"):
12+
if any(str(file_path).startswith(excluded) for excluded in EXCLUDE_PATHS):
13+
continue
814
module_path = file_path.with_suffix("")
915
doc_path = file_path.with_suffix(".md")
1016
full_doc_path = Path("code-reference", doc_path)

docs/user/lib_getting_started.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,12 @@ from pyntc.utils.models import FileCopyModel
264264
... checksum='abc123def456',
265265
... hashing_algorithm='md5',
266266
... file_name='newconfig.cfg',
267-
vrf='Mgmt-vrf'
267+
... # file_size is optional. When supplied, remote_file_copy verifies
268+
... # the target device has room before starting the transfer. When
269+
... # omitted, the pre-transfer space check is skipped.
270+
... file_size=512,
271+
... file_size_unit='megabytes',
272+
... vrf='Mgmt-vrf',
268273
... )
269274
>>> for device in devices:
270275
... device.remote_file_copy(source_file)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ nav:
132132
- Uninstall: "admin/uninstall.md"
133133
- Release Notes:
134134
- "admin/release_notes/index.md"
135+
- v2.4: "admin/release_notes/version_2.4.md"
135136
- v2.3: "admin/release_notes/version_2.3.md"
136137
- v2.2: "admin/release_notes/version_2.2.md"
137138
- v2.1: "admin/release_notes/version_2.1.md"

poetry.lock

Lines changed: 237 additions & 279 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyntc/devices/aireos_device.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,12 +971,15 @@ def hostname(self):
971971
hostname_string = hostname.group(1)
972972
return hostname_string
973973

974-
def install_os(self, image_name, controller="both", save_config=True, disable_wlans=None, **vendor_specifics):
974+
def install_os(
975+
self, image_name, reboot=True, controller="both", save_config=True, disable_wlans=None, **vendor_specifics
976+
):
975977
"""
976978
Install an operating system on the controller.
977979
978980
Args:
979981
image_name (str): The version to install on the device.
982+
reboot (bool): Whether to reboot the device after setting the boot options. Defaults to true.
980983
controller (str): The controller(s) to reboot for install (only applies to HA device).
981984
save_config (bool): Whether the config should be saved to the device before reboot.
982985
disable_wlans (str|list): Which WLANs to disable/enable before/after upgrade. Default is None.
@@ -1014,6 +1017,9 @@ def install_os(self, image_name, controller="both", save_config=True, disable_wl
10141017
if not self._image_booted(image_name):
10151018
peer_redundancy = self.peer_redundancy_state
10161019
self.set_boot_options(image_name, **vendor_specifics)
1020+
if not reboot:
1021+
log.info("Host %s: OS image %s boot options set. Reboot the device to apply", self.host, image_name)
1022+
return True
10171023
if disable_wlans is not None:
10181024
self.disable_wlans(disable_wlans)
10191025
self.reboot(controller=controller, save_config=save_config)

pyntc/devices/asa_device.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,39 @@ def _get_file_system(self):
138138
log.debug("Host %s: File system %s.", self.host, file_system)
139139
return file_system
140140

141+
def _get_free_space(self, file_system=None): # pylint: disable=unused-argument
142+
"""Return free bytes on ``file_system`` as reported by ASA's ``dir`` output.
143+
144+
ASA exposes a single flash filesystem in practice and ``dir`` always prints
145+
a ``<total> bytes total (<free> bytes free...)`` trailer as the last line.
146+
Real platforms append an ``/<pct>% free`` suffix inside the parentheses
147+
(e.g., ``(3580170240 bytes free/86% free)``); older releases and some
148+
emulators omit it. The regex matches both shapes. The ``file_system``
149+
argument is accepted for API parity with other drivers but is otherwise
150+
unused.
151+
152+
Args:
153+
file_system (str, optional): Ignored; retained for BaseDevice API parity.
154+
155+
Returns:
156+
int: Free bytes available on the target filesystem.
157+
158+
Raises:
159+
CommandError: When the ``dir`` output does not contain a parseable
160+
``N bytes free`` trailer.
161+
"""
162+
raw_data = self.show("dir")
163+
# Examples seen in the wild:
164+
# 16777216 bytes total (1592488 bytes free)
165+
# 4118732800 bytes total (3580170240 bytes free/86% free)
166+
match = re.search(r"\((\d+)\s+bytes\s+free", raw_data)
167+
if match is None:
168+
log.error("Host %s: could not parse free space from 'dir' output.", self.host)
169+
raise CommandError(command="dir", message="Unable to parse free space from dir output.")
170+
free_bytes = int(match.group(1))
171+
log.debug("Host %s: %s bytes free on flash.", self.host, free_bytes)
172+
return free_bytes
173+
141174
def _get_ipv4_addresses(self, host: str) -> Dict[str, List[IPv4Address]]:
142175
"""
143176
Get IPv4 Addresses for ``host``.
@@ -555,6 +588,8 @@ def file_copy(
555588
556589
Raises:
557590
FileTransferError: When the ``src`` file is unable to transfer the file to any device.
591+
NotEnoughFreeSpaceError: When ``file_system`` has fewer free bytes than
592+
``src`` requires.
558593
559594
Example:
560595
>>> dev = ASADevice(**connection_args)
@@ -566,6 +601,8 @@ def file_copy(
566601
if file_system is None:
567602
file_system = self._get_file_system()
568603

604+
self._check_free_space(os.path.getsize(src), file_system=file_system)
605+
569606
# netmiko's enable_scp
570607
self.enable_scp()
571608
self._file_copy(src, dest, file_system)
@@ -648,12 +685,13 @@ def get_remote_checksum(self, filename, hashing_algorithm="md5", **kwargs: Any):
648685
log.error("Host %s: Unable to parse checksum for %s from output: %s", self.host, filename, result)
649686
raise CommandError(cmd, f"Unable to parse checksum for {filename}")
650687

651-
def install_os(self, image_name, **vendor_specifics):
688+
def install_os(self, image_name, reboot=True, **vendor_specifics):
652689
"""
653690
Install OS on device.
654691
655692
Args:
656693
image_name (str): Name of the image to be installed.
694+
reboot (bool): Whether to reboot the device after setting the boot options. Defaults to true.
657695
vendor_specifics (dict): Vendor specific arguments to pass to the install process.
658696
659697
Raises:
@@ -665,6 +703,9 @@ def install_os(self, image_name, **vendor_specifics):
665703
timeout = vendor_specifics.get("timeout", 3600)
666704
if not self._image_booted(image_name):
667705
self.set_boot_options(image_name, **vendor_specifics)
706+
if not reboot:
707+
log.info("Host %s: OS image %s boot options set. Reboot the device to apply", self.host, image_name)
708+
return True
668709
self.reboot()
669710
self._wait_for_device_reboot(timeout=timeout)
670711
if not self._image_booted(image_name):
@@ -1018,6 +1059,10 @@ def remote_file_copy(self, src: FileCopyModel = None, dest=None, **kwargs: Any):
10181059
Raises:
10191060
TypeError: If ``src`` is not a ``FileCopyModel`` instance.
10201061
FileTransferError: If the transfer fails or the file cannot be verified afterwards.
1062+
NotEnoughFreeSpaceError: If ``src.file_size_bytes`` is set and
1063+
``file_system`` has fewer free bytes than ``src.file_size_bytes``.
1064+
When ``file_size`` is omitted from ``src``, the pre-transfer space
1065+
check is skipped entirely.
10211066
"""
10221067
if not isinstance(src, FileCopyModel):
10231068
raise TypeError("src must be an instance of FileCopyModel")
@@ -1028,6 +1073,7 @@ def remote_file_copy(self, src: FileCopyModel = None, dest=None, **kwargs: Any):
10281073
dest = src.file_name
10291074

10301075
if not self.verify_file(src.checksum, dest, hashing_algorithm=src.hashing_algorithm, file_system=file_system):
1076+
self._pre_transfer_space_check(src, file_system=file_system)
10311077
current_prompt = self.native.find_prompt()
10321078
prompt_answers = {
10331079
r"Password": src.token or "",

pyntc/devices/base_device.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
import hashlib
44
import importlib
5+
import logging
56
import warnings
67

7-
from pyntc.errors import FeatureNotFoundError, NTCError
8+
from pyntc.errors import FeatureNotFoundError, NotEnoughFreeSpaceError, NTCError
89
from pyntc.utils.models import FileCopyModel
910

11+
log = logging.getLogger(__name__)
12+
1013

1114
def fix_docs(cls):
1215
"""Create docstring at runtime.
@@ -52,6 +55,90 @@ def __init__(self, host, username, password, device_type=None, **kwargs): # noq
5255
self._model = None
5356
self._vlans = None
5457

58+
def _check_free_space(self, required_bytes, file_system=None):
59+
"""Raise NotEnoughFreeSpaceError when the target filesystem lacks room for a transfer.
60+
61+
Drivers call this from ``file_copy`` and ``remote_file_copy`` before starting a
62+
transfer. The concrete per-platform probe is ``_get_free_space``; this helper
63+
centralises the comparison, logging, and error shape so every driver behaves the
64+
same way.
65+
66+
Args:
67+
required_bytes (int): Number of bytes the pending transfer needs.
68+
file_system (str, optional): Target filesystem passed through to
69+
``_get_free_space``. Drivers that auto-detect a filesystem should resolve
70+
it before calling.
71+
72+
Raises:
73+
NotEnoughFreeSpaceError: When the device reports fewer free bytes than
74+
``required_bytes``.
75+
"""
76+
available = self._get_free_space(file_system)
77+
log.debug(
78+
"Host %s: filesystem %s has %s bytes free; %s bytes required.",
79+
self.host,
80+
file_system,
81+
available,
82+
required_bytes,
83+
)
84+
if available < required_bytes:
85+
log.error(
86+
"Host %s: insufficient free space on %s (%s free, %s required).",
87+
self.host,
88+
file_system,
89+
available,
90+
required_bytes,
91+
)
92+
raise NotEnoughFreeSpaceError(
93+
hostname=self.host,
94+
required=required_bytes,
95+
available=available,
96+
file_system=file_system,
97+
)
98+
99+
def _get_free_space(self, file_system=None):
100+
"""Return the number of free bytes on ``file_system``.
101+
102+
Drivers override this to issue a platform-specific command (e.g., ``dir`` on
103+
EOS/IOS/NXOS, ``show system storage`` on JUNOS) and parse the result. Drivers
104+
whose platform exposes only one practical filesystem (e.g., EOS) may safely
105+
ignore ``file_system``.
106+
107+
Args:
108+
file_system (str, optional): The target filesystem to inspect. Drivers
109+
that support multiple filesystems should treat ``None`` as "auto-detect
110+
the default filesystem".
111+
112+
Returns:
113+
int: Free bytes available on ``file_system``.
114+
115+
Raises:
116+
NotImplementedError: When a driver has not yet implemented the probe.
117+
"""
118+
raise NotImplementedError(f"{self.__class__.__name__} does not implement _get_free_space")
119+
120+
def _pre_transfer_space_check(self, src, file_system=None):
121+
"""Run the free-space check if ``src.file_size_bytes`` is populated.
122+
123+
Drivers call this from ``remote_file_copy`` so the check is skipped
124+
(fail-open) when the caller omits ``FileCopyModel.file_size``; when set,
125+
``_check_free_space`` raises ``NotEnoughFreeSpaceError`` if the device
126+
lacks room. Lives on ``BaseDevice`` so every driver inherits the same
127+
shape without duplicating the if/else.
128+
129+
Args:
130+
src (FileCopyModel): The source specification for the pending transfer.
131+
file_system (str, optional): Target filesystem; passed through to
132+
``_check_free_space`` (and from there to ``_get_free_space``).
133+
"""
134+
if src.file_size_bytes is not None:
135+
self._check_free_space(src.file_size_bytes, file_system=file_system)
136+
else:
137+
log.debug(
138+
"Host %s: no file_size on FileCopyModel; skipping pre-transfer space check.",
139+
self.host,
140+
)
141+
55142
def _image_booted(self, image_name, **vendor_specifics):
56143
"""Determine if a particular image is serving as the active OS.
57144
@@ -363,11 +450,12 @@ def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs):
363450
"""
364451
raise NotImplementedError
365452

366-
def install_os(self, image_name, **vendor_specifics):
453+
def install_os(self, image_name, reboot=True, **vendor_specifics):
367454
"""Install the OS from specified image_name.
368455
369456
Args:
370457
image_name (str): The name of the image on the device to install.
458+
reboot (bool): Whether to reboot the device after setting the boot options. Defaults to true.
371459
372460
Keyword Args:
373461
kickstart (str): Option for ``NXOSDevice`` for devices that require a kickstart image.

0 commit comments

Comments
 (0)