feat: standards, shared CI and release tooling for NAPALM drivers
STANDARDS.md binds every repository in the NAPALM org: one default branch, Conventional Commits, the device-types contract, test-driven development, clean-code limits, a quality ratchet for existing code, packaging rules and releases by tag to PyPI. napalm-standard holds what a tool can check: the conformance check (with release rules on a tag), the quality ratchet (ported from netOrk's quality_ratchet.py), and the Gitea release from the CHANGELOG section. .gitea/workflows/driver.yml is the CI every driver calls; template/ is a new driver's starting point and passes the check as it is.
This commit is contained in:
commit
db6b2e4ea7
37 files changed
+2894
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from napalm_standard.cli import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""NAPALM driver for Example switches."""
|
||||
|
||||
from napalm_example.driver import ExampleDriver
|
||||
|
||||
__all__ = ["ExampleDriver"]
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
Reference in new issue
Block a user