Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Copyright (C) 2025, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

name: CI

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
name: Test (python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}

- name: Install the package and its test dependencies
run: python -m pip install -e ".[test]" -c requirements-ci.txt

- name: Run the test suite
run: python -m pytest --cov=setup_ovs --cov-report=xml --cov-report=term-missing

# Published in the run summary so the coverage figures are readable and
# linkable without any third party service. This is what the OpenSSF
# statement and branch coverage criteria are evidenced with.
- name: Publish the coverage figures in the run summary
if: matrix.python-version == '3.12'
run: |
echo "## Coverage" >> "$GITHUB_STEP_SUMMARY"
python -m coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY"

- name: Keep the coverage report for the Sonar job
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: coverage
path: coverage.xml

reproducible-build:
name: Reproducible build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

- run: python -m pip install build -c requirements-ci.txt

# Without SOURCE_DATE_EPOCH setuptools stamps the wheel with the file
# mtimes, which differ on every checkout. Pinning it to the commit date
# makes the build a pure function of the source tree.
# The output directories live outside the work tree on purpose: the
# project uses a flat layout, so a build/ directory sitting next to
# setup_ovs/ would be picked up as a second top-level package by
# setuptools auto-discovery and break the following build.
- name: Build the wheel twice and compare
run: |
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export SOURCE_DATE_EPOCH
echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH"
python -m build --wheel --outdir "$RUNNER_TEMP/first"
python -m build --wheel --outdir "$RUNNER_TEMP/second"
first_hash=$(sha256sum "$RUNNER_TEMP"/first/*.whl | cut -d' ' -f1)
second_hash=$(sha256sum "$RUNNER_TEMP"/second/*.whl | cut -d' ' -f1)
echo "first $first_hash"
echo "second $second_hash"
test "$first_hash" = "$second_hash"

sonar:
name: SonarCloud
runs-on: ubuntu-latest
needs: test
if: ${{ !github.event.pull_request.head.repo.fork }}
# Declared at job level so the step below can test it in its `if`.
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# Sonar needs the full history to assign blame, and therefore to
# tell new code from old code.
fetch-depth: 0

- name: Download the coverage report
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: coverage

# Requires Automatic Analysis to be turned off in the SonarCloud
# project settings, otherwise SonarCloud rejects the CI analysis and
# fails the build. Skipped while SONAR_TOKEN is unset, which is also
# the case for pull requests opened from a fork.
- name: Run the scanner
if: env.SONAR_TOKEN != ''
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
SONAR_HOST_URL: https://sonarcloud.io
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ build/
__pycache__/
dist
*.egg-info
.coverage
coverage.xml
coverage.json
htmlcov/
.pytest_cache/
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,42 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=seapath_python3-setup-ovs&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=seapath_python3-setup-ovs)
[![CI](https://github.com/seapath/python3-setup-ovs/actions/workflows/ci.yml/badge.svg)](https://github.com/seapath/python3-setup-ovs/actions/workflows/ci.yml)
# python3-setup-ovs
Python tool to setup the ovs topology in a Seapath cluster

## Tests

The test suite runs entirely off-target: every call to OVS, to the network
stack and to sysfs is mocked, so no cluster, no root access and no real
Open vSwitch are needed.

```sh
pip install -e ".[test]"
pytest
```

To reproduce the coverage figures published on SonarCloud:

```sh
pytest --cov=setup_ovs --cov-report=term-missing --cov-report=xml
```

Branch coverage is enabled in `pyproject.toml`, so the report covers both
the statement and the branch criteria.

A handful of tests are marked `xfail(strict=True)`. Each one documents a bug
found while writing the suite and pins the current, wrong behaviour: the
suite fails again the day the bug is fixed, which forces the marker to be
removed along with the fix. Their `reason` field states the defect.

## Reproducible build

The wheel is byte-for-byte reproducible provided `SOURCE_DATE_EPOCH` is set.
Without it setuptools stamps the archive with the source file mtimes, which
differ on every checkout:

```sh
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) python -m build --wheel
```

The `reproducible-build` CI job builds the wheel twice this way and compares
the SHA-256 of the two archives.
24 changes: 24 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ authors = [
license = { text = "Apache-2.0" }
readme = "README.md"
requires-python = ">=3.6"
dependencies = [
"PyYAML",
]

[project.optional-dependencies]
test = [
"pytest",
"pytest-cov",
"coverage[toml]",
]

[project.scripts]
setup_ovs = "setup_ovs.setup_ovs:main"
Expand All @@ -22,3 +32,17 @@ Homepage = "https://github.com/seapath/python3-setup-ovs"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["setup_ovs"]

[tool.coverage.report]
show_missing = true
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
]
13 changes: 13 additions & 0 deletions requirements-ci.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (C) 2025, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0
#
# Pinned toolchain for the CI jobs, so a run is reproducible and does not
# silently pick up a new release between two builds. Used as a pip
# constraints file, transitive dependencies are still resolved normally.
# Every pin below supports Python 3.9 to 3.13.

pytest==8.3.5
pytest-cov==5.0.0
coverage==7.6.1
PyYAML==6.0.2
build==1.2.2.post1
11 changes: 11 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copyright (C) 2025, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

sonar.projectKey=seapath_python3-setup-ovs
sonar.organization=seapath

sonar.sources=setup_ovs
sonar.tests=tests

sonar.python.version=3.9, 3.10, 3.11, 3.12, 3.13
sonar.python.coverage.reportPaths=coverage.xml
53 changes: 53 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright (C) 2025, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

import pytest

from setup_ovs import helpers


@pytest.fixture(autouse=True)
def reset_helpers_state(monkeypatch):
"""
helpers.dry_run is a module global and find_command is memoized.
Both leak between tests, so reset them around every test. monkeypatch
restores dry_run on teardown, tests that need it on just set it the
same way.
"""
# Captured before the test body runs, so the teardown still clears the
# real memoized function even when a test monkeypatches find_command.
real_find_command = helpers.find_command
real_find_command.cache_clear()
monkeypatch.setattr(helpers, "dry_run", False)
yield
real_find_command.cache_clear()


@pytest.fixture
def run_command(monkeypatch):
"""
Replace helpers.run_command by a recorder shared by every module.

The modules under test call helpers.run_command through the module
object, so patching the attribute once covers ovs, openflow and check.
"""

class Recorder:
def __init__(self):
self.calls = []

def __call__(self, *cmd_args, **kwargs):
self.calls.append((cmd_args, kwargs))
return None

@property
def commands(self):
"""Every call flattened to a single string, for substring asserts."""
return [" ".join(map(str, args)) for args, _ in self.calls]

def commands_containing(self, needle):
return [cmd for cmd in self.commands if needle in cmd]

recorder = Recorder()
monkeypatch.setattr(helpers, "run_command", recorder)
return recorder
Loading
Loading