tox: add tox

`tox` is a standard testing tool for Python projects, this allows you to
test locally with all your installed Python version with the following
command:

`tox -m test -p all`

To run the tests in parallel for all supported Python versions.

To run linters or type analysis:

```
tox -m lint -p all
tox -m type -p all
```

This commit *also* disables the `import-error` warning from `pylint`,
not all Python versions have the system-installed Python libraries
available and they can't be fetched from PyPI.

Some linters have been added and the general order linters run in has
been changed. This allows for quicker test failure when running
`tox -m lint`. As a consequence the `test_pylint` test has been removed
as it's role can now be fulfilled by `tox`.

Other assorted linter fixes due to newer versions:
- use a str.join method (`consider-using-join`)
- fix various (newer) mypy and pylint issues
- comments starting with `#` and no space due to `autopep8`

This also changes our CI to use the new `tox` setup and on top of that
pins the versions of linters used. This might move into separate
requirements.txt files later on to allow for easier updating of those
dependencies.
This commit is contained in:
Simon de Vlieger 2023-03-20 11:54:35 +01:00
parent a7b75bea3b
commit d60690ce46
23 changed files with 193 additions and 184 deletions

72
.github/workflows/check.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Checks
on: [pull_request, push]
permissions:
contents: read
jobs:
spelling_checker:
name: "Spelling"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: codespell-project/actions-codespell@master
with:
ignore_words_list: msdos, pullrequest
skip: ./.git,coverity,rpmbuild,samples
python_code_linters:
name: "Python Linters"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
linter:
- "ruff"
- "pylint"
- "autopep8"
- "isort"
steps:
- name: "Clone Repository"
uses: actions/checkout@v3
- name: "Run Linters"
uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
with:
image: ghcr.io/osbuild/osbuild-ci:latest-202304251412
run: |
tox -e "${{ matrix.linter }}"
python_code_types:
name: "Python Typing"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
typer:
- "mypy"
steps:
- name: "Clone Repository"
uses: actions/checkout@v3
- name: "Run Linters"
uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
with:
image: ghcr.io/osbuild/osbuild-ci:latest-202304251412
run: |
tox -e "${{ matrix.typer }}"
shell_linters:
name: "Shell Linters"
runs-on: ubuntu-latest
steps:
- name: "Clone Repository"
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: "Differential ShellCheck"
uses: redhat-plumbers-in-action/differential-shellcheck@v3
with:
severity: warning
token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,24 +0,0 @@
name: "Differential ShellCheck"
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint:
name: "Differential Shell Check"
runs-on: ubuntu-latest
steps:
- name: "Clone Repository"
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: "Differential ShellCheck"
uses: redhat-plumbers-in-action/differential-shellcheck@v3
with:
severity: warning
token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,10 +1,10 @@
name: Checks name: Generate
on: [pull_request, push] on: [pull_request, push]
jobs: jobs:
documentation: generate_documentation:
name: "📚 Documentation" name: "Documentation"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.io/library/python:3.7 image: docker.io/library/python:3.7
@ -33,8 +33,8 @@ jobs:
test -d docs test -d docs
test -f docs/osbuild.1 test -f docs/osbuild.1
test_data: generate_test_data:
name: "Regenerate Test Data" name: "Test Data"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Clone Repository" - name: "Clone Repository"
@ -42,17 +42,7 @@ jobs:
- name: "Regenerate Test Data" - name: "Regenerate Test Data"
uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
with: with:
image: ghcr.io/osbuild/osbuild-ci:latest-202304110753 image: ghcr.io/osbuild/osbuild-ci:latest-202304251412
run: | run: |
make test-data make test-data
git diff --exit-code -- ./test/data git diff --exit-code -- ./test/data
codespell:
name: "Spell check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: codespell-project/actions-codespell@master
with:
ignore_words_list: msdos, pullrequest
skip: ./.git,coverity,rpmbuild,samples

View file

@ -4,7 +4,7 @@ on: [pull_request, push]
jobs: jobs:
test_suite: test_suite:
name: "Test Suite" name: "Unittest"
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@ -19,17 +19,21 @@ jobs:
- "test.run.test_noop" - "test.run.test_noop"
- "test.run.test_sources" - "test.run.test_sources"
- "test.run.test_stages" - "test.run.test_stages"
- "test.src" environment:
- "py36"
- "py37"
- "py38"
- "py39"
- "py310"
- "py311"
- "py312"
steps: steps:
- name: "Clone Repository" - name: "Clone Repository"
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: "Run Tests" - name: "Run"
uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
with: with:
image: ghcr.io/osbuild/osbuild-ci:latest-202304110753 image: ghcr.io/osbuild/osbuild-ci:latest-202304251412
run: | run: |
python3 -m pytest \ TEST_CATEGORY="${{ matrix.test }}" \
--pyargs "${{ matrix.test }}" \ tox -e "${{ matrix.environment }}"
--rootdir=. \
--cov-report=xml --cov=osbuild \
-v

4
.gitignore vendored
View file

@ -20,4 +20,6 @@ cov-int/
/docs/osbuild-manifest.5 /docs/osbuild-manifest.5
venv venv
.venv .venv
/.tox

6
.ruff.toml Normal file
View file

@ -0,0 +1,6 @@
line-length = 120
ignore = [
"E741", # ambiguous variable names
"E501", # line too long
]

View file

@ -228,7 +228,7 @@ def create_oci_dir(tree, output_dir, options):
blobs = os.path.join(output_dir, "blobs", "sha256") blobs = os.path.join(output_dir, "blobs", "sha256")
os.makedirs(blobs) os.makedirs(blobs)
## layers / rootfs # layers / rootfs
digest, info = blobs_add_layer(blobs, tree) digest, info = blobs_add_layer(blobs, tree)

View file

@ -502,7 +502,7 @@ def grub2_partition_id(pt: PartitionTable):
return label2grub[pt.label] return label2grub[pt.label]
#pylint: disable=too-many-branches # pylint: disable=too-many-branches
def install_grub2(image: str, pt: PartitionTable, options): def install_grub2(image: str, pt: PartitionTable, options):
"""Install grub2 to image""" """Install grub2 to image"""
platform = options.get("platform", "i386-pc") platform = options.get("platform", "i386-pc")
@ -637,7 +637,7 @@ def install_zipl(root: str, device: str, pt: PartitionTable):
check=True) check=True)
#pylint: disable=too-many-branches # pylint: disable=too-many-branches
def main(tree, output_dir, options, loop_client): def main(tree, output_dir, options, loop_client):
fmt = options["format"] fmt = options["format"]
filename = options["filename"] filename = options["filename"]

View file

@ -15,7 +15,7 @@ import stat
import subprocess import subprocess
import tempfile import tempfile
import time import time
from typing import Optional, Set from typing import Set
from osbuild.api import BaseAPI from osbuild.api import BaseAPI
from osbuild.util import linux from osbuild.util import linux
@ -101,7 +101,7 @@ class BuildRoot(contextlib.AbstractContextManager):
self.proc = None self.proc = None
self.tmp = None self.tmp = None
self.mount_boot = True self.mount_boot = True
self.caps: Optional[set] = None self.caps = None
@staticmethod @staticmethod
def _mknod(path, name, mode, major, minor): def _mknod(path, name, mode, major, minor):

View file

@ -2,7 +2,7 @@
Second, and current, version of the manifest description Second, and current, version of the manifest description
""" """
from typing import Any, Dict from typing import Any, Dict, Optional
from osbuild.meta import Index, ModuleInfo, ValidationResult from osbuild.meta import Index, ModuleInfo, ValidationResult
@ -197,7 +197,7 @@ def sort_devices(devices: Dict) -> Dict:
desc = devices[name] desc = devices[name]
parent = desc.get("parent") parent = desc.get("parent")
if parent and not parent in result: if parent and parent not in result:
# if the parent is not in the `result` list, it must # if the parent is not in the `result` list, it must
# be in `todo`; otherwise it is missing # be in `todo`; otherwise it is missing
if parent not in todo: if parent not in todo:
@ -391,8 +391,8 @@ def load(description: Dict, index: Index) -> Manifest:
return manifest return manifest
#pylint: disable=too-many-branches # pylint: disable=too-many-branches
def output(manifest: Manifest, res: Dict, store: ObjectStore = None) -> Dict: def output(manifest: Manifest, res: Dict, store: Optional[ObjectStore] = None) -> Dict:
"""Convert a result into the v2 format""" """Convert a result into the v2 format"""
def collect_metadata(p: Pipeline) -> Dict[str, Any]: def collect_metadata(p: Pipeline) -> Dict[str, Any]:

View file

@ -272,7 +272,7 @@ class Service(abc.ABC):
# an exception in `sock.send` later. # an exception in `sock.send` later.
self._check_fds(reply_fds) self._check_fds(reply_fds)
except: # pylint: disable=bare-except except Exception: # pylint: disable=broad-exception-caught
reply_fds = self._close_all(reply_fds) reply_fds = self._close_all(reply_fds)
_, val, tb = sys.exc_info() _, val, tb = sys.exc_info()
reply = self.protocol.encode_exception(val, tb) reply = self.protocol.encode_exception(val, tb)
@ -351,7 +351,7 @@ class ServiceClient:
def call_with_fds(self, method: str, def call_with_fds(self, method: str,
args: Optional[Union[List[str], Dict[str, Any]]] = None, args: Optional[Union[List[str], Dict[str, Any]]] = None,
fds: Optional[List[int]] = None, fds: Optional[List[int]] = None,
on_signal: Callable[[Any, Optional[Iterable[int]]], None] = None on_signal: Optional[Callable[[Any, Optional[Iterable[int]]], None]] = None
) -> Tuple[Any, Optional[Iterable[int]]]: ) -> Tuple[Any, Optional[Iterable[int]]]:
""" """
Remotely call a method and return the result, including file Remotely call a method and return the result, including file

View file

@ -119,7 +119,7 @@ class Loop:
self.devname = f"loop{minor}" self.devname = f"loop{minor}"
self.minor = minor self.minor = minor
self.on_close: Optional[Callable[["Loop"], None]] = None self.on_close = None
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
if not dir_fd: if not dir_fd:

View file

@ -338,7 +338,7 @@ class ObjectStore(contextlib.AbstractContextManager):
@property @property
def active(self) -> bool: def active(self) -> bool:
#pylint: disable=protected-access # pylint: disable=protected-access
return self.cache._is_active() return self.cache._is_active()
@property @property

View file

@ -383,7 +383,7 @@ class Manifest:
def __init__(self): def __init__(self):
self.pipelines = collections.OrderedDict() self.pipelines = collections.OrderedDict()
self.sources: List[Source] = [] self.sources = []
def add_pipeline( def add_pipeline(
self, self,

View file

@ -6,7 +6,8 @@ import subprocess
import sys import sys
import tempfile import tempfile
import typing import typing
from typing import Any, List # pylint doesn't understand the string-annotation below
from typing import Any, List # pylint: disable=unused-import
from .types import PathLike from .types import PathLike
@ -222,7 +223,7 @@ class SubIdsDB:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.db: collections.OrderedDict[str, Any] = collections.OrderedDict() self.db: 'collections.OrderedDict[str, Any]' = collections.OrderedDict()
def read(self, fp) -> int: def read(self, fp) -> int:
idx = 0 idx = 0

View file

@ -9,7 +9,8 @@ disable=missing-docstring,
consider-using-with, consider-using-with,
consider-using-from-import, consider-using-from-import,
line-too-long, line-too-long,
useless-option-value useless-option-value,
import-error
[pylint.TYPECHECK] [pylint.TYPECHECK]
ignored-classes=osbuild.loop.LoopInfo ignored-classes=osbuild.loop.LoopInfo

View file

@ -129,7 +129,7 @@ def yesno(name: str, value: bool) -> str:
return f"--{prefix}{name}" return f"--{prefix}{name}"
#pylint: disable=too-many-branches # pylint: disable=too-many-branches
def main(tree, options): def main(tree, options):
kernels = options["kernel"] kernels = options["kernel"]
compress = options.get("compress") compress = options.get("compress")

View file

@ -50,7 +50,7 @@ After=network-online.target"""
execs = "\n" execs = "\n"
for command in commands: for command in commands:
execs += f"ExecStart={command}\n" execs += f"ExecStart={command}\n" # pylint: disable=consider-using-join
service = f"""[Unit] service = f"""[Unit]
Description=OSBuild First Boot Service Description=OSBuild First Boot Service

View file

@ -484,7 +484,7 @@ class GrubConfig:
return data return data
#pylint: disable=too-many-statements,too-many-branches # pylint: disable=too-many-statements,too-many-branches
def main(tree, options): def main(tree, options):
root_fs = options.get("rootfs") root_fs = options.get("rootfs")
boot_fs = options.get("bootfs") boot_fs = options.get("bootfs")

View file

@ -309,7 +309,7 @@ def create_oci_dir(inputs, output_dir, options, create_time):
blobs = os.path.join(output_dir, "blobs", "sha256") blobs = os.path.join(output_dir, "blobs", "sha256")
os.makedirs(blobs) os.makedirs(blobs)
## layers / rootfs # layers / rootfs
for ip in sorted(inputs.keys()): for ip in sorted(inputs.keys()):
tree = inputs[ip]["path"] tree = inputs[ip]["path"]
digest, info = blobs_add_layer(blobs, tree) digest, info = blobs_add_layer(blobs, tree)

View file

@ -36,7 +36,7 @@ SCHEMA_2 = """
OVF_TEMPLATE = """<?xml version="1.0"?> OVF_TEMPLATE = """<?xml version="1.0"?>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1" xmlns:cim="http://schemas.dmtf.org/wbem/wscim/1/common" xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1" xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData" xmlns:vmw="http://www.vmware.com/schema/ovf" xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1" xmlns:cim="http://schemas.dmtf.org/wbem/wscim/1/common" xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1" xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData" xmlns:vmw="http://www.vmware.com/schema/ovf" xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<References> <References>
<File ovf:href="{image_name}" ovf:id="file1" ovf:size="{vmdk_size}"/> <File ovf:href="{image_name}" ovf:id="file1" ovf:size="{vmdk_size}"/>
</References> </References>
<DiskSection> <DiskSection>
<Info>Virtual disk information</Info> <Info>Virtual disk information</Info>
@ -57,7 +57,7 @@ OVF_TEMPLATE = """<?xml version="1.0"?>
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName> <vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
<vssd:InstanceID>0</vssd:InstanceID> <vssd:InstanceID>0</vssd:InstanceID>
<vssd:VirtualSystemIdentifier>image</vssd:VirtualSystemIdentifier> <vssd:VirtualSystemIdentifier>image</vssd:VirtualSystemIdentifier>
<vssd:VirtualSystemType>vmx-15</vssd:VirtualSystemType> <vssd:VirtualSystemType>vmx-15</vssd:VirtualSystemType>
</System> </System>
<Item> <Item>
<rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits> <rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>
@ -88,7 +88,7 @@ OVF_TEMPLATE = """<?xml version="1.0"?>
<rasd:Description>SCSI Controller</rasd:Description> <rasd:Description>SCSI Controller</rasd:Description>
<rasd:ElementName>SCSI Controller 0</rasd:ElementName> <rasd:ElementName>SCSI Controller 0</rasd:ElementName>
<rasd:InstanceID>3</rasd:InstanceID> <rasd:InstanceID>3</rasd:InstanceID>
<rasd:ResourceSubType>VirtualSCSI</rasd:ResourceSubType> <rasd:ResourceSubType>VirtualSCSI</rasd:ResourceSubType>
<rasd:ResourceType>6</rasd:ResourceType> <rasd:ResourceType>6</rasd:ResourceType>
</Item> </Item>
<Item> <Item>

View file

@ -1,110 +0,0 @@
#
# Run `pylint` on all python sources.
#
import os
import subprocess
import pytest
from .. import test
@pytest.fixture(name="source_files")
def discover_source_files():
# Evaluate whether `path` is a python file. This assumes that the file is
# opened by the caller, anyway, hence it might invoke `file` to detect the
# file type if in doubt.
def is_python_script(path):
if path.endswith(".py"):
return True
mime_valid = [
"text/x-python",
"text/x-script.python",
]
mime_file = subprocess.check_output(
[
"file",
"-bi",
"--",
path,
],
encoding="utf-8",
)
return any(mime_file.startswith(v) for v in mime_valid)
# Enumerate all repository files. This will invoke `git ls-tree` to list
# all files. This also requires a temporary change of directory, since git
# operates on the current directory and does not allow path arguments.
def list_files(path):
cwd = os.getcwd()
os.chdir(path)
files = subprocess.check_output(
[
"git",
"ls-tree",
"-rz",
"--full-tree",
"--name-only",
"HEAD",
],
encoding="utf-8",
)
os.chdir(cwd)
files = files.split('\x00')
return (os.path.join(path, f) for f in files)
if not test.TestBase.have_test_checkout():
pytest.skip("no test-checkout access")
files = list_files(test.TestBase.locate_test_checkout())
files = filter(is_python_script, files)
return list(files)
def test_pylint(source_files):
#
# Run `pylint` on all python sources. We simply use `find` to locate
# all `*.py` files, and then manually select the reverse-domain named
# modules we have.
#
r = subprocess.run(["pylint"] + source_files, check=False)
if r.returncode != 0:
pytest.fail("pylint issues detected")
@pytest.mark.skipif(not test.TestBase.have_mypy(), reason="mypy not available")
def test_mypy():
#
# Run `mypy` on osbuild sources.
#
r = subprocess.run(["mypy", "osbuild/"], check=False)
if r.returncode != 0:
pytest.fail("mypy issues detected")
@pytest.mark.skipif(not test.TestBase.have_isort(), reason="isort not available")
def test_isort(source_files):
#
# Run `isort` on all python sources. We simply use `find` to locate
# all `*.py` files, and then manually select the reverse-domain named
# modules we have.
#
r = subprocess.run(["isort", "--check", "--diff"] + source_files, check=False)
if r.returncode != 0:
pytest.fail("isort issues detected")
@pytest.mark.skipif(not test.TestBase.have_autopep8(), reason="autopep8 not available")
def test_autopep8(source_files):
r = subprocess.run(["autopep8-3", "--diff", "--exit-code"] + source_files, check=False)
if r.returncode != 0:
pytest.fail("autopep8 has detected changes (see diff)")

67
tox.ini Normal file
View file

@ -0,0 +1,67 @@
[tox]
env_list =
py{36,37,38,39,310,311}
lint
type
labels =
test = py{36,37,38,39,310,311}
lint = ruff, isort, autopep8, pylint
type = mypy
[testenv]
description = "run osbuild unit tests"
deps =
pytest
jsonschema
mako
iniparse
pyyaml
setenv =
LINTABLES = osbuild/ assemblers/* devices/* inputs/* mounts/* runners/* sources/* stages/*
TYPEABLES = osbuild
passenv =
TEST_CATEGORY
commands =
bash -c 'python -m pytest --pyargs --rootdir=. {env:TEST_CATEGORY}'
allowlist_externals =
bash
[testenv:ruff]
deps =
ruff==0.0.263
commands =
bash -c 'python -m ruff {env:LINTABLES}'
[testenv:isort]
deps =
isort==5.12.0
commands =
bash -c 'python -m isort --check --diff {env:LINTABLES}'
[testenv:autopep8]
deps =
autopep8==2.0.2
commands =
bash -c 'python -m autopep8 -r --diff --exit-code {env:LINTABLES}'
[testenv:pylint]
deps =
pylint==2.17.3
commands =
bash -c 'python -m pylint {env:LINTABLES}'
[testenv:mypy]
deps =
mypy==1.2.0
commands =
bash -c 'python -m mypy {env:TYPEABLES}'