feat: standards, shared CI and release tooling for NAPALM drivers
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 30s
CI / test (3.13) (push) Successful in 37s

STANDARDS.md binds every repository in the NAPALM org: one default branch,
Conventional Commits, the device-types contract, test-driven development,
clean-code limits, a quality ratchet for existing code, packaging rules and
releases by tag to PyPI.

napalm-standard holds what a tool can check: the conformance check (with
release rules on a tag), the quality ratchet (ported from netOrk's
quality_ratchet.py), and the Gitea release from the CHANGELOG section.
.gitea/workflows/driver.yml is the CI every driver calls; template/ is a new
driver's starting point and passes the check as it is.
This commit is contained in:
christianmanivong committed 2026-10-22 19:01:14 +02:00
commit db6b2e4ea7
37 files changed
+2894

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
# 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`, line length 100.
- **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"`, `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`.