Repository: crsmithdev/arrow
Pull request #754 (2020-01-23 18:42:07)

Add support for parsing and formatting ISO 8601 week dates
## 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] 📚 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

This adds support for ISO 8601 [week dates](https://en.wikipedia.org/wiki/ISO_week_date) via a new token `"W"`.

```shell
(arrow) chris@ThinkPad:~/arrow$ python
Python 3.7.4 (default, Sep 19 2019, 11:01:37) 
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import arrow
>>> arrow.get("2013-W29-6", "W")
<Arrow [2013-07-20T00:00:00+00:00]>
>>> arrow.get("2013-W12", "W")
<Arrow [2013-03-18T00:00:00+00:00]>
>>> arrow.get("2013W296", "W")
<Arrow [2013-07-20T00:00:00+00:00]>
>>> arrow.get("2013W12", "W")
<Arrow [2013-03-18T00:00:00+00:00]>
>>> utc=arrow.utcnow()
>>> utc
<Arrow [2020-01-23T18:37:55.417624+00:00]>
>>> utc.format("W")
'2020-W04-4'
```

I still need to update the docs, let me know what you think @jadchaar.

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

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

```diff
@@          Coverage Diff          @@
##           master   #754   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           9      9           
  Lines        1669   1689   +20     
  Branches      282    286    +4     
=====================================
+ Hits         1669   1689   +20
```


| [Impacted Files](https://codecov.io/gh/crsmithdev/arrow/pull/754?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [arrow/formatter.py](https://codecov.io/gh/crsmithdev/arrow/pull/754/diff?src=pr&el=tree#diff-YXJyb3cvZm9ybWF0dGVyLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [arrow/parser.py](https://codecov.io/gh/crsmithdev/arrow/pull/754/diff?src=pr&el=tree#diff-YXJyb3cvcGFyc2VyLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/754?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/754?src=pr&el=footer). Last update [c72e885...7331137](https://codecov.io/gh/crsmithdev/arrow/pull/754?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..c360835
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,1 @@
+open_collective: arrow
diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml
new file mode 100644
index 0000000..57b6206
--- /dev/null
+++ b/.github/workflows/continuous_integration.yml
@@ -0,0 +1,102 @@
+name: tests
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+  schedule:
+    - cron: "0 0 1 * *" # run tests monthly
+
+jobs:
+  Linting:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v1
+      - name: Set up Python 3.8
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.8
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install tox
+      - name: Linting code
+        run: tox -e lint
+      - name: Linting docs
+        run: tox -e docs
+
+  Linux:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
+
+    steps:
+      - uses: actions/checkout@v1
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install tox tox-gh-actions
+      - name: Test with tox
+        run: tox
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          file: .coverage
+
+  MacOS:
+    runs-on: macos-latest
+    strategy:
+      matrix:
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
+
+    steps:
+      - uses: actions/checkout@v1
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install tox tox-gh-actions
+      - name: Test with tox
+        run: tox
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          file: .coverage
+
+  Windows:
+    runs-on: windows-latest
+    strategy:
+      matrix:
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
+
+    steps:
+      - uses: actions/checkout@v1
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install tox tox-gh-actions
+      - name: Test with tox
+        run: tox
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          file: .coverage
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4abab57..ea8b19f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ default_language_version:
     python: python3
 repos:
     - repo: https://github.com/pre-commit/pre-commit-hooks
-      rev: v2.4.0
+      rev: v2.5.0
       hooks:
           - id: trailing-whitespace
           - id: end-of-file-fixer
@@ -16,7 +16,7 @@ repos:
           - id: check-merge-conflict
           - id: debug-statements
     - repo: https://github.com/asottile/seed-isort-config
-      rev: v1.9.3
+      rev: v2.1.0
       hooks:
           - id: seed-isort-config
     - repo: https://github.com/pre-commit/mirrors-isort
@@ -24,11 +24,11 @@ repos:
       hooks:
           - id: isort
     - repo: https://github.com/asottile/pyupgrade
-      rev: v1.25.1
+      rev: v2.1.0
       hooks:
           - id: pyupgrade
     - repo: https://github.com/pre-commit/pygrep-hooks
-      rev: v1.4.2
+      rev: v1.5.1
       hooks:
           - id: python-no-eval
           - id: python-check-blanket-noqa
diff --git a/.travis.yml b/.travis.yml
index d322101..688c7ec 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -37,9 +37,6 @@ matrix:
     - name: "Linting"
       python: "3.7"
       env: TOXENV=lint,docs
-      cache:
-        directories:
-          - $HOME/.cache/pre-commit
 before_install: pip3 install --upgrade pip
 install: pip3 install -U codecov tox
 script: tox
diff --git a/Makefile b/Makefile
index f15a106..7c7f737 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 .PHONY: auto test docs clean
 
-auto: build27
+auto: build38
 
 build27: PYTHON_VER = python2.7
 build35: PYTHON_VER = python3.5
@@ -14,7 +14,7 @@ build27 build35 build36 build37 build38:
 	venv/bin/pre-commit install
 
 test:
-	rm -f .coverage coverage.xml
+	rm -f .coverage
 	. venv/bin/activate && pytest
 
 lint:
@@ -27,7 +27,7 @@ docs:
 clean:
 	rm -rf venv .tox ./**/__pycache__
 	rm -rf dist build .egg .eggs arrow.egg-info
-	rm -f ./**/*.pyc .coverage coverage.xml
+	rm -f ./**/*.pyc .coverage
 
 publish:
 	rm -rf dist build .egg .eggs arrow.egg-info
diff --git a/README.rst b/README.rst
index 2d86b39..d92180e 100644
--- a/README.rst
+++ b/README.rst
@@ -3,9 +3,9 @@ Arrow: Better dates & times for Python
 
 .. start-inclusion-marker-do-not-remove
 
-.. image:: https://travis-ci.org/crsmithdev/arrow.svg?branch=master
+.. image:: https://github.com/crsmithdev/arrow/workflows/tests/badge.svg?branch=master
    :alt: Build Status
-   :target: https://travis-ci.org/crsmithdev/arrow
+   :target: https://github.com/crsmithdev/arrow/actions?query=workflow%3Atests+branch%3Amaster
 
 .. image:: https://codecov.io/github/crsmithdev/arrow/coverage.svg?branch=master
    :alt: Codecov
diff --git a/arrow/formatter.py b/arrow/formatter.py
index 80dd2c9..d29d5e8 100644
--- a/arrow/formatter.py
+++ b/arrow/formatter.py
@@ -11,12 +11,12 @@ from arrow import locales, util
 
 class DateTimeFormatter(object):
 
-    # This pattern matches characters enclosed in square brackes are matched as
+    # This pattern matches characters enclosed in square brackets are matched as
     # an atomic group. For more info on atomic groups and how to they are
     # emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578
-    # TODO: test against full timezone DB
+
     _FORMAT_RE = re.compile(
-        r"(\[(?:(?=(?P<literal>[^]]))(?P=literal))*\]|YYY?Y?|MM?M?M?|Do|DD?D?D?|d?dd?d?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X)"
+        r"(\[(?:(?=(?P<literal>[^]]))(?P=literal))*\]|YYY?Y?|MM?M?M?|Do|DD?D?D?|d?dd?d?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X|W)"
     )
 
     def __init__(self, locale="en_us"):
@@ -116,3 +116,7 @@ class DateTimeFormatter(object):
 
         if token in ("a", "A"):
             return self.locale.meridian(dt.hour, token)
+
+        if token == "W":
+            year, week, day = dt.isocalendar()
+            return "{}-W{:02d}-{}".format(year, week, day)
diff --git a/arrow/locales.py b/arrow/locales.py
index a42947f..d850f3e 100644
--- a/arrow/locales.py
+++ b/arrow/locales.py
@@ -331,6 +331,8 @@ class ItalianLocale(Locale):
         "hours": "{0} ore",
         "day": "un giorno",
         "days": "{0} giorni",
+        "week": "una settimana,",
+        "weeks": "{0} settimane",
         "month": "un mese",
         "months": "{0} mesi",
         "year": "un anno",
diff --git a/arrow/parser.py b/arrow/parser.py
index 379c687..0f932b5 100644
--- a/arrow/parser.py
+++ b/arrow/parser.py
@@ -8,6 +8,7 @@ from dateutil import tz
 
 from arrow import locales
 from arrow.constants import MAX_TIMESTAMP, MAX_TIMESTAMP_MS, MAX_TIMESTAMP_US
+from arrow.util import iso_to_gregorian
 
 try:
     from functools import lru_cache
@@ -31,7 +32,7 @@ class ParserMatchError(ParserError):
 class DateTimeParser(object):
 
     _FORMAT_RE = re.compile(
-        r"(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|x|X)"
+        r"(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|x|X|W)"
     )
     _ESCAPE_RE = re.compile(r"\[[^\[\]]*\]")
 
@@ -49,6 +50,7 @@ class DateTimeParser(object):
     _TIMESTAMP_RE = re.compile(r"^\-?\d+\.?\d+$")
     _TIMESTAMP_EXPANDED_RE = re.compile(r"^\-?\d+$")
     _TIME_RE = re.compile(r"^(\d{2})(?:\:?(\d{2}))?(?:\:?(\d{2}))?(?:([\.\,])(\d+))?$")
+    _WEEK_DATE_RE = re.compile(r"(?P<year>\d{4})[\-]?W(?P<week>\d{2})[\-]?(?P<day>\d)?")
 
     _BASE_INPUT_RE_MAP = {
         "YYYY": _FOUR_DIGIT_RE,
@@ -73,6 +75,7 @@ class DateTimeParser(object):
         "ZZ": _TZ_ZZ_RE,
         "Z": _TZ_Z_RE,
         "S": _ONE_OR_MORE_DIGIT_RE,
+        "W": _WEEK_DATE_RE,
     }
 
     SEPARATORS = ["-", "/", "."]
@@ -147,6 +150,7 @@ class DateTimeParser(object):
             "YYYY/MM",
             "YYYY.MM",
             "YYYY",
+            "W",
         ]
 
         if has_time:
@@ -228,6 +232,8 @@ class DateTimeParser(object):
         for token in fmt_tokens:
             if token == "Do":
                 value = match.group("value")
+            elif token == "W":
+                value = (match.group("year"), match.group("week"), match.group("day"))
             else:
                 value = match.group(token)
             self._parse_token(token, value, parts)
@@ -377,9 +383,29 @@ class DateTimeParser(object):
             elif value in (self.locale.meridians["pm"], self.locale.meridians["PM"]):
                 parts["am_pm"] = "pm"
 
+        elif token == "W":
+            parts["weekdate"] = value
+
     @staticmethod
     def _build_datetime(parts):
 
+        weekdate = parts.get("weekdate")
+
+        if weekdate is not None:
+            # we can use strptime (%G, %V, %u) in python 3.6 but these tokens aren't available before that
+            year, week = int(weekdate[0]), int(weekdate[1])
+
+            if weekdate[2] is not None:
+                day = int(weekdate[2])
+            else:
+                # day not given, default to 1
+                day = 1
+
+            dt = iso_to_gregorian(year, week, day)
+            parts["year"] = dt.year
+            parts["month"] = dt.month
+            parts["day"] = dt.day
+
         timestamp = parts.get("timestamp")
 
         if timestamp is not None:
diff --git a/docs/index.rst b/docs/index.rst
index c310f05..f065300 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -332,7 +332,7 @@ Then get and use a factory for it:
 Supported Tokens
 ~~~~~~~~~~~~~~~~
 
-Use the following tokens in parsing and formatting.  Note that they're not the same as the tokens for `strptime(3) <https://www.gnu.org/software/libc/manual/html_node/Low_002dLevel-Time-String-Parsing.html#index-strptime>`_:
+Use the following tokens for parsing and formatting. Note that they are **not** the same as the tokens for `strptime <https://linux.die.net/man/3/strptime>`_:
 
 +--------------------------------+--------------+-------------------------------------------+
 |                                |Token         |Output                                     |
@@ -365,6 +365,8 @@ Use the following tokens in parsing and formatting.  Note that they're not the s
 +--------------------------------+--------------+-------------------------------------------+
 |                                |d             |1, 2, 3 ... 6, 7                           |
 +--------------------------------+--------------+-------------------------------------------+
+|**ISO week date**               |W             |2011-W05-4, 2019-W17                       |
++--------------------------------+--------------+-------------------------------------------+
 |**Hour**                        |HH            |00, 01, 02 ... 23, 24                      |
 +--------------------------------+--------------+-------------------------------------------+
 |                                |H             |0, 1, 2 ... 23, 24                         |
diff --git a/requirements.txt b/requirements.txt
index e1d16ca..dd2046d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,13 @@
 backports.functools_lru_cache==1.6.1; python_version == "2.7"
 dateparser==0.7.*
-pre-commit==1.20.*
+pre-commit==1.20.*; python_version <= "3.5"
+pre-commit==2.1.*; python_version >= "3.6"
 pytest==4.6.*; python_version == "2.7"
 pytest==5.3.*; python_version >= "3.5"
 pytest-cov==2.8.*
 pytest-mock==2.0.*
 python-dateutil==2.8.*
 pytz==2019.*
-simplejson==3.16.*
+simplejson==3.17.*
 sphinx==1.8.*; python_version == "2.7"
-sphinx==2.2.*; python_version >= "3.5"
+sphinx==2.4.*; python_version >= "3.5"
diff --git a/setup.cfg b/setup.cfg
index f8ea7e0..039cd09 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [tool:pytest]
-addopts = -v --cov-branch --cov=arrow tests --cov-fail-under=100 --cov-report=term-missing --cov-report=xml
+addopts = -v --cov-branch --cov=arrow tests --cov-fail-under=100 --cov-report=term-missing
 
 [tool:isort]
 line_length = 88


Test patch:
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index dbf2873..5ae484d 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -141,6 +141,14 @@ class TestDateTimeFormatterFormatToken:
         assert self.formatter._format_token(dt, "a") == "pm"
         assert self.formatter._format_token(dt, "A") == "PM"
 
+    def test_week(self):
+        dt = datetime(2017, 5, 19)
+        assert self.formatter._format_token(dt, "W") == "2017-W20-5"
+
+        # make sure week is zero padded when needed
+        dt_early = datetime(2011, 1, 20)
+        assert self.formatter._format_token(dt_early, "W") == "2011-W03-4"
+
     def test_nonsense(self):
         dt = datetime(2012, 1, 1, 11)
         assert self.formatter._format_token(dt, None) is None
@@ -193,5 +201,5 @@ class TestDateTimeFormatterFormatToken:
             == "Dec 31, 2017 |^${}().*+?<>-& 2:00 AM"
         )
 
-        # Escaping is atomic: brackets inside brackets are treated litterally
+        # Escaping is atomic: brackets inside brackets are treated literally
         assert self.formatter.format(datetime(1, 1, 1), "[[[ ]]") == "[[ ]"
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 039eb4c..3c7ee28 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -621,6 +621,43 @@ class TestDateTimeParserParse:
         with pytest.raises(ParserError):
             self.parser.parse("2019-12-31T24:00:00.999999", "YYYY-MM-DDTHH:mm:ss.S")
 
+    def test_parse_W(self):
+
+        assert self.parser.parse("2011-W05-4", "W") == datetime(2011, 2, 3)
+        assert self.parser.parse("2011W054", "W") == datetime(2011, 2, 3)
+        assert self.parser.parse("2011-W05", "W") == datetime(2011, 1, 31)
+        assert self.parser.parse("2011W05", "W") == datetime(2011, 1, 31)
+        assert self.parser.parse("2011-W05-4T14:17:01", "WTHH:mm:ss") == datetime(
+            2011, 2, 3, 14, 17, 1
+        )
+        assert self.parser.parse("2011W054T14:17:01", "WTHH:mm:ss") == datetime(
+            2011, 2, 3, 14, 17, 1
+        )
+        assert self.parser.parse("2011-W05T14:17:01", "WTHH:mm:ss") == datetime(
+            2011, 1, 31, 14, 17, 1
+        )
+        assert self.parser.parse("2011W05T141701", "WTHHmmss") == datetime(
+            2011, 1, 31, 14, 17, 1
+        )
+        assert self.parser.parse("2011W054T141701", "WTHHmmss") == datetime(
+            2011, 2, 3, 14, 17, 1
+        )
+
+        bad_formats = [
+            "201W22",
+            "1995-W1-4",
+            "2001-W34-90",
+            "2001--W34",
+            "2011-W03--3",
+            "thstrdjtrsrd676776r65",
+            "2002-W66-1T14:17:01",
+            "2002-W23-03T14:17:01",
+        ]
+
+        for fmt in bad_formats:
+            with pytest.raises(ParserError):
+                self.parser.parse(fmt, "W")
+
 
 class TestDateTimeParserRegex:
     @classmethod
@@ -964,6 +1001,20 @@ class TestDateTimeParserISO:
             2013, 2, 3, 4, 5, 6, 789120, tzinfo=tz.tzutc()
         )
 
+    def test_W(self):
+
+        assert self.parser.parse_iso("2011-W05-4") == datetime(2011, 2, 3)
+
+        assert self.parser.parse_iso("2011-W05-4T14:17:01") == datetime(
+            2011, 2, 3, 14, 17, 1
+        )
+
+        assert self.parser.parse_iso("2011W054") == datetime(2011, 2, 3)
+
+        assert self.parser.parse_iso("2011W054T141701") == datetime(
+            2011, 2, 3, 14, 17, 1
+        )
+
     def test_invalid_Z(self):
 
         with pytest.raises(ParserError):