From 89f962a050a6a697299a9de1509124ea9bd9e4d3 Mon Sep 17 00:00:00 2001 From: HrachShah <177451801+HrachShah@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:44:18 +0000 Subject: [PATCH] lifetime: reject negative, non-numeric, and bool values at the setter lifetime.setter stored whatever it received and trusted the call site to pass a non-negative number. A negative value, a non-numeric string, or a bool (bool is a subclass of int, so it slipped past the int check) would be remembered as the expiration window and only blow up later inside _try_break_expired_lock, or quietly compare against st_mtime and behave in surprising ways. Validate at the boundary the same way timeout.setter validates (it calls float(value) and lets ValueError propagate). The new behavior: - None is accepted (disables expiration, the documented contract). - int and float that are >= 0 are accepted. - bool is rejected with TypeError so True/False cannot be misread as a 1-second or 0-second lifetime. - non-numeric objects raise TypeError with the offending type name instead of a confusing downstream TypeError in arithmetic. - negative numbers raise ValueError with the offending value inlined, matching the docstring promise that lifetime is a maximum time in seconds. --- src/filelock/_api.py | 10 ++++++++++ tests/test_lock_expiry.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/filelock/_api.py b/src/filelock/_api.py index 2c97e54e..717c6ee2 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -346,7 +346,17 @@ def lifetime(self, value: float | None) -> None: :param value: the new value in seconds, or ``None`` to disable expiration + :raises ValueError: if *value* is a negative number + :raises TypeError: if *value* is not ``None`` and not a real number + """ + if value is not None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + msg = f"lifetime must be a non-negative number or None, not {type(value).__name__}" + raise TypeError(msg) + if value < 0: + msg = f"lifetime must be non-negative, not {value!r}" + raise ValueError(msg) self._context.lifetime = value @property diff --git a/tests/test_lock_expiry.py b/tests/test_lock_expiry.py index 081bd853..a43ddd14 100644 --- a/tests/test_lock_expiry.py +++ b/tests/test_lock_expiry.py @@ -73,6 +73,34 @@ def test_lifetime_default_none(tmp_path: Path) -> None: assert lock.lifetime is None +@pytest.mark.parametrize("bad_value", [-1, -0.5, -1e9]) +def test_lifetime_setter_rejects_negative_number(bad_value: float, tmp_path: Path) -> None: + lock = FileLock(tmp_path / "test.lock") + with pytest.raises(ValueError, match="non-negative"): + lock.lifetime = bad_value + + +@pytest.mark.parametrize("bad_value", ["5", b"5", object(), [1], {1: 2}, complex(1, 0)]) +def test_lifetime_setter_rejects_non_numeric(bad_value: object, tmp_path: Path) -> None: + lock = FileLock(tmp_path / "test.lock") + with pytest.raises(TypeError, match="lifetime must be"): + lock.lifetime = bad_value # ty: ignore[invalid-assignment] + + +@pytest.mark.parametrize("bad_value", [True, False]) # bool is an int subclass; reject it so it can't read as 1s/0s +def test_lifetime_setter_rejects_bool(bad_value: bool, tmp_path: Path) -> None: + lock = FileLock(tmp_path / "test.lock") + with pytest.raises(TypeError, match="lifetime must be"): + lock.lifetime = bad_value + + +@pytest.mark.parametrize("value", [0, 0.0]) +def test_lifetime_setter_accepts_zero(value: float, tmp_path: Path) -> None: + lock = FileLock(tmp_path / "test.lock") + lock.lifetime = value + assert lock.lifetime == 0 + + def test_lifetime_singleton_mismatch(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock1 = FileLock(lock_path, is_singleton=True, lifetime=10.0)