From 127121886816608338597679e3ce1ad68e14b74a Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 08:36:49 +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. --- README.md | 5 +++-- pyproject.toml | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5158a87..19cde89 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ pip install -e vendor/napalm-device-types/ -e vendor/napalm-vmware/ ```python from napalm_vmware import VmwareEsxiDriver -driver = VmwareEsxiDriver("esx01.example.lan", "root", "secret", - optional_args={"verify_ssl": False}) +driver = VmwareEsxiDriver( + "esx01.example.lan", "root", "secret", optional_args={"verify_ssl": False} +) driver.open() print(driver.get_facts()) for vm in driver.get_vms(): diff --git a/pyproject.toml b/pyproject.toml index f46336a..18ff110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,9 +69,12 @@ markers = [ line-length = 100 target-version = "py310" -# Same rule set the netOrk repo gates on. [tool.ruff.lint] -select = ["E", "F", "I", "UP"] +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" -- 2.54.0 From 06b446934fd5372f237d125a7916a77e52931a18 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 08:38:41 +0200 Subject: [PATCH 2/4] fix: what bandit and the remaining lint rules found - zip() in provisioning is strict (B905): the OVF asks for one adapter per requested NIC, so the two lists match. A VM that came back with fewer adapters used to get a seed that left a NIC unconfigured; it now fails, and create_vm_from_cloud_init discards it as on any other error after the import. A test covers it. - A failed abort of the import lease is logged at debug level instead of passed over (B110); the original error is still the one raised. - nosec for false positives, each with its reason: the 0.0.0.0 entries in the parsers are addresses vSphere reports, not binds (B104); qemu-img runs with a fixed argv and no shell (B404, B603); xml.sax.saxutils only escapes the OVF descriptor the driver writes, nothing is parsed (B406), so defusedxml would not apply. --- napalm_vmware/parse/hosts.py | 3 ++- napalm_vmware/parse/interfaces.py | 3 ++- napalm_vmware/provision/image.py | 10 +++++++--- napalm_vmware/provision/ovf.py | 3 ++- napalm_vmware/provisioning.py | 11 +++++++---- tests/test_provisioning.py | 7 +++++++ 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/napalm_vmware/parse/hosts.py b/napalm_vmware/parse/hosts.py index b14123b..a971a86 100644 --- a/napalm_vmware/parse/hosts.py +++ b/napalm_vmware/parse/hosts.py @@ -11,7 +11,8 @@ from typing import Any from napalm_device_types.models import HypervisorHostDict _CONNECTED = "connected" -_UNMANAGED = {None, "", "0.0.0.0"} +# Addresses vSphere reports for "no managing vCenter"; data, not a bind. +_UNMANAGED = {None, "", "0.0.0.0"} # nosec B104 def _host(host: dict[str, Any], standalone: bool) -> HypervisorHostDict: diff --git a/napalm_vmware/parse/interfaces.py b/napalm_vmware/parse/interfaces.py index fc02514..8721361 100644 --- a/napalm_vmware/parse/interfaces.py +++ b/napalm_vmware/parse/interfaces.py @@ -12,7 +12,8 @@ import ipaddress from typing import Any _DEFAULT_MTU = 1500 -_UNASSIGNED = {"", "0.0.0.0"} +# Addresses vSphere reports for an interface without one; data, not a bind. +_UNASSIGNED = {"", "0.0.0.0"} # nosec B104 def _uplink_mtus(host: dict[str, Any]) -> dict[str, int]: diff --git a/napalm_vmware/provision/image.py b/napalm_vmware/provision/image.py index b9e8bc2..311f8a4 100644 --- a/napalm_vmware/provision/image.py +++ b/napalm_vmware/provision/image.py @@ -12,7 +12,9 @@ import hashlib import json import os import shutil -import subprocess + +# qemu-img is run with a fixed argv and no shell (see below). +import subprocess # nosec B404 import tempfile from collections.abc import Callable from pathlib import Path @@ -46,7 +48,8 @@ def fetch(url: str, dest: Path, timeout: float) -> None: # pragma: no cover - n def convert_to_vmdk(src: Path, dst: Path) -> None: - subprocess.run( + # Fixed argv, no shell: qemu-img from PATH and the cache's own file paths. + subprocess.run( # nosec B603 [ _qemu_img(), "convert", @@ -64,7 +67,8 @@ def convert_to_vmdk(src: Path, dst: Path) -> None: def virtual_size(path: Path) -> int: """The disk size the image describes, in bytes (not the file size).""" - out = subprocess.run( + # Fixed argv, no shell: qemu-img from PATH and the cache's own file path. + out = subprocess.run( # nosec B603 [_qemu_img(), "info", "--output", "json", str(path)], check=True, capture_output=True, diff --git a/napalm_vmware/provision/ovf.py b/napalm_vmware/provision/ovf.py index 971d853..49dd123 100644 --- a/napalm_vmware/provision/ovf.py +++ b/napalm_vmware/provision/ovf.py @@ -11,7 +11,8 @@ VMXNET3. Both drivers are in the mainline kernel; whether every distribution's from __future__ import annotations -from xml.sax.saxutils import escape, quoteattr +# Escaping for the descriptor this module writes; no XML is parsed here. +from xml.sax.saxutils import escape, quoteattr # nosec B406 _HW_VERSION = "vmx-13" # ESXi 6.5 and later _GUEST_OS = "otherLinux64Guest" diff --git a/napalm_vmware/provisioning.py b/napalm_vmware/provisioning.py index c9cd65a..b173569 100644 --- a/napalm_vmware/provisioning.py +++ b/napalm_vmware/provisioning.py @@ -252,7 +252,7 @@ class VmwareProvisioningMixin: try: lease.HttpNfcLeaseAbort() except Exception: # noqa: BLE001 - the original error is the one to report - pass + logger.debug("Aborting the import lease failed: ignored", exc_info=True) raise return info["info"]["entity"] @@ -279,13 +279,16 @@ class VmwareProvisioningMixin: changes = _mac_changes(adapters, nics) + _disk_growth(devices, disk_gb, image_size) macs = [ (nic.get("mac") or adapter.macAddress or "").lower() - for adapter, nic in zip(adapters, nics) + for adapter, nic in zip(adapters, nics, strict=True) ] seed = nocloud_iso( user, meta_data(name, f"iid-{uuid.uuid4()}"), network_config( - [(mac, nic.get("dhcp", i == 0)) for i, (mac, nic) in enumerate(zip(macs, nics))] + [ + (mac, nic.get("dhcp", i == 0)) + for i, (mac, nic) in enumerate(zip(macs, nics, strict=True)) + ] ), ) vm_dir = posixpath.dirname(str(props["config.files.vmPathName"]).split("] ", 1)[1]) @@ -365,7 +368,7 @@ class VmwareProvisioningMixin: def _mac_changes(adapters: list[Any], nics: list[NICConfigDict]) -> list[Any]: """Device edits pinning the MACs the caller asked for.""" changes = [] - for adapter, nic in zip(adapters, nics): + for adapter, nic in zip(adapters, nics, strict=True): if not nic.get("mac"): continue adapter.addressType = "manual" diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py index 8d447d4..1bd87da 100644 --- a/tests/test_provisioning.py +++ b/tests/test_provisioning.py @@ -258,6 +258,13 @@ class TestFinishVm: _, spec = self._finish(driver, esxi_data, [{"bridge": "VM Network"}], disk_gb=1) assert not any(type(c.device).__name__.endswith("VirtualDisk") for c in spec.deviceChange) + def test_a_vm_without_an_adapter_per_requested_nic_is_refused(self, driver, esxi_data): + # The OVF asks for one adapter per NIC; a VM that came back with fewer would + # get a seed that leaves a NIC unconfigured. create_vm_from_cloud_init + # discards it on any error from here. + with pytest.raises(ValueError): + self._finish(driver, esxi_data, [{"bridge": "VM Network"}, {"bridge": "VM Network"}]) + class TestDestroyAndStatus: def test_destroy_powers_off_first(self, driver, esxi_data): -- 2.54.0 From 24617b7f825b6769d0739b1872451ffce92057c2 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 08:40:42 +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/ is gone. - setuptools>=77 with the SPDX license string, no License classifier, Python 3.13 classifier, napalm-device-types>=4.21.1, a dev extra with floors, Issues and Changelog links; README installs from PyPI and links pyVmomi on PyPI. - mypy strict for the package (the blanket ignore_missing_imports is now limited to napalm and yaml); exempt for now (35 errors): _inventory, _session, _tasks, base, esxi, parse.environment, parse.snapshots, parse.vm_devices, provision.seed, provision.transfer, provisioning. Recorded in the quality baseline with the long and complex functions. - Coverage floor 97 % (measured 97.11 %). --- .gitea/workflows/ci.yml | 15 ++++++++++++ .github/workflows/ci.yml | 48 ------------------------------------- CHANGELOG.md | 10 ++++++++ README.md | 11 ++++++--- pyproject.toml | 48 ++++++++++++++++++++++++++++++------- tests/quality_baseline.json | 31 ++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 60 deletions(-) create mode 100644 .gitea/workflows/ci.yml delete mode 100644 .github/workflows/ci.yml 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/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a7bc4d4..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: CI - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12"] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install package with dev extras - run: | - python -m pip install --upgrade pip - # 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. - python -m pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git" - python -m pip install -e ".[dev]" - - - name: Run unit tests - run: | - python -m pytest -q --tb=short - - - name: Build wheel and sdist - run: | - python -m pip install build - python -m build - - - name: Upload dist artifacts - # v4 refuses to run on Gitea ("not currently supported on GHES"). - uses: actions/upload-artifact@v3 - with: - name: dist-${{ matrix.python-version }} - path: dist/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 31af1eb..8bc0390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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. Python 3.10 remains the floor; + requires napalm-device-types 4.21.1. +- Provisioning fails, and removes the imported VM, when vSphere hands back fewer + network adapters than NICs were asked for, instead of leaving a NIC unconfigured. + A failed abort of the import lease is logged at debug level. + ### Added - VM provisioning (`create_vm_from_cloud_init`, `destroy_vm`, `get_vm_status`, `get_network_targets`, `get_image_storages`) from qcow2/raw diff --git a/README.md b/README.md index 19cde89..3db982d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # napalm-vmware NAPALM drivers for VMware vSphere, speaking the vSphere API through -[pyVmomi](https://github.com/vmware/pyvmomi): +[pyVmomi](https://pypi.org/project/pyvmomi/): | Driver | Endpoint | Device in netOrk | |---|---|---| @@ -57,8 +57,10 @@ Unverified assumptions, to be checked against real hardware: ## Install +Python 3.10 or later. + ```bash -pip install -e vendor/napalm-device-types/ -e vendor/napalm-vmware/ +pip install napalm-vmware ``` ## Usage @@ -86,8 +88,11 @@ by two VMs is refused rather than guessed. ## Tests +The rules are in [STANDARDS.md](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md). + ```bash -pytest # unit tests, no network +python -m venv .venv && .venv/bin/pip install -e ".[dev]" +.venv/bin/pytest --cov # unit tests, no network docker run -d --rm -p 127.0.0.1:8989:8989 vmware/vcsim -l 0.0.0.0:8989 docker run -d --rm -p 127.0.0.1:8990:8989 vmware/vcsim -esx -l 0.0.0.0:8989 VCSIM_VCENTER_PORT=8989 VCSIM_ESXI_PORT=8990 pytest -m vcsim diff --git a/pyproject.toml b/pyproject.toml index 18ff110..982e0d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68", "wheel"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,7 @@ version = "0.2.0" description = "NAPALM drivers for VMware ESXi hosts and vCenter Server (vSphere API via pyVmomi)" readme = "README.md" requires-python = ">=3.10" -license = { text = "Apache-2.0" } +license = "Apache-2.0" authors = [ { name = "Christian Manivong", email = "christian@manivong.de" }, ] @@ -17,19 +17,19 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: System :: Networking", "Topic :: System :: Systems Administration", "Typing :: Typed", ] dependencies = [ "napalm>=5.0.0", - "napalm-device-types>=4.14.0", + "napalm-device-types>=4.21.1", "pyvmomi>=8.0.1", # Provisioning: download the image, write the NoCloud seed ISO and its YAML. "requests>=2.31", @@ -39,10 +39,10 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest", - "pytest-cov", - "ruff", - "mypy", + "pytest>=8", + "pytest-cov>=5", + "mypy>=1.11", + "ruff>=0.6", ] [project.entry-points."napalm.drivers"] @@ -51,6 +51,8 @@ vmware_vcenter = "napalm_vmware:VmwareVcenterDriver" [project.urls] Repository = "https://git.netork.io/NAPALM/napalm-vmware" +Issues = "https://git.netork.io/NAPALM/napalm-vmware/issues" +Changelog = "https://git.netork.io/NAPALM/napalm-vmware/src/branch/main/CHANGELOG.md" [tool.setuptools.packages.find] where = ["."] @@ -78,5 +80,33 @@ select = ["E", "F", "I", "UP", "B"] [tool.mypy] python_version = "3.10" -warn_unused_configs = true +strict = true +# napalm itself is untyped, so every driver subclasses Any. +disallow_subclassing_any = false + +[[tool.mypy.overrides]] +module = ["napalm.*", "yaml.*"] ignore_missing_imports = true + +[[tool.mypy.overrides]] +# Not yet under strict mypy (quality baseline); may only shrink. +module = [ + "napalm_vmware._inventory", + "napalm_vmware._session", + "napalm_vmware._tasks", + "napalm_vmware.base", + "napalm_vmware.esxi", + "napalm_vmware.parse.environment", + "napalm_vmware.parse.snapshots", + "napalm_vmware.parse.vm_devices", + "napalm_vmware.provision.seed", + "napalm_vmware.provision.transfer", + "napalm_vmware.provisioning", +] +ignore_errors = true + +[tool.coverage.run] +source = ["napalm_vmware"] + +[tool.coverage.report] +fail_under = 97 diff --git a/tests/quality_baseline.json b/tests/quality_baseline.json new file mode 100644 index 0000000..ff722b6 --- /dev/null +++ b/tests/quality_baseline.json @@ -0,0 +1,31 @@ +{ + "coverage_floor": 97.0, + "function_complexity": { + "napalm_vmware/_plain.py::to_plain": 11, + "napalm_vmware/base.py::VmwareBaseDriver._find_vm": 12, + "napalm_vmware/parse/facts.py::host_serial": 11, + "napalm_vmware/parse/interfaces.py::host_interfaces": 11, + "napalm_vmware/parse/vms.py::_vm": 13, + "napalm_vmware/provision/placement.py::choose_placement": 16 + }, + "function_lines": { + "napalm_vmware/provision/ovf.py::ovf_descriptor": 80, + "napalm_vmware/provisioning.py::VmwareProvisioningMixin._import_ovf": 56, + "napalm_vmware/provisioning.py::VmwareProvisioningMixin.create_vm_from_cloud_init": 57 + }, + "mypy_relaxed": [ + "napalm_vmware._inventory", + "napalm_vmware._session", + "napalm_vmware._tasks", + "napalm_vmware.base", + "napalm_vmware.esxi", + "napalm_vmware.parse.environment", + "napalm_vmware.parse.snapshots", + "napalm_vmware.parse.vm_devices", + "napalm_vmware.provision.seed", + "napalm_vmware.provision.transfer", + "napalm_vmware.provisioning" + ], + "python_file_lines": {}, + "untyped_public": [] +} -- 2.54.0 From 46b595e8bac595989f81c20f6671d9128cf9751c Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 23 Oct 2026 10:29:09 +0200 Subject: [PATCH 4/4] chore(release): 0.3.0 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc0390..41fb624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-23 + +The first release on PyPI. + ### 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 982e0d2..c94264a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-vmware" -version = "0.2.0" +version = "0.3.0" description = "NAPALM drivers for VMware ESXi hosts and vCenter Server (vSphere API via pyVmomi)" readme = "README.md" requires-python = ">=3.10" -- 2.54.0