# 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 `/`: `feature/` (or `feat/`), `fix/`, `chore/`, `docs/`, `refactor/`. - **Commits** follow [Conventional Commits](https://www.conventionalcommits.org/): `(): ` 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-/ ├── napalm_/ │ ├── __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-` 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- 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`.