Files
driver-standard/STANDARDS.md
T
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

225 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](https://www.conventionalcommits.org/):
`<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 |
```bash
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](https://semver.org/). 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](https://keepachangelog.com/)). Merge it like any other change.
2. Tag that commit on `main` and push the tag:
```bash
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`.