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/.
11 KiB
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/(orfeat/),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 aBREAKING 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-Byfor one and no "generated with" footer; noCLAUDE.mdor.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-typesis 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
netorkin 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:
- Red — write a failing test that describes the behaviour. Run it; it must fail for the right reason, not an import error.
- Green — the minimum code that makes it pass.
- 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 formatandruff checkwithE, F, I, UP, B, line length 100. Only undertests/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. Thenapalm.driversentry point names do not change with the package name. pyproject.tomldeclaresname,version,description,readme,license = "Apache-2.0"(an SPDX string, PEP 639; no table, noLicense ::classifier,setuptools>=77),requires-python = ">=3.10", the dependencies, adevextra,[project.urls]withRepository(andIssues) onhttps://git.netork.io/NAPALM/…, and thenapalm.driversentry points.- Dependencies are ranges with true floors. A driver is a library: it never pins
==. The floor onnapalm-device-types(and every other dependency) is the oldest version that has everything the driver uses — thelowestCI job proves it. An upper bound only for a known incompatibility, with a comment saying which. py.typedships 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:
-
On a branch, set
versioninpyproject.tomland move the[Unreleased]entries ofCHANGELOG.mdunder## [X.Y.Z] - YYYY-MM-DD(Keep a Changelog). Merge it like any other change. -
Tag that commit on
mainand push the tag:git tag -a vX.Y.Z -m "napalm-<name> X.Y.Z" git push origin vX.Y.Z -
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
- Create the repository in the
NAPALMorg. - Copy
template/from this repository and replaceexample/Example. - Write the first parser test against a recorded fixture — then the parser.
- 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.