From e53a4b4cc673440cb4e88b2f0bf553f1bde5b7df Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 08:45:32 +0200 Subject: [PATCH 1/4] style: ruff format and the lint rules of the driver standard Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM driver uses (E, F, I, UP, B at line length 100, Python 3.10). What ruff cannot fix comes in the next commit. --- napalm_tplink_jetstream/tplink_jetstream.py | 146 +++++++++++--------- pyproject.toml | 9 +- tests/unit/test_driver.py | 33 +++-- tests/unit/test_snmp_agent.py | 8 +- tests/unit/test_ssh_host_key.py | 4 +- 5 files changed, 119 insertions(+), 81 deletions(-) diff --git a/napalm_tplink_jetstream/tplink_jetstream.py b/napalm_tplink_jetstream/tplink_jetstream.py index 76921fd..1336b2b 100644 --- a/napalm_tplink_jetstream/tplink_jetstream.py +++ b/napalm_tplink_jetstream/tplink_jetstream.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -20,26 +19,24 @@ Netmiko device_type: ``tplink_jetstream`` from __future__ import annotations import re -import socket from typing import Any, ClassVar +import napalm.base.constants as C import netaddr -from netmiko import ConnectHandler -from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException - -from napalm_device_types import ConfigLifecycleMixin, FingerprintRule, SnmpAgentMixin, SwitchDriver -from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict from napalm.base import helpers as napalm_helpers from napalm.base.exceptions import ( - ConnectionException, - ConnectionClosedException, CommandErrorException, + ConnectionClosedException, + ConnectionException, MergeConfigException, ReplaceConfigException, ) from napalm.base.netmiko_helpers import netmiko_args +from napalm_device_types import ConfigLifecycleMixin, FingerprintRule, SnmpAgentMixin, SwitchDriver +from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict from napalm_device_types.ssh_host_keys import HostKeyCheck, SshHostKeyMismatch -import napalm.base.constants as C +from netmiko import ConnectHandler +from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): @@ -105,7 +102,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): # Config management state self._candidate_config: str | None = None - self._candidate_mode: str | None = None # 'merge' or 'replace' + self._candidate_mode: str | None = None # 'merge' or 'replace' self._backup_config: str | None = None # ------------------------------------------------------------------ @@ -130,13 +127,9 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): except SshHostKeyMismatch: raise # refused before the login; nothing was sent except NetmikoTimeoutException as exc: - raise ConnectionException( - f"Cannot connect to {self.hostname}: {exc}" - ) from exc + raise ConnectionException(f"Cannot connect to {self.hostname}: {exc}") from exc except NetmikoAuthenticationException as exc: - raise ConnectionException( - f"Authentication failed for {self.hostname}: {exc}" - ) from exc + raise ConnectionException(f"Authentication failed for {self.hostname}: {exc}") from exc def close(self) -> None: """Close the SSH connection.""" @@ -155,7 +148,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): return {"is_alive": False} try: return {"is_alive": self.device.remote_conn.transport.is_active()} - except (socket.error, EOFError, AttributeError): + except (OSError, EOFError, AttributeError): return {"is_alive": False} # ------------------------------------------------------------------ @@ -190,7 +183,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): return output return output return _do_send(command) - except (socket.error, EOFError) as exc: + except (OSError, EOFError) as exc: raise ConnectionClosedException(str(exc)) from exc # ------------------------------------------------------------------ @@ -320,14 +313,12 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): model = hw_version.split()[0] if hw_version else "" # "Software Version" (Omada) or "Firmware Version" (classic) - os_version = ( - self._parse_key_value(sys_info, "Software Version") - or self._parse_key_value(sys_info, "Firmware Version") + os_version = self._parse_key_value(sys_info, "Software Version") or self._parse_key_value( + sys_info, "Firmware Version" ) # "Running Time" (Omada) or "Run Time" (classic) - uptime_str = ( - self._parse_key_value(sys_info, "Running Time") - or self._parse_key_value(sys_info, "Run Time") + uptime_str = self._parse_key_value(sys_info, "Running Time") or self._parse_key_value( + sys_info, "Run Time" ) uptime = self._parse_uptime_seconds(uptime_str) @@ -358,7 +349,10 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): ) if match: interfaces.append(match.group(1) + match.group(2)) - return sorted(set(interfaces), key=lambda s: [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", s)]) + return sorted( + set(interfaces), + key=lambda s: [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", s)], + ) def get_interfaces(self) -> dict[str, dict]: """Return a dictionary of interface details. @@ -436,7 +430,9 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): "lag_mode": lag_mode, } - def _parse_interfaces(self, status_output: str, config_output: str = "", mtu: int = 1518) -> dict[str, dict]: + def _parse_interfaces( + self, status_output: str, config_output: str = "", mtu: int = 1518 + ) -> dict[str, dict]: """Parse tabular output of 'show interface status' and 'show interface configuration'.""" interfaces: dict[str, dict] = {} @@ -451,8 +447,14 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): if not match: continue port, status, speed, duplex, _, _, _, _, description = ( - match.group(1), match.group(2), match.group(3), match.group(4), - match.group(5), match.group(6), match.group(7), match.group(8), + match.group(1), + match.group(2), + match.group(3), + match.group(4), + match.group(5), + match.group(6), + match.group(7), + match.group(8), match.group(9).strip(), ) speed_val = 0.0 @@ -751,7 +753,9 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): # Port Description (column 4+shift) may also be "gigabitEthernet X/X/X" pd_idx = 4 + shift - desc_shift = 1 if (len(parts) > pd_idx and parts[pd_idx].lower() in PORT_TYPE_MAP) else 0 + desc_shift = ( + 1 if (len(parts) > pd_idx and parts[pd_idx].lower() in PORT_TYPE_MAP) else 0 + ) rows.append( { @@ -760,7 +764,9 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): "port_id": port_id, "mgmt_address": parts[3 + shift] if len(parts) > 3 + shift else "", "port_description": parts[pd_idx] if len(parts) > pd_idx else "", - "system_name": parts[5 + shift + desc_shift] if len(parts) > 5 + shift + desc_shift else "", + "system_name": parts[5 + shift + desc_shift] + if len(parts) > 5 + shift + desc_shift + else "", } ) @@ -1112,7 +1118,9 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): if self._candidate_config is None: raise MergeConfigException("No candidate configuration is staged.") - ex_cls = ReplaceConfigException if self._candidate_mode == "replace" else MergeConfigException + ex_cls = ( + ReplaceConfigException if self._candidate_mode == "replace" else MergeConfigException + ) # Save backup for potential rollback self._backup_config = self._send_command("show running-config") @@ -1334,12 +1342,18 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): counters[current_port] = current current_port = m.group(1) current = { - "tx_errors": 0, "rx_errors": 0, - "tx_discards": 0, "rx_discards": 0, - "tx_octets": 0, "rx_octets": 0, - "tx_unicast_packets": 0, "rx_unicast_packets": 0, - "tx_multicast_packets": 0, "rx_multicast_packets": 0, - "tx_broadcast_packets": 0, "rx_broadcast_packets": 0, + "tx_errors": 0, + "rx_errors": 0, + "tx_discards": 0, + "rx_discards": 0, + "tx_octets": 0, + "rx_octets": 0, + "tx_unicast_packets": 0, + "rx_unicast_packets": 0, + "tx_multicast_packets": 0, + "rx_multicast_packets": 0, + "tx_broadcast_packets": 0, + "rx_broadcast_packets": 0, } continue @@ -1353,18 +1367,18 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): val = val.strip() mapping = { - "Tx Errors": "tx_errors", - "Rx Errors": "rx_errors", - "Tx Discards": "tx_discards", - "Rx Discards": "rx_discards", - "Tx Bytes": "tx_octets", - "Rx Bytes": "rx_octets", - "Tx Ucast": "tx_unicast_packets", - "Rx Ucast": "rx_unicast_packets", - "Tx Mcast": "tx_multicast_packets", - "Rx Mcast": "rx_multicast_packets", - "Tx Bcast": "tx_broadcast_packets", - "Rx Bcast": "rx_broadcast_packets", + "Tx Errors": "tx_errors", + "Rx Errors": "rx_errors", + "Tx Discards": "tx_discards", + "Rx Discards": "rx_discards", + "Tx Bytes": "tx_octets", + "Rx Bytes": "rx_octets", + "Tx Ucast": "tx_unicast_packets", + "Rx Ucast": "rx_unicast_packets", + "Tx Mcast": "tx_multicast_packets", + "Rx Mcast": "rx_multicast_packets", + "Tx Bcast": "tx_broadcast_packets", + "Rx Bcast": "rx_broadcast_packets", } if key in mapping: current[mapping[key]] = _int(val) @@ -1400,7 +1414,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): if len(parts) < 3: continue try: - int(parts[0]) # first column is index + int(parts[0]) # first column is index except ValueError: continue @@ -1583,9 +1597,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): lines.append("exit") errors = self._apply_config_lines("\n".join(lines)) if errors: - raise CommandErrorException( - f"set_vlan({vlan_id}) errors: {errors}" - ) + raise CommandErrorException(f"set_vlan({vlan_id}) errors: {errors}") finally: self._exit_config_mode() self._save_config() @@ -1628,14 +1640,14 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): if "access_vlan" in config: for old_vid in old_untagged_vlans: lines.append(f" no switchport general allowed vlan {old_vid}") - lines.append(f" switchport general allowed vlan {config['access_vlan']} untagged") + lines.append( + f" switchport general allowed vlan {config['access_vlan']} untagged" + ) lines.append(f" switchport pvid {config['access_vlan']}") lines.append("exit") errors = self._apply_config_lines("\n".join(lines)) if errors: - raise CommandErrorException( - f"set_interface({interface}) errors: {errors}" - ) + raise CommandErrorException(f"set_interface({interface}) errors: {errors}") finally: self._exit_config_mode() self._save_config() @@ -1749,11 +1761,16 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): iface = self._to_cli_interface(interface) lines = [f"interface {iface}"] if "is_poe_enabled" in config: - lines.append("power inline supply" if config["is_poe_enabled"] else "no power inline supply") + lines.append( + "power inline supply" if config["is_poe_enabled"] else "no power inline supply" + ) if "poe_priority" in config: priority = self._POE_PRIORITY_MAP_REV.get(config["poe_priority"], "low") lines.append(f"power inline priority {priority}") - if "allocated_power_in_watts" in config and config.get("poe_allocation_method") == "PPAM_VALUE": + if ( + "allocated_power_in_watts" in config + and config.get("poe_allocation_method") == "PPAM_VALUE" + ): lines.append(f"power inline power-limit {config['allocated_power_in_watts']}") lines.append("exit") @@ -1803,7 +1820,9 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): # Parse RTT: "Minimum = 0ms , Maximum = 10ms , Average = 2ms" rtt_min = rtt_max = rtt_avg = 0.0 - m = re.search(r"Minimum\s*=\s*(\d+)ms.*?Maximum\s*=\s*(\d+)ms.*?Average\s*=\s*(\d+)ms", output) + m = re.search( + r"Minimum\s*=\s*(\d+)ms.*?Maximum\s*=\s*(\d+)ms.*?Average\s*=\s*(\d+)ms", output + ) if m: rtt_min = float(m.group(1)) rtt_max = float(m.group(2)) @@ -1895,9 +1914,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): ) -> dict[str, str | dict[str, Any]]: """Execute a list of CLI commands and return their output.""" if encoding != "text": - raise NotImplementedError( - f"Encoding '{encoding}' is not supported by this driver." - ) + raise NotImplementedError(f"Encoding '{encoding}' is not supported by this driver.") result: dict[str, str | dict[str, Any]] = {} for cmd in commands: result[cmd] = self._send_command(cmd) @@ -1937,7 +1954,6 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): return SNMPConfigDict(running=True, community=community, port=161, version="2c") - def get_device_warnings(self) -> list: """Return device warnings. Currently delegates to SNMP detection only.""" return [] diff --git a/pyproject.toml b/pyproject.toml index 7f85cc4..9cda733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,4 +50,11 @@ include = ["napalm_tplink_jetstream*"] [tool.ruff] line-length = 100 -target-version = "py38" +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.ruff.lint.per-file-ignores] +# Recorded device output keeps its lines. +"tests/**" = ["E501"] diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index 5ea81c2..b794051 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -1,15 +1,16 @@ """Unit tests for TPLinkJetstreamDriver — no real device required.""" -import pytest from unittest.mock import MagicMock, patch -from napalm_tplink_jetstream.tplink_jetstream import TPLinkJetstreamDriver +import pytest +from napalm_tplink_jetstream.tplink_jetstream import TPLinkJetstreamDriver # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def driver(): """Return a driver instance with a mocked Netmiko connection.""" @@ -83,12 +84,11 @@ Gi1/0/1 00-AA-BB-CC-DD-EE Gi0/1 10.0.0.1 uplink """ - - # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- + class TestGetFacts: def test_returns_required_keys(self, driver): driver.device.send_command.return_value = SHOW_SYSTEM_INFO @@ -100,8 +100,16 @@ class TestGetFacts: ) facts = driver.get_facts() - for key in ("vendor", "model", "hostname", "os_version", "serial_number", - "uptime", "interface_list", "fqdn"): + for key in ( + "vendor", + "model", + "hostname", + "os_version", + "serial_number", + "uptime", + "interface_list", + "fqdn", + ): assert key in facts def test_vendor(self, driver): @@ -257,11 +265,14 @@ class TestIsAlive: class TestUptimeParser: - @pytest.mark.parametrize("uptime_str,expected", [ - ("0 day(s) 0 hour(s) 0 min(s) 0 sec(s)", 0), - ("1 day(s) 0 hour(s) 0 min(s) 0 sec(s)", 86400), - ("5 day(s) 2 hour(s) 35 min(s) 16 sec(s)", 5 * 86400 + 2 * 3600 + 35 * 60 + 16), - ]) + @pytest.mark.parametrize( + "uptime_str,expected", + [ + ("0 day(s) 0 hour(s) 0 min(s) 0 sec(s)", 0), + ("1 day(s) 0 hour(s) 0 min(s) 0 sec(s)", 86400), + ("5 day(s) 2 hour(s) 35 min(s) 16 sec(s)", 5 * 86400 + 2 * 3600 + 35 * 60 + 16), + ], + ) def test_parse_uptime_seconds(self, uptime_str, expected): result = TPLinkJetstreamDriver._parse_uptime_seconds(uptime_str) assert result == float(expected) diff --git a/tests/unit/test_snmp_agent.py b/tests/unit/test_snmp_agent.py index c1e8fce..48d5f23 100644 --- a/tests/unit/test_snmp_agent.py +++ b/tests/unit/test_snmp_agent.py @@ -35,9 +35,11 @@ def driver(): d._exit_config_mode = MagicMock() d._save_config = MagicMock() d._send_command = MagicMock( - side_effect=lambda cmd: SHOW_IP_INTERFACE - if cmd == "show ip interface" - else "Index Community-Name Access\n1 c0mm read-only\n" + side_effect=lambda cmd: ( + SHOW_IP_INTERFACE + if cmd == "show ip interface" + else "Index Community-Name Access\n1 c0mm read-only\n" + ) ) return d diff --git a/tests/unit/test_ssh_host_key.py b/tests/unit/test_ssh_host_key.py index c6a1afb..5298e3f 100644 --- a/tests/unit/test_ssh_host_key.py +++ b/tests/unit/test_ssh_host_key.py @@ -24,7 +24,9 @@ HANDLER = "napalm_tplink_jetstream.tplink_jetstream.ConnectHandler" def _device() -> MagicMock: device = MagicMock() - device.remote_conn_pre.get_transport.return_value.get_remote_server_key.return_value = SERVER_KEY + device.remote_conn_pre.get_transport.return_value.get_remote_server_key.return_value = ( + SERVER_KEY + ) return device -- 2.54.0 From bef2f23ab2be26f6725b23ce3145408f745bf6b9 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 08:46:36 +0200 Subject: [PATCH 2/4] fix: what bandit and the remaining lint rules found - set_interface: the except Exception: pass around reading the old untagged VLANs now logs at debug level (B110), with a module logger. - nosec for the empty password field of get_users (B105, now a named constant); a false positive. - Two docstrings quoting tabular device output keep their lines (noqa E501); a long docstring and an error message are shortened or split; the unused duplex column is named _duplex. - tests: a get_facts test named SHOW_INTERFACE, which does not exist; it is SHOW_INTERFACE_STATUS. raise ... from exc. --- napalm_tplink_jetstream/tplink_jetstream.py | 21 ++++++++++++++------- tests/unit/test_driver.py | 2 +- tests/unit/test_ssh_host_key.py | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/napalm_tplink_jetstream/tplink_jetstream.py b/napalm_tplink_jetstream/tplink_jetstream.py index 1336b2b..402ab8a 100644 --- a/napalm_tplink_jetstream/tplink_jetstream.py +++ b/napalm_tplink_jetstream/tplink_jetstream.py @@ -18,6 +18,7 @@ Netmiko device_type: ``tplink_jetstream`` from __future__ import annotations +import logging import re from typing import Any, ClassVar @@ -38,6 +39,11 @@ from napalm_device_types.ssh_host_keys import HostKeyCheck, SshHostKeyMismatch from netmiko import ConnectHandler from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException +logger = logging.getLogger(__name__) + +#: get_users' password field: the switch never shows a password, so it stays empty. +_PASSWORD_NOT_SHOWN = "" # nosec B105 + class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): """NAPALM driver for TP-Link Jetstream managed switches.""" @@ -372,7 +378,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): Port State Speed Duplex FlowCtrl Description Gi1/0/1 Enable Auto Auto Disable uplink - """ + """ # noqa: E501 - recorded device output keeps its lines jumbo_out = self._send_command("show jumbo") global_mtu = 1518 m = re.search(r"(\d+)", jumbo_out) @@ -446,7 +452,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): ) if not match: continue - port, status, speed, duplex, _, _, _, _, description = ( + port, status, speed, _duplex, _, _, _, _, description = ( match.group(1), match.group(2), match.group(3), @@ -703,7 +709,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): Port Device ID Port ID management address Port Description System Name ---- ------------ -------- ------------------ ---------------- ----------- Gi1/0/9 64:E8:81:E1:29:00 2 10.7.0.10 2 swt-l0-1-10 - """ + """ # noqa: E501 - recorded device output keeps its lines neighbors: dict[str, list[dict]] = {} for row in self._get_lldp_table(): neighbors.setdefault(row["local_port"], []).append( @@ -1026,7 +1032,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): @staticmethod def _to_cli_interface(port: str) -> str: - """Convert abbreviated port name (e.g. ``Gi1/0/1``) to CLI form (``gigabitEthernet 1/0/1``).""" + """Convert a short port name (``Gi1/0/1``) to CLI form (``gigabitEthernet 1/0/1``).""" _PREFIX_MAP = { "gi": "gigabitEthernet", "te": "ten-gigabitEthernet", @@ -1150,7 +1156,8 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): """ if self._backup_config is None: raise CommandErrorException( - "No backup configuration available – commit_config has not been called in this session." + "No backup configuration available – " + "commit_config has not been called in this session." ) current = self._send_command("show running-config") @@ -1423,7 +1430,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): # Map TP-Link roles to NAPALM privilege levels (1-15) level = 15 if role == "admin" else 1 - users[username] = {"level": level, "password": "", "sshkeys": []} + users[username] = {"level": level, "password": _PASSWORD_NOT_SHOWN, "sshkeys": []} return users @@ -1618,7 +1625,7 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver): if interface in vlan_info.get("untagged", []) and vid_str != new_vid: old_untagged_vlans.append(vid_str) except Exception: - pass + logger.debug("reading the old untagged VLANs: ignored", exc_info=True) self._enter_config_mode() try: diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index b794051..5294fa8 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -96,7 +96,7 @@ class TestGetFacts: # _get_interface_list needs show interface driver.device.send_command.side_effect = lambda cmd, **kw: ( - SHOW_SYSTEM_INFO if "system-info" in cmd else SHOW_INTERFACE + SHOW_SYSTEM_INFO if "system-info" in cmd else SHOW_INTERFACE_STATUS ) facts = driver.get_facts() diff --git a/tests/unit/test_ssh_host_key.py b/tests/unit/test_ssh_host_key.py index 5298e3f..4cbcea6 100644 --- a/tests/unit/test_ssh_host_key.py +++ b/tests/unit/test_ssh_host_key.py @@ -76,7 +76,7 @@ def test_a_changed_key_is_refused(): try: raise paramiko.BadHostKeyException("10.0.0.8", SERVER_KEY, OTHER_KEY) except paramiko.SSHException as exc: # what netmiko does with it - raise NetmikoTimeoutException(f"A paramiko SSHException occurred: {exc}") + raise NetmikoTimeoutException(f"A paramiko SSHException occurred: {exc}") from exc driver = _driver(seen, SshHostKey.of(OTHER_KEY)) with patch(HANDLER, side_effect=refuse): -- 2.54.0 From 4e171bff309712daab3946309a2eeb0c34b207a2 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 08:48:12 +0200 Subject: [PATCH 3/4] chore: adopt the NAPALM driver standard - CI moves to .gitea/workflows/ci.yml and calls the shared driver workflow of NAPALM/driver-standard. .github/ goes with publish.yml and tests.yml: PyPI Trusted Publishing never worked from Gitea. - requirements.txt goes: it only repeated the pyproject dependencies, with an older napalm-device-types floor and without paramiko. - LICENSE file (the README already pointed to it), CHANGELOG, py.typed, SPDX license string, links to git.netork.io (README included); napalm-device-types >=4.21.1, a dev extra with floors. - netaddr gets a true floor (>=0.7.10): unbounded, the lowest job installed a version without IPAddress, which napalm imports. - mypy strict for the package; napalm_tplink_jetstream.tplink_jetstream is exempt for now (96 errors), recorded in the quality baseline with its long functions and the 2000-line module. Coverage floor 36 % (measured 36.71 %). --- .gitea/workflows/ci.yml | 15 +++ .github/workflows/publish.yml | 51 --------- .github/workflows/tests.yml | 34 ------ CHANGELOG.md | 69 +++++++++++ LICENSE | 191 +++++++++++++++++++++++++++++++ README.md | 4 +- napalm_tplink_jetstream/py.typed | 0 pyproject.toml | 49 ++++++-- requirements.txt | 4 - tests/quality_baseline.json | 35 ++++++ 10 files changed, 351 insertions(+), 101 deletions(-) create mode 100644 .gitea/workflows/ci.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 napalm_tplink_jetstream/py.typed delete mode 100644 requirements.txt create mode 100644 tests/quality_baseline.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7530c79 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,15 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + driver: + uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main + with: + standard-ref: main + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 09e935c..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Publish to PyPI - -on: - push: - tags: - - "v*" - release: - types: [published] - -jobs: - build: - name: Build distribution - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install build - run: pip install build - - - name: Build package - run: python -m build - - - name: Upload dist artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ - - publish-pypi: - name: Publish to PyPI - needs: build - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/napalm-tplink-jetstream - permissions: - id-token: write # Required for Trusted Publishing (OIDC) - steps: - - name: Download dist artifacts - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 7b98221..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Tests - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -jobs: - test: - name: Test (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - # napalm-device-types lives in git.netork.io/NAPALM, not on PyPI: without this - # pip looks there, finds an unrelated 0.1.0 and the job dies before any test. - pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git" - pip install -e ".[dev]" - - - name: Run tests - run: pytest tests/ -v --cov=napalm_tplink_jetstream diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d3fc0cb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to this driver. The format follows +[Keep a Changelog](https://keepachangelog.com/), versions follow +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Changed +- The driver follows the NAPALM driver standard + (https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the + tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and + a build check, and a release tag publishes to PyPI. The package now ships `py.typed` + and a LICENSE file. It requires Python 3.10 and napalm-device-types 4.21.1. +- A failure to read the current VLANs before `set_interface()` moves a port to another + access VLAN is now logged at debug level. + +## [0.4.0] - 2026-10-18 + +### Changed +- The SSH host key is verified: with a known key in `optional_args["ssh_host_keys"]` + any other key is refused before login, and every key seen is reported to + `optional_args["ssh_host_key_observer"]`. Without either argument the driver + connects as before. Requires napalm-device-types 4.19.0. + +## [0.3.0] - 2026-10-17 + +### Added +- `configure_snmp_agent()`: the caller's community (read-only), the source interface + set to the VLAN whose address is exactly the listen or management address, saved + and checked in `show snmp community`. + +### Removed +- **Breaking:** `fix_snmp` and `run_device_action`; `fix_snmp` set the community + `public` and could pick the wrong VLAN. + +## [0.2.0] - 2026-10-16 + +### Added +- SNMP configuration (`get_snmp_config()`), PoE status and configuration + (`get_poe_status()`, `set_poe()`), interface enable and description, LAG membership + in `get_interfaces()`, and fingerprints for discovery. + +### Changed +- The driver no longer collects over SNMP: `SNMP_HEALTH_PROFILE` declares where the + switch publishes uptime, CPU and memory, and interface counters come from the + 64-bit ifXTable. +- `get_config()` drops the timestamped event-log lines the switch embeds in the + running configuration. +- `set_interface()` uses the switch's own commands for the access VLAN and the native + VLAN of a trunk. +- Requires Python 3.10 and paramiko 5.0.0 or newer (CVE-2026-44405). + +## [0.1.2] - 2026-05-29 + +### Added +- VLAN and interface configuration; TL-SG3210 support. + +## [0.1.1] - 2026-05-12 + +### Fixed +- VLAN membership is read correctly when the switch wraps it over several lines. + +## [0.1.0] - 2026-05-09 + +### Added +- `tplink_jetstream` driver for TP-Link Jetstream switches over SSH: facts, + interfaces and their addresses, configuration, ARP and MAC tables, LLDP neighbours + and VLANs. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68d39ec --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other transformations + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and incorporated + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combined Contribution(s) and the Work + to which such Contribution(s) was submitted. If You institute patent + litigation against any entity (including a cross-claim or counterclaim + in a lawsuit) alleging that the Work or any Contribution embodied + within the Work constitutes direct or contributory patent infringement, + then any patent licenses granted to You under this License for that + Work shall terminate as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text file + distributed as part of the Derivative Works; within the Source + form or documentation, if provided along with the Derivative + Works; or, within a display generated by the Derivative Works, + if and wherever such third-party notices normally appear. The + contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Work, + and to permit persons to whom the Work is furnished to do so, + subject to the following conditions: + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any conditions of TITLE, + MERCHANTIBILITY, or FITNESS FOR A PARTICULAR PURPOSE. + See the License for the specific language governing permissions and + limitations under the License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (even if such Contributor has been advised of the possibility + of such damages). + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may offer only + conditions consistent with this License, subject to the following + additional conditions: + + (a) You may offer additional warranty, indemnity, or other liability + terms and conditions; and + + (b) You may offer support for the Work in return for a fee. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format in question. It may also be + possible to make it available with a creative commons license. + + Copyright 2026 Christian Manivong + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 4f598f5..748e27b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ NAPALM community driver for **TP-Link Jetstream** managed switches (T1500G, T160 | Dependency | Minimum version | |---|---| -| Python | 3.8 | +| Python | 3.10 | | NAPALM | 4.0 | | Netmiko | 4.0 | @@ -28,7 +28,7 @@ pip install napalm-tplink-jetstream Or from source: ```bash -git clone https://github.com/napalm-automation-community/napalm-tplink-jetstream +git clone https://git.netork.io/NAPALM/napalm-tplink-jetstream cd napalm-tplink-jetstream pip install -e . ``` diff --git a/napalm_tplink_jetstream/py.typed b/napalm_tplink_jetstream/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 9cda733..800c3fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64", "wheel"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] @@ -7,47 +7,52 @@ name = "napalm-tplink-jetstream" version = "0.4.0" description = "NAPALM driver for TP-Link Jetstream managed switches" readme = "README.md" -license = { text = "Apache-2.0" } +license = "Apache-2.0" requires-python = ">=3.10" authors = [ { name = "Christian Manivong" }, ] classifiers = [ "Topic :: Utilities", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", ] dependencies = [ "napalm>=4.0.0", - "napalm_device_types>=4.19.0", + "napalm-device-types>=4.21.1", "netmiko>=4.0.0", "paramiko>=5.0.0", # CVE-2026-44405 - "netaddr", + "netaddr>=0.7.10", ] [project.optional-dependencies] dev = [ - "pytest", - "pytest-cov", - "black", - "ruff", + "pytest>=8", + "pytest-cov>=5", + "mypy>=1.11", + "ruff>=0.6", ] [project.entry-points."napalm.drivers"] tplink_jetstream = "napalm_tplink_jetstream:TPLinkJetstreamDriver" [project.urls] -Repository = "https://github.com/chrismanivong/napalm-tplink-jetstream" +Repository = "https://git.netork.io/NAPALM/napalm-tplink-jetstream" +Issues = "https://git.netork.io/NAPALM/napalm-tplink-jetstream/issues" +Changelog = "https://git.netork.io/NAPALM/napalm-tplink-jetstream/src/branch/main/CHANGELOG.md" [tool.setuptools.packages.find] where = ["."] include = ["napalm_tplink_jetstream*"] +[tool.setuptools.package-data] +napalm_tplink_jetstream = ["py.typed"] + [tool.ruff] line-length = 100 target-version = "py310" @@ -58,3 +63,27 @@ select = ["E", "F", "I", "UP", "B"] [tool.ruff.lint.per-file-ignores] # Recorded device output keeps its lines. "tests/**" = ["E501"] + +[tool.mypy] +python_version = "3.10" +strict = true +# napalm itself is untyped, so every driver subclasses Any. +disallow_subclassing_any = false + +[[tool.mypy.overrides]] +module = ["napalm.*", "netmiko.*", "paramiko.*", "netaddr.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +# Not yet under strict mypy (quality baseline); may only shrink. +module = ["napalm_tplink_jetstream.tplink_jetstream"] +ignore_errors = true + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.run] +source = ["napalm_tplink_jetstream"] + +[tool.coverage.report] +fail_under = 36 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b34b5f9..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -napalm>=4.0.0 -napalm_device_types>=4.13.0 -netmiko>=4.0.0 -netaddr diff --git a/tests/quality_baseline.json b/tests/quality_baseline.json new file mode 100644 index 0000000..6354393 --- /dev/null +++ b/tests/quality_baseline.json @@ -0,0 +1,35 @@ +{ + "coverage_floor": 36.0, + "function_complexity": { + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver._add_lag_info": 16, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver._get_lldp_table": 17, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver._parse_lldp_detail": 13, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_mac_address_table": 11, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_poe_status": 12, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_route_to": 14, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.set_interface": 18, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.traceroute": 12 + }, + "function_lines": { + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver._get_lldp_table": 60, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver._parse_interfaces": 70, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.delete_vlan": 56, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_arp_table": 55, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_facts": 51, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_interfaces_counters": 69, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_mac_address_table": 60, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_route_to": 68, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_vlans_detail": 52, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.ping": 64, + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.traceroute": 55 + }, + "mypy_relaxed": [ + "napalm_tplink_jetstream.tplink_jetstream" + ], + "python_file_lines": { + "napalm_tplink_jetstream/tplink_jetstream.py": 2029 + }, + "untyped_public": [ + "napalm_tplink_jetstream/tplink_jetstream.py::TPLinkJetstreamDriver.get_snmp_config" + ] +} -- 2.54.0 From 06c55fabbe0431381294f346ba616f9424a96716 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 10:29:05 +0200 Subject: [PATCH 4/4] chore(release): 0.5.0 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3fc0cb..52ff979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this driver. The format follows ## [Unreleased] +## [0.5.0] - 2026-10-23 + +The first release on PyPI through the shared workflow; 0.1.0 and 0.1.1 there are an old upload. + ### Changed - The driver follows the NAPALM driver standard (https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the diff --git a/pyproject.toml b/pyproject.toml index 800c3fb..dd06ecb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-tplink-jetstream" -version = "0.4.0" +version = "0.5.0" description = "NAPALM driver for TP-Link Jetstream managed switches" readme = "README.md" license = "Apache-2.0" -- 2.54.0