diff --git a/src/ahriman/core/repository/executor.py b/src/ahriman/core/repository/executor.py index 20be5efc..5265b354 100644 --- a/src/ahriman/core/repository/executor.py +++ b/src/ahriman/core/repository/executor.py @@ -20,7 +20,6 @@ import shutil from collections.abc import Iterable -from filelock import FileLock from pathlib import Path from tempfile import TemporaryDirectory @@ -28,7 +27,7 @@ from ahriman.core.build_tools.package_archive import PackageArchive from ahriman.core.build_tools.task import Task from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.package_info import PackageInfo -from ahriman.core.utils import atomic_move, list_flatmap, package_like, safe_filename, symlink_relative +from ahriman.core.utils import atomic_move, filelock, list_flatmap, package_like, safe_filename, symlink_relative from ahriman.models.changes import Changes from ahriman.models.event import EventType from ahriman.models.package import Package @@ -112,7 +111,7 @@ class Executor(PackageInfo, Cleaner): self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version) built = [] for artifact in prebuilt: - with FileLock(artifact.with_name(f".{artifact.name}.lock")): + with filelock(artifact): shutil.copy(artifact, path) built.append(path / artifact.name) else: diff --git a/src/ahriman/core/utils.py b/src/ahriman/core/utils.py index d27fd2da..f10932d6 100644 --- a/src/ahriman/core/utils.py +++ b/src/ahriman/core/utils.py @@ -18,6 +18,7 @@ # along with this program. If not, see . # # pylint: disable=too-many-lines +import contextlib import datetime import io import itertools @@ -47,6 +48,7 @@ __all__ = [ "dataclass_view", "enum_values", "extract_user", + "filelock", "filter_json", "full_version", "list_flatmap", @@ -87,7 +89,7 @@ def atomic_move(src: Path, dst: Path) -> None: >>> atomic_move(src, dst) """ - with FileLock(dst.with_name(f".{dst.name}.lock")): + with filelock(dst): shutil.move(src, dst) @@ -262,6 +264,25 @@ def extract_user() -> str | None: return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER") +@contextlib.contextmanager +def filelock(path: Path) -> Iterator[FileLock]: + """ + wrapper around :class:`filelock.FileLock`, which also removes locks afterward + + Args: + path(Path): path to lock on. The lock file will be created as ``.{path.name}.lock`` + + Yields: + FileLock: acquired file lock instance + """ + lock_path = path.with_name(f".{path.name}.lock") + try: + with FileLock(lock_path) as lock: + yield lock + finally: + lock_path.unlink(missing_ok=True) + + def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]: """ filter json object by fields used for json-to-object conversion diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py index d2649f90..10a84aea 100644 --- a/tests/ahriman/core/test_utils.py +++ b/tests/ahriman/core/test_utils.py @@ -20,11 +20,11 @@ def test_atomic_move(mocker: MockerFixture) -> None: """ must move file with locking """ - filelock_mock = mocker.patch("ahriman.core.utils.FileLock") + filelock_mock = mocker.patch("ahriman.core.utils.filelock") move_mock = mocker.patch("shutil.move") atomic_move(Path("source"), Path("destination")) - filelock_mock.assert_called_once_with(Path(".destination.lock")) + filelock_mock.assert_called_once_with(Path("destination")) move_mock.assert_called_once_with(Path("source"), Path("destination")) @@ -247,6 +247,30 @@ def test_extract_user() -> None: assert extract_user() == "doas" +def test_filelock(tmp_path: Path) -> None: + """ + must acquire lock and remove lock file after + """ + local = tmp_path / "local" + lock = local.with_name(f".{local.name}.lock") + + with filelock(local): + assert lock.exists() + assert not lock.exists() + + +def test_filelock_cleanup_on_missing(tmp_path: Path) -> None: + """ + must not fail if lock file is already removed + """ + local = tmp_path / "local" + lock = local.with_name(f".{local.name}.lock") + + with filelock(local): + lock.unlink(missing_ok=True) + assert not lock.exists() + + def test_filter_json(package_ahriman: Package) -> None: """ must filter fields by known list