commit db6b2e4ea7b6323d71fcdd5fa53225e2c4f8548d Author: Christian Manivong Date: Thu Oct 22 19:01:14 2026 +0200 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. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ccc186b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/driver.yml b/.gitea/workflows/driver.yml new file mode 100644 index 0000000..6cbf1b5 --- /dev/null +++ b/.gitea/workflows/driver.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a368c9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.egg-info/ +.venv/ +build/ +dist/ +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38c9981 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68d39ec --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..48fc7d1 --- /dev/null +++ b/README.md @@ -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 diff --git a/STANDARDS.md b/STANDARDS.md new file mode 100644 index 0000000..098bfc8 --- /dev/null +++ b/STANDARDS.md @@ -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 `/`: `feature/` (or `feat/`), `fix/`, + `chore/`, `docs/`, `refactor/`. +- **Commits** follow [Conventional Commits](https://www.conventionalcommits.org/): + `(): ` in the imperative, under 72 characters, issue in the + footer (`Closes #12`). A breaking change says so: `feat!:` or a + `BREAKING CHANGE:` footer. +- **Pull requests** are small and focused, describe what changed and why, name the + issue, and say how it was tested (which device, which firmware, or "fixtures only"). +- **Issues** live in the driver's own repository. Each carries a priority label + (`prio/P0`–`prio/P4`). +- **CI** lives in `.gitea/workflows/` and calls the shared workflow (§7). No `.github/`. +- **No traces of tooling in the history.** Commit messages, pull requests and files + name no AI assistant, carry no `Co-Authored-By` for one and no "generated with" + footer; no `CLAUDE.md` or `.claude/` in any repository. + +## 2. Layout + +``` +napalm-/ +├── napalm_/ +│ ├── __init__.py exports the driver class(es) +│ ├── driver.py the NetworkDriver subclass: connection + getters +│ ├── parsers/ pure functions: device output in, typed dicts out +│ └── py.typed +├── tests/ +│ ├── fixtures/ recorded device output, anonymised +│ ├── test_parsers.py +│ └── test_driver.py +├── .gitea/workflows/ci.yml +├── CHANGELOG.md +├── LICENSE Apache-2.0 +├── README.md +└── pyproject.toml +``` + +The README says which devices and firmware versions the driver was tested against, +how it connects (protocol, port, required privileges), which getters and actions it +supports, and what it does **not** do. + +## 3. The contract + +- **`napalm-device-types` is authoritative.** A driver implements a role base + (`SwitchDriver`, `FirewallDriver`, `OSDriver`, …) and its capability mixins. A + method that more than one driver could offer is declared in device-types first, + with its TypedDict, and only then implemented. +- **Generic logic belongs in device-types, mechanics in the driver.** Algorithms + (diffing, reconciling, template methods) live on the base; the driver implements + the device-communication steps they call. A driver never re-implements what the + base provides. +- **Getters never write.** A `get_*` reads. Anything that changes the device is a + named action. +- **A failed read raises.** Returning an empty result for "could not read" makes a + broken device look like an empty one. +- **Drivers report facts, not policy.** Whether something is a warning, a risk or + worth a notification is the application's decision. A driver never names its + consumer (no `netork` in identifiers, file names or messages written to a device). +- **Public methods only.** An application calls what the contract declares; a + private helper is not an interface and may change in any release. +- **Driver metadata is data.** Vendor OIDs, fingerprint rules and similar knowledge + are class attributes, not code paths. + +## 4. Test-driven development — mandatory + +**The test is written before the code it tests.** Red, green, refactor: + +1. **Red** — write a failing test that describes the behaviour. Run it; it must fail + for the right reason, not an import error. +2. **Green** — the minimum code that makes it pass. +3. **Refactor** — clean up code and test, both stay green. + +A pull request without tests for what it adds or changes is not merged. + +| Code | Required | +|---|---| +| Parsers (pure functions) | Every branch, every edge case, against recorded fixtures | +| Getters | Happy path plus at least one failed read, with the transport mocked | +| Actions | The commands or requests sent, plus a refused or failed step | +| Connection helpers | Refused login, timeout, unreachable host — with a stub transport | + +- **Fixtures are real device output**, recorded from a named model and firmware and + anonymised (addresses, serials, hostnames, keys). The fixture name says where it + came from: `procurve-2530-YA.16.11.json`. +- **Tests never open a socket.** Only the happy path of a live connection to a real + device may be excluded from coverage, with `# pragma: no cover - live I/O`. +- A bug fix starts with a test that reproduces the bug. + +## 5. Clean code + +- **Format and lint:** `ruff format` and `ruff check`, line length 100. +- **Types:** every public function is annotated; `mypy --strict` (§6 for the old code). +- **Small functions:** under 30 lines recommended, 50 at most; cyclomatic + complexity 10 at most. One function does one thing. +- **File size:** under 300 lines recommended, 500 soft maximum, 1000 hard maximum. +- **Explicit over implicit:** no mutable default arguments, no hidden side effects, + one clear return type. +- **No magic values:** command strings, OIDs, API paths and timeouts are named + constants. +- **Errors:** catch what you can handle, as narrowly as possible; never a bare + `except:`. Re-raise with context (`raise X from exc`). Errors that cross the + contract use NAPALM's and device-types' exception types. +- **Logging, not print:** `logging.getLogger(__name__)`. Never log a password, token + or private key, not even at debug level. +- **Docstrings** on public modules, classes and functions; private helpers with a + self-explanatory name need none. +- **Secrets** come in as arguments (`optional_args`), never from files or + environment variables the driver chooses to read. + +## 6. The quality ratchet + +The rules above are newer than most of the code. Instead of a rewrite, each repo +records its current state and may only improve from there: + +| What | Where it is recorded | Rule | +|---|---|---| +| Functions over 50 lines or complexity 10, files over 1000 lines, unannotated public functions | `tests/quality_baseline.json` | An entry may shrink, never grow; anything not in it keeps the limit | +| Modules not yet under strict mypy | `[[tool.mypy.overrides]]` in `pyproject.toml`, mirrored in the baseline | The list may shrink, never grow; every new module is strict | +| Coverage floor | `[tool.coverage.report] fail_under`, mirrored in the baseline | May rise, never fall | + +```bash +napalm-standard ratchet # what is over and not recorded, and what can be tightened +napalm-standard ratchet --write # record today's state after an improvement +``` + +A fixed entry left in the baseline fails CI as *stale*: write it down, so the limit +holds there from then on. A new entry in the baseline is a deliberate exception and +needs the same justification as any other deviation, in the commit that adds it. + +`ruff`, `ruff format` and `bandit` have no ratchet: they hold from day one. + +## 7. CI + +Every driver's `.gitea/workflows/ci.yml` calls the shared workflow +(`template/.gitea/workflows/ci.yml`). It runs on pull requests, on `main` and on +release tags: + +| Job | What | +|---|---| +| `standard` | `napalm-standard check` (with `--tag` on a tag) and `napalm-standard ratchet` | +| `lint` | `ruff format --check`, `ruff check` | +| `types` | `mypy` on the package | +| `test` | `pytest --cov` on Python 3.10–3.13; the floor is `fail_under` | +| `lowest` | the tests again with every direct dependency at its declared floor | +| `security` | `bandit`, `pip-audit` | +| `build` | wheel and sdist, `twine check --strict` | +| `publish` | tag only, after all of the above: upload to PyPI, then the Gitea release | + +## 8. Packaging + +- **Name:** `napalm-` on PyPI when that name is free; a name already taken by + someone else gets a descriptive one instead. The `napalm.drivers` entry point + names do not change with the package name. +- **`pyproject.toml`** declares `name`, `version`, `description`, `readme`, + `license = "Apache-2.0"`, `requires-python = ">=3.10"`, the dependencies, a + `dev` extra, `[project.urls]` with `Repository` (and `Issues`) on + `https://git.netork.io/NAPALM/…`, and the `napalm.drivers` entry points. +- **Dependencies are ranges with true floors.** A driver is a library: it never pins + `==`. The floor on `napalm-device-types` (and every other dependency) is the + oldest version that has everything the driver uses — the `lowest` CI job proves + it. An upper bound only for a known incompatibility, with a comment saying which. +- **`py.typed`** ships with the package. + +`napalm-standard check` verifies: LICENSE, README and CHANGELOG present; the +metadata fields; `requires-python` ≥ 3.10; a floor on `napalm-device-types`; a +`napalm.drivers` entry point; URLs in the org; `py.typed`; CI in `.gitea/` and no +`.github/`; strict mypy, a coverage source and floor; on a tag, tag = version and a +CHANGELOG section for it. + +## 9. Versions and releases + +[Semantic Versioning](https://semver.org/). For a driver: + +| Bump | When | +|---|---| +| MAJOR | A public method, its arguments or a returned field change or disappear; the floor on device-types crosses a device-types MAJOR | +| MINOR | A new getter, action, field or supported device | +| PATCH | A fix that changes no interface | + +Before 1.0.0 a breaking change bumps MINOR and says so in the CHANGELOG. + +**A release is made by hand, by tag:** + +1. On a branch, set `version` in `pyproject.toml` and move the `[Unreleased]` + entries of `CHANGELOG.md` under `## [X.Y.Z] - YYYY-MM-DD` + ([Keep a Changelog](https://keepachangelog.com/)). Merge it like any other change. +2. Tag that commit on `main` and push the tag: + + ```bash + git tag -a vX.Y.Z -m "napalm- X.Y.Z" + git push origin vX.Y.Z + ``` + +3. CI runs everything, uploads to PyPI with the repo secret `PYPI_TOKEN`, and + creates the Gitea release with the CHANGELOG section as its text. + +A version on PyPI can never be replaced, only yanked. A broken release is followed +by a new PATCH, not a re-tag. The CHANGELOG is written for the people who use the +driver: what they can do now, what changed for them — not how it was built. + +## 10. A new driver + +1. Create the repository in the `NAPALM` org. +2. Copy `template/` from this repository and replace `example`/`Example`. +3. Write the first parser test against a recorded fixture — then the parser. +4. The first upload creates the project on PyPI and needs an account-wide token. + Replace it right after with a token scoped to the project, as the repo secret + `PYPI_TOKEN`. diff --git a/napalm_standard/__init__.py b/napalm_standard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/napalm_standard/__main__.py b/napalm_standard/__main__.py new file mode 100644 index 0000000..4e1d1f3 --- /dev/null +++ b/napalm_standard/__main__.py @@ -0,0 +1,5 @@ +import sys + +from napalm_standard.cli import main + +sys.exit(main()) diff --git a/napalm_standard/changelog.py b/napalm_standard/changelog.py new file mode 100644 index 0000000..3c633ba --- /dev/null +++ b/napalm_standard/changelog.py @@ -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[^\]]+)\]", 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 diff --git a/napalm_standard/check.py b/napalm_standard/check.py new file mode 100644 index 0000000..a112284 --- /dev/null +++ b/napalm_standard/check.py @@ -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 diff --git a/napalm_standard/cli.py b/napalm_standard/cli.py new file mode 100644 index 0000000..802560f --- /dev/null +++ b/napalm_standard/cli.py @@ -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 diff --git a/napalm_standard/py.typed b/napalm_standard/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/napalm_standard/ratchet.py b/napalm_standard/ratchet.py new file mode 100644 index 0000000..226e740 --- /dev/null +++ b/napalm_standard/ratchet.py @@ -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") diff --git a/napalm_standard/release.py b/napalm_standard/release.py new file mode 100644 index 0000000..b7e5f88 --- /dev/null +++ b/napalm_standard/release.py @@ -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/``.""" + 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6043b7a --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/template/.gitea/workflows/ci.yml b/template/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7530c79 --- /dev/null +++ b/template/.gitea/workflows/ci.yml @@ -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 }} diff --git a/template/.gitignore b/template/.gitignore new file mode 100644 index 0000000..a368c9d --- /dev/null +++ b/template/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.egg-info/ +.venv/ +build/ +dist/ +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/template/CHANGELOG.md b/template/CHANGELOG.md new file mode 100644 index 0000000..58991a3 --- /dev/null +++ b/template/CHANGELOG.md @@ -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. diff --git a/template/LICENSE b/template/LICENSE new file mode 100644 index 0000000..68d39ec --- /dev/null +++ b/template/LICENSE @@ -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. diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..8df194e --- /dev/null +++ b/template/README.md @@ -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 diff --git a/template/napalm_example/__init__.py b/template/napalm_example/__init__.py new file mode 100644 index 0000000..1c59935 --- /dev/null +++ b/template/napalm_example/__init__.py @@ -0,0 +1,5 @@ +"""NAPALM driver for Example switches.""" + +from napalm_example.driver import ExampleDriver + +__all__ = ["ExampleDriver"] diff --git a/template/napalm_example/driver.py b/template/napalm_example/driver.py new file mode 100644 index 0000000..4e94dce --- /dev/null +++ b/template/napalm_example/driver.py @@ -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": [], + } diff --git a/template/napalm_example/parsers/__init__.py b/template/napalm_example/parsers/__init__.py new file mode 100644 index 0000000..5eafc4a --- /dev/null +++ b/template/napalm_example/parsers/__init__.py @@ -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[^:\n]+?)\s*:\s*(?P.*)$", re.MULTILINE) +#: "3 days, 4:05:06" or "4:05:06". +_UPTIME = re.compile(r"^(?:(?P\d+) days?, )?(?P\d+):(?P\d\d):(?P\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 diff --git a/template/napalm_example/py.typed b/template/napalm_example/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/template/pyproject.toml b/template/pyproject.toml new file mode 100644 index 0000000..018b52d --- /dev/null +++ b/template/pyproject.toml @@ -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"] diff --git a/template/tests/fixtures/example24-1.0.0-show-version.txt b/template/tests/fixtures/example24-1.0.0-show-version.txt new file mode 100644 index 0000000..304b62e --- /dev/null +++ b/template/tests/fixtures/example24-1.0.0-show-version.txt @@ -0,0 +1,5 @@ +Example 24 Switch +Hostname : sw-lab-01 +Firmware : 1.0.0 +Serial number : EX0000000001 +Uptime : 3 days, 4:05:06 diff --git a/template/tests/test_driver.py b/template/tests/test_driver.py new file mode 100644 index 0000000..a06325d --- /dev/null +++ b/template/tests/test_driver.py @@ -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 diff --git a/template/tests/test_parsers.py b/template/tests/test_parsers.py new file mode 100644 index 0000000..05421e0 --- /dev/null +++ b/template/tests/test_parsers.py @@ -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") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e83cd3f --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..7ad7511 --- /dev/null +++ b/tests/test_changelog.py @@ -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" diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..fc91a64 --- /dev/null +++ b/tests/test_check.py @@ -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"} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a335d21 --- /dev/null +++ b/tests/test_cli.py @@ -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" diff --git a/tests/test_ratchet.py b/tests/test_ratchet.py new file mode 100644 index 0000000..33db9bb --- /dev/null +++ b/tests/test_ratchet.py @@ -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 diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..6944f11 --- /dev/null +++ b/tests/test_release.py @@ -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]) diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..788ec03 --- /dev/null +++ b/tests/test_template.py @@ -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