Files
christianmanivong 0b21f62553 fix(check): SPDX license string; E501 off in tests (device-types pilot)
The pilot's build warned that setuptools refuses a license table and
License classifiers from 2027-02-18 (PEP 639); check now reports both and
the template builds with setuptools>=77. Recorded device output in tests
keeps its lines, so the template turns E501 off under tests/.
2026-10-22 19:08:06 +02:00

11 KiB
Raw Permalink Blame History

Standards for NAPALM drivers

These rules bind every repository in the NAPALM org on git.netork.io: the role contract napalm-device-types and every driver built on it. They are not suggestions. Where a rule cannot be kept in a concrete case, say so in the pull request and propose the alternative; do not deviate silently.

napalm-standard check (§8) enforces what a tool can see. The rest is for review.


1. Repository

  • One default branch: main. Nothing is committed to it directly; every change arrives through a pull request whose CI is green.
  • Branches are <type>/<short-description>: feature/ (or feat/), fix/, chore/, docs/, refactor/.
  • Commits follow Conventional Commits: <type>(<scope>): <summary> in the imperative, under 72 characters, issue in the footer (Closes #12). A breaking change says so: feat!: or a BREAKING CHANGE: footer.
  • Pull requests are small and focused, describe what changed and why, name the issue, and say how it was tested (which device, which firmware, or "fixtures only").
  • Issues live in the driver's own repository. Each carries a priority label (prio/P0–prio/P4).
  • CI lives in .gitea/workflows/ and calls the shared workflow (§7). No .github/.
  • No traces of tooling in the history. Commit messages, pull requests and files name no AI assistant, carry no Co-Authored-By for one and no "generated with" footer; no CLAUDE.md or .claude/ in any repository.

2. Layout

napalm-<name>/
├── napalm_<name>/
│   ├── __init__.py        exports the driver class(es)
│   ├── driver.py          the NetworkDriver subclass: connection + getters
│   ├── parsers/           pure functions: device output in, typed dicts out
│   └── py.typed
├── tests/
│   ├── fixtures/          recorded device output, anonymised
│   ├── test_parsers.py
│   └── test_driver.py
├── .gitea/workflows/ci.yml
├── CHANGELOG.md
├── LICENSE                Apache-2.0
├── README.md
└── pyproject.toml

The README says which devices and firmware versions the driver was tested against, how it connects (protocol, port, required privileges), which getters and actions it supports, and what it does not do.

3. The contract

  • napalm-device-types is authoritative. A driver implements a role base (SwitchDriver, FirewallDriver, OSDriver, …) and its capability mixins. A method that more than one driver could offer is declared in device-types first, with its TypedDict, and only then implemented.
  • Generic logic belongs in device-types, mechanics in the driver. Algorithms (diffing, reconciling, template methods) live on the base; the driver implements the device-communication steps they call. A driver never re-implements what the base provides.
  • Getters never write. A get_* reads. Anything that changes the device is a named action.
  • A failed read raises. Returning an empty result for "could not read" makes a broken device look like an empty one.
  • Drivers report facts, not policy. Whether something is a warning, a risk or worth a notification is the application's decision. A driver never names its consumer (no netork in identifiers, file names or messages written to a device).
  • Public methods only. An application calls what the contract declares; a private helper is not an interface and may change in any release.
  • Driver metadata is data. Vendor OIDs, fingerprint rules and similar knowledge are class attributes, not code paths.

4. Test-driven development — mandatory

The test is written before the code it tests. Red, green, refactor:

  1. Red — write a failing test that describes the behaviour. Run it; it must fail for the right reason, not an import error.
  2. Green — the minimum code that makes it pass.
  3. Refactor — clean up code and test, both stay green.

A pull request without tests for what it adds or changes is not merged.

Code Required
Parsers (pure functions) Every branch, every edge case, against recorded fixtures
Getters Happy path plus at least one failed read, with the transport mocked
Actions The commands or requests sent, plus a refused or failed step
Connection helpers Refused login, timeout, unreachable host — with a stub transport
  • Fixtures are real device output, recorded from a named model and firmware and anonymised (addresses, serials, hostnames, keys). The fixture name says where it came from: procurve-2530-YA.16.11.json.
  • Tests never open a socket. Only the happy path of a live connection to a real device may be excluded from coverage, with # pragma: no cover - live I/O.
  • A bug fix starts with a test that reproduces the bug.

5. Clean code

  • Format and lint: ruff format and ruff check with E, F, I, UP, B, line length 100. Only under tests/ may a line be longer (E501 off there): recorded device output keeps its lines.
  • Types: every public function is annotated; mypy --strict (§6 for the old code).
  • Small functions: under 30 lines recommended, 50 at most; cyclomatic complexity 10 at most. One function does one thing.
  • File size: under 300 lines recommended, 500 soft maximum, 1000 hard maximum.
  • Explicit over implicit: no mutable default arguments, no hidden side effects, one clear return type.
  • No magic values: command strings, OIDs, API paths and timeouts are named constants.
  • Errors: catch what you can handle, as narrowly as possible; never a bare except:. Re-raise with context (raise X from exc). Errors that cross the contract use NAPALM's and device-types' exception types.
  • Logging, not print: logging.getLogger(__name__). Never log a password, token or private key, not even at debug level.
  • Docstrings on public modules, classes and functions; private helpers with a self-explanatory name need none.
  • Secrets come in as arguments (optional_args), never from files or environment variables the driver chooses to read.

6. The quality ratchet

The rules above are newer than most of the code. Instead of a rewrite, each repo records its current state and may only improve from there:

What Where it is recorded Rule
Functions over 50 lines or complexity 10, files over 1000 lines, unannotated public functions tests/quality_baseline.json An entry may shrink, never grow; anything not in it keeps the limit
Modules not yet under strict mypy [[tool.mypy.overrides]] in pyproject.toml, mirrored in the baseline The list may shrink, never grow; every new module is strict
Coverage floor [tool.coverage.report] fail_under, mirrored in the baseline May rise, never fall
napalm-standard ratchet           # what is over and not recorded, and what can be tightened
napalm-standard ratchet --write   # record today's state after an improvement

A fixed entry left in the baseline fails CI as stale: write it down, so the limit holds there from then on. A new entry in the baseline is a deliberate exception and needs the same justification as any other deviation, in the commit that adds it.

ruff, ruff format and bandit have no ratchet: they hold from day one.

7. CI

Every driver's .gitea/workflows/ci.yml calls the shared workflow (template/.gitea/workflows/ci.yml). It runs on pull requests, on main and on release tags:

Job What
standard napalm-standard check (with --tag on a tag) and napalm-standard ratchet
lint ruff format --check, ruff check
types mypy on the package
test pytest --cov on Python 3.10–3.13; the floor is fail_under
lowest the tests again with every direct dependency at its declared floor
security bandit, pip-audit
build wheel and sdist, twine check --strict
publish tag only, after all of the above: upload to PyPI, then the Gitea release

8. Packaging

  • Name: napalm-<name> on PyPI when that name is free; a name already taken by someone else gets a descriptive one instead. The napalm.drivers entry point names do not change with the package name.
  • pyproject.toml declares name, version, description, readme, license = "Apache-2.0" (an SPDX string, PEP 639; no table, no License :: classifier, setuptools>=77), requires-python = ">=3.10", the dependencies, a dev extra, [project.urls] with Repository (and Issues) on https://git.netork.io/NAPALM/…, and the napalm.drivers entry points.
  • Dependencies are ranges with true floors. A driver is a library: it never pins ==. The floor on napalm-device-types (and every other dependency) is the oldest version that has everything the driver uses — the lowest CI job proves it. An upper bound only for a known incompatibility, with a comment saying which.
  • py.typed ships with the package.

napalm-standard check verifies: LICENSE, README and CHANGELOG present; the metadata fields; requires-python ≥ 3.10; a floor on napalm-device-types; a napalm.drivers entry point; URLs in the org; py.typed; CI in .gitea/ and no .github/; strict mypy, a coverage source and floor; on a tag, tag = version and a CHANGELOG section for it.

9. Versions and releases

Semantic Versioning. For a driver:

Bump When
MAJOR A public method, its arguments or a returned field change or disappear; the floor on device-types crosses a device-types MAJOR
MINOR A new getter, action, field or supported device
PATCH A fix that changes no interface

Before 1.0.0 a breaking change bumps MINOR and says so in the CHANGELOG.

A release is made by hand, by tag:

  1. On a branch, set version in pyproject.toml and move the [Unreleased] entries of CHANGELOG.md under ## [X.Y.Z] - YYYY-MM-DD (Keep a Changelog). Merge it like any other change.

  2. Tag that commit on main and push the tag:

    git tag -a vX.Y.Z -m "napalm-<name> X.Y.Z"
    git push origin vX.Y.Z
    
  3. CI runs everything, uploads to PyPI with the repo secret PYPI_TOKEN, and creates the Gitea release with the CHANGELOG section as its text.

A version on PyPI can never be replaced, only yanked. A broken release is followed by a new PATCH, not a re-tag. The CHANGELOG is written for the people who use the driver: what they can do now, what changed for them — not how it was built.

10. A new driver

  1. Create the repository in the NAPALM org.
  2. Copy template/ from this repository and replace example/Example.
  3. Write the first parser test against a recorded fixture — then the parser.
  4. The first upload creates the project on PyPI and needs an account-wide token. Replace it right after with a token scoped to the project, as the repo secret PYPI_TOKEN.