mirror of
https://github.com/kevinveenbirkenbach/create-linux-swapfile.git
synced 2026-08-16 20:02:47 +00:00
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:
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
63
tests/unit/test_cli.py
Normal file
63
tests/unit/test_cli.py
Normal 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
173
tests/unit/test_core.py
Normal 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()
|
||||
Reference in New Issue
Block a user