From 2950a4853121e97b9b7b233b1878bd4e359d2480 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 11 Jun 2026 08:10:34 +0200 Subject: [PATCH 1/3] tools: Add barrage We are going to port our tests to . Get it into the box via pip; this will allow dependabot (or renovate) to keep it up to date without custom logic, once we move from "pull from git" to "pull latest release from pypi". With renovate, we could also keep the git SHA pinning, but renovate requires some more set up effort. Note: An alternative way for this would be to pull in barrage as a submodule. Dependabot/renovate work great for these, but the human ergonomics are not very pleasant. --- REUSE.toml | 1 + mkosi.tools.conf/mkosi.conf.d/arch.conf | 1 + .../mkosi.conf.d/azure-centos-fedora.conf | 1 + .../mkosi.conf.d/debian-kali-ubuntu.conf | 1 + mkosi.tools.conf/mkosi.conf.d/opensuse.conf | 1 + mkosi.tools.conf/mkosi.conf.d/postmarketos.conf | 1 + mkosi.tools.conf/mkosi.prepare.chroot | 2 ++ mkosi.tools.conf/test-requirements.txt | 4 ++++ pyproject.toml | 12 ++++++++++++ 9 files changed, 24 insertions(+) create mode 100644 mkosi.tools.conf/test-requirements.txt diff --git a/REUSE.toml b/REUSE.toml index 10324ff052..a9a9c11add 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -39,6 +39,7 @@ path = [ "**.zsh", "mkosi.credentials/*", "mkosi/resources/pandoc/*.lua", + "mkosi.tools.conf/test-requirements.txt", ] precedence = "aggregate" SPDX-FileCopyrightText = "Mkosi Contributors" diff --git a/mkosi.tools.conf/mkosi.conf.d/arch.conf b/mkosi.tools.conf/mkosi.conf.d/arch.conf index 3a84ab16de..188c2e16ce 100644 --- a/mkosi.tools.conf/mkosi.conf.d/arch.conf +++ b/mkosi.tools.conf/mkosi.conf.d/arch.conf @@ -10,6 +10,7 @@ Packages= mkinitcpio mypy pyright + python-pip python-pytest reuse ruff diff --git a/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf b/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf index d0adb35302..d9539c070a 100644 --- a/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf +++ b/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf @@ -10,6 +10,7 @@ Packages= codespell cryptsetup python3-mypy + python3-pip npm python3-pytest shellcheck diff --git a/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf b/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf index 96b9284e0b..e55b8cbc23 100644 --- a/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf +++ b/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf @@ -12,6 +12,7 @@ Packages= fdisk mypy npm + python3-pip python3-pytest reuse sqop diff --git a/mkosi.tools.conf/mkosi.conf.d/opensuse.conf b/mkosi.tools.conf/mkosi.conf.d/opensuse.conf index 0456df4ce8..66fa58490a 100644 --- a/mkosi.tools.conf/mkosi.conf.d/opensuse.conf +++ b/mkosi.tools.conf/mkosi.conf.d/opensuse.conf @@ -10,6 +10,7 @@ Packages= grub2 # TODO: Move to default tools tree when https://bugzilla.opensuse.org/show_bug.cgi?id=1227464 is resolved. mypy npm + python3-pip python3-pytest reuse ruff diff --git a/mkosi.tools.conf/mkosi.conf.d/postmarketos.conf b/mkosi.tools.conf/mkosi.conf.d/postmarketos.conf index 9963e433c1..d4533a8c9f 100644 --- a/mkosi.tools.conf/mkosi.conf.d/postmarketos.conf +++ b/mkosi.tools.conf/mkosi.conf.d/postmarketos.conf @@ -6,5 +6,6 @@ Distribution=postmarketos [Content] Packages= py3-codespell + py3-pip py3-pytest ruff diff --git a/mkosi.tools.conf/mkosi.prepare.chroot b/mkosi.tools.conf/mkosi.prepare.chroot index 04606aff29..5ca0c004e4 100755 --- a/mkosi.tools.conf/mkosi.prepare.chroot +++ b/mkosi.tools.conf/mkosi.prepare.chroot @@ -8,3 +8,5 @@ if ! command -v pyright &>/dev/null; then fi done fi + +python3 -m pip install --break-system-packages -r "$SRCDIR/mkosi.tools.conf/test-requirements.txt" diff --git a/mkosi.tools.conf/test-requirements.txt b/mkosi.tools.conf/test-requirements.txt new file mode 100644 index 0000000000..3de848adfe --- /dev/null +++ b/mkosi.tools.conf/test-requirements.txt @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# Test runner installed into the tools tree. +# FIXME: move to version pin once published on pypi, to get dependabot coverage +barrage @ git+https://github.com/amutable-systems/barrage.git@8361c35b7ad7c00e6a446f16d5a269f07a35393b diff --git a/pyproject.toml b/pyproject.toml index 51c76db4e7..ace0a2d03c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,18 @@ warn_unreachable = true strict_equality = true scripts_are_modules = true +# barrage (the async test runner used by tests/) requires Python 3.12+ syntax, which mypy cannot parse +# when checking under our supported floor (3.9/3.10). Skip following it and don't flag the test classes +# that subclass its (now Any-typed) AsyncTestCase. pyright still type-checks tests/ fully. +[[tool.mypy.overrides]] +module = "barrage.*" +ignore_missing_imports = true +follow_imports = "skip" + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_subclassing_any = false + [tool.ruff] target-version = "py39" line-length = 109 From f799294fe8e2c41e754f7ad601e860b4cd88c899 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 11 Jun 2026 08:09:16 +0200 Subject: [PATCH 2/3] tests: Port unit and install tests to barrage barrage does not have the equivalent of pytest markers, but it can select by file pattern. So unit tests retain `test_*.py`, and rename the other two kinds to `install_*.py` and `integration_*.py`. This parallelizes the subprocess-y parts like test_linters.py. --- .github/workflows/ci.yml | 4 +- AGENTS.md | 6 +- README.md | 21 +- pyproject.toml | 4 +- tests/{test_install.py => install_mkosi.py} | 12 +- tests/{test_boot.py => integration_boot.py} | 0 ..._extension.py => integration_extension.py} | 0 .../{test_initrd.py => integration_initrd.py} | 0 ...test_signing.py => integration_signing.py} | 0 tests/test_config.py | 1161 +++++++++-------- tests/test_json.py | 23 +- tests/test_kmod.py | 104 +- tests/test_linters.py | 65 +- tests/test_run.py | 45 +- tests/test_util.py | 30 +- tests/test_versioncomp.py | 165 ++- 16 files changed, 867 insertions(+), 773 deletions(-) rename tests/{test_install.py => install_mkosi.py} (89%) rename tests/{test_boot.py => integration_boot.py} (100%) rename tests/{test_extension.py => integration_extension.py} (100%) rename tests/{test_initrd.py => integration_initrd.py} (100%) rename tests/{test_signing.py => integration_signing.py} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 675a72824e..a7ecd873fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,10 +30,10 @@ jobs: mkosi -f box -- true - name: Run unit tests - run: mkosi box -- python3 -m pytest -sv + run: mkosi box -- python3 -m barrage -v - name: Run installation tests - run: mkosi box -- python3 -m pytest -sv -m install + run: mkosi box -- python3 -m barrage -v --pattern 'install_*.py' - name: Test execution from current working directory (sudo call) run: sudo python3 -m mkosi -h diff --git a/AGENTS.md b/AGENTS.md index 516b7c3595..da49641d09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,12 +18,12 @@ Always consult these files as needed: ## Build and Test Commands - Running tests: See the "Hacking on mkosi" section in `README.md` for complete instructions. -- `bin/mkosi box -- pytest` to run all unit tests including linters and type checkers -- append usual pytest options like `-k test_mypy` to run a specific check +- `bin/mkosi box -- python3 -m barrage` to run all unit tests including linters and type checkers +- select a subset by name, e.g. `bin/mkosi box -- python3 -m barrage test_mypy` to run a specific check - `bin/mkosi box -- ruff format mkosi tests kernel-install/*.install` to format code - `bin/mkosi box -- ruff check --fix mkosi tests kernel-install/*.install` to fix ruff issues - `python3 -m pytest -m integration ...` to run integration tests. No need to run these by default. -- `bin/mkosi box -- pytest -m install` to run installation tests (venv/pip/zipapp). Skipped by default as they install from the network. No need to run these by default. +- `bin/mkosi box -- python3 -m barrage --pattern 'install_*.py'` to run installation tests (venv/pip/zipapp). Skipped by default as they install from the network. No need to run these by default. - Never invent your own build commands or try to optimize the build process. - Never use `head`, `tail`, or pipe (`|`) the output of build or test commands. Always let the full output diff --git a/README.md b/README.md index fa1f89d0a4..3e9e889e70 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ modifications. To hack on mkosi itself, you can run the full test suite locally, just like CI does. The tests include linting, type checking, and unit tests, -all runnable via [pytest](https://github.com/pytest-dev/pytest). +all runnable via [barrage](https://github.com/amutable-systems/barrage). All linters such as `ruff` or `mypy` are run inside `mkosi box` (i.e. from inside `mkosi.tools/`) for a consistent environment. Build that with: @@ -134,25 +134,26 @@ All linters such as `ruff` or `mypy` are run inside `mkosi box` bin/mkosi -f box -- true ``` -Then run the full test suite inside the tools tree: +Then run the full unit test suite inside the tools tree: ```bash -bin/mkosi box -- pytest +bin/mkosi box -- python3 -m barrage ``` -You can use [pytest options](https://docs.pytest.org/en/stable/how-to/usage.html) to only run a subset, for -example only run the linters: +You can select a subset, for example only run the linters or only a single test: ```sh -bin/mkosi box -- pytest -k test_linters +bin/mkosi box -- python3 -m barrage tests/test_linters.py + +bin/mkosi box -- python3 -m barrage test_conversion ``` -Installation tests (venv, editable and zipapp installs) are marked with the -`install` marker and are skipped by default, as they create virtual -environments and install packages from the network. Run them explicitly with: +Installation tests (venv, editable and zipapp installs) live in `install_*.py` +files and are skipped by default, as they create virtual environments and +install packages from the network. Run them explicitly with: ```sh -bin/mkosi box -- pytest -m install +bin/mkosi box -- python3 -m barrage --pattern 'install_*.py' ``` When a tool that mkosi runs inside its sandbox fails, see diff --git a/pyproject.toml b/pyproject.toml index ace0a2d03c..9e753f0e6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,5 +116,7 @@ markers = [ "integration: mark a test as an integration test.", "install: mark a test as an installation test (venv/pip/zipapp).", ] -addopts = "-m \"not integration and not install\"" testpaths = ["tests"] +# The unit and install tests run under barrage now; pytest only runs the integration tests, which live +# in integration_*.py files (not pytest's default test_*.py pattern). +python_files = ["integration_*.py"] diff --git a/tests/test_install.py b/tests/install_mkosi.py similarity index 89% rename from tests/test_install.py rename to tests/install_mkosi.py index d588b6542a..5b4dd79776 100644 --- a/tests/test_install.py +++ b/tests/install_mkosi.py @@ -4,21 +4,17 @@ import tempfile from pathlib import Path -import pytest - from mkosi.run import run REPO_ROOT = Path(__file__).parent.parent -pytestmark = pytest.mark.install - -def test_mkosi_help_direct() -> None: +async def test_mkosi_help_direct() -> None: """Test mkosi can be run from current directory.""" run(["python3", "-m", "mkosi", "-h"], cwd=REPO_ROOT) -def test_venv_installation() -> None: +async def test_venv_installation() -> None: """Test mkosi can be installed in a venv.""" with tempfile.TemporaryDirectory() as tmpdir: venv = Path(tmpdir) / "testvenv" @@ -40,7 +36,7 @@ def test_venv_installation() -> None: run([os.fspath(venv / "bin/mkosi"), "-h"], cwd=REPO_ROOT) -def test_editable_venv_installation() -> None: +async def test_editable_venv_installation() -> None: """Test mkosi can be installed in editable mode.""" with tempfile.TemporaryDirectory() as tmpdir: venv = Path(tmpdir) / "testvenv" @@ -61,7 +57,7 @@ def test_editable_venv_installation() -> None: run([os.fspath(venv / "bin/mkosi"), "-h"], cwd=REPO_ROOT) -def test_zipapp_creation() -> None: +async def test_zipapp_creation() -> None: """Test zipapp generation.""" run(["./tools/generate-zipapp.sh"], cwd=REPO_ROOT) diff --git a/tests/test_boot.py b/tests/integration_boot.py similarity index 100% rename from tests/test_boot.py rename to tests/integration_boot.py diff --git a/tests/test_extension.py b/tests/integration_extension.py similarity index 100% rename from tests/test_extension.py rename to tests/integration_extension.py diff --git a/tests/test_initrd.py b/tests/integration_initrd.py similarity index 100% rename from tests/test_initrd.py rename to tests/integration_initrd.py diff --git a/tests/test_signing.py b/tests/integration_signing.py similarity index 100% rename from tests/test_signing.py rename to tests/integration_signing.py diff --git a/tests/test_config.py b/tests/test_config.py index bdb5774332..0b00ae87fc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,13 +1,16 @@ # SPDX-License-Identifier: LGPL-2.1-or-later import argparse +import contextlib import itertools import logging import operator import os +import tempfile from pathlib import Path +from typing import cast -import pytest +import barrage.assertions as Assert import mkosi.resources from mkosi import expand_kernel_specifiers @@ -29,39 +32,53 @@ from mkosi.util import chdir, resource_path -def test_compression_enum_creation() -> None: - assert Compression["none"] == Compression.none - assert Compression["zstd"] == Compression.zstd - assert Compression["zst"] == Compression.zstd - assert Compression["xz"] == Compression.xz - assert Compression["bz2"] == Compression.bz2 - assert Compression["gz"] == Compression.gz - assert Compression["lz4"] == Compression.lz4 - assert Compression["lzma"] == Compression.lzma +class _ListHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) -def test_compression_enum_bool() -> None: - assert not bool(Compression.none) - assert bool(Compression.zstd) - assert bool(Compression.xz) - assert bool(Compression.bz2) - assert bool(Compression.gz) - assert bool(Compression.lz4) - assert bool(Compression.lzma) +def tmp_dir(stack: contextlib.AsyncExitStack) -> Path: + return Path(stack.enter_context(tempfile.TemporaryDirectory())) -def test_compression_enum_str() -> None: - assert str(Compression.none) == "none" - assert str(Compression.zstd) == "zstd" - assert str(Compression.zst) == "zstd" - assert str(Compression.xz) == "xz" - assert str(Compression.bz2) == "bz2" - assert str(Compression.gz) == "gz" - assert str(Compression.lz4) == "lz4" - assert str(Compression.lzma) == "lzma" +async def test_compression_enum_creation() -> None: + Assert.eq(Compression["none"], Compression.none) + Assert.eq(Compression["zstd"], Compression.zstd) + Assert.eq(Compression["zst"], Compression.zstd) + Assert.eq(Compression["xz"], Compression.xz) + Assert.eq(Compression["bz2"], Compression.bz2) + Assert.eq(Compression["gz"], Compression.gz) + Assert.eq(Compression["lz4"], Compression.lz4) + Assert.eq(Compression["lzma"], Compression.lzma) -def test_parse_ini(tmp_path: Path) -> None: + +async def test_compression_enum_bool() -> None: + Assert.false(bool(Compression.none)) + Assert.true(bool(Compression.zstd)) + Assert.true(bool(Compression.xz)) + Assert.true(bool(Compression.bz2)) + Assert.true(bool(Compression.gz)) + Assert.true(bool(Compression.lz4)) + Assert.true(bool(Compression.lzma)) + + +async def test_compression_enum_str() -> None: + Assert.eq(str(Compression.none), "none") + Assert.eq(str(Compression.zstd), "zstd") + Assert.eq(str(Compression.zst), "zstd") + Assert.eq(str(Compression.xz), "xz") + Assert.eq(str(Compression.bz2), "bz2") + Assert.eq(str(Compression.gz), "gz") + Assert.eq(str(Compression.lz4), "lz4") + Assert.eq(str(Compression.lzma), "lzma") + + +async def test_parse_ini(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) p = tmp_path / "ini" p.write_text( """\ @@ -83,16 +100,17 @@ def test_parse_ini(tmp_path: Path) -> None: g = parse_ini(p) - assert next(g) == ("MySection", "Value", "abc") - assert next(g) == ("MySection", "Other", "def") - assert next(g) == ("MySection", "ALLCAPS", "txt") - assert next(g) == ("MySection", "", "") - assert next(g) == ("EmptySection", "", "") - assert next(g) == ("AnotherSection", "EmptyValue", "") - assert next(g) == ("AnotherSection", "Multiline", "abc\ndef\nqed\nord") + Assert.eq(next(g), ("MySection", "Value", "abc")) + Assert.eq(next(g), ("MySection", "Other", "def")) + Assert.eq(next(g), ("MySection", "ALLCAPS", "txt")) + Assert.eq(next(g), ("MySection", "", "")) + Assert.eq(next(g), ("EmptySection", "", "")) + Assert.eq(next(g), ("AnotherSection", "EmptyValue", "")) + Assert.eq(next(g), ("AnotherSection", "Multiline", "abc\ndef\nqed\nord")) -def test_parse_config(tmp_path: Path) -> None: +async def test_parse_config(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -120,11 +138,11 @@ def test_parse_config(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.distribution == Distribution.ubuntu - assert config.architecture == Architecture.arm64 - assert config.profiles == ["abc"] - assert config.output_format == OutputFormat.cpio - assert config.image_id == "base" + Assert.eq(config.distribution, Distribution.ubuntu) + Assert.eq(config.architecture, Architecture.arm64) + Assert.eq(config.profiles, ["abc"]) + Assert.eq(config.output_format, OutputFormat.cpio) + Assert.eq(config.image_id, "base") with chdir(d): _, _, [config] = parse_config( @@ -137,10 +155,10 @@ def test_parse_config(tmp_path: Path) -> None: ) # fmt: skip # Values from the CLI should take priority. - assert config.distribution == Distribution.fedora - assert config.environment["MY_KEY"] == "CLI_VALUE" - assert any(c.name == "my.cred" and c.value == "cli.value" for c in config.credentials) - assert config.repositories == ["epel", "epel-next", "universe"] + Assert.eq(config.distribution, Distribution.fedora) + Assert.eq(config.environment["MY_KEY"], "CLI_VALUE") + Assert.true(any(c.name == "my.cred" and c.value == "cli.value" for c in config.credentials)) + Assert.eq(config.repositories, ["epel", "epel-next", "universe"]) with chdir(d): _, _, [config] = parse_config( @@ -152,11 +170,11 @@ def test_parse_config(tmp_path: Path) -> None: ] ) # fmt: skip - # Empty values on the CLIs resets non-collection based settings to their defaults and collection based - # settings to empty collections. - assert "MY_KEY" not in config.environment - assert not any(c.name == "my.cred" for c in config.credentials) - assert config.repositories == [] + # Empty values on the CLIs resets non-collection based settings to their defaults and collection + # based settings to empty collections. + Assert.not_in("MY_KEY", config.environment) + Assert.false(any(c.name == "my.cred" for c in config.credentials)) + Assert.eq(config.repositories, []) (d / "mkosi.conf.d").mkdir() (d / "mkosi.conf.d/d1.conf").write_text( @@ -179,15 +197,15 @@ def test_parse_config(tmp_path: Path) -> None: _, _, [config] = parse_config(["--profile", "last"]) # Setting a value explicitly in a dropin should override the default from mkosi.conf. - assert config.distribution == Distribution.debian + Assert.eq(config.distribution, Distribution.debian) # Lists should be merged by appending the new values to the existing values. Any values from the CLI # should be appended to the values from the configuration files. - assert config.profiles == ["abc", "qed", "def", "last"] - assert config.output_format == OutputFormat.cpio - assert config.image_id == "00-dropin" - assert config.image_version == "0" + Assert.eq(config.profiles, ["abc", "qed", "def", "last"]) + Assert.eq(config.output_format, OutputFormat.cpio) + Assert.eq(config.image_id, "00-dropin") + Assert.eq(config.image_version, "0") # '@' specifier should be automatically dropped. - assert config.output == "abc" + Assert.eq(config.output, "abc") (d / "mkosi.version").write_text("1.2.3") @@ -205,10 +223,10 @@ def test_parse_config(tmp_path: Path) -> None: _, _, [config] = parse_config() # Test that empty assignment resets settings. - assert config.packages == [] - assert config.image_id is None + Assert.eq(config.packages, []) + Assert.none(config.image_id) # mkosi.version should only be used if no version is set explicitly. - assert config.image_version == "0" + Assert.eq(config.image_version, "0") (d / "mkosi.conf.d/d1.conf").unlink() @@ -216,7 +234,7 @@ def test_parse_config(tmp_path: Path) -> None: _, _, [config] = parse_config() # ImageVersion= is not set explicitly anymore, so now the version from mkosi.version should be used. - assert config.image_version == "1.2.3" + Assert.eq(config.image_version, "1.2.3") (d / "abc").mkdir() (d / "abc/mkosi.conf").write_text( @@ -238,20 +256,20 @@ def test_parse_config(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert not config.cxl - assert config.split_artifacts == ArtifactOutput.compat_no() + Assert.false(config.cxl) + Assert.eq(config.split_artifacts, ArtifactOutput.compat_no()) # Passing the directory should include both the main config file and the dropin. _, _, [config] = parse_config(["--include", os.fspath(d / "abc")] * 2) - assert config.cxl - assert config.split_artifacts == ArtifactOutput.compat_yes() + Assert.true(config.cxl) + Assert.eq(config.split_artifacts, ArtifactOutput.compat_yes()) # The same extra config should not be parsed more than once. - assert config.build_packages == ["abc"] + Assert.eq(config.build_packages, ["abc"]) # Passing the main config file should not include the dropin. _, _, [config] = parse_config(["--include", os.fspath(d / "abc/mkosi.conf")]) - assert config.cxl - assert config.split_artifacts == ArtifactOutput.compat_no() + Assert.true(config.cxl) + Assert.eq(config.split_artifacts, ArtifactOutput.compat_no()) (d / "mkosi.images").mkdir() @@ -280,40 +298,41 @@ def test_parse_config(tmp_path: Path) -> None: ) # Universal settings should always come from the main image. - assert one.distribution == config.distribution - assert two.distribution == config.distribution - assert one.release == config.release - assert two.release == config.release + Assert.eq(one.distribution, config.distribution) + Assert.eq(two.distribution, config.distribution) + Assert.eq(one.release, config.release) + Assert.eq(two.release, config.release) # Non-universal settings should not be passed to the subimages. - assert one.packages == ["one"] - assert two.packages == ["two"] - assert one.build_packages == [] - assert two.build_packages == [] + Assert.eq(one.packages, ["one"]) + Assert.eq(two.packages, ["two"]) + Assert.eq(one.build_packages, []) + Assert.eq(two.build_packages, []) # But should apply to the main image of course. - assert config.packages == ["qed"] - assert config.build_packages == ["def"] + Assert.eq(config.packages, ["qed"]) + Assert.eq(config.build_packages, ["def"]) # Inherited settings should be passed down to subimages but overridable by subimages. - assert one.image_version == "1.2.3" - assert two.image_version == "4.5.6" + Assert.eq(one.image_version, "1.2.3") + Assert.eq(two.image_version, "4.5.6") # Default values from subimages for universal settings should not be picked up. - assert len(one.sandbox_trees) == 0 - assert len(two.sandbox_trees) == 0 + Assert.eq(len(one.sandbox_trees), 0) + Assert.eq(len(two.sandbox_trees), 0) with chdir(d): _, _, [one, two, config] = parse_config(["--image-version", "7.8.9"]) # Inherited settings specified on the CLI should not override subimages that configure the setting # explicitly. - assert config.image_version == "7.8.9" - assert one.image_version == "7.8.9" - assert two.image_version == "4.5.6" + Assert.eq(config.image_version, "7.8.9") + Assert.eq(one.image_version, "7.8.9") + Assert.eq(two.image_version, "4.5.6") -def test_parse_includes_once(tmp_path: Path) -> None: +async def test_parse_includes_once(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -331,7 +350,7 @@ def test_parse_includes_once(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config(["--include", "abc.conf", "--include", "abc.conf"]) - assert config.build_packages == ["abc", "def"] + Assert.eq(config.build_packages, ["abc", "def"]) (d / "mkosi.images").mkdir() @@ -345,11 +364,12 @@ def test_parse_includes_once(tmp_path: Path) -> None: with chdir(d): _, _, [one, two, config] = parse_config([]) - assert one.build_packages == ["def"] - assert two.build_packages == ["def"] + Assert.eq(one.build_packages, ["def"]) + Assert.eq(two.build_packages, ["def"]) -def test_profiles(tmp_path: Path) -> None: +async def test_profiles(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.profiles").mkdir() @@ -381,20 +401,20 @@ def test_profiles(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.profiles == ["profile"] + Assert.eq(config.profiles, ["profile"]) # The profile should override mkosi.conf.d/ - assert config.distribution == Distribution.fedora - assert config.kvm == ConfigFeature.enabled + Assert.eq(config.distribution, Distribution.fedora) + Assert.eq(config.kvm, ConfigFeature.enabled) (d / "mkosi.conf").unlink() with chdir(d): _, _, [config] = parse_config(["--profile", "profile"]) - assert config.profiles == ["profile"] + Assert.eq(config.profiles, ["profile"]) # The profile should override mkosi.conf.d/ - assert config.distribution == Distribution.fedora - assert config.kvm == ConfigFeature.enabled + Assert.eq(config.distribution, Distribution.fedora) + Assert.eq(config.kvm, ConfigFeature.enabled) (d / "mkosi.conf").write_text( """\ @@ -416,8 +436,8 @@ def test_profiles(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.profiles == ["profile", "abc"] - assert config.distribution == Distribution.opensuse + Assert.eq(config.profiles, ["profile", "abc"]) + Assert.eq(config.distribution, Distribution.opensuse) # Check that mkosi.profiles/ is parsed in subimages as well. (d / "mkosi.images/subimage/mkosi.profiles").mkdir(parents=True) @@ -431,10 +451,11 @@ def test_profiles(tmp_path: Path) -> None: with chdir(d): _, _, [subimage, config] = parse_config() - assert subimage.environment["Image"] == "subimage" + Assert.eq(subimage.environment["Image"], "subimage") -def test_override_default(tmp_path: Path) -> None: +async def test_override_default(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -448,8 +469,8 @@ def test_override_default(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config(["--tools-tree", "", "--environment", ""]) - assert config.tools_tree is None - assert "MY_KEY" not in config.environment + Assert.none(config.tools_tree) + Assert.not_in("MY_KEY", config.environment) (d / "mkosi.tools.conf").touch() @@ -463,10 +484,11 @@ def test_override_default(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config([]) - assert config.tools_tree is None + Assert.none(config.tools_tree) -def test_local_config(tmp_path: Path) -> None: +async def test_local_config(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.local.conf").write_text( @@ -484,7 +506,7 @@ def test_local_config(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.distribution == Distribution.debian + Assert.eq(config.distribution, Distribution.debian) (d / "mkosi.conf").write_text( """\ @@ -502,14 +524,14 @@ def test_local_config(tmp_path: Path) -> None: _, _, [config] = parse_config() # Local config should take precedence over non-local config. - assert config.distribution == Distribution.debian - assert config.with_tests + Assert.eq(config.distribution, Distribution.debian) + Assert.true(config.with_tests) with chdir(d): _, _, [config] = parse_config(["--distribution", "fedora", "-T"]) - assert config.distribution == Distribution.fedora - assert not config.with_tests + Assert.eq(config.distribution, Distribution.fedora) + Assert.false(config.with_tests) (d / "mkosi.local/mkosi.conf.d").mkdir(parents=True) (d / "mkosi.local/mkosi.conf.d/10-test.conf").write_text( @@ -523,45 +545,48 @@ def test_local_config(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.environment == {"FOO": "override", "BAR": "override", "BAZ": "override"} + Assert.eq(config.environment, {"FOO": "override", "BAR": "override", "BAZ": "override"}) -def test_parse_load_verb(tmp_path: Path) -> None: +async def test_parse_load_verb(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): - assert parse_config(["build"])[0].verb == Verb.build - assert parse_config(["clean"])[0].verb == Verb.clean - assert parse_config(["genkey"])[0].verb == Verb.genkey - assert parse_config(["bump"])[0].verb == Verb.bump - assert parse_config(["serve"])[0].verb == Verb.serve - assert parse_config(["build"])[0].verb == Verb.build - assert parse_config(["shell"])[0].verb == Verb.shell - assert parse_config(["boot"])[0].verb == Verb.boot - assert parse_config(["qemu"])[0].verb == Verb.qemu - assert parse_config(["vm"])[0].verb == Verb.vm - assert parse_config(["journalctl"])[0].verb == Verb.journalctl - assert parse_config(["coredumpctl"])[0].verb == Verb.coredumpctl - with pytest.raises(SystemExit): + Assert.eq(parse_config(["build"])[0].verb, Verb.build) + Assert.eq(parse_config(["clean"])[0].verb, Verb.clean) + Assert.eq(parse_config(["genkey"])[0].verb, Verb.genkey) + Assert.eq(parse_config(["bump"])[0].verb, Verb.bump) + Assert.eq(parse_config(["serve"])[0].verb, Verb.serve) + Assert.eq(parse_config(["build"])[0].verb, Verb.build) + Assert.eq(parse_config(["shell"])[0].verb, Verb.shell) + Assert.eq(parse_config(["boot"])[0].verb, Verb.boot) + Assert.eq(parse_config(["qemu"])[0].verb, Verb.qemu) + Assert.eq(parse_config(["vm"])[0].verb, Verb.vm) + Assert.eq(parse_config(["journalctl"])[0].verb, Verb.journalctl) + Assert.eq(parse_config(["coredumpctl"])[0].verb, Verb.coredumpctl) + with Assert.raises(SystemExit): parse_config(["invalid"]) -def test_os_distribution(tmp_path: Path) -> None: +async def test_os_distribution(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): for dist in Distribution: _, _, [config] = parse_config(["-d", dist.value]) - assert config.distribution == dist + Assert.eq(config.distribution, dist) - with pytest.raises(tuple((argparse.ArgumentError, SystemExit))): + with Assert.raises((argparse.ArgumentError, SystemExit)): # type: ignore[arg-type] parse_config(["-d", "invalidDistro"]) - with pytest.raises(tuple((argparse.ArgumentError, SystemExit))): + with Assert.raises((argparse.ArgumentError, SystemExit)): # type: ignore[arg-type] parse_config(["-d"]) for dist in Distribution: Path("mkosi.conf").write_text(f"[Distribution]\nDistribution={dist}") _, _, [config] = parse_config() - assert config.distribution == dist + Assert.eq(config.distribution, dist) -def test_parse_config_files_filter(tmp_path: Path) -> None: +async def test_parse_config_files_filter(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): confd = Path("mkosi.conf.d") confd.mkdir() @@ -570,16 +595,18 @@ def test_parse_config_files_filter(tmp_path: Path) -> None: (confd / "20-file.noconf").write_text("[Content]\nPackages=nope") _, _, [config] = parse_config() - assert config.packages == ["yes"] + Assert.eq(config.packages, ["yes"]) -def test_compression(tmp_path: Path) -> None: +async def test_compression(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): _, _, [config] = parse_config(["--format", "disk", "--compress-output", "False"]) - assert config.compress_output == Compression.none + Assert.eq(config.compress_output, Compression.none) -def test_match_only(tmp_path: Path) -> None: +async def test_match_only(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): Path("mkosi.conf").write_text( """\ @@ -598,10 +625,11 @@ def test_match_only(tmp_path: Path) -> None: ) _, _, [config] = parse_config(["--format", "tar"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") -def test_match_multiple(tmp_path: Path) -> None: +async def test_match_multiple(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): Path("mkosi.conf").write_text( """\ @@ -620,15 +648,15 @@ def test_match_multiple(tmp_path: Path) -> None: # Both sections are not matched, so image ID should not be "abcde". _, _, [config] = parse_config(["--format", "tar", "--architecture", "s390x"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") # Only a single section is matched, so image ID should not be "abcde". _, _, [config] = parse_config(["--format", "disk", "--architecture", "s390x"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") # Both sections are matched, so image ID should be "abcde". _, _, [config] = parse_config(["--format", "disk", "--architecture", "x86-64"]) - assert config.image_id == "abcde" + Assert.eq(config.image_id, "abcde") Path("mkosi.conf").write_text( """\ @@ -647,19 +675,19 @@ def test_match_multiple(tmp_path: Path) -> None: # Both sections are not matched, so image ID should not be "abcde". _, _, [config] = parse_config(["--format", "tar", "--architecture", "s390x"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") # The first section is matched, so image ID should be "abcde". _, _, [config] = parse_config(["--format", "disk", "--architecture", "x86-64"]) - assert config.image_id == "abcde" + Assert.eq(config.image_id, "abcde") # The second section is matched, so image ID should be "abcde". _, _, [config] = parse_config(["--format", "directory", "--architecture", "arm64"]) - assert config.image_id == "abcde" + Assert.eq(config.image_id, "abcde") # Parts of all section are matched, but none is matched fully, so image ID should not be "abcde". _, _, [config] = parse_config(["--format", "disk", "--architecture", "arm64"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") Path("mkosi.conf").write_text( """\ @@ -678,7 +706,7 @@ def test_match_multiple(tmp_path: Path) -> None: # The first section is matched, so image ID should be "abcde". _, _, [config] = parse_config(["--format", "disk"]) - assert config.image_id == "abcde" + Assert.eq(config.image_id, "abcde") Path("mkosi.conf").write_text( """\ @@ -698,7 +726,7 @@ def test_match_multiple(tmp_path: Path) -> None: # No sections are matched, so image ID should be not "abcde". _, _, [config] = parse_config(["--format", "disk", "--architecture=arm64"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") # Mixing both [Match] and [TriggerMatch] Path("mkosi.conf").write_text( @@ -719,18 +747,19 @@ def test_match_multiple(tmp_path: Path) -> None: # Match and first TriggerMatch sections match _, _, [config] = parse_config(["--format", "disk", "--architecture=arm64"]) - assert config.image_id == "abcde" + Assert.eq(config.image_id, "abcde") # Match section matches, but no TriggerMatch section matches _, _, [config] = parse_config(["--format", "disk", "--architecture=s390x"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") # Second TriggerMatch section matches, but the Match section does not _, _, [config] = parse_config(["--format", "tar", "--architecture=x86-64"]) - assert config.image_id != "abcde" + Assert.ne(config.image_id, "abcde") -def test_match_empty(tmp_path: Path) -> None: +async def test_match_empty(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) with chdir(tmp_path): Path("mkosi.conf").write_text( """\ @@ -744,126 +773,132 @@ def test_match_empty(tmp_path: Path) -> None: _, _, [config] = parse_config([]) - assert config.environment.get("ABC") == "QED" + Assert.eq(config.environment.get("ABC"), "QED") _, _, [config] = parse_config(["--profile", "profile"]) - assert config.environment.get("ABC") is None + Assert.none(config.environment.get("ABC")) -@pytest.mark.parametrize( - "dist1,dist2", - itertools.combinations_with_replacement([Distribution.debian, Distribution.opensuse], 2), -) -def test_match_distribution(tmp_path: Path, dist1: Distribution, dist2: Distribution) -> None: - with chdir(tmp_path): - parent = Path("mkosi.conf") - parent.write_text( - f"""\ - [Distribution] - Distribution={dist1} - """ - ) - - Path("mkosi.conf.d").mkdir() - - child1 = Path("mkosi.conf.d/child1.conf") - child1.write_text( - f"""\ - [Match] - Distribution={dist1} +async def test_match_distribution(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) + for dist1, dist2 in itertools.combinations_with_replacement( + [Distribution.debian, Distribution.opensuse], 2 + ): + with chdir(tmp_path): + parent = Path("mkosi.conf") + parent.write_text( + f"""\ + [Distribution] + Distribution={dist1} + """ + ) - [Content] - Packages=testpkg1 - """ - ) - child2 = Path("mkosi.conf.d/child2.conf") - child2.write_text( - f"""\ - [Match] - Distribution={dist2} + confd = Path("mkosi.conf.d") + if not confd.exists(): + confd.mkdir() - [Content] - Packages=testpkg2 - """ - ) - child3 = Path("mkosi.conf.d/child3.conf") - child3.write_text( - f"""\ - [Match] - Distribution=|{dist1} - Distribution=|{dist2} + child1 = Path("mkosi.conf.d/child1.conf") + child1.write_text( + f"""\ + [Match] + Distribution={dist1} - [Content] - Packages=testpkg3 - """ - ) + [Content] + Packages=testpkg1 + """ + ) + child2 = Path("mkosi.conf.d/child2.conf") + child2.write_text( + f"""\ + [Match] + Distribution={dist2} - _, _, [conf] = parse_config() - assert "testpkg1" in conf.packages - if dist1 == dist2: - assert "testpkg2" in conf.packages - else: - assert "testpkg2" not in conf.packages - assert "testpkg3" in conf.packages + [Content] + Packages=testpkg2 + """ + ) + child3 = Path("mkosi.conf.d/child3.conf") + child3.write_text( + f"""\ + [Match] + Distribution=|{dist1} + Distribution=|{dist2} + [Content] + Packages=testpkg3 + """ + ) -@pytest.mark.parametrize("release1,release2", itertools.combinations_with_replacement([36, 37], 2)) -def test_match_release(tmp_path: Path, release1: int, release2: int) -> None: - with chdir(tmp_path): - parent = Path("mkosi.conf") - parent.write_text( - f"""\ - [Distribution] - Distribution=fedora - Release={release1} - """ - ) + _, _, [conf] = parse_config() + Assert.in_("testpkg1", conf.packages) + if dist1 == dist2: + Assert.in_("testpkg2", conf.packages) + else: + Assert.not_in("testpkg2", conf.packages) + Assert.in_("testpkg3", conf.packages) + + +async def test_match_release(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) + for release1, release2 in itertools.combinations_with_replacement([36, 37], 2): + with chdir(tmp_path): + parent = Path("mkosi.conf") + parent.write_text( + f"""\ + [Distribution] + Distribution=fedora + Release={release1} + """ + ) - Path("mkosi.conf.d").mkdir() + confd = Path("mkosi.conf.d") + if not confd.exists(): + confd.mkdir() - child1 = Path("mkosi.conf.d/child1.conf") - child1.write_text( - f"""\ - [Match] - Release={release1} + child1 = Path("mkosi.conf.d/child1.conf") + child1.write_text( + f"""\ + [Match] + Release={release1} - [Content] - Packages=testpkg1 - """ - ) - child2 = Path("mkosi.conf.d/child2.conf") - child2.write_text( - f"""\ - [Match] - Release={release2} + [Content] + Packages=testpkg1 + """ + ) + child2 = Path("mkosi.conf.d/child2.conf") + child2.write_text( + f"""\ + [Match] + Release={release2} - [Content] - Packages=testpkg2 - """ - ) - child3 = Path("mkosi.conf.d/child3.conf") - child3.write_text( - f"""\ - [Match] - Release=|{release1} - Release=|{release2} + [Content] + Packages=testpkg2 + """ + ) + child3 = Path("mkosi.conf.d/child3.conf") + child3.write_text( + f"""\ + [Match] + Release=|{release1} + Release=|{release2} - [Content] - Packages=testpkg3 - """ - ) + [Content] + Packages=testpkg3 + """ + ) - _, _, [conf] = parse_config() - assert "testpkg1" in conf.packages - if release1 == release2: - assert "testpkg2" in conf.packages - else: - assert "testpkg2" not in conf.packages - assert "testpkg3" in conf.packages + _, _, [conf] = parse_config() + Assert.in_("testpkg1", conf.packages) + if release1 == release2: + Assert.in_("testpkg2", conf.packages) + else: + Assert.not_in("testpkg2", conf.packages) + Assert.in_("testpkg3", conf.packages) -def test_match_build_sources(tmp_path: Path) -> None: +async def test_match_build_sources(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -880,10 +915,11 @@ def test_match_build_sources(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config(["--build-sources", ".:kernel"]) - assert config.output == "abc" + Assert.eq(config.output, "abc") -def test_match_repositories(tmp_path: Path) -> None: +async def test_match_repositories(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -899,10 +935,11 @@ def test_match_repositories(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config(["--repositories", "epel,epel-next"]) - assert config.output == "qed" + Assert.eq(config.output, "qed") -def test_match_architecture(tmp_path: Path) -> None: +async def test_match_architecture(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -918,145 +955,149 @@ def test_match_architecture(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config(["--architecture", "arm64"]) - assert config.output == "qed" + Assert.eq(config.output, "qed") -@pytest.mark.parametrize("image1,image2", itertools.combinations_with_replacement(["image_a", "image_b"], 2)) -def test_match_imageid(tmp_path: Path, image1: str, image2: str) -> None: - with chdir(tmp_path): - parent = Path("mkosi.conf") - parent.write_text( - f"""\ - [Distribution] - Distribution=fedora - - [Output] - ImageId={image1} - """ - ) +async def test_match_imageid(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) + for image1, image2 in itertools.combinations_with_replacement(["image_a", "image_b"], 2): + with chdir(tmp_path): + parent = Path("mkosi.conf") + parent.write_text( + f"""\ + [Distribution] + Distribution=fedora - Path("mkosi.conf.d").mkdir() + [Output] + ImageId={image1} + """ + ) - child1 = Path("mkosi.conf.d/child1.conf") - child1.write_text( - f"""\ - [Match] - ImageId={image1} + confd = Path("mkosi.conf.d") + if not confd.exists(): + confd.mkdir() - [Content] - Packages=testpkg1 - """ - ) - child2 = Path("mkosi.conf.d/child2.conf") - child2.write_text( - f"""\ - [Match] - ImageId={image2} + child1 = Path("mkosi.conf.d/child1.conf") + child1.write_text( + f"""\ + [Match] + ImageId={image1} - [Content] - Packages=testpkg2 - """ - ) - child3 = Path("mkosi.conf.d/child3.conf") - child3.write_text( - f"""\ - [Match] - ImageId=|{image1} - ImageId=|{image2} + [Content] + Packages=testpkg1 + """ + ) + child2 = Path("mkosi.conf.d/child2.conf") + child2.write_text( + f"""\ + [Match] + ImageId={image2} - [Content] - Packages=testpkg3 - """ - ) - child4 = Path("mkosi.conf.d/child4.conf") - child4.write_text( - """\ - [Match] - ImageId=image* + [Content] + Packages=testpkg2 + """ + ) + child3 = Path("mkosi.conf.d/child3.conf") + child3.write_text( + f"""\ + [Match] + ImageId=|{image1} + ImageId=|{image2} - [Content] - Packages=testpkg4 - """ - ) + [Content] + Packages=testpkg3 + """ + ) + child4 = Path("mkosi.conf.d/child4.conf") + child4.write_text( + """\ + [Match] + ImageId=image* + + [Content] + Packages=testpkg4 + """ + ) - _, _, [conf] = parse_config() - assert "testpkg1" in conf.packages - if image1 == image2: - assert "testpkg2" in conf.packages - else: - assert "testpkg2" not in conf.packages - assert "testpkg3" in conf.packages - assert "testpkg4" in conf.packages + _, _, [conf] = parse_config() + Assert.in_("testpkg1", conf.packages) + if image1 == image2: + Assert.in_("testpkg2", conf.packages) + else: + Assert.not_in("testpkg2", conf.packages) + Assert.in_("testpkg3", conf.packages) + Assert.in_("testpkg4", conf.packages) -@pytest.mark.parametrize( - "op,version", - itertools.product( +async def test_match_imageversion(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) + for op, version in itertools.product( ["", "==", "<", ">", "<=", ">="], [122, 123], - ), -) -def test_match_imageversion(tmp_path: Path, op: str, version: str) -> None: - opfunc = { - "==": operator.eq, - "!=": operator.ne, - "<": operator.lt, - "<=": operator.le, - ">": operator.gt, - ">=": operator.ge, - }.get(op, operator.eq) - - with chdir(tmp_path): - parent = Path("mkosi.conf") - parent.write_text( - """\ - [Output] - ImageId=testimage - ImageVersion=123 - """ - ) + ): + opfunc = { + "==": operator.eq, + "!=": operator.ne, + "<": operator.lt, + "<=": operator.le, + ">": operator.gt, + ">=": operator.ge, + }.get(op, operator.eq) + + with chdir(tmp_path): + parent = Path("mkosi.conf") + parent.write_text( + """\ + [Output] + ImageId=testimage + ImageVersion=123 + """ + ) - Path("mkosi.conf.d").mkdir() - child1 = Path("mkosi.conf.d/child1.conf") - child1.write_text( - f"""\ - [Match] - ImageVersion={op}{version} + confd = Path("mkosi.conf.d") + if not confd.exists(): + confd.mkdir() + child1 = Path("mkosi.conf.d/child1.conf") + child1.write_text( + f"""\ + [Match] + ImageVersion={op}{version} - [Content] - Packages=testpkg1 - """ - ) - child2 = Path("mkosi.conf.d/child2.conf") - child2.write_text( - f"""\ - [Match] - ImageVersion=<200 - ImageVersion={op}{version} + [Content] + Packages=testpkg1 + """ + ) + child2 = Path("mkosi.conf.d/child2.conf") + child2.write_text( + f"""\ + [Match] + ImageVersion=<200 + ImageVersion={op}{version} - [Content] - Packages=testpkg2 - """ - ) - child3 = Path("mkosi.conf.d/child3.conf") - child3.write_text( - f"""\ - [Match] - ImageVersion=>9000 - ImageVersion={op}{version} + [Content] + Packages=testpkg2 + """ + ) + child3 = Path("mkosi.conf.d/child3.conf") + child3.write_text( + f"""\ + [Match] + ImageVersion=>9000 + ImageVersion={op}{version} - [Content] - Packages=testpkg3 - """ - ) + [Content] + Packages=testpkg3 + """ + ) - _, _, [conf] = parse_config() - assert ("testpkg1" in conf.packages) == opfunc(123, version) - assert ("testpkg2" in conf.packages) == opfunc(123, version) - assert "testpkg3" not in conf.packages + _, _, [conf] = parse_config() + Assert.eq(("testpkg1" in conf.packages), opfunc(123, version)) + Assert.eq(("testpkg2" in conf.packages), opfunc(123, version)) + Assert.not_in("testpkg3", conf.packages) -def test_match_environment(tmp_path: Path) -> None: +async def test_match_environment(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1071,13 +1112,13 @@ def test_match_environment(tmp_path: Path) -> None: with chdir(d): _, _, [conf] = parse_config(["--environment", "MYENV=abc"]) - assert conf.image_id == "matched" + Assert.eq(conf.image_id, "matched") _, _, [conf] = parse_config(["--environment", "MYENV=bad"]) - assert conf.image_id != "matched" + Assert.ne(conf.image_id, "matched") _, _, [conf] = parse_config(["--environment", "MYEN=abc"]) - assert conf.image_id != "matched" + Assert.ne(conf.image_id, "matched") _, _, [conf] = parse_config(["--environment", "MYEN=bad"]) - assert conf.image_id != "matched" + Assert.ne(conf.image_id, "matched") (d / "mkosi.conf").write_text( """\ @@ -1091,29 +1132,34 @@ def test_match_environment(tmp_path: Path) -> None: with chdir(d): _, _, [conf] = parse_config(["--environment", "MYENV=abc"]) - assert conf.image_id == "matched" + Assert.eq(conf.image_id, "matched") _, _, [conf] = parse_config(["--environment", "MYENV=bad"]) - assert conf.image_id == "matched" + Assert.eq(conf.image_id, "matched") _, _, [conf] = parse_config(["--environment", "MYEN=abc"]) - assert conf.image_id != "matched" + Assert.ne(conf.image_id, "matched") -def test_paths_with_default_factory(tmp_path: Path) -> None: +async def test_paths_with_default_factory(stack: contextlib.AsyncExitStack) -> None: """ If both paths= and default_factory= are defined, default_factory= should not be used when at least one of the files/directories from paths= has been found. """ + tmp_path = tmp_dir(stack) with chdir(tmp_path): Path("mkosi.sandbox.tar").touch() _, _, [config] = parse_config() - assert config.sandbox_trees == [ - ConfigTree(Path.cwd() / "mkosi.sandbox.tar", None), - ] + Assert.eq( + config.sandbox_trees, + [ + ConfigTree(Path.cwd() / "mkosi.sandbox.tar", None), + ], + ) -def test_glob_expansion(tmp_path: Path) -> None: +async def test_glob_expansion(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "script_a.sh").touch() @@ -1132,7 +1178,7 @@ def test_glob_expansion(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.prepare_scripts == [d / "script_a.sh", d / "script_b.sh", d / "script_c.sh"] + Assert.eq(config.prepare_scripts, [d / "script_a.sh", d / "script_b.sh", d / "script_c.sh"]) # Glob patterns that match nothing should result in an empty list. (d / "mkosi.conf").write_text( @@ -1145,7 +1191,7 @@ def test_glob_expansion(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.prepare_scripts == [] + Assert.eq(config.prepare_scripts, []) # Non-glob paths should be ordered before glob results when listed first. (d / "mkosi.conf").write_text( @@ -1158,12 +1204,15 @@ def test_glob_expansion(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.prepare_scripts == [ - d / "other.py", - d / "script_a.sh", - d / "script_b.sh", - d / "script_c.sh", - ] + Assert.eq( + config.prepare_scripts, + [ + d / "other.py", + d / "script_a.sh", + d / "script_b.sh", + d / "script_c.sh", + ], + ) # Glob expansion should work with other script options too. (d / "mkosi.conf").write_text( @@ -1176,69 +1225,70 @@ def test_glob_expansion(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.build_scripts == [d / "script_a.sh", d / "script_b.sh", d / "script_c.sh"] + Assert.eq(config.build_scripts, [d / "script_a.sh", d / "script_b.sh", d / "script_c.sh"]) -@pytest.mark.parametrize( - "sections,args,warning_count", - [ +async def test_wrong_section_warning(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) + for sections, args, warning_count in [ (["Output"], [], 0), (["Content"], [], 1), (["Content", "Output"], [], 1), (["Output", "Content"], [], 1), (["Output", "Content", "Distribution"], [], 2), (["Content"], ["--image-id=testimage"], 1), - ], -) -def test_wrong_section_warning( - tmp_path: Path, - caplog: pytest.LogCaptureFixture, - sections: list[str], - args: list[str], - warning_count: int, -) -> None: - with chdir(tmp_path): - # Create a config with ImageId in the wrong section, - # and sometimes in the correct section - Path("mkosi.conf").write_text( - "\n".join( - f"""\ - [{section}] - ImageId=testimage - """ - for section in sections + ]: + with chdir(tmp_path): + # Create a config with ImageId in the wrong section, + # and sometimes in the correct section + Path("mkosi.conf").write_text( + "\n".join( + f"""\ + [{section}] + ImageId=testimage + """ + for section in sections + ) ) - ) - - with caplog.at_level(logging.WARNING): - # Parse the config, with --image-id sometimes given on the command line - parse_config(args) - - assert len(caplog.records) == warning_count - -def test_config_parse_bytes() -> None: - assert config_parse_bytes(None) is None - assert config_parse_bytes("1") == 4096 - assert config_parse_bytes("8000") == 8192 - assert config_parse_bytes("8K") == 8192 - assert config_parse_bytes("4097") == 8192 - assert config_parse_bytes("1M") == 1024**2 - assert config_parse_bytes("1.9M") == 1994752 - assert config_parse_bytes("1G") == 1024**3 - assert config_parse_bytes("7.3G") == 7838318592 - - with pytest.raises(SystemExit): + handler = _ListHandler() + logger = logging.getLogger() + prev_level = logger.level + logger.addHandler(handler) + logger.setLevel(logging.WARNING) + try: + # Parse the config, with --image-id sometimes given on the command line + parse_config(args) + finally: + logger.removeHandler(handler) + logger.setLevel(prev_level) + + Assert.eq(len(handler.records), warning_count) + + +async def test_config_parse_bytes() -> None: + Assert.none(config_parse_bytes(None)) + Assert.eq(config_parse_bytes("1"), 4096) + Assert.eq(config_parse_bytes("8000"), 8192) + Assert.eq(config_parse_bytes("8K"), 8192) + Assert.eq(config_parse_bytes("4097"), 8192) + Assert.eq(config_parse_bytes("1M"), 1024**2) + Assert.eq(config_parse_bytes("1.9M"), 1994752) + Assert.eq(config_parse_bytes("1G"), 1024**3) + Assert.eq(config_parse_bytes("7.3G"), 7838318592) + + with Assert.raises(SystemExit): config_parse_bytes("-1") - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): config_parse_bytes("-2K") - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): config_parse_bytes("-3M") - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): config_parse_bytes("-4G") -def test_specifiers(tmp_path: Path) -> None: +async def test_specifiers(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1321,12 +1371,12 @@ def test_specifiers(tmp_path: Path) -> None: "Filesystem": "ext4", } - assert {k: v for k, v in config.environment.items() if k in expected} == expected + Assert.eq({k: v for k, v in config.environment.items() if k in expected}, expected) - assert subimage.environment["Image"] == "subimage" + Assert.eq(subimage.environment["Image"], "subimage") -def test_kernel_specifiers(tmp_path: Path) -> None: +async def test_kernel_specifiers() -> None: kver = "13.0.8-5.10.0-1057-oem" # taken from reporter of #1638 token = "MySystemImage" roothash = "67e893261799236dcf20529115ba9fae4fd7c2269e1e658d42269503e5760d38" @@ -1339,16 +1389,17 @@ def test_expand_kernel_specifiers(text: str) -> str: roothash=roothash, ) - assert test_expand_kernel_specifiers("&&") == "&" - assert test_expand_kernel_specifiers("&k") == kver - assert test_expand_kernel_specifiers("&e") == token - assert test_expand_kernel_specifiers("&h") == roothash + Assert.eq(test_expand_kernel_specifiers("&&"), "&") + Assert.eq(test_expand_kernel_specifiers("&k"), kver) + Assert.eq(test_expand_kernel_specifiers("&e"), token) + Assert.eq(test_expand_kernel_specifiers("&h"), roothash) - assert test_expand_kernel_specifiers("Image_1.0.3") == "Image_1.0.3" - assert test_expand_kernel_specifiers("Image+&h-&k-&e") == f"Image+{roothash}-{kver}-{token}" + Assert.eq(test_expand_kernel_specifiers("Image_1.0.3"), "Image_1.0.3") + Assert.eq(test_expand_kernel_specifiers("Image+&h-&k-&e"), f"Image+{roothash}-{kver}-{token}") -def test_output_id_version(tmp_path: Path) -> None: +async def test_output_id_version(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1362,14 +1413,15 @@ def test_output_id_version(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.output == "output_1.2.3" + Assert.eq(config.output, "output_1.2.3") -def test_deterministic() -> None: - assert Config.default() == Config.default() +async def test_deterministic() -> None: + Assert.eq(Config.default(), Config.default()) -def test_environment(tmp_path: Path) -> None: +async def test_environment(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1413,15 +1465,16 @@ def test_environment(tmp_path: Path) -> None: } # Only check values for keys from expected, as config.environment contains other items as well - assert {k: config.finalize_environment()[k] for k in expected.keys()} == expected + Assert.eq({k: config.finalize_environment()[k] for k in expected.keys()}, expected) - assert config.environment_files == [Path.cwd() / "mkosi.env", Path.cwd() / "other.env"] + Assert.eq(config.environment_files, [Path.cwd() / "mkosi.env", Path.cwd() / "other.env"]) - assert sub.environment["PassThisEnv"] == "abc" - assert "TestValue2" not in sub.environment + Assert.eq(sub.environment["PassThisEnv"], "abc") + Assert.not_in("TestValue2", sub.environment) -def test_proxy(tmp_path: Path) -> None: +async def test_proxy(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path # Verify environment variables are set correctly when GIT_CONFIG_COUNT is not set @@ -1444,7 +1497,7 @@ def test_proxy(tmp_path: Path) -> None: } # Only check values for keys from expected, as config.environment contains other items as well - assert {k: config.finalize_environment()[k] for k in expected.keys()} == expected + Assert.eq({k: config.finalize_environment()[k] for k in expected.keys()}, expected) (d / "mkosi.conf").write_text( """\ @@ -1470,30 +1523,32 @@ def test_proxy(tmp_path: Path) -> None: } # Only check values for keys from expected, as config.environment contains other items as well - assert {k: config.finalize_environment()[k] for k in expected.keys()} == expected + Assert.eq({k: config.finalize_environment()[k] for k in expected.keys()}, expected) -def test_mkosi_version_executable(tmp_path: Path) -> None: +async def test_mkosi_version_executable(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path version = d / "mkosi.version" version.write_text("#!/bin/sh\necho '1.2.3'\n") with chdir(d): - with pytest.raises(SystemExit) as error: + with Assert.raises(SystemExit) as error: _, _, [config] = parse_config() - assert error.type is SystemExit - assert error.value.code != 0 + Assert.is_(error.exception.__class__, SystemExit) + Assert.ne(error.exception.code, 0) version.chmod(0o755) with chdir(d): _, _, [config] = parse_config() - assert config.image_version == "1.2.3" + Assert.eq(config.image_version, "1.2.3") -def test_split_artifacts(tmp_path: Path) -> None: +async def test_split_artifacts(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1505,7 +1560,7 @@ def test_split_artifacts(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.split_artifacts == [ArtifactOutput.uki] + Assert.eq(config.split_artifacts, [ArtifactOutput.uki]) (d / "mkosi.conf").write_text( """ @@ -1518,19 +1573,23 @@ def test_split_artifacts(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.split_artifacts == [ - ArtifactOutput.uki, - ArtifactOutput.kernel, - ArtifactOutput.initrd, - ] + Assert.eq( + config.split_artifacts, + [ + ArtifactOutput.uki, + ArtifactOutput.kernel, + ArtifactOutput.initrd, + ], + ) -def test_split_artifacts_compat(tmp_path: Path) -> None: +async def test_split_artifacts_compat(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path with chdir(d): _, _, [config] = parse_config() - assert config.split_artifacts == ArtifactOutput.compat_no() + Assert.eq(config.split_artifacts, ArtifactOutput.compat_no()) (d / "mkosi.conf").write_text( """ @@ -1541,10 +1600,11 @@ def test_split_artifacts_compat(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config() - assert config.split_artifacts == ArtifactOutput.compat_yes() + Assert.eq(config.split_artifacts, ArtifactOutput.compat_yes()) -def test_cli_collection_reset(tmp_path: Path) -> None: +async def test_cli_collection_reset(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1556,32 +1616,35 @@ def test_cli_collection_reset(tmp_path: Path) -> None: with chdir(d): _, _, [config] = parse_config(["--package", ""]) - assert config.packages == [] + Assert.eq(config.packages, []) _, _, [config] = parse_config(["--package", "", "--package", "foo"]) - assert config.packages == ["foo"] + Assert.eq(config.packages, ["foo"]) _, _, [config] = parse_config(["--package", "foo", "--package", "", "--package", "bar"]) - assert config.packages == ["bar"] + Assert.eq(config.packages, ["bar"]) _, _, [config] = parse_config(["--package", "foo", "--package", ""]) - assert config.packages == [] + Assert.eq(config.packages, []) -def test_tools(tmp_path: Path) -> None: +async def test_tools(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path argv = ["--tools-tree=default"] if in_box(): - pytest.skip("Cannot run test_tools() test within mkosi box environment") + Assert.skip("Cannot run test_tools() test within mkosi box environment") with resource_path(mkosi.resources) as resources, chdir(d): _, tools, _ = parse_config(argv, resources=resources) - assert tools + Assert.not_none(tools) + tools = cast(Config, tools) host = detect_distribution()[0] if isinstance(host, Distribution): - assert tools.distribution == ( - host.installer.default_tools_tree_distribution() or tools.distribution + Assert.eq( + tools.distribution, + (host.installer.default_tools_tree_distribution() or tools.distribution), ) (d / "mkosi.tools.conf").write_text( @@ -1592,20 +1655,23 @@ def test_tools(tmp_path: Path) -> None: ) _, tools, _ = parse_config(argv, resources=resources) - assert tools - assert tools.package_directories == [Path(d)] + Assert.not_none(tools) + tools = cast(Config, tools) + Assert.eq(tools.package_directories, [Path(d)]) _, tools, _ = parse_config( argv + ["--tools-tree-distribution=arch", "--tools-tree-package-directory=/tmp"], resources=resources, ) - assert tools - assert tools.distribution == Distribution.arch - assert tools.package_directories == [Path(d), Path("/tmp")] + Assert.not_none(tools) + tools = cast(Config, tools) + Assert.eq(tools.distribution, Distribution.arch) + Assert.eq(tools.package_directories, [Path(d), Path("/tmp")]) _, tools, _ = parse_config(argv + ["--tools-tree-package-directory="], resources=resources) - assert tools - assert tools.package_directories == [] + Assert.not_none(tools) + tools = cast(Config, tools) + Assert.eq(tools.package_directories, []) (d / "mkosi.conf").write_text( """ @@ -1615,11 +1681,13 @@ def test_tools(tmp_path: Path) -> None: ) _, tools, _ = parse_config(argv, resources=resources) - assert tools - assert tools.distribution == Distribution.arch + Assert.not_none(tools) + tools = cast(Config, tools) + Assert.eq(tools.distribution, Distribution.arch) -def test_subdir(tmp_path: Path) -> None: +async def test_subdir(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path with chdir(d): @@ -1632,7 +1700,7 @@ def test_subdir(tmp_path: Path) -> None: ) _, _, [config] = parse_config() - assert config.output == "qed" + Assert.eq(config.output, "qed") os.chdir(d) @@ -1644,10 +1712,11 @@ def test_subdir(tmp_path: Path) -> None: ) _, _, [config] = parse_config() - assert config.output == "abc" + Assert.eq(config.output, "abc") -def test_assert(tmp_path: Path) -> None: +async def test_assert(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path with chdir(d): @@ -1658,7 +1727,7 @@ def test_assert(tmp_path: Path) -> None: """ ) - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): parse_config() # Does not raise, i.e. parses successfully, but we don't care for the content. @@ -1674,11 +1743,11 @@ def test_assert(tmp_path: Path) -> None: """ ) - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): parse_config([]) - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): parse_config(["--image-id", "abcde"]) - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): parse_config(["--environment", "ABC=QED"]) parse_config(["--image-id", "abcde", "--environment", "ABC=QED"]) @@ -1693,7 +1762,7 @@ def test_assert(tmp_path: Path) -> None: """ ) - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): parse_config() parse_config(["--image-id", "abcde"]) @@ -1712,14 +1781,15 @@ def test_assert(tmp_path: Path) -> None: """ ) - with pytest.raises(SystemExit): + with Assert.raises(SystemExit): parse_config() parse_config(["--image-id", "abcde", "--environment", "ABC=QED"]) parse_config(["--image-id", "abcde", "--environment", "DEF=QEE"]) -def test_initrd_packages(tmp_path: Path) -> None: +async def test_initrd_packages(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -1736,8 +1806,8 @@ def test_initrd_packages(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [initrd, _] = parse_config(resources=resources) - assert "package1" in initrd.packages - assert "package2" in initrd.packages + Assert.in_("package1", initrd.packages) + Assert.in_("package2", initrd.packages) # Make sure the InitrdPackages= are also picked up when a subimage is defined. (d / "mkosi.images").mkdir() @@ -1746,11 +1816,12 @@ def test_initrd_packages(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [_, initrd, _] = parse_config(resources=resources) - assert "package1" in initrd.packages - assert "package2" in initrd.packages + Assert.in_("package1", initrd.packages) + Assert.in_("package2", initrd.packages) -def test_config_default_initrds(tmp_path: Path) -> None: +async def test_config_default_initrds(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path # Default initrd should be built when Bootable=yes and the image format supports it. @@ -1764,8 +1835,8 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [initrd, main] = parse_config(resources=resources) - assert len(main.initrds) == 1 - assert initrd.image == "default-initrd" + Assert.eq(len(main.initrds), 1) + Assert.eq(initrd.image, "default-initrd") # Default initrd should not be built when Bootable=disabled. (d / "mkosi.conf").write_text( @@ -1778,7 +1849,7 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) # Default initrd should not be built for UKI output format. (d / "mkosi.conf").write_text( @@ -1794,7 +1865,7 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) # Default initrd should not be built for ESP output format. (d / "mkosi.conf").write_text( @@ -1810,7 +1881,7 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) # Default initrd should not be built when Bootable=auto and output is cpio. (d / "mkosi.conf").write_text( @@ -1823,7 +1894,7 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) # Default initrd should not be built when Bootable=auto and output is a sysext image. (d / "mkosi.conf").write_text( @@ -1836,7 +1907,7 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) # Default initrd should not be built when Overlay=yes. (d / "mkosi.conf").write_text( @@ -1849,10 +1920,11 @@ def test_config_default_initrds(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) -def test_initrds_default_value(tmp_path: Path) -> None: +async def test_initrds_default_value(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path # The "default" special value should explicitly request the default initrd. @@ -1866,8 +1938,8 @@ def test_initrds_default_value(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [initrd, main] = parse_config(resources=resources) - assert len(main.initrds) == 1 - assert initrd.image == "default-initrd" + Assert.eq(len(main.initrds), 1) + Assert.eq(initrd.image, "default-initrd") (d / "myinitrd.cpio").touch() @@ -1885,10 +1957,10 @@ def test_initrds_default_value(tmp_path: Path) -> None: _, _, [initrd, main] = parse_config(resources=resources) # The main image should have two initrds: the custom one and the default one (resolved path). - assert len(main.initrds) == 2 - assert main.initrds[0] == d / "myinitrd.cpio" + Assert.eq(len(main.initrds), 2) + Assert.eq(main.initrds[0], d / "myinitrd.cpio") # Second initrd should be the resolved path from the default initrd image. - assert initrd.image == "default-initrd" + Assert.eq(initrd.image, "default-initrd") # When only "default" is specified, the default initrd should be built. (d / "mkosi.conf").write_text( @@ -1902,11 +1974,12 @@ def test_initrds_default_value(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [initrd, main] = parse_config(resources=resources) - assert len(main.initrds) == 1 - assert initrd.image == "default-initrd" + Assert.eq(len(main.initrds), 1) + Assert.eq(initrd.image, "default-initrd") -def test_initrds_empty_resets(tmp_path: Path) -> None: +async def test_initrds_empty_resets(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path # An empty value for Initrds= should disable the default initrd. @@ -1922,7 +1995,7 @@ def test_initrds_empty_resets(tmp_path: Path) -> None: _, _, [config] = parse_config(resources=resources) # Empty string should reset to empty, not to default. - assert not config.initrds + Assert.false(config.initrds) # Make sure dropin can override with empty to disable default initrd. (d / "mkosi.conf").write_text( @@ -1943,10 +2016,11 @@ def test_initrds_empty_resets(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert not config.initrds + Assert.false(config.initrds) -def test_initrds_custom_only(tmp_path: Path) -> None: +async def test_initrds_custom_only(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "myinitrd.cpio").touch() @@ -1963,11 +2037,12 @@ def test_initrds_custom_only(tmp_path: Path) -> None: with chdir(d), resource_path(mkosi.resources) as resources: _, _, [config] = parse_config(resources=resources) - assert len(config.initrds) == 1 - assert config.initrds[0] == d / "myinitrd.cpio" + Assert.eq(len(config.initrds), 1) + Assert.eq(config.initrds[0], d / "myinitrd.cpio") -def test_history_empty_list(tmp_path: Path) -> None: +async def test_history_empty_list(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "packages").mkdir() @@ -1985,16 +2060,17 @@ def test_history_empty_list(tmp_path: Path) -> None: with chdir(d): _, _, [main] = parse_config(["--package-directory=", "build"]) - assert (d / ".mkosi-private/history/latest.json").exists() - assert main.package_directories == [] + Assert.true((d / ".mkosi-private/history/latest.json").exists()) + Assert.eq(main.package_directories, []) with chdir(d): _, _, [main] = parse_config(["summary"]) - assert main.package_directories == [] + Assert.eq(main.package_directories, []) -def test_history_found_via_configured_output_directory(tmp_path: Path) -> None: +async def test_history_found_via_configured_output_directory(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path out = d / "out" @@ -2023,10 +2099,11 @@ def test_history_found_via_configured_output_directory(tmp_path: Path) -> None: # the build's history. _, _, [config] = parse_config(["summary"]) - assert config.image_id == "img-x" + Assert.eq(config.image_id, "img-x") -def test_history_isolated_per_output_directory(tmp_path: Path) -> None: +async def test_history_isolated_per_output_directory(stack: contextlib.AsyncExitStack) -> None: + tmp_path = tmp_dir(stack) d = tmp_path (d / "mkosi.conf").write_text( @@ -2054,6 +2131,6 @@ def test_history_isolated_per_output_directory(tmp_path: Path) -> None: _, _, [config_a] = parse_config(["--output-directory", os.fspath(out_a), "summary"]) _, _, [config_b] = parse_config(["--output-directory", os.fspath(out_b), "summary"]) - assert config_last.image_id == "img-b" - assert config_a.image_id == "img-a" - assert config_b.image_id == "img-b" + Assert.eq(config_last.image_id, "img-b") + Assert.eq(config_a.image_id, "img-a") + Assert.eq(config_b.image_id, "img-b") diff --git a/tests/test_json.py b/tests/test_json.py index 26f68b14a1..3ffdd8dc17 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Optional -import pytest +import barrage.assertions as Assert from mkosi.config import ( Architecture, @@ -50,8 +50,7 @@ from mkosi.distribution import Distribution -@pytest.mark.parametrize("path", [None, "/baz/qux"]) -def test_args(path: Optional[Path]) -> None: +def check_args(path: Optional[Path]) -> None: dump = textwrap.dedent( f"""\ {{ @@ -99,11 +98,19 @@ def test_args(path: Optional[Path]) -> None: wipe_build_dir=True, ) - assert dump_json(args.to_dict()) == dump.rstrip() - assert Args.from_json(dump) == args + Assert.eq(dump_json(args.to_dict()), dump.rstrip()) + Assert.eq(Args.from_json(dump), args) -def test_config() -> None: +async def test_args_no_directory() -> None: + check_args(None) + + +async def test_args_directory() -> None: + check_args(Path("/baz/qux")) + + +async def test_config() -> None: dump = textwrap.dedent( """\ { @@ -654,5 +661,5 @@ def test_config() -> None: workspace_dir=Path("/cwd"), ) - assert dump_json(args.to_dict()) == dump.rstrip() - assert Config.from_json(dump) == args + Assert.eq(dump_json(args.to_dict()), dump.rstrip()) + Assert.eq(Config.from_json(dump), args) diff --git a/tests/test_kmod.py b/tests/test_kmod.py index e8f2a5e4b4..ee864ba70a 100644 --- a/tests/test_kmod.py +++ b/tests/test_kmod.py @@ -1,60 +1,62 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +import barrage.assertions as Assert + from mkosi import kmod -def test_globs_match_module() -> None: - assert kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["ahci"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz.2", ["ahci"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["ata"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["drivers"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["/drivers"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["/drivers"]) - assert not kmod.globs_match_module("drivers/ata/ahci-2.ko.xz", ["ahci"]) - assert not kmod.globs_match_module("drivers/ata/ahci2.ko.zst", ["ahci"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["ata/*"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["/ata/*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["drivers/*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["/drivers/*"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko", ["ahci/*"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko", ["bahci*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.zst", ["ahci*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["ahc*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["ah*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["ata/"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["drivers/"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["drivers/ata/"]) - - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["-ahci", "*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko", ["-ahci", "*", "ahciahci"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["-ahci", "*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.zst", ["-ahci", "*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "*"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "drivers/"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "ata/"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "ata/ata/"]) - assert kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "drivers/ata/"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko", ["*", "-ahci"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko", ["ahci", "-*"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.zst", ["-*"]) - assert not kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["-*"]) +async def test_globs_match_module() -> None: + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["ahci"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz.2", ["ahci"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["ata"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["drivers"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["/drivers"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["/drivers"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci-2.ko.xz", ["ahci"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci2.ko.zst", ["ahci"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["ata/*"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["/ata/*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["drivers/*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["/drivers/*"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko", ["ahci/*"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko", ["bahci*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.zst", ["ahci*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["ahc*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["ah*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["ata/"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["drivers/"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["drivers/ata/"])) + + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["-ahci", "*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko", ["-ahci", "*", "ahciahci"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["-ahci", "*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.zst", ["-ahci", "*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "*"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "drivers/"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "ata/"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "ata/ata/"])) + Assert.true(kmod.globs_match_module("drivers/ata/ahci.ko.gz", ["-ahci", "drivers/ata/"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko", ["*", "-ahci"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko", ["ahci", "-*"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.zst", ["-*"])) + Assert.false(kmod.globs_match_module("drivers/ata/ahci.ko.xz", ["-*"])) # absolute glob behavior unchanged when paths are relative to /lib/module/ - assert kmod.globs_match_module("kernel/drivers/ata/ahci.ko", ["drivers/*"]) - assert kmod.globs_match_module("kernel/drivers/ata/ahci.ko", ["/drivers/*"]) - assert not kmod.globs_match_module("kernel/drivers/ata/ahci.ko.xz", ["/ata/*"]) + Assert.true(kmod.globs_match_module("kernel/drivers/ata/ahci.ko", ["drivers/*"])) + Assert.true(kmod.globs_match_module("kernel/drivers/ata/ahci.ko", ["/drivers/*"])) + Assert.false(kmod.globs_match_module("kernel/drivers/ata/ahci.ko.xz", ["/ata/*"])) # absolute globs match both relative to kernel/ and module_dir root - assert kmod.globs_match_module("kernel/drivers/ata/ahci.ko.xz", ["/drivers/ata/ahci"]) - assert kmod.globs_match_module("kernel/drivers/ata/ahci.ko.xz", ["/kernel/drivers/ata/ahci"]) - - -def test_normalize_module_glob() -> None: - assert kmod.normalize_module_glob("raid[0-9]") == "raid[0-9]" - assert kmod.normalize_module_glob("raid[0_9]") == "raid[0_9]" - assert kmod.normalize_module_glob("raid[0_9]a_z") == "raid[0_9]a-z" - assert kmod.normalize_module_glob("0_9") == "0-9" - assert kmod.normalize_module_glob("[0_9") == "[0_9" - assert kmod.normalize_module_glob("0_9]") == "0-9]" - assert kmod.normalize_module_glob("raid[0_9]a_z[a_c]") == "raid[0_9]a-z[a_c]" + Assert.true(kmod.globs_match_module("kernel/drivers/ata/ahci.ko.xz", ["/drivers/ata/ahci"])) + Assert.true(kmod.globs_match_module("kernel/drivers/ata/ahci.ko.xz", ["/kernel/drivers/ata/ahci"])) + + +async def test_normalize_module_glob() -> None: + Assert.eq(kmod.normalize_module_glob("raid[0-9]"), "raid[0-9]") + Assert.eq(kmod.normalize_module_glob("raid[0_9]"), "raid[0_9]") + Assert.eq(kmod.normalize_module_glob("raid[0_9]a_z"), "raid[0_9]a-z") + Assert.eq(kmod.normalize_module_glob("0_9"), "0-9") + Assert.eq(kmod.normalize_module_glob("[0_9"), "[0_9") + Assert.eq(kmod.normalize_module_glob("0_9]"), "0-9]") + Assert.eq(kmod.normalize_module_glob("raid[0_9]a_z[a_c]"), "raid[0_9]a-z[a_c]") diff --git a/tests/test_linters.py b/tests/test_linters.py index f1d6464af6..d48da62a49 100644 --- a/tests/test_linters.py +++ b/tests/test_linters.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path -import pytest +import barrage.assertions as Assert from mkosi.run import run @@ -25,29 +25,36 @@ def skip_if_missing(tool: str) -> bool: return SKIP_MISSING_TOOLS and shutil.which(tool) is None -@pytest.mark.skipif(skip_if_missing("ruff"), reason="ruff not found") -def test_ruff_format_check() -> None: +async def test_ruff_format_check() -> None: """Check that code is formatted with ruff format.""" - run(["ruff", "format", "--check", "--diff", "mkosi/", "tests/", *kernel_install_files()], cwd=REPO_ROOT) + if skip_if_missing("ruff"): + Assert.skip("ruff not found") + run( + ["ruff", "format", "--check", "--diff", "mkosi/", "tests/", *kernel_install_files()], + cwd=REPO_ROOT, + ) -@pytest.mark.skipif(skip_if_missing("ruff"), reason="ruff not found") -def test_ruff_check() -> None: +async def test_ruff_check() -> None: """Check code quality with ruff.""" + if skip_if_missing("ruff"): + Assert.skip("ruff not found") run( ["ruff", "check", "--output-format=github", "mkosi/", "tests/", *kernel_install_files()], cwd=REPO_ROOT, ) -@pytest.mark.skipif(skip_if_missing("git"), reason="git not found") -def test_no_tabs_in_code() -> None: +async def test_no_tabs_in_code() -> None: + if skip_if_missing("git"): + Assert.skip("git not found") result = run(["git", "grep", "-P", r"\t", "*.py"], check=False, cwd=REPO_ROOT) - assert result.returncode != 0, "Found tabs in Python code" + Assert.ne(result.returncode, 0, "Found tabs in Python code") -@pytest.mark.skipif(skip_if_missing("codespell"), reason="codespell not found") -def test_codespell() -> None: +async def test_codespell() -> None: + if skip_if_missing("codespell"): + Assert.skip("codespell not found") run(["codespell", "--version"], cwd=REPO_ROOT) files = run(["git", "ls-files"], stdout=subprocess.PIPE, cwd=REPO_ROOT).stdout.strip().split("\n") # Filter out files we want to skip @@ -55,28 +62,33 @@ def test_codespell() -> None: run(["codespell", *files_to_check], cwd=REPO_ROOT) -@pytest.mark.skipif(skip_if_missing("reuse"), reason="reuse not found") -def test_reuse_lint() -> None: +async def test_reuse_lint() -> None: + if skip_if_missing("reuse"): + Assert.skip("reuse not found") run(["reuse", "lint"], cwd=REPO_ROOT) -@pytest.mark.skipif(skip_if_missing("mypy"), reason="mypy not found") -def test_mypy() -> None: +async def test_mypy() -> None: + if skip_if_missing("mypy"): + Assert.skip("mypy not found") run(["mypy", "mkosi/", *kernel_install_files()], cwd=REPO_ROOT) -@pytest.mark.skipif(skip_if_missing("mypy"), reason="mypy not found") -def test_mypy_python310() -> None: +async def test_mypy_python310() -> None: + if skip_if_missing("mypy"): + Assert.skip("mypy not found") run(["mypy", "--python-version", "3.10", "mkosi/", "tests/", *kernel_install_files()], cwd=REPO_ROOT) -@pytest.mark.skipif(skip_if_missing("pyright"), reason="pyright not found") -def test_pyright() -> None: +async def test_pyright() -> None: + if skip_if_missing("pyright"): + Assert.skip("pyright not found") run(["pyright", "mkosi/", "tests/", *kernel_install_files()], cwd=REPO_ROOT) -@pytest.mark.skipif(skip_if_missing("shellcheck"), reason="shellcheck not found") -def test_shellcheck() -> None: +async def test_shellcheck() -> None: + if skip_if_missing("shellcheck"): + Assert.skip("shellcheck not found") # Check bin/mkosi and tools/*.sh tools_scripts = [os.fspath(p) for p in (REPO_ROOT / "tools").glob("*.sh")] run(["shellcheck", "bin/mkosi", *tools_scripts], cwd=REPO_ROOT) @@ -86,10 +98,9 @@ def test_shellcheck() -> None: run(["shellcheck", "-"], input=completion, cwd=REPO_ROOT) -@pytest.mark.skipif(skip_if_missing("pandoc"), reason="pandoc not found") -@pytest.mark.skipif( - SKIP_MISSING_TOOLS and not (REPO_ROOT / "tools/make-man-page.sh").exists(), - reason="tools/make-man-page.sh not found", -) -def test_man_page_generation() -> None: +async def test_man_page_generation() -> None: + if skip_if_missing("pandoc"): + Assert.skip("pandoc not found") + if SKIP_MISSING_TOOLS and not (REPO_ROOT / "tools/make-man-page.sh").exists(): + Assert.skip("tools/make-man-page.sh not found") run(["tools/make-man-page.sh"], cwd=REPO_ROOT) diff --git a/tests/test_run.py b/tests/test_run.py index 52b4306756..fa7967df13 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -3,62 +3,65 @@ import contextlib import os import subprocess +import tempfile from pathlib import Path -import pytest +import barrage.assertions as Assert from mkosi.run import fork_and_wait -def test_fork_and_wait_returns_value() -> None: +async def test_fork_and_wait_returns_value() -> None: result = fork_and_wait(lambda: 42) - assert result == 42 + Assert.eq(result, 42) -def test_fork_and_wait_returns_none() -> None: +async def test_fork_and_wait_returns_none() -> None: result = fork_and_wait(lambda: None) - assert result is None + Assert.none(result) -def test_fork_and_wait_returns_string() -> None: +async def test_fork_and_wait_returns_string() -> None: result = fork_and_wait(lambda: "hello world") - assert result == "hello world" + Assert.eq(result, "hello world") -def test_fork_and_wait_returns_complex_type() -> None: +async def test_fork_and_wait_returns_complex_type() -> None: result = fork_and_wait(lambda: {"key": [1, 2, 3], "nested": {"a": True}}) - assert result == {"key": [1, 2, 3], "nested": {"a": True}} + Assert.eq(result, {"key": [1, 2, 3], "nested": {"a": True}}) -def test_fork_and_wait_passes_args() -> None: +async def test_fork_and_wait_passes_args() -> None: def add(a: int, b: int) -> int: return a + b result = fork_and_wait(add, 3, 4) - assert result == 7 + Assert.eq(result, 7) -def test_fork_and_wait_passes_kwargs() -> None: +async def test_fork_and_wait_passes_kwargs() -> None: def greet(name: str, greeting: str = "Hello") -> str: return f"{greeting}, {name}!" result = fork_and_wait(greet, "world", greeting="Hi") - assert result == "Hi, world!" + Assert.eq(result, "Hi, world!") -def test_fork_and_wait_child_failure() -> None: +async def test_fork_and_wait_child_failure() -> None: def fail() -> None: raise RuntimeError("boom") - with pytest.raises(subprocess.CalledProcessError): + with Assert.raises(subprocess.CalledProcessError): fork_and_wait(fail) -def test_fork_and_wait_sandbox(tmp_path: Path) -> None: - (tmp_path / "abc").mkdir() +async def test_fork_and_wait_sandbox() -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + (tmp_path / "abc").mkdir() - def exists() -> bool: - return Path("/abc").exists() + def exists() -> bool: + return Path("/abc").exists() - result = fork_and_wait(exists, sandbox=contextlib.nullcontext(["--bind", os.fspath(tmp_path), "/"])) - assert result + result = fork_and_wait(exists, sandbox=contextlib.nullcontext(["--bind", os.fspath(tmp_path), "/"])) + Assert.true(result) diff --git a/tests/test_util.py b/tests/test_util.py index 8796b7826a..7c77b5340c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,51 +2,51 @@ from pathlib import Path -import pytest +import barrage.assertions as Assert from mkosi.util import parents_below -def test_parents_below_basic() -> None: +async def test_parents_below_basic() -> None: path = Path("/a/b/c/d/e") below = Path("/a/b") - assert parents_below(path, below) == [Path("/a/b/c/d"), Path("/a/b/c")] + Assert.eq(parents_below(path, below), [Path("/a/b/c/d"), Path("/a/b/c")]) -def test_parents_below_root() -> None: +async def test_parents_below_root() -> None: path = Path("/a/b/c") below = Path("/") - assert parents_below(path, below) == [Path("/a/b"), Path("/a")] + Assert.eq(parents_below(path, below), [Path("/a/b"), Path("/a")]) -def test_parents_below_direct_child() -> None: +async def test_parents_below_direct_child() -> None: path = Path("/a/b/c") below = Path("/a/b") - assert parents_below(path, below) == [] + Assert.eq(parents_below(path, below), []) -def test_parents_below_relative_paths() -> None: +async def test_parents_below_relative_paths() -> None: path = Path("a/b/c/d") below = Path("a/b") - assert parents_below(path, below) == [Path("a/b/c")] + Assert.eq(parents_below(path, below), [Path("a/b/c")]) -def test_parents_below_same_path_raises() -> None: +async def test_parents_below_same_path_raises() -> None: path = Path("/a/b/c") below = Path("/a/b/c") - with pytest.raises(ValueError): + with Assert.raises(ValueError): parents_below(path, below) -def test_parents_below_not_parent_raises() -> None: +async def test_parents_below_not_parent_raises() -> None: path = Path("/a/b/c") below = Path("/x/y/z") - with pytest.raises(ValueError): + with Assert.raises(ValueError): parents_below(path, below) -def test_parents_below_below_is_child_raises() -> None: +async def test_parents_below_below_is_child_raises() -> None: path = Path("/a/b") below = Path("/a/b/c") - with pytest.raises(ValueError): + with Assert.raises(ValueError): parents_below(path, below) diff --git a/tests/test_versioncomp.py b/tests/test_versioncomp.py index e23f7d340b..c4c61a230e 100644 --- a/tests/test_versioncomp.py +++ b/tests/test_versioncomp.py @@ -2,103 +2,98 @@ import itertools -import pytest +import barrage.assertions as Assert from mkosi.versioncomp import GenericVersion -def test_conversion() -> None: - assert GenericVersion("1") < 2 - assert GenericVersion("1") < "2" - assert GenericVersion("2") > 1 - assert GenericVersion("2") > "1" - assert GenericVersion("1") == "1" +def RPMVERCMP(a: str, b: str, expected: int) -> None: + Assert.eq( + (GenericVersion(a) > GenericVersion(b)) - (GenericVersion(a) < GenericVersion(b)), + expected, + ) + +async def test_conversion() -> None: + Assert.lt(GenericVersion("1"), 2) + Assert.lt(GenericVersion("1"), "2") + Assert.gt(GenericVersion("2"), 1) + Assert.gt(GenericVersion("2"), "1") + Assert.eq(GenericVersion("1"), "1") -def test_generic_version_systemd() -> None: + +async def test_generic_version_systemd() -> None: """Same as the first block of systemd/test/test-compare-versions.sh""" - assert GenericVersion("1") < GenericVersion("2") - assert GenericVersion("1") <= GenericVersion("2") - assert GenericVersion("1") != GenericVersion("2") - assert not (GenericVersion("1") > GenericVersion("2")) - assert not (GenericVersion("1") == GenericVersion("2")) - assert not (GenericVersion("1") >= GenericVersion("2")) - assert GenericVersion.compare_versions("1", "2") == -1 - assert GenericVersion.compare_versions("2", "2") == 0 - assert GenericVersion.compare_versions("2", "1") == 1 - - -def test_generic_version_spec() -> None: + Assert.lt(GenericVersion("1"), GenericVersion("2")) + Assert.le(GenericVersion("1"), GenericVersion("2")) + Assert.ne(GenericVersion("1"), GenericVersion("2")) + Assert.false(GenericVersion("1") > GenericVersion("2")) + Assert.false(GenericVersion("1") == GenericVersion("2")) + Assert.false(GenericVersion("1") >= GenericVersion("2")) + Assert.eq(GenericVersion.compare_versions("1", "2"), -1) + Assert.eq(GenericVersion.compare_versions("2", "2"), 0) + Assert.eq(GenericVersion.compare_versions("2", "1"), 1) + + +async def test_generic_version_spec() -> None: """Examples from the uapi group version format spec""" - assert GenericVersion("11") == GenericVersion("11") - assert GenericVersion("systemd-123") == GenericVersion("systemd-123") - assert GenericVersion("bar-123") < GenericVersion("foo-123") - assert GenericVersion("123a") > GenericVersion("123") - assert GenericVersion("123.a") > GenericVersion("123") - assert GenericVersion("123.a") < GenericVersion("123.b") - assert GenericVersion("123a") > GenericVersion("123.a") - assert GenericVersion("11α") == GenericVersion("11β") - assert GenericVersion("A") < GenericVersion("a") - assert GenericVersion("") < GenericVersion("0") - assert GenericVersion("0.") > GenericVersion("0") - assert GenericVersion("0.0") > GenericVersion("0") - assert GenericVersion("0") > GenericVersion("~") - assert GenericVersion("") > GenericVersion("~") - assert GenericVersion("1_") == GenericVersion("1") - assert GenericVersion("_1") == GenericVersion("1") - assert GenericVersion("1_") < GenericVersion("1.2") - assert GenericVersion("1_2_3") > GenericVersion("1.3.3") - assert GenericVersion("1+") == GenericVersion("1") - assert GenericVersion("+1") == GenericVersion("1") - assert GenericVersion("1+") < GenericVersion("1.2") - assert GenericVersion("1+2+3") > GenericVersion("1.3.3") - - -@pytest.mark.parametrize( - "s1,s2", - itertools.combinations_with_replacement( - enumerate( - [ - GenericVersion("122.1"), - GenericVersion("123~rc1-1"), - GenericVersion("123"), - GenericVersion("123-a"), - GenericVersion("123-a.1"), - GenericVersion("123-1"), - GenericVersion("123-1.1"), - GenericVersion("123^post1"), - GenericVersion("123.a-1"), - GenericVersion("123.1-1"), - GenericVersion("123a-1"), - GenericVersion("124-1"), - ], - ), - 2, - ), -) -def test_generic_version_strverscmp_improved_doc( - s1: tuple[int, GenericVersion], - s2: tuple[int, GenericVersion], -) -> None: + Assert.eq(GenericVersion("11"), GenericVersion("11")) + Assert.eq(GenericVersion("systemd-123"), GenericVersion("systemd-123")) + Assert.lt(GenericVersion("bar-123"), GenericVersion("foo-123")) + Assert.gt(GenericVersion("123a"), GenericVersion("123")) + Assert.gt(GenericVersion("123.a"), GenericVersion("123")) + Assert.lt(GenericVersion("123.a"), GenericVersion("123.b")) + Assert.gt(GenericVersion("123a"), GenericVersion("123.a")) + Assert.eq(GenericVersion("11α"), GenericVersion("11β")) + Assert.lt(GenericVersion("A"), GenericVersion("a")) + Assert.lt(GenericVersion(""), GenericVersion("0")) + Assert.gt(GenericVersion("0."), GenericVersion("0")) + Assert.gt(GenericVersion("0.0"), GenericVersion("0")) + Assert.gt(GenericVersion("0"), GenericVersion("~")) + Assert.gt(GenericVersion(""), GenericVersion("~")) + Assert.eq(GenericVersion("1_"), GenericVersion("1")) + Assert.eq(GenericVersion("_1"), GenericVersion("1")) + Assert.lt(GenericVersion("1_"), GenericVersion("1.2")) + Assert.gt(GenericVersion("1_2_3"), GenericVersion("1.3.3")) + Assert.eq(GenericVersion("1+"), GenericVersion("1")) + Assert.eq(GenericVersion("+1"), GenericVersion("1")) + Assert.lt(GenericVersion("1+"), GenericVersion("1.2")) + Assert.gt(GenericVersion("1+2+3"), GenericVersion("1.3.3")) + + +async def test_generic_version_strverscmp_improved_doc() -> None: """Example from the doc string of strverscmp_improved. strverscmp_improved can be found in systemd/src/fundamental/string-util-fundamental.c """ - i1, v1 = s1 - i2, v2 = s2 - assert (v1 == v2) == (i1 == i2) - assert (v1 < v2) == (i1 < i2) - assert (v1 <= v2) == (i1 <= i2) - assert (v1 > v2) == (i1 > i2) - assert (v1 >= v2) == (i1 >= i2) - assert (v1 != v2) == (i1 != i2) - - -def RPMVERCMP(a: str, b: str, expected: int) -> None: - assert (GenericVersion(a) > GenericVersion(b)) - (GenericVersion(a) < GenericVersion(b)) == expected - - -def test_generic_version_rpmvercmp() -> None: + versions = enumerate( + [ + GenericVersion("122.1"), + GenericVersion("123~rc1-1"), + GenericVersion("123"), + GenericVersion("123-a"), + GenericVersion("123-a.1"), + GenericVersion("123-1"), + GenericVersion("123-1.1"), + GenericVersion("123^post1"), + GenericVersion("123.a-1"), + GenericVersion("123.1-1"), + GenericVersion("123a-1"), + GenericVersion("124-1"), + ], + ) + for s1, s2 in itertools.combinations_with_replacement(versions, 2): + i1, v1 = s1 + i2, v2 = s2 + Assert.eq(v1 == v2, i1 == i2) + Assert.eq(v1 < v2, i1 < i2) + Assert.eq(v1 <= v2, i1 <= i2) + Assert.eq(v1 > v2, i1 > i2) + Assert.eq(v1 >= v2, i1 >= i2) + Assert.eq(v1 != v2, i1 != i2) + + +async def test_generic_version_rpmvercmp() -> None: EQUAL = 0 RIGHT_SMALLER = 1 LEFT_SMALLER = -1 From 6ef9b7bd3c952734e7d2fd39af83770ba3afc358 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 11 Jun 2026 11:32:16 +0200 Subject: [PATCH 3/3] tests: Port integration tests to barrage barrage has no equivalent to pytest's `parametrize`. Open-code that with `do_test_*(params...)` helpers and individual tests calling that. Stop passing the explicit `--distribution` to the test. The new `ImageConfigManager` singleton (which replaces the old `config` pytest fixture) reads the distribution from `mkosi.local.conf`. This avoids having to specify it twice, and leaves the configuration to `integration-test-setup.sh`. Drop the `log_setup()` call. The integration tests themselves don't use `logging`, this mostly just affects the called `mkosi` subprocesses which already do it. This is very prone to interfere with barrage's own stderr capturing. Replace the `--debug-shell` pytest option with a `TEST_DEBUG_SHELL` env variable, as barrage doesn't have test-defined CLI options. Make the mkosi invocations in tests/__init__.py async, so that they can actually run in parallel. Remove installation and remaining traces of pytest. --- .github/workflows/ci.yml | 10 +- .gitignore | 1 - AGENTS.md | 3 +- README.md | 34 ++++++- mkosi.tools.conf/mkosi.conf.d/arch.conf | 1 - .../mkosi.conf.d/azure-centos-fedora.conf | 1 - .../mkosi.conf.d/debian-kali-ubuntu.conf | 1 - mkosi.tools.conf/mkosi.conf.d/opensuse.conf | 1 - pyproject.toml | 10 -- tests/__init__.py | 98 ++++++++++--------- tests/conftest.py | 79 --------------- tests/integration_boot.py | 98 +++++++++++++++---- tests/integration_extension.py | 27 +++-- tests/integration_initrd.py | 96 +++++++++++------- tests/integration_signing.py | 20 ++-- 15 files changed, 250 insertions(+), 230 deletions(-) delete mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ecd873fc..590a54a21d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,14 +148,14 @@ jobs: - name: Run integration tests run: | # Without KVM the tests are way too slow and time out + # barrage currently has no dynamic runtime throttling to resources + # each test runs a ~ 1.5 GiB VM, and GitHub runners have 4 GiB if [[ -e /dev/kvm ]]; then sudo mkosi box -- \ timeout -k 30 1h \ - python3 -m pytest \ - --tb=no \ - --capture=no \ + python3 -m barrage \ --verbose \ - -m integration \ - --distribution ${{ matrix.distro }} \ + --max-concurrency=2 \ + --pattern 'integration_*.py' \ tests/ fi diff --git a/.gitignore b/.gitignore index adcd113f43..0b94187d87 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ .mypy_cache/ .project .pydevproject -.pytest_cache/ /.mkosi-* /SHA256SUMS /SHA256SUMS.gpg diff --git a/AGENTS.md b/AGENTS.md index da49641d09..1099b5a011 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,8 @@ Always consult these files as needed: - select a subset by name, e.g. `bin/mkosi box -- python3 -m barrage test_mypy` to run a specific check - `bin/mkosi box -- ruff format mkosi tests kernel-install/*.install` to format code - `bin/mkosi box -- ruff check --fix mkosi tests kernel-install/*.install` to fix ruff issues -- `python3 -m pytest -m integration ...` to run integration tests. No need to run these by default. +- `bin/mkosi box -- python3 -m barrage --pattern 'integration_*.py' tests/` to run integration tests (after `tools/integration-test-setup.sh`). No need to run these by default. - `bin/mkosi box -- python3 -m barrage --pattern 'install_*.py'` to run installation tests (venv/pip/zipapp). Skipped by default as they install from the network. No need to run these by default. - - Never invent your own build commands or try to optimize the build process. - Never use `head`, `tail`, or pipe (`|`) the output of build or test commands. Always let the full output display. This is critical for diagnosing build and test failures. diff --git a/README.md b/README.md index 3e9e889e70..cfc2625b36 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,9 @@ When a tool that mkosi runs inside its sandbox fails, see # Integration tests -Integration tests build and boot full images. They are marked with the -`integration` marker, and are skipped by default. They need a tools tree and an -image to be built first. `tools/integration-test-setup.sh` writes a local +Integration tests build and boot full images. They live in `integration_*.py` +files and are not run as part of the normal test suite. They need a tools tree +and an image to be built first; `tools/integration-test-setup.sh` writes a local configuration for the given image and tools tree distribution and builds both: ```sh @@ -176,13 +176,37 @@ single integration test: ```sh tools/integration-test-setup.sh arch fedora -bin/mkosi box -- pytest -m integration --distribution arch --capture=no --verbose \ - 'tests/test_boot.py::test_bootloader[systemd-boot]' +bin/mkosi box -- python3 -m barrage -v \ + 'tests/integration_boot.py::test_bootloader_systemd_boot' +``` + +To run all integration tests, select them by file name pattern: + +```sh +bin/mkosi box -- python3 -m barrage --pattern 'integration_*.py' tests/ ``` The integration tests require KVM and are skipped (or very slow) without `/dev/kvm`. +To debug a failing build, set `TEST_DEBUG_SHELL=1` to pass `--debug-shell` to +mkosi, which drops into an interactive shell when a build step fails. This only +makes sense when running a single test interactively (`barrage -i`): + +```sh +TEST_DEBUG_SHELL=1 bin/mkosi box -- python3 -m barrage -i \ + 'tests/integration_boot.py::test_bootloader_systemd_boot' +``` + +barrage runs tests concurrently with no limit by default. Pass +`--max-concurrency N` to restrict the parallelism. + +When running unprivileged (i.e. not as root), building several images in the +same session tends to exhaust systemd-nsresourced's pool of dynamic UID ranges, +which makes builds fail with `io.systemd.NamespaceResource.NoDynamicRange`. Run +the tests one at a time (or with `--max-concurrency 1`) to avoid this; it does +not happen when the tests run as root, as they do in CI. + # References * [Primary mkosi git repository on GitHub](https://github.com/systemd/mkosi/) diff --git a/mkosi.tools.conf/mkosi.conf.d/arch.conf b/mkosi.tools.conf/mkosi.conf.d/arch.conf index 188c2e16ce..d5012bc0ba 100644 --- a/mkosi.tools.conf/mkosi.conf.d/arch.conf +++ b/mkosi.tools.conf/mkosi.conf.d/arch.conf @@ -11,7 +11,6 @@ Packages= mypy pyright python-pip - python-pytest reuse ruff sequoia-sop diff --git a/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf b/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf index d9539c070a..eaa4837fab 100644 --- a/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf +++ b/mkosi.tools.conf/mkosi.conf.d/azure-centos-fedora.conf @@ -12,5 +12,4 @@ Packages= python3-mypy python3-pip npm - python3-pytest shellcheck diff --git a/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf b/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf index e55b8cbc23..bcee6d94f2 100644 --- a/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf +++ b/mkosi.tools.conf/mkosi.conf.d/debian-kali-ubuntu.conf @@ -13,7 +13,6 @@ Packages= mypy npm python3-pip - python3-pytest reuse sqop shellcheck diff --git a/mkosi.tools.conf/mkosi.conf.d/opensuse.conf b/mkosi.tools.conf/mkosi.conf.d/opensuse.conf index 66fa58490a..e47d863f99 100644 --- a/mkosi.tools.conf/mkosi.conf.d/opensuse.conf +++ b/mkosi.tools.conf/mkosi.conf.d/opensuse.conf @@ -11,7 +11,6 @@ Packages= mypy npm python3-pip - python3-pytest reuse ruff ShellCheck diff --git a/pyproject.toml b/pyproject.toml index 9e753f0e6b..0c2b873ae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,13 +110,3 @@ lint.select = [ [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = ["mkosi.run.nosandbox"] - -[tool.pytest.ini_options] -markers = [ - "integration: mark a test as an integration test.", - "install: mark a test as an installation test (venv/pip/zipapp).", -] -testpaths = ["tests"] -# The unit and install tests run under barrage now; pytest only runs the integration tests, which live -# in integration_*.py files (not pytest's default test_*.py pattern). -python_files = ["integration_*.py"] diff --git a/tests/__init__.py b/tests/__init__.py index 440d90a7c3..3028af4278 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,23 +1,25 @@ # SPDX-License-Identifier: LGPL-2.1-or-later -import contextlib +import asyncio import dataclasses import os import subprocess import sys import uuid -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Mapping, Sequence from pathlib import Path from types import TracebackType -from typing import Any, Optional +from typing import Optional -import pytest +from barrage import Singleton +import mkosi.resources +from mkosi.config import parse_config from mkosi.distribution import Distribution from mkosi.run import CompletedProcess, run from mkosi.tree import rmtree from mkosi.user import INVOKING_USER -from mkosi.util import _FILE, PathString +from mkosi.util import _FILE, PathString, resource_path @dataclasses.dataclass(frozen=True) @@ -28,6 +30,36 @@ class ImageConfig: snapshot: Optional[str] = None +class ImageConfigManager(Singleton): + """Provide the integration test ImageConfig + + The distribution and release are read from mkosi.local.conf as written by + tools/integration-test-setup.sh. + """ + + config: ImageConfig + + async def __aenter__(self) -> "ImageConfigManager": + if not Path("mkosi.local.conf").exists(): + raise RuntimeError( + "mkosi.local.conf not found: run 'tools/integration-test-setup.sh " + " ' to configure and build the image before " + "running the integration tests." + ) + + with resource_path(mkosi.resources) as resources: + config = parse_config(resources=resources)[2][0] + self.config = ImageConfig( + distribution=config.distribution, + release=config.release, + debug_shell=bool(os.getenv("TEST_DEBUG_SHELL")), + # Pin the same snapshot as the main image so builds that don't read mkosi.local.conf still + # use it (e.g. the extension build, which passes --directory ''). + snapshot=config.snapshot, + ) + return self + + class Image: def __init__(self, config: ImageConfig) -> None: self.config = config @@ -53,7 +85,7 @@ def __exit__( ) -> None: rmtree(self.output_dir) - def mkosi( + async def mkosi( self, verb: str, options: Sequence[PathString] = (), @@ -62,7 +94,11 @@ def mkosi( check: bool = True, env: Mapping[str, str] = {}, ) -> CompletedProcess: - return run( + # mkosi.run.run() is synchronous, so run it in a worker thread. + # Safe because the test-level invocation uses no sandbox (and thus no preexec_fn) and installs no + # signal handlers; all sandboxing happens inside the spawned mkosi subprocess. + return await asyncio.to_thread( + run, [ "python3", "-m", "mkosi", "--debug", @@ -73,10 +109,10 @@ def mkosi( check=check, stdin=stdin, stdout=sys.stdout, - env=os.environ | env, + env={**os.environ, **env}, ) # fmt: skip - def build( + async def build( self, options: Sequence[PathString] = (), args: Sequence[str] = (), @@ -107,9 +143,9 @@ def build( *options, ] # fmt: skip - self.mkosi("summary", opt, env=env) + await self.mkosi("summary", opt, env=env) - return self.mkosi( + return await self.mkosi( "build", opt, args, @@ -117,8 +153,8 @@ def build( env=env, ) - def boot(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> CompletedProcess: - result = self.mkosi( + async def boot(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> CompletedProcess: + result = await self.mkosi( "boot", [ "--runtime-build-sources=no", @@ -139,7 +175,7 @@ def boot(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> Complet return result - def vm( + async def vm( self, options: Sequence[str] = (), args: Sequence[str] = (), @@ -147,7 +183,7 @@ def vm( ) -> CompletedProcess: need_hyperv_workaround = os.uname().machine == "x86_64" - result = self.mkosi( + result = await self.mkosi( "vm", [ "--runtime-build-sources=no", @@ -171,35 +207,3 @@ def vm( raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) return result - - -@pytest.fixture(scope="session", autouse=True) -def suspend_capture_stdin(pytestconfig: Any) -> Iterator[None]: - """ - When --capture=no (or -s) is specified, pytest will still intercept - stdin. Let's explicitly make it not capture stdin when --capture=no is - specified so we can debug image boot failures by logging into the emergency - shell. - """ - - capmanager: Any = pytestconfig.pluginmanager.getplugin("capturemanager") - - if pytestconfig.getoption("capture") == "no": - capmanager.suspend_global_capture(in_=True) - - yield - - if pytestconfig.getoption("capture") == "no": - capmanager.resume_global_capture() - - -@contextlib.contextmanager -def ci_group(s: str) -> Iterator[None]: - github_actions = os.getenv("GITHUB_ACTIONS") - if github_actions: - print(f"\n::group::{s}", flush=True) - try: - yield - finally: - if github_actions: - print("\n::endgroup::", flush=True) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 0e5e118f74..0000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,79 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -from collections.abc import Iterator -from typing import Any, cast - -import pytest - -import mkosi.resources -from mkosi.config import parse_config -from mkosi.distribution import Distribution, detect_distribution -from mkosi.log import log_setup -from mkosi.util import resource_path - -from . import ImageConfig, ci_group - - -def pytest_addoption(parser: Any) -> None: - distribution = detect_distribution()[0] - parser.addoption( - "-D", - "--distribution", - metavar="DISTRIBUTION", - help="Run the integration tests for the given distribution.", - default=distribution if isinstance(distribution, Distribution) else None, - type=Distribution, - choices=[Distribution(d) for d in Distribution.values()], - ) - parser.addoption( - "-R", - "--release", - metavar="RELEASE", - help="Run the integration tests for the given release.", - ) - parser.addoption( - "--debug-shell", - help="Pass --debug-shell when running mkosi", - action="store_true", - ) - - -@pytest.fixture(scope="session") -def config(request: Any) -> ImageConfig: - distribution = cast(Distribution, request.config.getoption("--distribution")) - with resource_path(mkosi.resources) as resources: - # Reads the local configuration (mkosi.local.conf) written by integration-test-setup.sh. - parsed = parse_config(["-d", str(distribution)], resources=resources)[2][0] - release = cast(str, request.config.getoption("--release") or parsed.release) - return ImageConfig( - distribution=distribution, - release=release, - debug_shell=request.config.getoption("--debug-shell"), - # Pin the same snapshot as the main image so builds that don't read mkosi.local.conf still - # use it (e.g. the extension build, which passes --directory ''). - snapshot=parsed.snapshot, - ) - - -@pytest.fixture(autouse=True) -def ci_sections(request: Any) -> Iterator[None]: - with ci_group(request.node.name): - yield - - -@pytest.fixture(scope="session", autouse=True) -def logging() -> None: - log_setup() - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item: Any, call: Any) -> Iterator[None]: - """Suppress tracebacks for test_linters.py tests. - - These are not helpful and just noise. - """ - outcome = yield - report = outcome.get_result() # type: ignore - - if item.parent.name == "test_linters.py" and report.failed and call.excinfo is not None: - report.longrepr = str(call.excinfo.value) diff --git a/tests/integration_boot.py b/tests/integration_boot.py index 2b5a331ef2..698ed472ef 100644 --- a/tests/integration_boot.py +++ b/tests/integration_boot.py @@ -3,16 +3,14 @@ import os import subprocess -import pytest +import barrage.assertions as Assert from mkosi.config import Bootloader, Firmware, OutputFormat from mkosi.distribution import Distribution from mkosi.run import find_binary, run from mkosi.versioncomp import GenericVersion -from . import Image, ImageConfig - -pytestmark = pytest.mark.integration +from . import Image, ImageConfigManager def have_vmspawn() -> bool: @@ -21,26 +19,25 @@ def have_vmspawn() -> bool: ) -@pytest.mark.parametrize("format", [f for f in OutputFormat if not f.is_extension_image()]) -def test_format(config: ImageConfig, format: OutputFormat) -> None: - with Image(config) as image: +async def do_test_format(image_config: ImageConfigManager, format: OutputFormat) -> None: + with Image(image_config.config) as image: if image.config.distribution == Distribution.rhel_ubi and format in ( OutputFormat.esp, OutputFormat.uki, ): - pytest.skip("Cannot build RHEL-UBI images with format 'esp' or 'uki'") + Assert.skip("Cannot build RHEL-UBI images with format 'esp' or 'uki'") - image.build(options=["--format", str(format)]) + await image.build(options=["--format", str(format)]) # FIXME: Also boot directory images when the CI runs systemd v260 or newer. if format == OutputFormat.directory: return if format == OutputFormat.disk and os.getuid() == 0: - image.boot() + await image.boot() if format in (OutputFormat.cpio, OutputFormat.uki, OutputFormat.esp): - pytest.skip("Default image is too large to be able to boot in CPIO/UKI/ESP format") + Assert.skip("Default image is too large to be able to boot in CPIO/UKI/ESP format") if image.config.distribution == Distribution.rhel_ubi: return @@ -48,22 +45,85 @@ def test_format(config: ImageConfig, format: OutputFormat) -> None: if format in (OutputFormat.tar, OutputFormat.oci, OutputFormat.none, OutputFormat.portable): return - image.vm() + await image.vm() if have_vmspawn() and format == OutputFormat.disk: - image.vm(options=["--vmm=vmspawn"]) + await image.vm(options=["--vmm=vmspawn"]) if format != OutputFormat.disk: return -@pytest.mark.parametrize("bootloader", Bootloader) -def test_bootloader(config: ImageConfig, bootloader: Bootloader) -> None: - if config.distribution == Distribution.rhel_ubi or bootloader.is_signed(): +async def test_format_cpio(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.cpio) + + +async def test_format_directory(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.directory) + + +async def test_format_disk(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.disk) + + +async def test_format_esp(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.esp) + + +async def test_format_none(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.none) + + +async def test_format_portable(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.portable) + + +async def test_format_tar(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.tar) + + +async def test_format_uki(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.uki) + + +async def test_format_oci(image_config: ImageConfigManager) -> None: + await do_test_format(image_config, OutputFormat.oci) + + +async def do_test_bootloader(image_config: ImageConfigManager, bootloader: Bootloader) -> None: + if image_config.config.distribution == Distribution.rhel_ubi or bootloader.is_signed(): return firmware = Firmware.linux if bootloader == Bootloader.none else Firmware.auto - with Image(config) as image: - image.build(["--format=disk", "--bootloader", str(bootloader)]) - image.vm(["--firmware", str(firmware)]) + with Image(image_config.config) as image: + await image.build(["--format=disk", "--bootloader", str(bootloader)]) + await image.vm(["--firmware", str(firmware)]) + + +async def test_bootloader_none(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.none) + + +async def test_bootloader_uki(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.uki) + + +async def test_bootloader_systemd_boot(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.systemd_boot) + + +async def test_bootloader_grub(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.grub) + + +async def test_bootloader_uki_signed(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.uki_signed) + + +async def test_bootloader_systemd_boot_signed(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.systemd_boot_signed) + + +async def test_bootloader_grub_signed(image_config: ImageConfigManager) -> None: + await do_test_bootloader(image_config, Bootloader.grub_signed) diff --git a/tests/integration_extension.py b/tests/integration_extension.py index b205390ecc..6b804bbdb9 100644 --- a/tests/integration_extension.py +++ b/tests/integration_extension.py @@ -2,22 +2,17 @@ from pathlib import Path -import pytest - from mkosi.config import OutputFormat -from . import Image, ImageConfig - -pytestmark = pytest.mark.integration +from . import Image, ImageConfigManager -@pytest.mark.parametrize("format", [f for f in OutputFormat if f.is_extension_image()]) -def test_extension(config: ImageConfig, format: OutputFormat) -> None: - with Image(config) as image: - image.build(["--clean-package-metadata=no", "--format=directory"]) +async def do_test(image_config: ImageConfigManager, format: OutputFormat) -> None: + with Image(image_config.config) as image: + await image.build(["--clean-package-metadata=no", "--format=directory"]) with Image(image.config) as sysext: - sysext.build( + await sysext.build( [ "--directory", "", @@ -29,3 +24,15 @@ def test_extension(config: ImageConfig, format: OutputFormat) -> None: f"--format={format}", ] ) # fmt: skip + + +async def test_confext(image_config: ImageConfigManager) -> None: + await do_test(image_config, OutputFormat.confext) + + +async def test_sysext(image_config: ImageConfigManager) -> None: + await do_test(image_config, OutputFormat.sysext) + + +async def test_addon(image_config: ImageConfigManager) -> None: + await do_test(image_config, OutputFormat.addon) diff --git a/tests/integration_initrd.py b/tests/integration_initrd.py index ef77c48065..105a9713f2 100644 --- a/tests/integration_initrd.py +++ b/tests/integration_initrd.py @@ -8,16 +8,14 @@ from collections.abc import Iterator from pathlib import Path -import pytest +import barrage.assertions as Assert from mkosi.run import run from mkosi.sandbox import umask from mkosi.tree import copy_tree from mkosi.util import PathString -from . import Image, ImageConfig - -pytestmark = pytest.mark.integration +from . import Image, ImageConfigManager @contextlib.contextmanager @@ -35,10 +33,10 @@ def mount(what: PathString, where: PathString) -> Iterator[Path]: run(["umount", "--no-mtab", where]) -@pytest.fixture(scope="module") +@contextlib.contextmanager def passphrase() -> Iterator[Path]: - # We can't use tmp_path fixture because pytest creates it in a nested directory we can't access using our - # unprivileged user. + # We use a NamedTemporaryFile in /tmp rather than a per-test temporary directory because the latter is + # created in a nested directory we can't access using our unprivileged user. # TODO: Use delete_on_close=False and close() instead of flush() when we require Python 3.12 or newer. with tempfile.NamedTemporaryFile(prefix="mkosi.passphrase", mode="w") as passphrase: passphrase.write("mkosi") @@ -49,16 +47,18 @@ def passphrase() -> Iterator[Path]: yield Path(passphrase.name) -def test_initrd(config: ImageConfig) -> None: - with Image(config) as image: - image.build(options=["--format=disk"]) - image.vm() +async def test_initrd(image_config: ImageConfigManager) -> None: + with Image(image_config.config) as image: + await image.build(options=["--format=disk"]) + await image.vm() -@pytest.mark.skipif(os.getuid() != 0, reason="mkosi-initrd LVM test can only be executed as root") -def test_initrd_lvm(config: ImageConfig) -> None: - with Image(config) as image, contextlib.ExitStack() as stack: - image.build(["--format=disk"]) +async def test_initrd_lvm(image_config: ImageConfigManager) -> None: + if os.getuid() != 0: + Assert.skip("mkosi-initrd LVM test can only be executed as root") + + with Image(image_config.config) as image, contextlib.ExitStack() as stack: + await image.build(["--format=disk"]) lvm = Path(image.output_dir) / "lvm.raw" lvm.touch() @@ -68,7 +68,10 @@ def test_initrd_lvm(config: ImageConfig) -> None: ["losetup", "--show", "--find", "--partscan", lvm], stdout=subprocess.PIPE ).stdout.strip() stack.callback(run, ["losetup", "--detach", lodev]) - run(["sfdisk", "--label", "gpt", lodev], input="type=E6D6D379-F507-44C2-A23C-238F2A3DF928 bootable") + run( + ["sfdisk", "--label", "gpt", lodev], + input="type=E6D6D379-F507-44C2-A23C-238F2A3DF928 bootable", + ) run(["lvm", "pvcreate", "--devicesfile", "", f"{lodev}p1"]) run(["lvm", "pvs", "--devicesfile", ""]) run(["lvm", "vgcreate", "--devicesfile", "", "-An", "vg_mkosi", f"{lodev}p1"]) @@ -78,7 +81,14 @@ def test_initrd_lvm(config: ImageConfig) -> None: run(["lvm", "lvcreate", "--devicesfile", "", "-An", "-l", "100%FREE", "-n", "lv0", "vg_mkosi"]) run(["lvm", "lvs", "--devicesfile", ""]) run(["udevadm", "wait", "--timeout=30", "/dev/vg_mkosi/lv0"]) - run([f"mkfs.{image.config.distribution.installer.filesystem()}", "-L", "root", "/dev/vg_mkosi/lv0"]) + run( + [ + f"mkfs.{image.config.distribution.installer.filesystem()}", + "-L", + "root", + "/dev/vg_mkosi/lv0", + ] + ) src = Path(stack.enter_context(tempfile.TemporaryDirectory())) run(["systemd-dissect", "--mount", "--mkdir", Path(image.output_dir) / "image.raw", src]) @@ -93,7 +103,7 @@ def test_initrd_lvm(config: ImageConfig) -> None: lvm.rename(Path(image.output_dir) / "image.raw") - image.vm( + await image.vm( [ "--firmware=linux", # LVM confuses systemd-repart so we mask it for this test. @@ -103,8 +113,8 @@ def test_initrd_lvm(config: ImageConfig) -> None: ) -def test_initrd_luks(config: ImageConfig, passphrase: Path) -> None: - with tempfile.TemporaryDirectory() as repartd: +async def test_initrd_luks(image_config: ImageConfigManager) -> None: + with passphrase() as pp, tempfile.TemporaryDirectory() as repartd: st = Path.cwd().stat() os.chown(repartd, st.st_uid, st.st_gid) @@ -140,7 +150,7 @@ def test_initrd_luks(config: ImageConfig, passphrase: Path) -> None: f"""\ [Partition] Type=root - Format={config.distribution.installer.filesystem()} + Format={image_config.config.distribution.installer.filesystem()} Minimize=guess Encrypt=key-file CopyFiles=/ @@ -148,17 +158,19 @@ def test_initrd_luks(config: ImageConfig, passphrase: Path) -> None: ) ) - with Image(config) as image: - image.build(["--repart-directory", repartd, "--passphrase", passphrase, "--format=disk"]) + with Image(image_config.config) as image: + await image.build(["--repart-directory", repartd, "--passphrase", pp, "--format=disk"]) # repart's default LUKS2 KDF (Argon2id) is memory-hard and needs ~1 GiB of RAM just to derive # the key, so the default VM RAM isn't enough to unlock the root. - image.vm(["--credential=cryptsetup.passphrase=mkosi"], ram="2G") + await image.vm(["--credential=cryptsetup.passphrase=mkosi"], ram="2G") + +async def test_initrd_luks_lvm(image_config: ImageConfigManager) -> None: + if os.getuid() != 0: + Assert.skip("mkosi-initrd LUKS+LVM test can only be executed as root") -@pytest.mark.skipif(os.getuid() != 0, reason="mkosi-initrd LUKS+LVM test can only be executed as root") -def test_initrd_luks_lvm(config: ImageConfig, passphrase: Path) -> None: - with Image(config) as image, contextlib.ExitStack() as stack: - image.build(["--format=disk"]) + with passphrase() as pp, Image(image_config.config) as image, contextlib.ExitStack() as stack: + await image.build(["--format=disk"]) lvm = Path(image.output_dir) / "lvm.raw" lvm.touch() @@ -168,11 +180,14 @@ def test_initrd_luks_lvm(config: ImageConfig, passphrase: Path) -> None: ["losetup", "--show", "--find", "--partscan", lvm], stdout=subprocess.PIPE ).stdout.strip() stack.callback(run, ["losetup", "--detach", lodev]) - run(["sfdisk", "--label", "gpt", lodev], input="type=E6D6D379-F507-44C2-A23C-238F2A3DF928 bootable") + run( + ["sfdisk", "--label", "gpt", lodev], + input="type=E6D6D379-F507-44C2-A23C-238F2A3DF928 bootable", + ) run( [ "cryptsetup", - "--key-file", passphrase, + "--key-file", pp, "--use-random", "--pbkdf", "pbkdf2", "--pbkdf-force-iterations", "1000", @@ -180,7 +195,7 @@ def test_initrd_luks_lvm(config: ImageConfig, passphrase: Path) -> None: f"{lodev}p1", ] ) # fmt: skip - run(["cryptsetup", "--key-file", passphrase, "luksOpen", f"{lodev}p1", "lvm_root"]) + run(["cryptsetup", "--key-file", pp, "luksOpen", f"{lodev}p1", "lvm_root"]) stack.callback(run, ["cryptsetup", "close", "lvm_root"]) luks_uuid = run(["cryptsetup", "luksUUID", f"{lodev}p1"], stdout=subprocess.PIPE).stdout.strip() run(["lvm", "pvcreate", "--devicesfile", "", "/dev/mapper/lvm_root"]) @@ -192,7 +207,14 @@ def test_initrd_luks_lvm(config: ImageConfig, passphrase: Path) -> None: run(["lvm", "lvcreate", "--devicesfile", "", "-An", "-l", "100%FREE", "-n", "lv0", "vg_mkosi"]) run(["lvm", "lvs", "--devicesfile", ""]) run(["udevadm", "wait", "--timeout=30", "/dev/vg_mkosi/lv0"]) - run([f"mkfs.{image.config.distribution.installer.filesystem()}", "-L", "root", "/dev/vg_mkosi/lv0"]) + run( + [ + f"mkfs.{image.config.distribution.installer.filesystem()}", + "-L", + "root", + "/dev/vg_mkosi/lv0", + ] + ) src = Path(stack.enter_context(tempfile.TemporaryDirectory())) run(["systemd-dissect", "--mount", "--mkdir", Path(image.output_dir) / "image.raw", src]) @@ -207,7 +229,7 @@ def test_initrd_luks_lvm(config: ImageConfig, passphrase: Path) -> None: lvm.rename(Path(image.output_dir) / "image.raw") - image.vm( + await image.vm( [ "--format=disk", "--credential=cryptsetup.passphrase=mkosi", @@ -218,12 +240,12 @@ def test_initrd_luks_lvm(config: ImageConfig, passphrase: Path) -> None: ) -def test_initrd_size(config: ImageConfig) -> None: - with Image(config) as image: - image.build() +async def test_initrd_size(image_config: ImageConfigManager) -> None: + with Image(image_config.config) as image: + await image.build() # Set a reasonably high limit to avoid having to bump it every single time by # small amounts. 100M should do. maxsize = 1024**2 * 100 - assert (Path(image.output_dir) / "image.initrd").stat().st_size <= maxsize + Assert.le((Path(image.output_dir) / "image.initrd").stat().st_size, maxsize) diff --git a/tests/integration_signing.py b/tests/integration_signing.py index 2ded84191a..ccabac7273 100644 --- a/tests/integration_signing.py +++ b/tests/integration_signing.py @@ -4,20 +4,18 @@ import tempfile from pathlib import Path -import pytest +import barrage.assertions as Assert from mkosi.run import find_binary, run -from . import Image, ImageConfig +from . import Image, ImageConfigManager -pytestmark = pytest.mark.integration - -def test_signing_checksums_with_sop(config: ImageConfig) -> None: +async def test_signing_checksums_with_sop(image_config: ImageConfigManager) -> None: if find_binary("sqop") is None: - pytest.skip("Need 'sqop' binary to perform sop tests.") + Assert.skip("Need 'sqop' binary to perform sop tests.") - with tempfile.TemporaryDirectory() as path, Image(config) as image: + with tempfile.TemporaryDirectory() as path, Image(image_config.config) as image: tmp_path = Path(path) signing_key = tmp_path / "signing-key.pgp" @@ -31,7 +29,7 @@ def test_signing_checksums_with_sop(config: ImageConfig) -> None: with open(signing_key, "rb") as i, open(signing_cert, "wb") as o: run(cmdline=["sqop", "extract-cert"], stdin=i, stdout=o) - image.build( + await image.build( options=["--checksum=true", "--openpgp-tool=sqop", "--sign=true", f"--key={signing_key}"] ) @@ -42,8 +40,8 @@ def test_signing_checksums_with_sop(config: ImageConfig) -> None: run(cmdline=["sqop", "verify", signature, signing_cert], stdin=i) -def test_signing_checksums_with_gpg(config: ImageConfig) -> None: - with tempfile.TemporaryDirectory() as path, Image(config) as image: +async def test_signing_checksums_with_gpg(image_config: ImageConfigManager) -> None: + with tempfile.TemporaryDirectory() as path, Image(image_config.config) as image: tmp_path = Path(path) signing_key = "mkosi-test@example.org" @@ -66,7 +64,7 @@ def test_signing_checksums_with_gpg(config: ImageConfig) -> None: stdout=o, ) - image.build(options=["--checksum=true", "--sign=true", f"--key={signing_key}"], env=env) + await image.build(options=["--checksum=true", "--sign=true", f"--key={signing_key}"], env=env) signed_file = image.output_dir / "image.SHA256SUMS" signature = image.output_dir / "image.SHA256SUMS.gpg"