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

+32
View File
@@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
python -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
.venv/bin/pip install -q -e ".[dev]"
- name: Lint
run: |
ruff format --check .
ruff check .
- name: Types
run: mypy napalm_standard
- name: Tests
run: pytest -q --tb=short
+200
View File
@@ -0,0 +1,200 @@
# The CI every NAPALM driver runs (STANDARDS.md §7). A driver repo calls it from
# its own .gitea/workflows/ci.yml — see template/.gitea/workflows/ci.yml.
#
# Every job that installs builds its own venv in the workspace: jobs on one
# runner share /opt/hostedtoolcache, and two `pip install -e .` into the same
# site-packages break each other (netOrk REPOSITORY_GUIDELINES §10).
#
# upload-artifact/download-artifact stay on v3: v4 refuses to run on Gitea
# ("not currently supported on GHES").
name: driver
on:
workflow_call:
inputs:
standard-ref:
description: "Version of napalm-standard to check with; keep it equal to the @ref of this workflow"
type: string
default: "main"
pre-install:
description: "pip specs installed before the package (until its dependencies are on PyPI)"
type: string
default: ""
python-versions:
description: "JSON list of the Python versions the tests run on"
type: string
default: '["3.10", "3.11", "3.12", "3.13"]'
pypi-url:
description: "Upload target; https://test.pypi.org/legacy/ for a dry run"
type: string
default: "https://upload.pypi.org/legacy/"
secrets:
PYPI_TOKEN:
required: false
env:
STANDARD: "napalm-standard @ git+https://git.netork.io/NAPALM/driver-standard.git@${{ inputs.standard-ref }}"
jobs:
standard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install napalm-standard
run: |
python -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
.venv/bin/pip install -q "$STANDARD"
- name: Conformance
run: |
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
napalm-standard check --tag "$GITHUB_REF_NAME"
else
napalm-standard check
fi
- name: Quality ratchet
run: napalm-standard ratchet
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: ruff
run: |
python -m venv .venv
.venv/bin/pip install -q ruff
.venv/bin/ruff format --check .
.venv/bin/ruff check .
types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: |
python -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
.venv/bin/pip install -q "$STANDARD" mypy
if [ -n "${{ inputs.pre-install }}" ]; then .venv/bin/pip install -q ${{ inputs.pre-install }}; fi
.venv/bin/pip install -q -e ".[dev]"
- name: mypy
run: mypy "$(napalm-standard package)"
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJSON(inputs.python-versions) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
python -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
if [ -n "${{ inputs.pre-install }}" ]; then .venv/bin/pip install -q ${{ inputs.pre-install }}; fi
.venv/bin/pip install -q -e ".[dev]" pytest pytest-cov
- name: pytest
# --cov takes its source from [tool.coverage.run], the floor from fail_under.
run: python -m pytest -q --tb=short --cov --cov-report=term-missing
lowest:
# The floors in pyproject.toml are a promise: install exactly them and run the tests.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install the lowest versions the package allows
run: |
python -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
.venv/bin/pip install -q uv
if [ -n "${{ inputs.pre-install }}" ]; then .venv/bin/uv pip install --python .venv/bin/python -q ${{ inputs.pre-install }}; fi
.venv/bin/uv pip install --python .venv/bin/python -q --resolution lowest-direct -e ".[dev]" pytest
- name: pytest
run: python -m pytest -q --tb=short -p no:cacheprovider
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: |
python -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
.venv/bin/pip install -q "$STANDARD" bandit pip-audit
if [ -n "${{ inputs.pre-install }}" ]; then .venv/bin/pip install -q ${{ inputs.pre-install }}; fi
.venv/bin/pip install -q .
- name: bandit
run: bandit -q -r "$(napalm-standard package)"
- name: pip-audit
# The installed set: what a user gets today from the package's ranges.
# A package not on PyPI yet (the driver itself, a git pre-install) is skipped with a note.
run: pip-audit --skip-editable --progress-spinner off
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build wheel and sdist
run: |
python -m venv .venv
.venv/bin/pip install -q build twine
.venv/bin/python -m build
.venv/bin/twine check --strict dist/*
- uses: actions/upload-artifact@v3
with:
name: dist
path: dist/*
publish:
# Only a release tag publishes, and only when everything above passed.
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
needs: [standard, lint, types, test, lowest, security, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/download-artifact@v3
with:
name: dist
path: dist
- name: Upload to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
TWINE_REPOSITORY_URL: ${{ inputs.pypi-url }}
run: |
if [ -z "$TWINE_PASSWORD" ]; then echo "no PYPI_TOKEN secret in this repo"; exit 1; fi
python -m venv .venv
.venv/bin/pip install -q twine "$STANDARD"
.venv/bin/twine upload --non-interactive dist/*
- name: Gitea release
env:
GITEA_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_TOKEN: ${{ github.token }}
run: .venv/bin/napalm-standard release "$GITHUB_REF_NAME"
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.egg-info/
.venv/
build/
dist/
.coverage
.pytest_cache/
.mypy_cache/
.ruff_cache/
+19
View File
@@ -0,0 +1,19 @@
# Changelog
The format follows [Keep a Changelog](https://keepachangelog.com/), versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.1.0] - 2026-10-22
### Added
- STANDARDS.md: repository usage, the device-types contract, TDD, clean code, the
quality ratchet, packaging, versions and releases.
- `napalm-standard check`: the conformance check, with release rules on a tag.
- `napalm-standard ratchet`: function length, complexity, file size, annotations,
strict-mypy exemptions and the coverage floor against `tests/quality_baseline.json`.
- `napalm-standard notes` and `release`: the CHANGELOG section of a version, and the
Gitea release made from it.
- The shared driver workflow `.gitea/workflows/driver.yml`, publishing to PyPI on a tag.
- A template for new drivers.
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship made available under
the License, as indicated by a copyright notice that is included in
or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other transformations
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean, as submitted to the Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal Entity
authorized to submit on behalf of the copyright owner. For the purposes
of this definition, "submitted" means any form of electronic, verbal,
or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed
by, or on behalf of, the Licensor for the purpose of discussing and
improving the Work, but excluding communication that is conspicuously
marked or designated in writing by the copyright owner as "Not a
Contribution."
"Contributor" shall mean Licensor and any Legal Entity on behalf of
whom a Contribution has been received by the Licensor and incorporated
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by the combined Contribution(s) and the Work
to which such Contribution(s) was submitted. If You institute patent
litigation against any entity (including a cross-claim or counterclaim
in a lawsuit) alleging that the Work or any Contribution embodied
within the Work constitutes direct or contributory patent infringement,
then any patent licenses granted to You under this License for that
Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, You must include a readable copy of the
attribution notices contained within such NOTICE file, in
at least one of the following places: within a NOTICE text file
distributed as part of the Derivative Works; within the Source
form or documentation, if provided along with the Derivative
Works; or, within a display generated by the Derivative Works,
if and wherever such third-party notices normally appear. The
contents of the NOTICE file are for informational purposes only
and do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own license statement for Your modifications and
may provide additional grant of rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Work,
and to permit persons to whom the Work is furnished to do so,
subject to the following conditions:
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any conditions of TITLE,
MERCHANTIBILITY, or FITNESS FOR A PARTICULAR PURPOSE.
See the License for the specific language governing permissions and
limitations under the License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or exemplary damages of any character arising as a
result of this License or out of the use or inability to use the
Work (even if such Contributor has been advised of the possibility
of such damages).
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may offer only
conditions consistent with this License, subject to the following
additional conditions:
(a) You may offer additional warranty, indemnity, or other liability
terms and conditions; and
(b) You may offer support for the Work in return for a fee.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format in question. It may also be
possible to make it available with a creative commons license.
Copyright 2026 Christian Manivong
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+57
View File
@@ -0,0 +1,57 @@
# driver-standard
The rules every driver in the `NAPALM` org follows, and the tools that hold them.
| | |
|---|---|
| [STANDARDS.md](STANDARDS.md) | Repository usage, the contract, TDD, clean code, the quality ratchet, packaging, releases |
| `.gitea/workflows/driver.yml` | The CI every driver calls: conformance, lint, types, tests, lowest versions, security, build, publish |
| `napalm-standard` | The command line that CI runs: `check`, `ratchet`, `notes`, `release`, `package` |
| [template/](template/) | A new driver's starting point; it meets the standard as it is |
## Using it in a driver
`.gitea/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
driver:
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
with:
standard-ref: main
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
```
Inputs: `pre-install` (pip specs installed first, e.g. a dependency not on PyPI
yet), `python-versions` (JSON list), `pypi-url` (`https://test.pypi.org/legacy/`
for a dry run), `standard-ref` (keep it equal to the `@ref` of the `uses:` line).
Locally:
```bash
pip install "napalm-standard @ git+https://git.netork.io/NAPALM/driver-standard.git"
napalm-standard check # does this repo meet the standard?
napalm-standard ratchet # is it within its quality baseline?
napalm-standard ratchet --write # record the baseline after an improvement
```
## Development
```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest
.venv/bin/ruff format --check . && .venv/bin/ruff check . && .venv/bin/mypy napalm_standard
```
## License
Apache-2.0
+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`.
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
import sys
from napalm_standard.cli import main
sys.exit(main())
+25
View File
@@ -0,0 +1,25 @@
"""The CHANGELOG.md section of one version, the text of its release."""
from __future__ import annotations
import re
#: A version heading in CHANGELOG.md: "## [1.2.3] - 2026-10-01".
_HEADING = re.compile(r"^## \[(?P<version>[^\]]+)\]", re.MULTILINE)
def changelog_section(text: str, version: str) -> str | None:
"""The body under ``## [version]`` up to the next version heading, or None."""
version = version.removeprefix("v")
headings = list(_HEADING.finditer(text))
for i, heading in enumerate(headings):
if heading.group("version") != version:
continue
line_end = text.find("\n", heading.end())
start = len(text) if line_end == -1 else line_end + 1
end = headings[i + 1].start() if i + 1 < len(headings) else len(text)
body = text[start:end].strip()
# Sections are sometimes closed by a horizontal rule; it is layout, not content.
body = re.sub(r"\n*^---\s*\Z", "", body, flags=re.MULTILINE).strip()
return body or None
return None
+196
View File
@@ -0,0 +1,196 @@
"""Does a driver repository meet the standard? (STANDARDS.md §8)
Every rule is a function over a :class:`Repo` that returns its findings. A
rule reports what it can see and nothing else, so one broken thing produces
one finding, not a cascade.
"""
from __future__ import annotations
import pathlib
import re
import tomllib
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from typing import Any
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name
from packaging.version import Version
from napalm_standard.changelog import changelog_section
DEVICE_TYPES = "napalm-device-types"
MIN_PYTHON = Version("3.10")
URL_PREFIX = "https://git.netork.io/NAPALM/"
REQUIRED_FILES = ("LICENSE", "README.md", "CHANGELOG.md")
REQUIRED_FIELDS = ("name", "version", "description", "license")
RELEASE_TAG = re.compile(r"^v(\d+\.\d+\.\d+)$")
@dataclass(frozen=True)
class Finding:
rule: str
message: str
def __str__(self) -> str:
return f"[{self.rule}] {self.message}"
@dataclass(frozen=True)
class Repo:
root: pathlib.Path
pyproject: dict[str, Any]
@property
def project(self) -> dict[str, Any]:
project: dict[str, Any] = self.pyproject.get("project", {})
return project
@property
def name(self) -> str:
return canonicalize_name(str(self.project.get("name", "")))
@property
def is_device_types(self) -> bool:
return self.name == DEVICE_TYPES
@property
def package(self) -> str:
"""The import package: ``[tool.napalm-standard] package``, else the name."""
own: dict[str, Any] = self.pyproject.get("tool", {}).get("napalm-standard", {})
return str(own.get("package", self.name.replace("-", "_")))
Rule = Callable[[Repo], Iterator[Finding]]
def _files(repo: Repo) -> Iterator[Finding]:
for rel in REQUIRED_FILES:
path = repo.root / rel
if not path.is_file() or not path.read_text().strip():
yield Finding("files", f"{rel} is missing or empty")
def _metadata(repo: Repo) -> Iterator[Finding]:
for field in REQUIRED_FIELDS:
if not repo.project.get(field):
yield Finding("metadata", f"[project] has no {field}")
def _python(repo: Repo) -> Iterator[Finding]:
spec = SpecifierSet(str(repo.project.get("requires-python", "")))
floors = [Version(s.version) for s in spec if s.operator in (">=", ">")]
if not floors or max(floors) < MIN_PYTHON:
yield Finding(
"python",
f"requires-python is {str(spec) or 'unset'}; napalm-device-types needs >={MIN_PYTHON}",
)
def _requirements(repo: Repo) -> Iterator[Requirement]:
for line in repo.project.get("dependencies", []):
try:
yield Requirement(line)
except InvalidRequirement:
continue
def _device_types(repo: Repo) -> Iterator[Finding]:
if repo.is_device_types:
return
found = [r for r in _requirements(repo) if canonicalize_name(r.name) == DEVICE_TYPES]
if not found:
yield Finding("device-types", f"no dependency on {DEVICE_TYPES}")
elif not any(s.operator == ">=" for s in found[0].specifier):
yield Finding(
"device-types",
f"{found[0]} has no floor: name the oldest version that has what the driver uses",
)
def _entry_point(repo: Repo) -> Iterator[Finding]:
if repo.is_device_types:
return
if not repo.project.get("entry-points", {}).get("napalm.drivers"):
yield Finding("entry-point", 'no [project.entry-points."napalm.drivers"]')
def _urls(repo: Repo) -> Iterator[Finding]:
urls: dict[str, str] = repo.project.get("urls", {})
if "Repository" not in urls:
yield Finding("urls", "[project.urls] has no Repository")
for key, url in sorted(urls.items()):
if not url.startswith(URL_PREFIX):
yield Finding("urls", f"{key} = {url} is outside {URL_PREFIX}")
def _py_typed(repo: Repo) -> Iterator[Finding]:
if not repo.package:
return # no name: _metadata says so
if not (repo.root / repo.package / "py.typed").is_file():
yield Finding("py.typed", f"{repo.package}/py.typed is missing")
def _ci(repo: Repo) -> Iterator[Finding]:
workflows = repo.root / ".gitea" / "workflows"
if not workflows.is_dir() or not any(workflows.glob("*.y*ml")):
yield Finding("ci", "no workflow under .gitea/workflows/")
if (repo.root / ".github").exists():
yield Finding("ci", ".github/ is left over; CI lives in .gitea/workflows/")
def _ratchet(repo: Repo) -> Iterator[Finding]:
tool = repo.pyproject.get("tool", {})
if tool.get("mypy", {}).get("strict") is not True:
yield Finding("ratchet", "[tool.mypy] is not strict = true")
coverage = tool.get("coverage", {})
if not coverage.get("run", {}).get("source"):
yield Finding("ratchet", "[tool.coverage.run] has no source (CI runs pytest --cov)")
if "fail_under" not in coverage.get("report", {}):
yield Finding("ratchet", "[tool.coverage.report] has no fail_under")
RULES: tuple[Rule, ...] = (
_files,
_metadata,
_python,
_device_types,
_entry_point,
_urls,
_py_typed,
_ci,
_ratchet,
)
def _release(repo: Repo, tag: str) -> Iterator[Finding]:
match = RELEASE_TAG.match(tag)
if not match:
yield Finding("release", f"{tag} is not a release tag (vMAJOR.MINOR.PATCH)")
return
version = str(repo.project.get("version", ""))
if match.group(1) != version:
yield Finding("release", f"tag {tag} does not match the version {version}")
return
changelog = repo.root / "CHANGELOG.md"
text = changelog.read_text() if changelog.is_file() else ""
if changelog_section(text, version) is None:
yield Finding("release", f"CHANGELOG.md has no section for [{version}]")
def check_repo(root: pathlib.Path, tag: str | None = None) -> list[Finding]:
"""Every finding for the repository at *root*; with *tag*, also the release rules."""
path = root / "pyproject.toml"
if not path.is_file():
return [Finding("pyproject", "pyproject.toml is missing")]
try:
pyproject = tomllib.loads(path.read_text())
except tomllib.TOMLDecodeError as exc:
return [Finding("pyproject", f"pyproject.toml does not parse: {exc}")]
repo = Repo(root, pyproject)
findings = [finding for rule in RULES for finding in rule(repo)]
if tag is not None:
findings.extend(_release(repo, tag))
return findings
+101
View File
@@ -0,0 +1,101 @@
"""napalm-standard: the commands the shared workflow runs in every driver repo.
napalm-standard check [--tag vX.Y.Z] [PATH] does the repo meet the standard?
napalm-standard ratchet [--write] [PATH] hold the quality baseline, or rewrite it
napalm-standard notes vX.Y.Z [PATH] print that version's CHANGELOG section
napalm-standard release vX.Y.Z [PATH] create or update the Gitea release;
reads GITEA_URL, GITEA_REPOSITORY, GITEA_TOKEN
napalm-standard package [PATH] print the import package (for mypy, bandit, cov)
"""
from __future__ import annotations
import argparse
import os
import pathlib
import sys
import tomllib
from napalm_standard import ratchet
from napalm_standard.changelog import changelog_section
from napalm_standard.check import Repo, check_repo
from napalm_standard.release import gitea_api, publish
_RELEASE_ENV = ("GITEA_URL", "GITEA_REPOSITORY", "GITEA_TOKEN")
def _repo(path: pathlib.Path) -> Repo:
return Repo(path, tomllib.loads((path / "pyproject.toml").read_text()))
def _check(args: argparse.Namespace) -> int:
findings = check_repo(args.path, tag=args.tag)
for finding in findings:
print(finding)
if findings:
print(f"{len(findings)} finding(s); see STANDARDS.md in NAPALM/driver-standard")
return 1
print("meets the standard")
return 0
def _ratchet(args: argparse.Namespace) -> int:
code, text = ratchet.run(args.path, _repo(args.path).package, write=args.write)
print(text)
return code
def _notes(args: argparse.Namespace) -> int:
section = changelog_section((args.path / "CHANGELOG.md").read_text(), args.tag)
if section is None:
return 1
print(section)
return 0
def _release(args: argparse.Namespace) -> int:
missing = [name for name in _RELEASE_ENV if not os.environ.get(name)]
if missing:
print(f"missing environment: {', '.join(missing)}", file=sys.stderr)
return 2
api = gitea_api(*(os.environ[name] for name in _RELEASE_ENV))
changelog = (args.path / "CHANGELOG.md").read_text()
result = publish(args.tag, _repo(args.path).name, changelog, api)
print(f"release {result.get('id')} for {args.tag}")
return 0
def _package(args: argparse.Namespace) -> int:
print(_repo(args.path).package)
return 0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="napalm-standard",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
commands = parser.add_subparsers(dest="command", required=True)
def command(name: str, handler: object) -> argparse.ArgumentParser:
sub = commands.add_parser(name)
sub.set_defaults(handler=handler)
return sub
check = command("check", _check)
check.add_argument("--tag", help="also apply the release rules for this tag")
ratchet_cmd = command("ratchet", _ratchet)
ratchet_cmd.add_argument("--write", action="store_true", help="record today's state")
for name, handler in (("notes", _notes), ("release", _release)):
command(name, handler).add_argument("tag")
command("package", _package)
for sub in commands.choices.values():
sub.add_argument("path", nargs="?", type=pathlib.Path, default=pathlib.Path("."))
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
code: int = args.handler(args)
return code
View File
Whitespace-only changes.
+232
View File
@@ -0,0 +1,232 @@
"""Measure a driver against the limits of STANDARDS.md §5, and hold the line (§6).
The limits:
- function length 50 lines, cyclomatic complexity 10
- file length 1000 lines (hard maximum)
- public functions annotated
- no module exempt from strict mypy
- the coverage floor in ``[tool.coverage.report] fail_under``
Existing code exceeds them in places. That is recorded in
``tests/quality_baseline.json``: an entry may shrink but not grow, and
everything else keeps the limit. A baseline entry that was fixed is reported as
stale, so progress is written down and holds from then on.
Code is measured from the AST, not with ruff or radon, so the numbers do not
move with a tool's version. Complexity is mccabe-style: 1 plus one per
``if``/``elif``, loop, ``except``, ternary, ``match`` case, comprehension
``for`` and ``if``, and each extra operand of ``and``/``or``. A nested
function counts for itself, not for the function around it.
Ported from netOrk's scripts/quality_ratchet.py (#402).
"""
from __future__ import annotations
import ast
import json
import pathlib
import tomllib
from collections.abc import Iterator
from typing import Any
LIMITS = {
"function_lines": 50,
"function_complexity": 10,
"python_file_lines": 1000,
}
BASELINE = pathlib.Path("tests") / "quality_baseline.json"
#: mypy settings that strict = true turns on; an override setting one to False loosens it.
_STRICT_FLAGS = frozenset(
{
"strict",
"check_untyped_defs",
"disallow_any_generics",
"disallow_incomplete_defs",
"disallow_untyped_calls",
"disallow_untyped_decorators",
"disallow_untyped_defs",
"extra_checks",
"no_implicit_reexport",
"strict_equality",
"warn_return_any",
}
)
_FUNCTION = (ast.FunctionDef, ast.AsyncFunctionDef)
FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
def complexity(func: ast.AST) -> int:
score = 1
stack = list(ast.iter_child_nodes(func))
while stack:
node = stack.pop()
if isinstance(node, (*_FUNCTION, ast.ClassDef, ast.Lambda)):
continue # counts for itself
if isinstance(
node, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.IfExp, ast.ExceptHandler)
):
score += 1
elif isinstance(node, ast.BoolOp):
score += len(node.values) - 1
elif isinstance(node, ast.comprehension):
score += 1 + len(node.ifs)
elif isinstance(node, ast.match_case):
score += 1
stack.extend(ast.iter_child_nodes(node))
return score
def _typed(func: FunctionNode, is_method: bool) -> bool:
args = [*func.args.posonlyargs, *func.args.args, *func.args.kwonlyargs]
if is_method and args and args[0].arg in ("self", "cls"):
args = args[1:]
extras = [a for a in (func.args.vararg, func.args.kwarg) if a is not None]
return func.returns is not None and all(a.annotation is not None for a in [*args, *extras])
def iter_python_functions(tree: ast.AST) -> Iterator[tuple[str, FunctionNode, bool, bool]]:
"""Every function in *tree*, outermost first: its qualified name, its node,
whether it sits directly in a class, and whether it is public."""
def visit(
node: ast.AST, prefix: str, in_class: bool, public_scope: bool
) -> Iterator[tuple[str, FunctionNode, bool, bool]]:
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.ClassDef):
yield from visit(
child,
f"{prefix}{child.name}.",
True,
public_scope and not child.name.startswith("_"),
)
elif isinstance(child, _FUNCTION):
name = f"{prefix}{child.name}"
yield name, child, in_class, public_scope and not child.name.startswith("_")
yield from visit(child, f"{name}.", False, False)
elif isinstance(child, ast.stmt):
# a def under if/with/try/for belongs to the same scope
yield from visit(child, prefix, in_class, public_scope)
yield from visit(tree, "", False, True)
def python_functions_in(source: str, relpath: str) -> dict[str, dict[str, Any]]:
"""Every function in *source*: lines, complexity, whether it is typed and public."""
return {
f"{relpath}::{name}": {
"lines": (node.end_lineno or node.lineno) - node.lineno + 1,
"complexity": complexity(node),
"typed": _typed(node, in_class),
"public": public,
}
for name, node, in_class, public in iter_python_functions(ast.parse(source))
}
def relaxed_mypy_modules(pyproject: dict[str, Any]) -> list[str]:
"""Modules a ``[[tool.mypy.overrides]]`` entry exempts from strict checking."""
out: set[str] = set()
for override in pyproject.get("tool", {}).get("mypy", {}).get("overrides", []):
loosens = override.get("ignore_errors") is True or any(
override.get(flag) is False for flag in _STRICT_FLAGS
)
if loosens:
modules = override.get("module", [])
out.update([modules] if isinstance(modules, str) else modules)
return sorted(out)
def coverage_floor(pyproject: dict[str, Any]) -> float:
floor = pyproject.get("tool", {}).get("coverage", {}).get("report", {}).get("fail_under", 0)
return float(floor)
def _lines(source: str) -> int:
return source.count("\n") + (0 if source.endswith("\n") else 1)
def measure(repo: pathlib.Path, package: str) -> dict[str, Any]:
"""Today's excesses, keyed the way the baseline stores them."""
function_lines: dict[str, int] = {}
function_complexity: dict[str, int] = {}
python_file_lines: dict[str, int] = {}
untyped_public: list[str] = []
for path in sorted((repo / package).rglob("*.py")):
rel = path.relative_to(repo).as_posix()
source = path.read_text()
if _lines(source) > LIMITS["python_file_lines"]:
python_file_lines[rel] = _lines(source)
for key, f in python_functions_in(source, rel).items():
if f["lines"] > LIMITS["function_lines"]:
function_lines[key] = f["lines"]
if f["complexity"] > LIMITS["function_complexity"]:
function_complexity[key] = f["complexity"]
if f["public"] and not f["typed"]:
untyped_public.append(key)
pyproject = tomllib.loads((repo / "pyproject.toml").read_text())
return {
"function_lines": function_lines,
"function_complexity": function_complexity,
"python_file_lines": python_file_lines,
"untyped_public": sorted(untyped_public),
"mypy_relaxed": relaxed_mypy_modules(pyproject),
"coverage_floor": coverage_floor(pyproject),
}
def problems(measured: dict[str, Any], baseline: dict[str, Any]) -> list[str]:
"""What is new over a limit, or grew past what the baseline allows."""
out = []
for metric in LIMITS:
allowed = baseline.get(metric, {})
for key, value in sorted(measured[metric].items()):
if key not in allowed:
out.append(f"{metric}: {key} = {value} (new)")
elif value > allowed[key]:
out.append(f"{metric}: {key} = {value} (recorded {allowed[key]})")
for metric in ("untyped_public", "mypy_relaxed"):
new = sorted(set(measured[metric]) - set(baseline.get(metric, [])))
out.extend(f"{metric}: {key} (new)" for key in new)
recorded_floor = baseline.get("coverage_floor", 0)
if measured["coverage_floor"] < recorded_floor:
out.append(f"coverage_floor: {measured['coverage_floor']:g} (recorded {recorded_floor:g})")
return out
def stale_entries(measured: dict[str, Any], baseline: dict[str, Any]) -> list[str]:
"""Baseline entries that are fixed, shrunk or gone: the ratchet can tighten."""
out = []
for metric in LIMITS:
for key, value in sorted(baseline.get(metric, {}).items()):
now = measured[metric].get(key)
if now is None or now < value:
out.append(f"{metric}: {key}")
for metric in ("untyped_public", "mypy_relaxed"):
gone = sorted(set(baseline.get(metric, [])) - set(measured[metric]))
out.extend(f"{metric}: {key}" for key in gone)
recorded_floor = baseline.get("coverage_floor")
if recorded_floor is not None and measured["coverage_floor"] > recorded_floor:
out.append(f"coverage_floor: {recorded_floor:g} -> {measured['coverage_floor']:g}")
return out
def run(repo: pathlib.Path, package: str, write: bool = False) -> tuple[int, str]:
"""Check the repo against its baseline, or (*write*) record today's state as it."""
measured = measure(repo, package)
path = repo / BASELINE
if write:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(measured, indent=1, sort_keys=True) + "\n")
return 0, f"wrote {BASELINE}"
baseline = json.loads(path.read_text()) if path.is_file() else {}
lines = problems(measured, baseline)
lines += [
f"stale: {entry} (run napalm-standard ratchet --write)"
for entry in stale_entries(measured, baseline)
]
return (1, "\n".join(lines)) if lines else (0, "within the baseline")
+67
View File
@@ -0,0 +1,67 @@
"""Create or update the Gitea release of a version tag (STANDARDS.md §9).
The upload to PyPI is twine's job in the workflow; this only writes the
release page, with that version's CHANGELOG.md section as its text. It never
creates a tag, so it never pushes anything and never starts a CI run.
Ported from netOrk's scripts/release.py (#561).
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Callable
from typing import Any
from napalm_standard.changelog import changelog_section
Api = Callable[..., Any]
class NotFound(Exception):
"""The API answered 404."""
def publish(tag: str, package: str, changelog: str, api: Api) -> Any:
"""Create the release of *tag*, or update it when it exists already."""
version = tag.removeprefix("v")
body = changelog_section(changelog, version)
if body is None:
raise ValueError(f"CHANGELOG.md has no section for [{version}]")
payload = {
"tag_name": tag,
"name": f"{package} {version}",
"body": body,
"draft": False,
"prerelease": False,
}
try:
existing = api("GET", f"/releases/tags/{tag}")
except NotFound:
return api("POST", "/releases", payload)
return api("PATCH", f"/releases/{existing['id']}", payload)
def gitea_api(base_url: str, repository: str, token: str) -> Api: # pragma: no cover - I/O
"""A small JSON client for ``/api/v1/repos/<repository>``."""
root = f"{base_url.rstrip('/')}/api/v1/repos/{repository}"
def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
request = urllib.request.Request(
root + path,
method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise NotFound(path) from exc
raise
return json.loads(body) if body else {}
return call
+50
View File
@@ -0,0 +1,50 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-standard"
version = "0.1.0"
description = "Rules, CI and release tooling shared by the drivers of the NAPALM org"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.11"
authors = [{ name = "Christian Manivong", email = "christian@manivong.de" }]
dependencies = ["packaging>=23"]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11"]
[project.scripts]
napalm-standard = "napalm_standard.cli:main"
[project.urls]
Repository = "https://git.netork.io/NAPALM/driver-standard"
Issues = "https://git.netork.io/NAPALM/driver-standard/issues"
[tool.setuptools.packages.find]
include = ["napalm_standard*"]
[tool.setuptools.package-data]
napalm_standard = ["py.typed"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.11"
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=napalm_standard --cov-report=term-missing"
[tool.coverage.report]
fail_under = 95
[tool.coverage.run]
omit = ["napalm_standard/__main__.py"]
+15
View File
@@ -0,0 +1,15 @@
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
driver:
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
with:
standard-ref: main
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.egg-info/
.venv/
build/
dist/
.coverage
.pytest_cache/
.mypy_cache/
.ruff_cache/
+12
View File
@@ -0,0 +1,12 @@
# Changelog
All notable changes to this driver. The format follows
[Keep a Changelog](https://keepachangelog.com/), versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.1.0] - 2026-10-22
### Added
- `example` driver: facts from Example switches over SSH.
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship made available under
the License, as indicated by a copyright notice that is included in
or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other transformations
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean, as submitted to the Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal Entity
authorized to submit on behalf of the copyright owner. For the purposes
of this definition, "submitted" means any form of electronic, verbal,
or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed
by, or on behalf of, the Licensor for the purpose of discussing and
improving the Work, but excluding communication that is conspicuously
marked or designated in writing by the copyright owner as "Not a
Contribution."
"Contributor" shall mean Licensor and any Legal Entity on behalf of
whom a Contribution has been received by the Licensor and incorporated
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by the combined Contribution(s) and the Work
to which such Contribution(s) was submitted. If You institute patent
litigation against any entity (including a cross-claim or counterclaim
in a lawsuit) alleging that the Work or any Contribution embodied
within the Work constitutes direct or contributory patent infringement,
then any patent licenses granted to You under this License for that
Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, You must include a readable copy of the
attribution notices contained within such NOTICE file, in
at least one of the following places: within a NOTICE text file
distributed as part of the Derivative Works; within the Source
form or documentation, if provided along with the Derivative
Works; or, within a display generated by the Derivative Works,
if and wherever such third-party notices normally appear. The
contents of the NOTICE file are for informational purposes only
and do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own license statement for Your modifications and
may provide additional grant of rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Work,
and to permit persons to whom the Work is furnished to do so,
subject to the following conditions:
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any conditions of TITLE,
MERCHANTIBILITY, or FITNESS FOR A PARTICULAR PURPOSE.
See the License for the specific language governing permissions and
limitations under the License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or exemplary damages of any character arising as a
result of this License or out of the use or inability to use the
Work (even if such Contributor has been advised of the possibility
of such damages).
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may offer only
conditions consistent with this License, subject to the following
additional conditions:
(a) You may offer additional warranty, indemnity, or other liability
terms and conditions; and
(b) You may offer support for the Work in return for a fee.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format in question. It may also be
possible to make it available with a creative commons license.
Copyright 2026 Christian Manivong
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+45
View File
@@ -0,0 +1,45 @@
# napalm-example
NAPALM driver for Example switches, built on
[napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types) (`SwitchDriver`).
## Tested against
| Model | Firmware |
|---|---|
| Example 24 | 1.0.0 |
## Connection
SSH on port 22. The account needs read access to `show version`.
```python
from napalm import get_network_driver
driver = get_network_driver("example")
with driver("192.0.2.10", "admin", "secret") as device:
print(device.get_facts())
```
## Supported
| Getter / action | |
|---|---|
| `get_facts` | yes |
## Not supported
Configuration changes.
## Development
The rules are in [STANDARDS.md](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md).
```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest --cov
```
## License
Apache-2.0
+5
View File
@@ -0,0 +1,5 @@
"""NAPALM driver for Example switches."""
from napalm_example.driver import ExampleDriver
__all__ = ["ExampleDriver"]
+93
View File
@@ -0,0 +1,93 @@
"""NAPALM driver for Example switches over SSH.
Optional args
-------------
port : int
SSH port (default 22).
"""
from __future__ import annotations
import logging
from typing import Any, Protocol
from napalm.base.exceptions import ConnectionException
from napalm.base.models import FactsDict
from napalm_device_types import SwitchDriver
from napalm_example.parsers import parse_version
LOG = logging.getLogger(__name__)
VENDOR = "Example"
DEFAULT_PORT = 22
class Transport(Protocol):
def command(self, line: str) -> str: ...
def close(self) -> None: ...
class ExampleDriver(SwitchDriver):
"""A switch that answers line commands over SSH."""
def __init__(
self,
hostname: str,
username: str,
password: str,
timeout: int = 60,
optional_args: dict[str, Any] | None = None,
) -> None:
self.hostname = hostname
self.username = username
self.password = password
self.timeout = timeout
self.port = int((optional_args or {}).get("port", DEFAULT_PORT))
self.transport: Transport | None = None
def open(self) -> None: # pragma: no cover - live I/O
from netmiko import ConnectHandler
session = ConnectHandler(
device_type="generic",
host=self.hostname,
port=self.port,
username=self.username,
password=self.password,
timeout=self.timeout,
)
class _Netmiko:
def command(self, line: str) -> str:
return str(session.send_command(line))
def close(self) -> None:
session.disconnect()
self.transport = _Netmiko()
def close(self) -> None:
if self.transport is not None:
self.transport.close()
self.transport = None
def _command(self, line: str) -> str:
if self.transport is None:
raise ConnectionException(f"{self.hostname}: not connected; call open() first")
LOG.debug("%s: %s", self.hostname, line)
return self.transport.command(line)
def get_facts(self) -> FactsDict:
version = parse_version(self._command("show version"))
return {
"vendor": VENDOR,
"model": version["model"],
"hostname": version["hostname"],
"fqdn": version["hostname"],
"os_version": version["os_version"],
"serial_number": version["serial_number"],
"uptime": version["uptime"],
"interface_list": [],
}
@@ -0,0 +1,45 @@
"""Pure functions: device output in, typed values out. No I/O here."""
from __future__ import annotations
import re
from typing import TypedDict
#: "Key : value" lines of `show version`.
_FIELD = re.compile(r"^(?P<key>[^:\n]+?)\s*:\s*(?P<value>.*)$", re.MULTILINE)
#: "3 days, 4:05:06" or "4:05:06".
_UPTIME = re.compile(r"^(?:(?P<days>\d+) days?, )?(?P<h>\d+):(?P<m>\d\d):(?P<s>\d\d)$")
_SECONDS_PER_DAY = 86400
class VersionDict(TypedDict):
model: str
hostname: str
os_version: str
serial_number: str
uptime: float
def parse_uptime(text: str) -> float:
"""Seconds from an uptime like ``3 days, 4:05:06``."""
match = _UPTIME.match(text.strip())
if not match:
raise ValueError(f"unreadable uptime: {text!r}")
days = int(match.group("days") or 0)
hours, minutes, seconds = (int(match.group(k)) for k in ("h", "m", "s"))
return float(days * _SECONDS_PER_DAY + hours * 3600 + minutes * 60 + seconds)
def parse_version(text: str) -> VersionDict:
"""Facts from ``show version``; a field the device did not print is an error."""
fields = {m.group("key").strip(): m.group("value").strip() for m in _FIELD.finditer(text)}
try:
return {
"model": text.splitlines()[0].removesuffix(" Switch").strip(),
"hostname": fields["Hostname"],
"os_version": fields["Firmware"],
"serial_number": fields["Serial number"],
"uptime": parse_uptime(fields["Uptime"]),
}
except KeyError as exc:
raise ValueError(f"show version lacks {exc.args[0]}") from exc
View File
Whitespace-only changes.
+64
View File
@@ -0,0 +1,64 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-example"
version = "0.1.0"
description = "NAPALM driver for Example switches"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [{ name = "Christian Manivong", email = "christian@manivong.de" }]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
"Topic :: System :: Networking",
]
dependencies = [
"napalm>=5.0",
"napalm-device-types>=4.21",
]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6"]
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-example"
Issues = "https://git.netork.io/NAPALM/napalm-example/issues"
[project.entry-points."napalm.drivers"]
example = "napalm_example:ExampleDriver"
[tool.setuptools.packages.find]
include = ["napalm_example*"]
[tool.setuptools.package-data]
napalm_example = ["py.typed"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.10"
strict = true
# napalm itself is untyped, so every driver subclasses Any.
disallow_subclassing_any = false
[[tool.mypy.overrides]]
module = ["napalm.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["napalm_example"]
[tool.coverage.report]
fail_under = 90
exclude_also = ["pragma: no cover - live I/O"]
@@ -0,0 +1,5 @@
Example 24 Switch
Hostname : sw-lab-01
Firmware : 1.0.0
Serial number : EX0000000001
Uptime : 3 days, 4:05:06
+66
View File
@@ -0,0 +1,66 @@
"""The driver with its transport replaced: what it sends, what it returns."""
from __future__ import annotations
import pathlib
import pytest
from napalm.base.exceptions import ConnectionException
from napalm_example import ExampleDriver
FIXTURES = pathlib.Path(__file__).parent / "fixtures"
class FakeTransport:
def __init__(self, answers: dict[str, str]) -> None:
self.answers = answers
self.sent: list[str] = []
self.closed = False
def command(self, line: str) -> str:
self.sent.append(line)
return self.answers[line]
def close(self) -> None:
self.closed = True
@pytest.fixture
def driver() -> ExampleDriver:
return ExampleDriver("192.0.2.10", "admin", "secret")
def test_get_facts(driver: ExampleDriver) -> None:
show_version = (FIXTURES / "example24-1.0.0-show-version.txt").read_text()
driver.transport = FakeTransport({"show version": show_version})
facts = driver.get_facts()
assert facts["vendor"] == "Example"
assert facts["hostname"] == "sw-lab-01"
assert facts["fqdn"] == "sw-lab-01"
assert facts["interface_list"] == []
assert facts["uptime"] == 273906.0
def test_a_getter_before_open_raises(driver: ExampleDriver) -> None:
with pytest.raises(ConnectionException):
driver.get_facts()
def test_close_closes_the_transport_once(driver: ExampleDriver) -> None:
transport = FakeTransport({})
driver.transport = transport
driver.close()
driver.close()
assert transport.closed
assert driver.transport is None
def test_the_driver_is_registered_under_its_name() -> None:
from napalm import get_network_driver
assert get_network_driver("example") is ExampleDriver
+41
View File
@@ -0,0 +1,41 @@
"""Parsers against recorded device output."""
from __future__ import annotations
import pathlib
import pytest
from napalm_example.parsers import parse_uptime, parse_version
FIXTURES = pathlib.Path(__file__).parent / "fixtures"
def test_version_output_becomes_facts() -> None:
text = (FIXTURES / "example24-1.0.0-show-version.txt").read_text()
assert parse_version(text) == {
"model": "Example 24",
"hostname": "sw-lab-01",
"os_version": "1.0.0",
"serial_number": "EX0000000001",
"uptime": 273906.0,
}
def test_a_missing_field_is_an_error_not_an_empty_value() -> None:
with pytest.raises(ValueError, match="Serial number"):
parse_version("Example 24 Switch\nHostname : a\nFirmware : 1\nUptime : 0:00:01\n")
@pytest.mark.parametrize(
("text", "seconds"),
[("0:00:01", 1.0), ("1 day, 0:00:00", 86400.0), ("3 days, 4:05:06", 273906.0)],
)
def test_uptime(text: str, seconds: float) -> None:
assert parse_uptime(text) == seconds
def test_an_unreadable_uptime_is_an_error() -> None:
with pytest.raises(ValueError, match="uptime"):
parse_uptime("soon")
View File
Whitespace-only changes.
+88
View File
@@ -0,0 +1,88 @@
"""A driver repository on disk that meets the standard, to break one rule at a time."""
from __future__ import annotations
import pathlib
import textwrap
from collections.abc import Callable
import pytest
PYPROJECT = """\
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-example"
version = "1.2.0"
description = "NAPALM driver for Example devices"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"napalm>=5.0",
"napalm-device-types>=4.21",
]
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-example"
Issues = "https://git.netork.io/NAPALM/napalm-example/issues"
[project.entry-points."napalm.drivers"]
example = "napalm_example:ExampleDriver"
[tool.mypy]
strict = true
[tool.coverage.run]
source = ["napalm_example"]
[tool.coverage.report]
fail_under = 80
"""
CHANGELOG = """\
# Changelog
## [Unreleased]
## [1.2.0] - 2026-10-22
### Added
- Interfaces.
## [1.1.0] - 2026-10-01
### Fixed
- Facts.
"""
RepoFactory = Callable[..., pathlib.Path]
@pytest.fixture
def make_repo(tmp_path: pathlib.Path) -> RepoFactory:
"""Write a conforming repo; ``files`` overrides or (with None) removes a path."""
def make(files: dict[str, str | None] | None = None) -> pathlib.Path:
layout: dict[str, str | None] = {
"pyproject.toml": PYPROJECT,
"README.md": "# napalm-example\n\nA driver.\n",
"CHANGELOG.md": CHANGELOG,
"LICENSE": "Apache License\nVersion 2.0, January 2004\n",
"napalm_example/__init__.py": "",
"napalm_example/py.typed": "",
".gitea/workflows/ci.yml": "name: CI\n",
"tests/test_driver.py": "def test_nothing():\n pass\n",
}
layout.update(files or {})
for rel, content in layout.items():
if content is None:
continue
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content))
return tmp_path
return make
+48
View File
@@ -0,0 +1,48 @@
"""Release notes come from CHANGELOG.md (Keep a Changelog headings)."""
from __future__ import annotations
from napalm_standard.changelog import changelog_section
TEXT = """\
# Changelog
## [Unreleased]
- not yet
## [1.2.0] - 2026-10-22
### Added
- Interfaces.
---
## [1.1.0] - 2026-10-01
- Facts.
"""
def test_the_section_runs_to_the_next_version_heading() -> None:
assert changelog_section(TEXT, "1.2.0") == "### Added\n- Interfaces."
def test_a_leading_v_is_ignored() -> None:
assert changelog_section(TEXT, "v1.1.0") == "- Facts."
def test_an_unknown_version_has_no_section() -> None:
assert changelog_section(TEXT, "9.9.9") is None
def test_an_empty_section_has_none() -> None:
assert changelog_section("## [1.0.0] - x\n\n## [0.9.0] - y\n- a\n", "1.0.0") is None
def test_a_heading_without_brackets_is_not_a_version_heading() -> None:
assert changelog_section("## 1.0.0\n\n- a\n", "1.0.0") is None
def test_the_last_section_runs_to_the_end() -> None:
assert changelog_section("## [1.0.0] - x\n- a\n- b", "1.0.0") == "- a\n- b"
+215
View File
@@ -0,0 +1,215 @@
"""The conformance check: one test per rule, each breaking exactly that rule."""
from __future__ import annotations
import pytest
from napalm_standard.check import check_repo
from tests.conftest import PYPROJECT, RepoFactory
def rules(findings: list) -> set[str]:
return {f.rule for f in findings}
def test_a_conforming_repo_has_no_findings(make_repo: RepoFactory) -> None:
assert check_repo(make_repo()) == []
def test_a_conforming_repo_passes_at_its_own_tag(make_repo: RepoFactory) -> None:
assert check_repo(make_repo(), tag="v1.2.0") == []
@pytest.mark.parametrize("path", ["LICENSE", "README.md", "CHANGELOG.md"])
def test_a_missing_top_level_file_is_reported(make_repo: RepoFactory, path: str) -> None:
findings = check_repo(make_repo({path: None}))
assert rules(findings) == {"files"}
assert path in findings[0].message
def test_an_empty_readme_counts_as_missing(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"README.md": "\n"}))) == {"files"}
def test_a_repo_without_pyproject_reports_only_that(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"pyproject.toml": None}))) == {"pyproject"}
def test_an_unparsable_pyproject_is_reported(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"pyproject.toml": "[project\n"}))) == {"pyproject"}
@pytest.mark.parametrize("field", ["name", "version", "description", "license"])
def test_a_missing_project_field_is_reported(make_repo: RepoFactory, field: str) -> None:
pyproject = "\n".join(
line for line in PYPROJECT.splitlines() if not line.startswith(f"{field} = ")
)
findings = check_repo(make_repo({"pyproject.toml": pyproject}))
assert rules(findings) == {"metadata"}
assert field in findings[0].message
def test_a_license_table_is_accepted(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('license = "Apache-2.0"', 'license = { text = "Apache-2.0" }')
assert check_repo(make_repo({"pyproject.toml": pyproject})) == []
@pytest.mark.parametrize("spec", [">=3.8", ">=3.9", ""])
def test_python_below_310_or_unbounded_is_reported(make_repo: RepoFactory, spec: str) -> None:
pyproject = PYPROJECT.replace('requires-python = ">=3.10"', f'requires-python = "{spec}"')
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"python"}
def test_a_higher_python_floor_is_fine(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('">=3.10"', '">=3.11"')
assert check_repo(make_repo({"pyproject.toml": pyproject})) == []
def test_a_driver_must_depend_on_device_types(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace(' "napalm-device-types>=4.21",\n', "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"device-types"}
@pytest.mark.parametrize("spec", ["napalm-device-types", "napalm-device-types<5"])
def test_device_types_without_a_floor_is_reported(make_repo: RepoFactory, spec: str) -> None:
pyproject = PYPROJECT.replace('"napalm-device-types>=4.21"', f'"{spec}"')
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"device-types"}
def test_device_types_is_matched_by_its_normalised_name(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('"napalm-device-types>=4.21"', '"napalm_device_types>=4.21"')
assert check_repo(make_repo({"pyproject.toml": pyproject})) == []
def test_device_types_itself_needs_no_such_dependency_or_entry_point(
make_repo: RepoFactory,
) -> None:
pyproject = (
PYPROJECT.replace('name = "napalm-example"', 'name = "napalm-device-types"')
.replace(' "napalm-device-types>=4.21",\n', "")
.replace('[project.entry-points."napalm.drivers"]\n', "")
.replace('example = "napalm_example:ExampleDriver"\n', "")
.replace("NAPALM/napalm-example", "NAPALM/napalm-device-types")
)
repo = make_repo(
{
"pyproject.toml": pyproject,
"napalm_device_types/__init__.py": "",
"napalm_device_types/py.typed": "",
}
)
assert check_repo(repo) == []
def test_a_driver_without_napalm_entry_point_is_reported(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('[project.entry-points."napalm.drivers"]\n', "").replace(
'example = "napalm_example:ExampleDriver"\n', ""
)
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"entry-point"}
def test_urls_must_name_a_repository(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace(
'Repository = "https://git.netork.io/NAPALM/napalm-example"\n', ""
)
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"urls"}
@pytest.mark.parametrize(
"url",
[
"https://github.com/chrismanivong/napalm-example",
"https://github.com/napalm-automation-community/napalm-example",
"https://git.netork.io/christianmanivong/napalm-example",
],
)
def test_urls_outside_the_napalm_org_are_reported(make_repo: RepoFactory, url: str) -> None:
pyproject = PYPROJECT.replace("https://git.netork.io/NAPALM/napalm-example/issues", url)
findings = check_repo(make_repo({"pyproject.toml": pyproject}))
assert rules(findings) == {"urls"}
assert url in findings[0].message
def test_a_package_without_py_typed_is_reported(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"napalm_example/py.typed": None}))) == {"py.typed"}
def test_the_package_directory_can_be_named_explicitly(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT + '\n[tool.napalm-standard]\npackage = "napalm_other"\n'
repo = make_repo(
{
"pyproject.toml": pyproject,
"napalm_example/py.typed": None,
"napalm_other/__init__.py": "",
"napalm_other/py.typed": "",
}
)
assert check_repo(repo) == []
def test_ci_must_live_under_gitea(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({".gitea/workflows/ci.yml": None}))) == {"ci"}
def test_a_leftover_github_workflow_is_reported(make_repo: RepoFactory) -> None:
findings = check_repo(make_repo({".github/workflows/ci.yml": "name: CI\n"}))
assert rules(findings) == {"ci"}
def test_mypy_must_be_strict(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace("[tool.mypy]\nstrict = true\n", "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"ratchet"}
def test_coverage_must_have_a_floor(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace("[tool.coverage.report]\nfail_under = 80\n", "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"ratchet"}
def test_a_tag_must_match_the_version(make_repo: RepoFactory) -> None:
findings = check_repo(make_repo(), tag="v1.3.0")
assert rules(findings) == {"release"}
assert "1.2.0" in findings[0].message
@pytest.mark.parametrize("tag", ["1.2.0", "v1.2", "release-1.2.0", "v1.2.0-rc1"])
def test_a_tag_that_is_no_release_tag_is_reported(make_repo: RepoFactory, tag: str) -> None:
assert rules(check_repo(make_repo(), tag=tag)) == {"release"}
def test_a_tag_needs_its_changelog_section(make_repo: RepoFactory) -> None:
changelog = "# Changelog\n\n## [1.1.0] - 2026-10-01\n\n- Facts.\n"
findings = check_repo(make_repo({"CHANGELOG.md": changelog}), tag="v1.2.0")
assert rules(findings) == {"release"}
assert "CHANGELOG" in findings[0].message
def test_an_empty_changelog_section_does_not_count(make_repo: RepoFactory) -> None:
changelog = "# Changelog\n\n## [1.2.0] - 2026-10-22\n\n## [1.1.0] - 2026-10-01\n\n- x\n"
assert rules(check_repo(make_repo({"CHANGELOG.md": changelog}), tag="v1.2.0")) == {"release"}
def test_coverage_must_name_its_source(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('[tool.coverage.run]\nsource = ["napalm_example"]\n', "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"ratchet"}
+137
View File
@@ -0,0 +1,137 @@
"""The command line the shared workflow calls."""
from __future__ import annotations
import pathlib
import pytest
from napalm_standard import cli
from napalm_standard.check import Finding
from tests.conftest import RepoFactory
def test_check_passes_a_conforming_repo(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["check", str(make_repo())]) == 0
assert "meets the standard" in capsys.readouterr().out
def test_check_lists_every_finding_and_fails(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
repo = make_repo({"LICENSE": None, "napalm_example/py.typed": None})
assert cli.main(["check", str(repo)]) == 1
out = capsys.readouterr().out
assert "[files] LICENSE is missing or empty" in out
assert "[py.typed]" in out
def test_check_with_a_tag_applies_the_release_rules(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["check", "--tag", "v9.0.0", str(make_repo())]) == 1
assert "[release]" in capsys.readouterr().out
def test_ratchet_writes_then_holds(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
repo = make_repo({"napalm_example/mod.py": "def raw(a):\n pass\n"})
assert cli.main(["ratchet", str(repo)]) == 1
assert cli.main(["ratchet", "--write", str(repo)]) == 0
assert cli.main(["ratchet", str(repo)]) == 0
assert "within the baseline" in capsys.readouterr().out
def test_notes_prints_the_section(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["notes", "v1.2.0", str(make_repo())]) == 0
assert capsys.readouterr().out.strip() == "### Added\n- Interfaces."
def test_notes_fails_for_a_version_without_a_section(make_repo: RepoFactory) -> None:
assert cli.main(["notes", "v9.9.9", str(make_repo())]) == 1
def test_release_publishes_with_the_environment(
make_repo: RepoFactory,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
seen: dict[str, object] = {}
def fake_api(base_url: str, repository: str, token: str) -> object:
seen["client"] = (base_url, repository, token)
return "api"
def fake_publish(tag: str, package: str, changelog: str, api: object) -> dict[str, int]:
seen["publish"] = (tag, package, api)
return {"id": 5}
monkeypatch.setenv("GITEA_URL", "https://git.example")
monkeypatch.setenv("GITEA_REPOSITORY", "NAPALM/napalm-example")
monkeypatch.setenv("GITEA_TOKEN", "t0ken")
monkeypatch.setattr(cli, "gitea_api", fake_api)
monkeypatch.setattr(cli, "publish", fake_publish)
assert cli.main(["release", "v1.2.0", str(make_repo())]) == 0
assert seen["client"] == ("https://git.example", "NAPALM/napalm-example", "t0ken")
assert seen["publish"] == ("v1.2.0", "napalm-example", "api")
assert "release 5 for v1.2.0" in capsys.readouterr().out
def test_release_without_its_environment_fails(
make_repo: RepoFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
for name in ("GITEA_URL", "GITEA_REPOSITORY", "GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
assert cli.main(["release", "v1.2.0", str(make_repo())]) == 2
def test_the_path_defaults_to_the_working_directory(
make_repo: RepoFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(make_repo())
assert cli.main(["check"]) == 0
def test_a_finding_reads_as_rule_and_message() -> None:
assert str(Finding("files", "LICENSE is missing")) == "[files] LICENSE is missing"
def test_an_unparsable_dependency_is_left_to_the_build(make_repo: RepoFactory) -> None:
from tests.conftest import PYPROJECT
pyproject = PYPROJECT.replace('"napalm>=5.0",', '"napalm >= = 5",')
assert cli.main(["check", str(make_repo({"pyproject.toml": pyproject}))]) == 0
def test_the_module_runs_as_a_script(tmp_path: pathlib.Path) -> None:
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-m", "napalm_standard", "check", str(tmp_path)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "[pyproject]" in result.stdout
def test_package_prints_the_import_package(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["package", str(make_repo())]) == 0
assert capsys.readouterr().out == "napalm_example\n"
+310
View File
@@ -0,0 +1,310 @@
"""The quality ratchet: measured from the AST, held against a baseline (STANDARDS.md §6).
Ported from netOrk's scripts/quality_ratchet.py (#402), Python only, plus the
two settings a driver ratchets in pyproject.toml: modules exempt from strict
mypy, and the coverage floor.
"""
from __future__ import annotations
import ast
import json
import pathlib
import textwrap
import pytest
from napalm_standard import ratchet as qr
def _functions(source: str) -> dict:
return qr.python_functions_in(textwrap.dedent(source), "mod.py")
class TestMeasuring:
def test_function_length_and_qualified_names(self) -> None:
funcs = _functions(
"""
def top():
return 1
class C:
def method(self):
def inner():
return 2
return inner()
"""
)
assert funcs["mod.py::top"]["lines"] == 2
assert funcs["mod.py::C.method"]["lines"] == 4
assert funcs["mod.py::C.method.inner"]["lines"] == 2
def test_complexity_counts_decision_points(self) -> None:
funcs = _functions(
"""
def f(a, b, xs):
if a and b:
pass
elif a:
pass
for x in xs:
while x:
x -= 1
try:
pass
except ValueError:
pass
return [y for y in xs if y] if a else None
"""
)
# 1 + if + and + elif + for + while + except + comprehension + its if + ternary
assert funcs["mod.py::f"]["complexity"] == 10
def test_match_cases_count(self) -> None:
funcs = _functions(
"""
def f(x):
match x:
case 1:
return 1
case _:
return 0
"""
)
assert funcs["mod.py::f"]["complexity"] == 3
def test_a_nested_function_counts_for_itself_only(self) -> None:
funcs = _functions(
"""
def outer():
def inner(x):
if x:
return 1
return 0
return inner
"""
)
assert funcs["mod.py::outer"]["complexity"] == 1
assert funcs["mod.py::outer.inner"]["complexity"] == 2
@pytest.mark.parametrize(
("source", "typed"),
[
("def f(a: int) -> int:\n return a\n", True),
("def f(a) -> int:\n return a\n", False),
("def f(a: int):\n return a\n", False),
("def f(*a: int, **k: int) -> None:\n pass\n", True),
("def f(*a) -> None:\n pass\n", False),
("class C:\n def f(self, a: int) -> None:\n pass\n", True),
],
)
def test_typed(self, source: str, typed: bool) -> None:
[entry] = _functions(source).values()
assert entry["typed"] is typed
def test_private_names_and_what_they_contain_are_not_public(self) -> None:
funcs = _functions(
"""
def _helper():
def inner():
pass
class _Hidden:
def method(self):
pass
class Shown:
def _private(self):
pass
def public(self):
pass
"""
)
public = {k for k, v in funcs.items() if v["public"]}
assert public == {"mod.py::Shown.public"}
def test_the_walker_yields_each_function_once_with_its_qualified_name(self) -> None:
tree = ast.parse(
"def top():\n pass\n\nclass C:\n def m(self):\n def inner():\n"
" pass\n"
)
walked = [
(name, node.name, in_class)
for name, node, in_class, _ in qr.iter_python_functions(tree)
]
assert walked == [("top", "top", False), ("C.m", "m", True), ("C.m.inner", "inner", False)]
def test_a_function_inside_a_block_is_still_measured(self) -> None:
funcs = _functions(
"""
def outer(flag):
with lock():
def _stream():
if flag:
return 1
return 0
return _stream
class C:
if True:
def m(self):
pass
"""
)
assert funcs["mod.py::outer._stream"]["complexity"] == 2
assert "mod.py::C.m" in funcs
# ── pyproject settings ───────────────────────────────────────────────────
def test_relaxed_mypy_modules_are_the_overrides_that_loosen_strict() -> None:
pyproject = {
"tool": {
"mypy": {
"strict": True,
"overrides": [
{"module": "napalm.*", "ignore_missing_imports": True},
{"module": ["pkg.old", "pkg.older"], "ignore_errors": True},
{"module": "pkg.legacy", "disallow_untyped_defs": False},
{"module": "pkg.loose", "strict": False},
],
}
}
}
assert qr.relaxed_mypy_modules(pyproject) == [
"pkg.legacy",
"pkg.loose",
"pkg.old",
"pkg.older",
]
def test_coverage_floor_is_read_from_pyproject() -> None:
assert qr.coverage_floor({"tool": {"coverage": {"report": {"fail_under": 63.5}}}}) == 63.5
assert qr.coverage_floor({}) == 0
# ── the ratchet against a repo ───────────────────────────────────────────
LONG = "def long(x: int) -> int:\n" + " x += 1\n" * 60 + " return x\n"
def _repo(tmp_path: pathlib.Path, source: str, pyproject: str = "") -> pathlib.Path:
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "mod.py").write_text(source)
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "pkg"\n\n' + (pyproject or "[tool.coverage.report]\nfail_under = 50\n")
)
return tmp_path
def test_measure_reports_only_what_is_over_the_limit(tmp_path: pathlib.Path) -> None:
repo = _repo(
tmp_path, LONG + "\ndef ok(a: int) -> int:\n return a\n\ndef raw(a):\n pass\n"
)
measured = qr.measure(repo, "pkg")
assert measured["function_lines"] == {"pkg/mod.py::long": 62}
assert measured["function_complexity"] == {}
assert measured["python_file_lines"] == {}
assert measured["untyped_public"] == ["pkg/mod.py::raw"]
assert measured["coverage_floor"] == 50
def test_a_file_over_the_hard_maximum_is_measured(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "x = 1\n" * 1001)
assert qr.measure(repo, "pkg")["python_file_lines"] == {"pkg/mod.py": 1001}
def test_without_a_baseline_everything_over_the_limit_is_new(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
problems = qr.problems(qr.measure(repo, "pkg"), {})
assert problems == ["function_lines: pkg/mod.py::long = 62 (new)"]
def test_a_recorded_excess_may_stay_but_not_grow(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
measured = qr.measure(repo, "pkg")
assert qr.problems(measured, {"function_lines": {"pkg/mod.py::long": 62}}) == []
assert qr.problems(measured, {"function_lines": {"pkg/mod.py::long": 61}}) == [
"function_lines: pkg/mod.py::long = 62 (recorded 61)"
]
def test_a_new_untyped_public_function_is_a_problem(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "def raw(a):\n pass\n")
assert qr.problems(qr.measure(repo, "pkg"), {"untyped_public": []}) == [
"untyped_public: pkg/mod.py::raw (new)"
]
def test_a_new_relaxed_mypy_module_is_a_problem(tmp_path: pathlib.Path) -> None:
pyproject = (
"[tool.mypy]\nstrict = true\n\n[[tool.mypy.overrides]]\n"
'module = "pkg.mod"\nignore_errors = true\n'
)
repo = _repo(tmp_path, "", pyproject)
assert qr.problems(qr.measure(repo, "pkg"), {}) == ["mypy_relaxed: pkg.mod (new)"]
assert qr.problems(qr.measure(repo, "pkg"), {"mypy_relaxed": ["pkg.mod"]}) == []
def test_a_lowered_coverage_floor_is_a_problem(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "")
assert qr.problems(qr.measure(repo, "pkg"), {"coverage_floor": 60}) == [
"coverage_floor: 50 (recorded 60)"
]
def test_progress_shows_up_as_stale_entries(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "def ok(a: int) -> int:\n return a\n")
baseline = {
"function_lines": {"pkg/mod.py::gone": 70},
"untyped_public": ["pkg/mod.py::fixed"],
"mypy_relaxed": ["pkg.mod"],
"coverage_floor": 40,
}
assert qr.stale_entries(qr.measure(repo, "pkg"), baseline) == [
"function_lines: pkg/mod.py::gone",
"untyped_public: pkg/mod.py::fixed",
"mypy_relaxed: pkg.mod",
"coverage_floor: 40 -> 50",
]
def test_write_then_check_is_clean(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
assert qr.run(repo, "pkg", write=True)[0] == 0
baseline = json.loads((repo / qr.BASELINE).read_text())
assert baseline["function_lines"] == {"pkg/mod.py::long": 62}
assert qr.run(repo, "pkg") == (0, "within the baseline")
def test_check_fails_on_a_problem_and_on_a_stale_entry(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
(repo / "tests").mkdir()
(repo / qr.BASELINE).write_text(json.dumps({"function_lines": {"pkg/mod.py::old": 99}}))
code, text = qr.run(repo, "pkg")
assert code == 1
assert "pkg/mod.py::long = 62 (new)" in text
assert "stale: function_lines: pkg/mod.py::old" in text
+71
View File
@@ -0,0 +1,71 @@
"""The Gitea release that goes with a tag (STANDARDS.md §9)."""
from __future__ import annotations
import json
from typing import Any
import pytest
from napalm_standard.release import NotFound, publish
CHANGELOG = "# Changelog\n\n## [1.2.0] - 2026-10-22\n\n- Interfaces.\n\n## [1.1.0] - x\n\n- y\n"
class FakeApi:
def __init__(self, existing: dict[str, Any] | None = None) -> None:
self.existing = existing
self.calls: list[tuple[str, str, dict[str, Any] | None]] = []
def __call__(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
self.calls.append((method, path, payload))
if method == "GET":
if self.existing is None:
raise NotFound(path)
return self.existing
return {"id": 7, **(payload or {})}
def test_a_missing_release_is_created_from_the_changelog() -> None:
api = FakeApi()
publish("v1.2.0", "napalm-example", CHANGELOG, api)
assert api.calls[0][:2] == ("GET", "/releases/tags/v1.2.0")
method, path, payload = api.calls[1]
assert (method, path) == ("POST", "/releases")
assert payload == {
"tag_name": "v1.2.0",
"name": "napalm-example 1.2.0",
"body": "- Interfaces.",
"draft": False,
"prerelease": False,
}
def test_an_existing_release_is_updated_not_duplicated() -> None:
api = FakeApi(existing={"id": 3})
publish("v1.2.0", "napalm-example", CHANGELOG, api)
assert [c[:2] for c in api.calls] == [
("GET", "/releases/tags/v1.2.0"),
("PATCH", "/releases/3"),
]
def test_a_version_without_a_changelog_section_is_refused() -> None:
api = FakeApi()
with pytest.raises(ValueError, match="1.3.0"):
publish("v1.3.0", "napalm-example", CHANGELOG, api)
assert api.calls == []
def test_the_payload_is_json_serialisable() -> None:
api = FakeApi()
publish("v1.2.0", "napalm-example", CHANGELOG, api)
json.dumps(api.calls[1][2])
+29
View File
@@ -0,0 +1,29 @@
"""The template a new driver starts from meets the standard as it is."""
from __future__ import annotations
import pathlib
import shutil
from napalm_standard import ratchet
from napalm_standard.check import check_repo
TEMPLATE = pathlib.Path(__file__).resolve().parents[1] / "template"
def test_the_template_passes_the_check() -> None:
assert check_repo(TEMPLATE) == []
def test_the_template_is_within_its_ratchet(tmp_path: pathlib.Path) -> None:
repo = tmp_path / "repo"
shutil.copytree(TEMPLATE, repo)
assert ratchet.run(repo, "napalm_example") == (0, "within the baseline")
def test_the_template_ci_calls_the_shared_workflow() -> None:
ci = (TEMPLATE / ".gitea" / "workflows" / "ci.yml").read_text()
assert "uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@" in ci
assert "PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}" in ci