diff --git a/src/pendulum/date.py b/src/pendulum/date.py index 50554f435..ee6654e3a 100644 --- a/src/pendulum/date.py +++ b/src/pendulum/date.py @@ -750,6 +750,13 @@ def replace( month: SupportsIndex | None = None, day: SupportsIndex | None = None, ) -> Self: + # bool is a subclass of int; reject so year=True does not become year 1 + for name, value in (("year", year), ("month", month), ("day", day)): + if isinstance(value, bool): + raise TypeError( + f"{name} must be an integer, not bool (got {value!r})." + ) + year = year if year is not None else self.year month = month if month is not None else self.month day = day if day is not None else self.day diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index da89b13db..52f160b71 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -208,6 +208,21 @@ def set( microsecond: int | None = None, tz: str | float | Timezone | FixedTimezone | datetime.tzinfo | None = None, ) -> Self: + # bool is a subclass of int; reject so year=True does not become year 1 + for name, value in ( + ("year", year), + ("month", month), + ("day", day), + ("hour", hour), + ("minute", minute), + ("second", second), + ("microsecond", microsecond), + ): + if isinstance(value, bool): + raise TypeError( + f"{name} must be an integer, not bool (got {value!r})." + ) + if year is None: year = self.year if month is None: @@ -1299,6 +1314,23 @@ def replace( tzinfo: bool | datetime.tzinfo | Literal[True] | None = True, fold: int | None = None, ) -> Self: + # bool is a subclass of int; reject so year=True does not become year 1 + # (tzinfo=True remains the sentinel for "keep current timezone") + for name, value in ( + ("year", year), + ("month", month), + ("day", day), + ("hour", hour), + ("minute", minute), + ("second", second), + ("microsecond", microsecond), + ("fold", fold), + ): + if isinstance(value, bool): + raise TypeError( + f"{name} must be an integer, not bool (got {value!r})." + ) + if year is None: year = self.year if month is None: diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index d6cc0657d..dd61810c8 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -80,6 +80,23 @@ def __new__( years: float = 0, months: float = 0, ) -> Self: + # bool is a subclass of int; reject so years=True does not become 1 year + for name, value in ( + ("days", days), + ("seconds", seconds), + ("microseconds", microseconds), + ("milliseconds", milliseconds), + ("minutes", minutes), + ("hours", hours), + ("weeks", weeks), + ("years", years), + ("months", months), + ): + if isinstance(value, bool): + raise TypeError( + f"{name} must be a number, not bool (got {value!r})." + ) + if not isinstance(years, int) or not isinstance(months, int): raise ValueError("Float year and months are not supported") diff --git a/src/pendulum/helpers.py b/src/pendulum/helpers.py index db42b31e8..3fa674428 100644 --- a/src/pendulum/helpers.py +++ b/src/pendulum/helpers.py @@ -94,6 +94,22 @@ def add_duration( """ Adds a duration to a date/datetime instance. """ + # bool is a subclass of int; reject so days=True does not add 1 day + for name, value in ( + ("years", years), + ("months", months), + ("weeks", weeks), + ("days", days), + ("hours", hours), + ("minutes", minutes), + ("seconds", seconds), + ("microseconds", microseconds), + ): + if isinstance(value, bool): + raise TypeError( + f"{name} must be an integer, not bool (got {value!r})." + ) + days += weeks * 7 if ( diff --git a/tests/date/test_add.py b/tests/date/test_add.py index a435f4651..f29813d3a 100644 --- a/tests/date/test_add.py +++ b/tests/date/test_add.py @@ -86,3 +86,12 @@ def test_addition_invalid_type(): with pytest.raises(TypeError): 3 + d + + +def test_add_rejects_bool(): + import pytest + + d = pendulum.date(2020, 5, 15) + for key in ("years", "months", "weeks", "days"): + with pytest.raises(TypeError, match=key): + d.add(**{key: True}) diff --git a/tests/date/test_fluent_setters.py b/tests/date/test_fluent_setters.py index c76cc2f86..da2f86a30 100644 --- a/tests/date/test_fluent_setters.py +++ b/tests/date/test_fluent_setters.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + import pendulum from tests.conftest import assert_date @@ -27,3 +29,12 @@ def test_fluid_day_setter(): assert new.day == 9 assert d.day == 2 + + +def test_replace_rejects_bool(): + d = pendulum.date(2020, 5, 15) + for key in ("year", "month", "day"): + with pytest.raises(TypeError, match=key): + d.replace(**{key: True}) + with pytest.raises(TypeError, match=key): + d.replace(**{key: False}) diff --git a/tests/datetime/test_add.py b/tests/datetime/test_add.py index 409f5bd40..3bffcb37a 100644 --- a/tests/datetime/test_add.py +++ b/tests/datetime/test_add.py @@ -278,3 +278,12 @@ def test_interval_over_midnight_tz(): new_end = start + interval assert new_end == end + + +def test_add_rejects_bool(): + dt = pendulum.datetime(2020, 5, 15, 12, 30, 45) + for key in ("years", "months", "weeks", "days", "hours", "minutes", "seconds", "microseconds"): + with pytest.raises(TypeError, match=key): + dt.add(**{key: True}) + with pytest.raises(TypeError, match=key): + dt.add(**{key: False}) diff --git a/tests/datetime/test_fluent_setters.py b/tests/datetime/test_fluent_setters.py index cedbd2694..25ed586d4 100644 --- a/tests/datetime/test_fluent_setters.py +++ b/tests/datetime/test_fluent_setters.py @@ -179,3 +179,14 @@ def test_replace_tzinfo_dst_transitioning_off(): assert new.is_dst() assert new.offset == 7200 assert new.timezone_name == "Europe/Paris" + + +def test_set_rejects_bool(): + import pytest + + dt = pendulum.datetime(2020, 5, 15, 12, 30, 45) + for key in ("year", "month", "day", "hour", "minute", "second", "microsecond"): + with pytest.raises(TypeError, match=key): + dt.set(**{key: True}) + with pytest.raises(TypeError, match=key): + dt.set(**{key: False}) diff --git a/tests/datetime/test_replace.py b/tests/datetime/test_replace.py index 694ef7b90..7451aae93 100644 --- a/tests/datetime/test_replace.py +++ b/tests/datetime/test_replace.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + import pendulum from tests.conftest import assert_datetime @@ -59,3 +61,19 @@ def test_replace_tzinfo_dst_transitioning_off(): assert not in_paris.is_dst() assert in_paris.offset == 3600 assert in_paris.timezone_name == "Europe/Paris" + + +def test_replace_rejects_bool(): + dt = pendulum.datetime(2020, 5, 15, 12, 30, 45) + for key in ("year", "month", "day", "hour", "minute", "second", "microsecond", "fold"): + with pytest.raises(TypeError, match=key): + dt.replace(**{key: True}) + with pytest.raises(TypeError, match=key): + dt.replace(**{key: False}) + + +def test_replace_tzinfo_true_still_allowed(): + # tzinfo=True is the documented sentinel for keeping the current timezone + dt = pendulum.datetime(2020, 5, 15, 12, 30, 45, tz="UTC") + replaced = dt.replace(tzinfo=True) + assert replaced.timezone_name == dt.timezone_name diff --git a/tests/duration/test_construct.py b/tests/duration/test_construct.py index aaa539095..454d6f976 100644 --- a/tests/duration/test_construct.py +++ b/tests/duration/test_construct.py @@ -97,3 +97,21 @@ def test_float_years_and_months(): with pytest.raises(ValueError): pendulum.duration(months=1.5) + + +def test_duration_rejects_bool(): + for key in ( + "days", + "seconds", + "microseconds", + "milliseconds", + "minutes", + "hours", + "weeks", + "years", + "months", + ): + with pytest.raises(TypeError, match=key): + pendulum.duration(**{key: True}) + with pytest.raises(TypeError, match=key): + pendulum.duration(**{key: False})