feat: standards, shared CI and release tooling for NAPALM drivers
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:
commit
db6b2e4ea7
37 files changed
+2894
No files matched your search
+221
@@ -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`.
|
||||
Reference in new issue
Block a user