From 427b603a657b8ee86bd742e456093accdd801942 Mon Sep 17 00:00:00 2001 From: Pitchfork-and-Torch <297513015+Pitchfork-and-Torch@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:45:57 +0000 Subject: [PATCH] Reject bool for retention, rotation, level, and handler ids bool subclasses int and numbers.Real, so retention=True kept 1 file, rotation=True used a 1-byte size limit, level(no=True) set severity 1, remove(True) removed handler id 1, and add(level=True) filtered at 1. Raise TypeError for bool so callers pass an explicit integer. --- loguru/_file_sink.py | 10 ++++++ loguru/_logger.py | 12 ++++--- tests/test_bool_numeric_rejection.py | 54 ++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/test_bool_numeric_rejection.py diff --git a/loguru/_file_sink.py b/loguru/_file_sink.py index 954df18c..c30d2e1d 100644 --- a/loguru/_file_sink.py +++ b/loguru/_file_sink.py @@ -347,6 +347,11 @@ def _make_rotation_function(rotation): step_forward = partial(Rotation.forward_weekday, weekday=day) return Rotation.RotationTime(step_forward, time, day) raise ValueError("Cannot parse rotation from: '%s'" % rotation) + # bool subclasses numbers.Real; True would silently become size_limit=1 + if isinstance(rotation, bool): + raise TypeError( + "Cannot infer rotation for objects of type: '%s'" % type(rotation).__name__ + ) if isinstance(rotation, (numbers.Real, decimal.Decimal)): return partial(Rotation.rotation_size, size_limit=rotation) if isinstance(rotation, datetime.time): @@ -367,6 +372,11 @@ def _make_retention_function(retention): if interval is None: raise ValueError("Cannot parse retention from: '%s'" % retention) return FileSink._make_retention_function(interval) + # bool subclasses int; True would silently keep 1 file, False keep 0 + if isinstance(retention, bool): + raise TypeError( + "Cannot infer retention for objects of type: '%s'" % type(retention).__name__ + ) if isinstance(retention, int): return partial(Retention.retention_count, number=retention) if isinstance(retention, datetime.timedelta): diff --git a/loguru/_logger.py b/loguru/_logger.py index 3385c1f1..4c42f8f0 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -987,7 +987,8 @@ def add( if isinstance(level, str): levelno = self.level(level).no - elif isinstance(level, int): + elif isinstance(level, int) and not isinstance(level, bool): + # bool subclasses int; level=True would silently become severity 1 levelno = level else: raise TypeError( @@ -1091,7 +1092,8 @@ def remove(self, handler_id=None): >>> logger.remove(i) >>> logger.info("No longer logging") """ - if not (handler_id is None or isinstance(handler_id, int)): + # bool subclasses int; remove(True) would silently remove handler id 1 + if not (handler_id is None or (isinstance(handler_id, int) and not isinstance(handler_id, bool))): raise TypeError( "Invalid handler id, it should be an integer as returned " "by the 'add()' method (or None), not: '%s'" % type(handler_id).__name__ @@ -1700,7 +1702,8 @@ def level(self, name, no=None, color=None, icon=None): if icon is None: icon = old_icon - if not isinstance(no, int): + # bool subclasses int; no=True would silently become severity 1 + if isinstance(no, bool) or not isinstance(no, int): raise TypeError( "Invalid level no, it should be an integer, not: '%s'" % type(no).__name__ ) @@ -2067,7 +2070,8 @@ def _log(self, level, from_decorator, options, message, args, kwargs): except (KeyError, TypeError): if isinstance(level, str): raise ValueError("Level '%s' does not exist" % level) from None - if not isinstance(level, int): + # bool subclasses int; logger.log(True, ...) would silently use severity 1 + if isinstance(level, bool) or not isinstance(level, int): raise TypeError( "Invalid level, it should be an integer or a string, not: '%s'" % type(level).__name__ diff --git a/tests/test_bool_numeric_rejection.py b/tests/test_bool_numeric_rejection.py new file mode 100644 index 00000000..8b63c482 --- /dev/null +++ b/tests/test_bool_numeric_rejection.py @@ -0,0 +1,54 @@ +"""bool subclasses int/Real; True must not silently become 1.""" +import sys + +import pytest + +from loguru import logger +from loguru._file_sink import FileSink + + +@pytest.mark.parametrize("value", [True, False]) +def test_retention_rejects_bool(value): + with pytest.raises(TypeError): + FileSink._make_retention_function(value) + + +@pytest.mark.parametrize("value", [True, False]) +def test_rotation_rejects_bool(value): + with pytest.raises(TypeError): + FileSink._make_rotation_function(value) + + +def test_retention_and_rotation_still_accept_int(): + assert FileSink._make_retention_function(0) is not None + assert FileSink._make_retention_function(1) is not None + assert FileSink._make_rotation_function(1) is not None + + +@pytest.mark.parametrize("value", [True, False]) +def test_level_no_rejects_bool(value): + with pytest.raises(TypeError, match="level no"): + logger.level("COOKBOOLLEVEL", no=value) + + +@pytest.mark.parametrize("value", [True, False]) +def test_remove_rejects_bool(value): + with pytest.raises(TypeError, match="handler id"): + logger.remove(value) + + +@pytest.mark.parametrize("value", [True, False]) +def test_add_level_rejects_bool(value): + with pytest.raises(TypeError, match="level"): + logger.add(sys.stderr, level=value) + + +@pytest.mark.parametrize("value", [True, False]) +def test_log_rejects_bool_level(value): + logger.remove() + logger.add(sys.stderr) + try: + with pytest.raises(TypeError, match="level"): + logger.log(value, "msg") + finally: + logger.remove()