Repository: sdispater/pendulum (python)

Issue:
Increase DateTime by 1 day across daylight saving change point fails
<!--
  Hi there! Thank you for discovering and submitting an issue.

  Before you submit this; let's make sure of a few things.
  Please make sure the following boxes are ticked if they are correct.
  If not, please try and fulfill these first.
-->

<!-- Checked checkbox should look like this: [x] -->
- [x] I am on the [latest](https://github.com/sdispater/pendulum/releases/latest) Pendulum version.
- [x] I have searched the [issues](https://github.com/sdispater/pendulum/issues) of this repo and believe that this is not a duplicate.

<!--
  Once those are done, if you're able to fill in the following list with your information,
  it'd be very helpful to whoever handles the issue.
-->

- **OS version and name**: <!-- Replace with version + name --> Manjaro Linux 6.6.0
- **Pendulum version**: <!-- Replace with version --> 2.1.2

## Issue
<!-- Now feel free to write your issue, but please be descriptive! Thanks again 🙌 ❤️ -->
```python
>>> import pendulum; pendulum.datetime(2023,11,5,0,0,tz='America/Chicago') + pendulum.duration(days=1)
DateTime(2023, 11, 5, 23, 0, 0, tzinfo=Timezone('America/Chicago'))
```
The result should be `DateTime(2023, 11, 6, 0, 0, 0, tzinfo=Timezone('America/Chicago'))`. A workaround is using the traditional datetime library as the base datetime:
```python
>>> start = pendulum.datetime(2023,11,5,0,0,tz='America/Chicago')
>>> import datetime
>>> pendulum.instance(datetime.datetime.fromtimestamp(start.timestamp(), tz=start.tzinfo) + pendulum.duration(days=1))
DateTime(2023, 11, 6, 0, 0, 0, tzinfo=Timezone('America/Chicago'))
```

This bug made my customized Airflow time schedule failed yesterday.

Pull request:
Fix errors where hours and days were not handled correctly when adding durations
Fixes #768

## Pull Request Check List

- [x] Added **tests** for changed code.
- [ ] Updated **documentation** for changed code.


Patch:
diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py
index 43346e5..3863b76 100644
--- a/src/pendulum/__init__.py
+++ b/src/pendulum/__init__.py
@@ -50,7 +50,6 @@ FRIDAY = WeekDay.FRIDAY
 SATURDAY = WeekDay.SATURDAY
 SUNDAY = WeekDay.SUNDAY
 
-
 _TEST_NOW: DateTime | None = None
 _LOCALE = "en"
 _WEEK_STARTS_AT: WeekDay = WeekDay.MONDAY
diff --git a/src/pendulum/date.py b/src/pendulum/date.py
index 5e69ec0..e7b862c 100644
--- a/src/pendulum/date.py
+++ b/src/pendulum/date.py
@@ -74,9 +74,7 @@ class Date(FormattableMixin, date):
 
     @property
     def week_of_month(self) -> int:
-        first_day_of_month = self.replace(day=1)
-
-        return self.week_of_year - first_day_of_month.week_of_year + 1
+        return math.ceil((self.day + self.first_of("month").isoweekday() - 1) / 7)
 
     @property
     def age(self) -> int:
diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py
index 6452f3b..752a9ac 100644
--- a/src/pendulum/datetime.py
+++ b/src/pendulum/datetime.py
@@ -685,9 +685,7 @@ class DateTime(datetime.datetime, Date):
                 microseconds=delta.microseconds,
             )
         elif isinstance(delta, pendulum.Duration):
-            return self.add(
-                years=delta.years, months=delta.months, seconds=delta._total
-            )
+            return self.add(**delta._signature)  # type: ignore[attr-defined]
 
         return self.add(seconds=delta.total_seconds())
 
@@ -1357,17 +1355,32 @@ class DateTime(datetime.datetime, Date):
     def __reduce__(
         self,
     ) -> tuple[
-        type[Self], tuple[int, int, int, int, int, int, int, datetime.tzinfo | None]
+        type[Self],
+        tuple[int, int, int, int, int, int, int, datetime.tzinfo | None],
     ]:
         return self.__reduce_ex__(2)
 
     def __reduce_ex__(
         self, protocol: SupportsIndex
     ) -> tuple[
-        type[Self], tuple[int, int, int, int, int, int, int, datetime.tzinfo | None]
+        type[Self],
+        tuple[int, int, int, int, int, int, int, datetime.tzinfo | None],
     ]:
         return self.__class__, self._getstate(protocol)
 
+    def __deepcopy__(self, _: dict[int, Self]) -> Self:
+        return self.__class__(
+            self.year,
+            self.month,
+            self.day,
+            self.hour,
+            self.minute,
+            self.second,
+            self.microsecond,
+            tzinfo=self.tz,
+            fold=self.fold,
+        )
+
     def _cmp(self, other: datetime.datetime, **kwargs: Any) -> int:
         # Fix for pypy which compares using this method
         # which would lead to infinite recursion if we didn't override
diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py
index 962f923..a4875fc 100644
--- a/src/pendulum/duration.py
+++ b/src/pendulum/duration.py
@@ -112,6 +112,17 @@ class Duration(timedelta):
         self._months = months
         self._years = years
 
+        self._signature = {  # type: ignore[attr-defined]
+            "years": years,
+            "months": months,
+            "weeks": weeks,
+            "days": days,
+            "hours": hours,
+            "minutes": minutes,
+            "seconds": seconds,
+            "microseconds": microseconds + milliseconds * 1000,
+        }
+
         return self
 
     def total_minutes(self) -> float:
@@ -440,7 +451,10 @@ class Duration(timedelta):
 
     def __divmod__(self, other: timedelta) -> tuple[int, Duration]:
         if isinstance(other, timedelta):
-            q, r = divmod(self._to_microseconds(), other._to_microseconds())  # type: ignore[attr-defined] # noqa: E501
+            q, r = divmod(
+                self._to_microseconds(),
+                other._to_microseconds(),  # type: ignore[attr-defined]
+            )
 
             return q, self.__class__(0, 0, r)
 


Test patch:
diff --git a/tests/datetime/test_add.py b/tests/datetime/test_add.py
index 6d43656..409f5bd 100644
--- a/tests/datetime/test_add.py
+++ b/tests/datetime/test_add.py
@@ -252,13 +252,25 @@ def test_add_time_to_new_transition_repeated_big():
     assert not dt.is_dst()
 
 
-def test_add_interval():
+def test_add_duration_across_transition():
     dt = pendulum.datetime(2017, 3, 11, 10, 45, tz="America/Los_Angeles")
     new = dt + pendulum.duration(hours=24)
 
     assert_datetime(new, 2017, 3, 12, 11, 45)
 
 
+def test_add_duration_across_transition_days():
+    dt = pendulum.datetime(2017, 3, 11, 10, 45, tz="America/Los_Angeles")
+    new = dt + pendulum.duration(days=1)
+
+    assert_datetime(new, 2017, 3, 12, 10, 45)
+
+    dt = pendulum.datetime(2023, 11, 5, 0, 0, tz="America/Chicago")
+    new = dt + pendulum.duration(days=1)
+
+    assert_datetime(new, 2023, 11, 6, 0, 0)
+
+
 def test_interval_over_midnight_tz():
     start = pendulum.datetime(2018, 2, 25, tz="Europe/Paris")
     end = start.add(hours=1)
diff --git a/tests/datetime/test_behavior.py b/tests/datetime/test_behavior.py
index e02323a..76de1d6 100644
--- a/tests/datetime/test_behavior.py
+++ b/tests/datetime/test_behavior.py
@@ -158,6 +158,14 @@ def test_deepcopy():
     assert dt == deepcopy(dt)
 
 
+def test_deepcopy_on_transition():
+    dt = pendulum.datetime(2023, 11, 5, 1, 0, 0, tz="US/Pacific")
+    clone = deepcopy(dt)
+
+    assert dt == clone
+    assert dt.offset == clone.offset
+
+
 def test_pickle_timezone():
     dt1 = pendulum.timezone("Europe/Amsterdam")
     s = pickle.dumps(dt1)
diff --git a/tests/datetime/test_getters.py b/tests/datetime/test_getters.py
index 1911cb1..8f4c1ae 100644
--- a/tests/datetime/test_getters.py
+++ b/tests/datetime/test_getters.py
@@ -195,6 +195,14 @@ def test_week_of_year_last_week():
     assert pendulum.datetime(2012, 12, 31).week_of_year == 1
 
 
+def test_week_of_month_edge_case():
+    assert pendulum.datetime(2020, 1, 1).week_of_month == 1
+    assert pendulum.datetime(2020, 1, 7).week_of_month == 2
+    assert pendulum.datetime(2020, 1, 14).week_of_month == 3
+    assert pendulum.datetime(2023, 1, 1).week_of_month == 1
+    assert pendulum.datetime(2023, 1, 31).week_of_month == 6
+
+
 def test_timezone():
     d = pendulum.datetime(2000, 1, 1, tz="America/Toronto")
     assert d.timezone.name == "America/Toronto"
@@ -247,3 +255,41 @@ def test_time():
     t = dt.time()
     assert isinstance(t, pendulum.Time)
     assert_time(t, 10, 40, 34, 123456)
+
+
+@pytest.mark.parametrize(
+    "date, expected",
+    [
+        (pendulum.Date(2000, 1, 1), 1),
+        (pendulum.Date(2000, 1, 3), 2),
+        (pendulum.Date(2019, 12, 29), 5),
+        (pendulum.Date(2019, 12, 30), 6),
+        (pendulum.Date(2019, 12, 31), 6),
+        (pendulum.Date(2020, 1, 7), 2),
+        (pendulum.Date(2020, 1, 14), 3),
+        (pendulum.Date(2021, 1, 1), 1),
+        (pendulum.Date(2021, 1, 2), 1),
+        (pendulum.Date(2021, 1, 9), 2),
+        (pendulum.Date(2021, 1, 10), 2),
+        (pendulum.Date(2021, 1, 11), 3),
+        (pendulum.Date(2021, 1, 15), 3),
+        (pendulum.Date(2021, 1, 16), 3),
+        (pendulum.Date(2021, 1, 17), 3),
+        (pendulum.Date(2021, 1, 23), 4),
+        (pendulum.Date(2021, 1, 31), 5),
+        (pendulum.Date(2021, 12, 19), 3),
+        (pendulum.Date(2021, 12, 25), 4),
+        (pendulum.Date(2021, 12, 26), 4),
+        (pendulum.Date(2021, 12, 29), 5),
+        (pendulum.Date(2021, 12, 30), 5),
+        (pendulum.Date(2021, 12, 31), 5),
+        (pendulum.Date(2022, 1, 1), 1),
+        (pendulum.Date(2022, 1, 3), 2),
+        (pendulum.Date(2022, 1, 10), 3),
+        (pendulum.Date(2023, 1, 1), 1),
+        (pendulum.Date(2023, 1, 2), 2),
+        (pendulum.Date(2029, 12, 31), 6),
+    ],
+)
+def test_week_of_month_negative(date, expected):
+    assert date.week_of_month == expected