Repository: crsmithdev/arrow
Pull request #823 (2020-07-13 06:00:33)

Fix arrow.get functionality when weekday is parsed alone
## Pull Request Checklist

Thank you for taking the time to improve Arrow! Before submitting your pull request, please check all *appropriate* boxes:

<!-- Check boxes by placing an x in the brackets: [x] -->
- [x] 🧪 Added **tests** for changed code.
- [x] 🛠️ All tests **pass** when run locally (run `tox` or `make test` to find out!).
- [x] 🧹 All linting checks **pass** when run locally (run `tox -e lint` or `make lint` to find out!).
- [x] 📚 Updated **documentation** for changed code.
- [x] ⏩ Code is **up-to-date** with the `master` branch.

If you have *any* questions about your code changes or any of the points above, please submit your questions along with the pull request and we will try our best to help!

## Description of Changes

<!--
Replace this commented text block with a description of your code changes.

If your PR has an associated issue, insert the issue number (e.g. #703) or directly link to the GitHub issue (e.g. https://github.com/crsmithdev/arrow/issues/703).

For example, doing the following will automatically close issue #703 when this PR is merged:

Closes: #703
-->

Closes: https://github.com/crsmithdev/arrow/issues/446

Replaces https://github.com/crsmithdev/arrow/pull/820 due to GitHub glitch.

codecov-commenter: # [Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=h1) Report
> Merging [#823](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=desc) into [master](https://codecov.io/gh/crsmithdev/arrow/commit/4c99ae3e6200ef683d045335d319918b0f00b31f&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/crsmithdev/arrow/pull/823/graphs/tree.svg?width=650&height=150&src=pr&token=4EkGnFsIGn)](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #823   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           10        10           
  Lines         1757      1761    +4     
  Branches       300       304    +4     
=========================================
+ Hits          1757      1761    +4     
```


| [Impacted Files](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [arrow/parser.py](https://codecov.io/gh/crsmithdev/arrow/pull/823/diff?src=pr&el=tree#diff-YXJyb3cvcGFyc2VyLnB5) | `100.00% <100.00%> (ø)` | |
| [arrow/util.py](https://codecov.io/gh/crsmithdev/arrow/pull/823/diff?src=pr&el=tree#diff-YXJyb3cvdXRpbC5weQ==) | `100.00% <100.00%> (ø)` | |
| [arrow/arrow.py](https://codecov.io/gh/crsmithdev/arrow/pull/823/diff?src=pr&el=tree#diff-YXJyb3cvYXJyb3cucHk=) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=footer). Last update [4c99ae3...5318768](https://codecov.io/gh/crsmithdev/arrow/pull/823?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

systemcatch: Hey Jad, do we want to create regression test based on the bug in #446 ?

`assert arrow.get("Tuesday", "dddd").format("dddd") == 'Monday'`
jadchaar: @systemcatch I was thinking of creating one, but I did not know where to put it. Do you think it relates more to parsing than formatting?
systemcatch: I think where you've put them is fine, later this evening I can check then approve the PR. 

Patch:
diff --git a/arrow/parser.py b/arrow/parser.py
index 344e8a1..7d72711 100644
--- a/arrow/parser.py
+++ b/arrow/parser.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
 from dateutil import tz
 
 from arrow import locales
-from arrow.util import iso_to_gregorian, normalize_timestamp
+from arrow.util import iso_to_gregorian, next_weekday, normalize_timestamp
 
 try:
     from functools import lru_cache
@@ -338,9 +338,15 @@ class DateTimeParser(object):
         elif token in ["DD", "D"]:
             parts["day"] = int(value)
 
-        elif token in ["Do"]:
+        elif token == "Do":
             parts["day"] = int(value)
 
+        elif token == "dddd":
+            parts["day_of_week"] = self.locale.day_names.index(value) - 1
+
+        elif token == "ddd":
+            parts["day_of_week"] = self.locale.day_abbreviations.index(value) - 1
+
         elif token.upper() in ["HH", "H"]:
             parts["hour"] = int(value)
 
@@ -444,6 +450,24 @@ class DateTimeParser(object):
             parts["month"] = dt.month
             parts["day"] = dt.day
 
+        day_of_week = parts.get("day_of_week")
+        day = parts.get("day")
+
+        # If day is passed, ignore day of week
+        if day_of_week is not None and day is None:
+            year = parts.get("year", 1970)
+            month = parts.get("month", 1)
+            day = 1
+
+            # dddd => first day of week after epoch
+            # dddd YYYY => first day of week in specified year
+            # dddd MM YYYY => first day of week in specified year and month
+            # dddd MM => first day after epoch in specified month
+            next_weekday_dt = next_weekday(datetime(year, month, day), day_of_week)
+            parts["year"] = next_weekday_dt.year
+            parts["month"] = next_weekday_dt.month
+            parts["day"] = next_weekday_dt.day
+
         am_pm = parts.get("am_pm")
         hour = parts.get("hour", 0)
 
diff --git a/arrow/util.py b/arrow/util.py
index 785da86..42db41e 100644
--- a/arrow/util.py
+++ b/arrow/util.py
@@ -4,9 +4,37 @@ from __future__ import absolute_import
 import datetime
 import numbers
 
+from dateutil.rrule import WEEKLY, rrule
+
 from arrow.constants import MAX_TIMESTAMP, MAX_TIMESTAMP_MS, MAX_TIMESTAMP_US
 
 
+def next_weekday(start_date, weekday):
+    """Get next weekday from the specified start date.
+
+    :param start_date: Datetime object representing the start date.
+    :param weekday: Next weekday to obtain. Can be a value between 0 (Monday) and 6 (Sunday).
+    :return: Datetime object corresponding to the next weekday after start_date.
+
+    Usage::
+
+        # Get first Monday after epoch
+        >>> next_weekday(datetime(1970, 1, 1), 0)
+        1970-01-05 00:00:00
+
+        # Get first Thursday after epoch
+        >>> next_weekday(datetime(1970, 1, 1), 3)
+        1970-01-01 00:00:00
+
+        # Get first Sunday after epoch
+        >>> next_weekday(datetime(1970, 1, 1), 6)
+        1970-01-04 00:00:00
+    """
+    if weekday < 0 or weekday > 6:
+        raise ValueError("Weekday must be between 0 (Monday) and 6 (Sunday).")
+    return rrule(freq=WEEKLY, dtstart=start_date, byweekday=weekday, count=1)[0]
+
+
 def total_seconds(td):
     """Get total seconds for timedelta."""
     return td.total_seconds()
@@ -77,4 +105,4 @@ except NameError:  # pragma: no cover
         return isinstance(s, str)
 
 
-__all__ = ["total_seconds", "is_timestamp", "isstr", "iso_to_gregorian"]
+__all__ = ["next_weekday", "total_seconds", "is_timestamp", "isstr", "iso_to_gregorian"]


Test patch:
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index ee3a6be..e97aeb5 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -22,7 +22,7 @@ from .utils import make_full_tz_list
 
 
 @pytest.mark.usefixtures("arrow_formatter")
-class TestDateTimeFormatterFormatToken:
+class TestFormatterFormatToken:
     def test_format(self):
 
         dt = datetime(2013, 2, 5, 12, 32, 51)
@@ -220,7 +220,7 @@ class TestDateTimeFormatterFormatToken:
 
 
 @pytest.mark.usefixtures("arrow_formatter", "time_1975_12_25")
-class TestDateTimeFormatterBuiltinFormats:
+class TestFormatterBuiltinFormats:
     def test_atom(self):
         assert (
             self.formatter.format(self.datetime, FORMAT_ATOM)
diff --git a/tests/test_parser.py b/tests/test_parser.py
index d7b6689..4bc5866 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -10,7 +10,7 @@ import pytest
 from dateutil import tz
 
 import arrow
-from arrow import parser
+from arrow import formatter, parser
 from arrow.constants import MAX_TIMESTAMP_US
 from arrow.parser import DateTimeParser, ParserError, ParserMatchError
 
@@ -580,6 +580,96 @@ class TestDateTimeParserParse:
         with pytest.raises(ParserError):
             self.parser.parse("145", "DDDD")
 
+    def test_parse_ddd_and_dddd(self):
+        fr_parser = parser.DateTimeParser("fr")
+
+        # Day of week should be ignored when a day is passed
+        # 2019-10-17 is a Thursday, so we know day of week
+        # is ignored if the same date is outputted
+        expected = datetime(2019, 10, 17)
+        assert self.parser.parse("Tue 2019-10-17", "ddd YYYY-MM-DD") == expected
+        assert fr_parser.parse("mar 2019-10-17", "ddd YYYY-MM-DD") == expected
+        assert self.parser.parse("Tuesday 2019-10-17", "dddd YYYY-MM-DD") == expected
+        assert fr_parser.parse("mardi 2019-10-17", "dddd YYYY-MM-DD") == expected
+
+        # Get first Tuesday after epoch
+        expected = datetime(1970, 1, 6)
+        assert self.parser.parse("Tue", "ddd") == expected
+        assert fr_parser.parse("mar", "ddd") == expected
+        assert self.parser.parse("Tuesday", "dddd") == expected
+        assert fr_parser.parse("mardi", "dddd") == expected
+
+        # Get first Tuesday in 2020
+        expected = datetime(2020, 1, 7)
+        assert self.parser.parse("Tue 2020", "ddd YYYY") == expected
+        assert fr_parser.parse("mar 2020", "ddd YYYY") == expected
+        assert self.parser.parse("Tuesday 2020", "dddd YYYY") == expected
+        assert fr_parser.parse("mardi 2020", "dddd YYYY") == expected
+
+        # Get first Tuesday in February 2020
+        expected = datetime(2020, 2, 4)
+        assert self.parser.parse("Tue 02 2020", "ddd MM YYYY") == expected
+        assert fr_parser.parse("mar 02 2020", "ddd MM YYYY") == expected
+        assert self.parser.parse("Tuesday 02 2020", "dddd MM YYYY") == expected
+        assert fr_parser.parse("mardi 02 2020", "dddd MM YYYY") == expected
+
+        # Get first Tuesday in February after epoch
+        expected = datetime(1970, 2, 3)
+        assert self.parser.parse("Tue 02", "ddd MM") == expected
+        assert fr_parser.parse("mar 02", "ddd MM") == expected
+        assert self.parser.parse("Tuesday 02", "dddd MM") == expected
+        assert fr_parser.parse("mardi 02", "dddd MM") == expected
+
+        # Times remain intact
+        expected = datetime(2020, 2, 4, 10, 25, 54, 123456, tz.tzoffset(None, -3600))
+        assert (
+            self.parser.parse(
+                "Tue 02 2020 10:25:54.123456-01:00", "ddd MM YYYY HH:mm:ss.SZZ"
+            )
+            == expected
+        )
+        assert (
+            fr_parser.parse(
+                "mar 02 2020 10:25:54.123456-01:00", "ddd MM YYYY HH:mm:ss.SZZ"
+            )
+            == expected
+        )
+        assert (
+            self.parser.parse(
+                "Tuesday 02 2020 10:25:54.123456-01:00", "dddd MM YYYY HH:mm:ss.SZZ"
+            )
+            == expected
+        )
+        assert (
+            fr_parser.parse(
+                "mardi 02 2020 10:25:54.123456-01:00", "dddd MM YYYY HH:mm:ss.SZZ"
+            )
+            == expected
+        )
+
+        # Regression test for issue #446
+        arw_formatter = formatter.DateTimeFormatter()
+        arw_formatter.format(self.parser.parse("Mon", "ddd"), "ddd") == "Mon"
+        arw_formatter.format(self.parser.parse("Monday", "dddd"), "dddd") == "Monday"
+        arw_formatter.format(self.parser.parse("Tue", "ddd"), "ddd") == "Tue"
+        arw_formatter.format(self.parser.parse("Tuesday", "dddd"), "dddd") == "Tuesday"
+        arw_formatter.format(self.parser.parse("Wed", "ddd"), "ddd") == "Wed"
+        arw_formatter.format(
+            self.parser.parse("Wednesday", "dddd"), "dddd"
+        ) == "Wednesday"
+        arw_formatter.format(self.parser.parse("Thu", "ddd"), "ddd") == "Thu"
+        arw_formatter.format(
+            self.parser.parse("Thursday", "dddd"), "dddd"
+        ) == "Thursday"
+        arw_formatter.format(self.parser.parse("Fri", "ddd"), "ddd") == "Fri"
+        arw_formatter.format(self.parser.parse("Friday", "dddd"), "dddd") == "Friday"
+        arw_formatter.format(self.parser.parse("Sat", "ddd"), "ddd") == "Sat"
+        arw_formatter.format(
+            self.parser.parse("Saturday", "dddd"), "dddd"
+        ) == "Saturday"
+        arw_formatter.format(self.parser.parse("Sun", "ddd"), "ddd") == "Sun"
+        arw_formatter.format(self.parser.parse("Sunday", "dddd"), "dddd") == "Sunday"
+
     def test_parse_HH_24(self):
         assert self.parser.parse(
             "2019-10-30T24:00:00", "YYYY-MM-DDTHH:mm:ss"
diff --git a/tests/test_util.py b/tests/test_util.py
index 056a60c..e48b4de 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -8,6 +8,35 @@ from arrow import util
 
 
 class TestUtil:
+    def test_next_weekday(self):
+        # Get first Monday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 0) == datetime(1970, 1, 5)
+
+        # Get first Tuesday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 1) == datetime(1970, 1, 6)
+
+        # Get first Wednesday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 2) == datetime(1970, 1, 7)
+
+        # Get first Thursday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 3) == datetime(1970, 1, 1)
+
+        # Get first Friday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 4) == datetime(1970, 1, 2)
+
+        # Get first Saturday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 5) == datetime(1970, 1, 3)
+
+        # Get first Sunday after epoch
+        assert util.next_weekday(datetime(1970, 1, 1), 6) == datetime(1970, 1, 4)
+
+        # Weekdays are 0-indexed
+        with pytest.raises(ValueError):
+            util.next_weekday(datetime(1970, 1, 1), 7)
+
+        with pytest.raises(ValueError):
+            util.next_weekday(datetime(1970, 1, 1), -1)
+
     def test_total_seconds(self):
         td = datetime(2019, 1, 1) - datetime(2018, 1, 1)
         assert util.total_seconds(td) == td.total_seconds()