feat: package as pip-installable distribution

Turns the loose main.py into the 'swap-forge' distribution with a src layout, shipping both the swap-forge and the older swafo command.

A failing swap command no longer produces a traceback: CalledProcessError is caught and reported as "'mkswap' failed with exit code 1" at exit 1, a missing binary as exit 127. Required binaries are declared per filesystem path so an ext4 host is never asked for btrfs tooling.

The fstab entry is still written last, after swapon, so a failed activation leaves /etc/fstab untouched. The e2e suite proves that byte for byte across a create, a same-size rerun and a resize cycle, with real fallocate, chmod and file sizes.

Adds unit, integration and container-based e2e tests, ruff and markdown/mermaid linting, CodeQL and Dependabot, and pins the core metadata to 2.4 so twine accepts the artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:28:08 +02:00
parent e1eef73ae7
commit 02760e5864
23 changed files with 1005 additions and 125 deletions

0
tests/__init__.py Normal file
View File

12
tests/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends util-linux e2fsprogs btrfs-progs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . /src
RUN pip install --no-cache-dir .
CMD ["sh", "/src/tests/e2e/run.sh"]

103
tests/e2e/run.sh Normal file
View File

@@ -0,0 +1,103 @@
#!/bin/sh
# An unprivileged container cannot activate swap, so the run is expected to fail
# at mkswap or swapon - which of the two depends on the host's /proc/swaps.
# Everything before it - findmnt, fallocate, chmod - runs for real.
set -eu
fail() {
echo "FAIL: $1" >&2
exit 1
}
SWAPFORGE="$(command -v swap-forge)" || fail "swap-forge is not on PATH after install"
echo "ok: swap-forge installed at ${SWAPFORGE}"
command -v swafo >/dev/null || fail "the swafo alias is not on PATH after install"
echo "ok: swafo alias installed"
case "${SWAPFORGE}" in
/usr/local/bin/*) ;;
*) fail "swap-forge resolved to ${SWAPFORGE}, outside the install prefix" ;;
esac
echo "ok: resolved from the installed package, not the source tree"
for binary in findmnt swapoff chmod mkswap swapon fallocate chattr btrfs dd; do
command -v "${binary}" >/dev/null || fail "${binary} is missing from the image"
done
echo "ok: every declared binary is present"
swap-forge --help | grep -q "Swapfile size" || fail "--help does not describe the tool"
echo "ok: --help"
set +e
swap-forge >/dev/null 2>&1
status=$?
set -e
[ "${status}" -eq 2 ] || fail "a missing size exited ${status}, expected 2"
echo "ok: a missing size exits 2"
set +e
output="$(swap-forge 64T 2>&1)"
status=$?
set -e
[ "${status}" -eq 1 ] || fail "an invalid size exited ${status}, expected 1"
echo "${output}" | grep -q "Invalid size format" || fail "invalid size message wrong"
if echo "${output}" | grep -q "Traceback"; then
fail "an invalid size produced a traceback"
fi
echo "ok: an invalid size exits 1 without a traceback"
printf 'UUID=deadbeef / ext4 defaults 0 1\n' >/etc/fstab
cp /etc/fstab /tmp/fstab.before
set +e
output="$(swap-forge 8M 2>&1)"
status=$?
set -e
[ "${status}" -eq 1 ] || fail "the blocked activation exited ${status}, expected 1"
echo "${output}" | grep -q "Creating 8 MiB swapfile" || fail "the creation banner is missing"
echo "${output}" | grep -qE "'(mkswap|swapon)' failed with exit code" || fail "the blocked activation was not reported cleanly"
if echo "${output}" | grep -q "Traceback"; then
fail "the blocked activation produced a traceback"
fi
echo "ok: real fallocate and chmod ran; the blocked activation exits 1 cleanly"
[ -f /swapfile ] || fail "/swapfile was not created"
size="$(stat -c %s /swapfile)"
[ "${size}" -eq 8388608 ] || fail "/swapfile is ${size} bytes, expected 8388608"
echo "ok: /swapfile exists with exactly the requested size"
cmp -s /etc/fstab /tmp/fstab.before || fail "/etc/fstab changed although activation failed"
echo "ok: /etc/fstab is untouched when activation fails"
set +e
output="$(swap-forge 8M 2>&1)"
status=$?
set -e
[ "${status}" -eq 0 ] || fail "the idempotent rerun exited ${status}, expected 0"
echo "${output}" | grep -q "already exists with correct size (8 MiB). Skipping." \
|| fail "an existing swapfile of the right size was not detected"
echo "ok: a rerun with the same size is a no-op - the idempotency the role relies on"
set +e
output="$(swap-forge 12M 2>&1)"
status=$?
set -e
echo "${output}" | grep -q "has size 8 MiB, expected 12 MiB. Recreating..." \
|| fail "a size change did not trigger a recreation"
size="$(stat -c %s /swapfile)"
[ "${size}" -eq 12582912 ] || fail "/swapfile is ${size} bytes after resize, expected 12582912"
echo "ok: a different size really removes and recreates the swapfile"
cmp -s /etc/fstab /tmp/fstab.before || fail "/etc/fstab changed during the resize cycle"
echo "ok: /etc/fstab survived the resize cycle byte for byte"
set +e
output="$(env PATH=/nonexistent-bin "${SWAPFORGE}" 8M 2>&1)"
status=$?
set -e
[ "${status}" -eq 127 ] || fail "missing findmnt exited ${status}, expected 127"
echo "${output}" | grep -q "required command 'findmnt' is not installed" || fail "missing findmnt message wrong"
echo "ok: missing findmnt exits 127 with a clean message"
echo "ALL E2E CHECKS PASSED"

View File

View File

@@ -0,0 +1,62 @@
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SRC_DIR = REPO_ROOT / "src"
def run_cli(*args, path=None):
"""Run 'python -m swap_forge' with the working tree on PYTHONPATH.
Args:
args: Command line arguments for the CLI.
path: Replacement PATH, used to hide the findmnt binary.
"""
env = dict(os.environ)
env["PYTHONPATH"] = str(SRC_DIR)
if path is not None:
env["PATH"] = str(path)
return subprocess.run(
[sys.executable, "-m", "swap_forge", *args],
capture_output=True,
text=True,
env=env,
check=False,
)
class TestCli(unittest.TestCase):
def test_help_exits_zero(self):
result = run_cli("--help")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Swapfile size", result.stdout)
def test_missing_size_is_rejected(self):
result = run_cli()
self.assertEqual(result.returncode, 2)
self.assertIn("size", result.stderr)
def test_missing_findmnt_fails_cleanly(self):
with tempfile.TemporaryDirectory() as empty:
result = run_cli("2048M", path=empty)
self.assertEqual(result.returncode, 127)
self.assertIn("required command 'findmnt' is not installed", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_invalid_size_fails_without_a_traceback(self):
result = run_cli("64T")
self.assertEqual(result.returncode, 1)
self.assertIn("Invalid size format", result.stderr)
self.assertNotIn("Traceback", result.stderr)
if __name__ == "__main__":
unittest.main()

0
tests/unit/__init__.py Normal file
View File

63
tests/unit/test_cli.py Normal file
View File

@@ -0,0 +1,63 @@
import unittest
from pathlib import Path
from subprocess import CalledProcessError
from unittest import mock
from swap_forge.cli import main
class TestMain(unittest.TestCase):
def _run(self, argv, fs="ext4", old_size=0):
with (
mock.patch("swap_forge.cli.detect_root_fs", return_value=fs),
mock.patch("swap_forge.cli.swap_path_for", return_value=Path("/swapfile")),
mock.patch("swap_forge.cli.get_swap_size", return_value=old_size),
mock.patch("swap_forge.cli.remove_swap") as remove,
mock.patch("swap_forge.cli.create_swap") as create,
):
exit_code = main(argv)
return exit_code, remove, create
def test_creates_a_missing_swapfile(self):
exit_code, remove, create = self._run(["2048M"])
remove.assert_not_called()
create.assert_called_once_with(Path("/swapfile"), 2048, "ext4")
self.assertEqual(exit_code, 0)
def test_matching_size_is_left_alone(self):
exit_code, remove, create = self._run(["2048M"], old_size=2048)
remove.assert_not_called()
create.assert_not_called()
self.assertEqual(exit_code, 0)
def test_wrong_size_is_recreated(self):
exit_code, remove, create = self._run(["4G"], old_size=2048)
remove.assert_called_once_with(Path("/swapfile"))
create.assert_called_once_with(Path("/swapfile"), 4096, "ext4")
self.assertEqual(exit_code, 0)
def test_failing_command_reports_cleanly(self):
with (
mock.patch("swap_forge.cli.detect_root_fs", return_value="ext4"),
mock.patch("swap_forge.cli.swap_path_for", return_value=Path("/swapfile")),
mock.patch("swap_forge.cli.get_swap_size", return_value=0),
mock.patch("swap_forge.cli.create_swap") as create,
):
create.side_effect = CalledProcessError(255, ["swapon", "/swapfile"])
exit_code = main(["2048M"])
self.assertEqual(exit_code, 1)
def test_invalid_size_exits_non_zero(self):
with mock.patch("swap_forge.cli.detect_root_fs") as detect:
exit_code = main(["64T"])
detect.assert_not_called()
self.assertEqual(exit_code, 1)
if __name__ == "__main__":
unittest.main()

173
tests/unit/test_core.py Normal file
View File

@@ -0,0 +1,173 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from swap_forge.core import (
BTRFS_BINARIES,
BTRFS_SWAP_PATH,
DEFAULT_SWAP_PATH,
NON_BTRFS_BINARIES,
REQUIRED_BINARIES,
MissingBinaryError,
create_swap,
get_swap_size,
parse_size_to_mib,
remove_swap,
run,
swap_fstab_line,
swap_path_for,
)
class TestRequiredBinaries(unittest.TestCase):
def test_declares_every_external_command_the_package_calls(self):
declared = (
set(REQUIRED_BINARIES) | set(BTRFS_BINARIES) | set(NON_BTRFS_BINARIES)
)
self.assertEqual(
declared,
{
"findmnt",
"swapoff",
"chmod",
"mkswap",
"swapon",
"chattr",
"btrfs",
"dd",
"fallocate",
},
)
def test_filesystem_specific_binaries_are_not_declared_as_always_required(self):
self.assertFalse(set(REQUIRED_BINARIES) & set(BTRFS_BINARIES))
self.assertFalse(set(REQUIRED_BINARIES) & set(NON_BTRFS_BINARIES))
def test_missing_binary_is_reported_with_its_name(self):
with mock.patch("swap_forge.core.subprocess.run") as subprocess_run:
subprocess_run.side_effect = FileNotFoundError
with self.assertRaises(MissingBinaryError) as caught:
run(["mkswap", "/swapfile"])
self.assertEqual(caught.exception.binary, "mkswap")
class TestParseSizeToMib(unittest.TestCase):
def test_gibibytes(self):
for value in ("64G", "64g", "64GB", " 64G "):
with self.subTest(value=value):
self.assertEqual(parse_size_to_mib(value), 65536)
def test_mebibytes(self):
for value in ("2048M", "2048m", "2048MB"):
with self.subTest(value=value):
self.assertEqual(parse_size_to_mib(value), 2048)
def test_bare_number_is_mebibytes(self):
self.assertEqual(parse_size_to_mib("512"), 512)
def test_invalid_format(self):
for value in ("", "abc", "64T", "-5G", "6.5G"):
with self.subTest(value=value), self.assertRaises(ValueError):
parse_size_to_mib(value)
def test_unsupported_unit(self):
with self.assertRaises(ValueError):
parse_size_to_mib("64b")
class TestSwapPathFor(unittest.TestCase):
def test_btrfs_uses_the_dedicated_subvolume_path(self):
self.assertEqual(swap_path_for("btrfs"), BTRFS_SWAP_PATH)
def test_other_filesystems_use_the_root_swapfile(self):
self.assertEqual(swap_path_for("ext4"), DEFAULT_SWAP_PATH)
class TestGetSwapSize(unittest.TestCase):
def test_missing_file_is_zero(self):
self.assertEqual(get_swap_size(Path("/nonexistent/swapfile")), 0)
def test_reports_size_in_mebibytes(self):
with tempfile.TemporaryDirectory() as base:
path = Path(base) / "swapfile"
path.write_bytes(b"\0" * (3 * 1024 * 1024))
self.assertEqual(get_swap_size(path), 3)
class TestSwapFstabLine(unittest.TestCase):
def test_line_shape(self):
self.assertEqual(
swap_fstab_line(Path("/swapfile")), "/swapfile none swap sw 0 0"
)
class TestRemoveSwap(unittest.TestCase):
def test_drops_file_and_fstab_entry(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swapfile"
swapfile.write_bytes(b"\0")
fstab = Path(base) / "fstab"
fstab.write_text(
f"UUID=abc / ext4 defaults 0 1\n{swapfile} none swap sw 0 0\n"
)
with mock.patch("swap_forge.core.run"):
remove_swap(swapfile, fstab=fstab)
self.assertFalse(swapfile.exists())
self.assertEqual(fstab.read_text(), "UUID=abc / ext4 defaults 0 1\n")
def test_keeps_unrelated_fstab_entries(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swapfile"
fstab = Path(base) / "fstab"
fstab.write_text("/other none swap sw 0 0\n")
with mock.patch("swap_forge.core.run"):
remove_swap(swapfile, fstab=fstab)
self.assertEqual(fstab.read_text(), "/other none swap sw 0 0\n")
def test_missing_fstab_is_tolerated(self):
with tempfile.TemporaryDirectory() as base:
with mock.patch("swap_forge.core.run"):
remove_swap(Path(base) / "swapfile", fstab=Path(base) / "fstab")
class TestCreateSwap(unittest.TestCase):
def test_non_btrfs_uses_fallocate_and_appends_to_fstab(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swapfile"
fstab = Path(base) / "fstab"
fstab.write_text("")
with mock.patch("swap_forge.core.run") as run:
create_swap(swapfile, 2048, "ext4", fstab=fstab)
commands = [call.args[0][0] for call in run.call_args_list]
self.assertEqual(commands, ["fallocate", "chmod", "mkswap", "swapon"])
self.assertEqual(fstab.read_text(), f"{swapfile} none swap sw 0 0\n")
def test_btrfs_disables_copy_on_write_and_uses_dd(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swap" / "swapfile"
fstab = Path(base) / "fstab"
fstab.write_text("")
with mock.patch("swap_forge.core.run") as run:
create_swap(swapfile, 1024, "btrfs", fstab=fstab)
commands = [call.args[0][0] for call in run.call_args_list]
self.assertEqual(
commands, ["chattr", "btrfs", "dd", "chmod", "mkswap", "swapon"]
)
self.assertTrue(swapfile.parent.is_dir())
if __name__ == "__main__":
unittest.main()