Repository: r1chardj0n3s/parse

Issue:
strftime formatting
Thanks for all the hard work on this package!

I'd like to make a request, similar to #62 but maybe a bit simpler. I'd like to be able to specify how I would like a datetime to be parsed. For example:

```python
from parse import parse
parse("my date:{my_date:%Y%M%d}", "my date:20200403").groups = {"my_date": datetime.datetime(2020, 4, 3)}
```

You'd need to construct a regex expression from a date format:

```python
dt_format_to_regex = {symbol: "[0-9]{2}" for symbol in "ymdIMSUW"}
dt_format_to_regex.update({"-" + symbol: "[0-9]{1,2}" for symbol in "ymdIMS"})

dt_format_to_regex.update(
    {
        "a": "[Sun|Mon|Tue|Wed|Thu|Fri|Sat]",
        "A": "[Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday]",
        "Y": "[0-9]{4}",
        "H": "[0-9]{1,2}",
        "B": "[January|February|March|April|May|June|July|August|September|October|November|December]",
        "b": "[Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec]",
        "f": "[0-9]{6}",
        "p": "[AM|PM]",
        "z": "[+|-][0-9]{4}",
        "j": "[0-9]{3}",
        "-j": "[0-9]{1,3}",
    }
)


def get_regex_for_datetime_format(format_):
    regex = copy(format_)
    for k, v in dt_format_to_regex.items():
        regex = regex.replace(f"%{k}", v)
    return regex
```

and you'd need to parse the string into a datetime, which is actually quite easy because you can just rely on `datetime.datetime.strptime`:

```python
self._type_conversions[group] = lambda x: datetime.strptime(x, format)
```

I guess the remaining bit would be to determine if the formatting is of a datetime, but that doesn't seem that hard to me. As a first pass, you could just look for "%Y" in the format string. Technically `datetime.datetime.strptime` does allow you to parse datestrings that do not have a year, but that results are non-sensical, so it would be fine to not support them:

```python
print(datetime.datetime.strptime("Sun", "%a"))
print(datetime.datetime.strptime("Mon", "%a"))
```
```
datetime.datetime(1900, 1, 1, 0, 0)
datetime.datetime(1900, 1, 1, 0, 0)
```

Discussion:
wimglenn: No f-string please
bendichter: @wimglenn done.
wimglenn: Thanks. I just blackened the code in https://github.com/r1chardj0n3s/parse/pull/166 which made some conflicts for you, sorry about that
bendichter: @wimglenn fixed

Patch:
diff --git a/README.rst b/README.rst
index cb70aac..9b6b9d9 100644
--- a/README.rst
+++ b/README.rst
@@ -206,6 +206,21 @@ tt    Time                                        time
       e.g. 10:21:36 PM -5:30
 ===== =========================================== ========
 
+The type can also be a datetime format string, following the
+`1989 C standard format codes`_, e.g. ``%Y-%m-%d``. Depending on the
+directives contained in the format string, parsed output may be an instance
+``datetime.datetime``, ``datetime.time``, or ``datetime.date``.
+
+.. code-block:: pycon
+
+    >>> parse("{:%Y-%m-%d %H:%M:%S}", "2023-11-23 12:56:47")
+    <Result (datetime.datetime(2023, 11, 23, 12, 56, 47),) {}>
+    >>> parse("{:%H:%M}", "10:26")
+    <Result (datetime.time(10, 26),) {}>
+    >>> parse("{:%Y/%m/%d}", "2023/11/25")
+    <Result (datetime.date(2023, 11, 25),) {}>
+
+
 Some examples of typed parsing with ``None`` returned if the typing
 does not match:
 
@@ -244,7 +259,7 @@ a maximum. For example:
     >>> parse('{:2d}{:2d}', '0440')           # parsing two contiguous numbers
     <Result (4, 40) {}>
 
-Some notes for the date and time types:
+Some notes for the special date and time types:
 
 - the presence of the time part is optional (including ISO 8601, starting
   at the "T"). A full datetime object will always be returned; the time
@@ -277,6 +292,9 @@ that this limit will be removed one day.
   https://docs.python.org/3/library/string.html#format-string-syntax
 .. _`Format Specification Mini-Language`:
   https://docs.python.org/3/library/string.html#format-specification-mini-language
+.. _`1989 C standard format codes`:
+  https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
+
 
 
 Result and Match Objects
@@ -423,6 +441,7 @@ To run the tests locally:
 Changelog
 ---------
 
+- 1.20.0 Added support for strptime codes (thanks @bendichter)
 - 1.19.1 Added support for sign specifiers in number formats (thanks @anntzer)
 - 1.19.0 Added slice access to fixed results (thanks @jonathangjertsen).
   Also corrected matching of *full string* vs. *full line* (thanks @giladreti)
diff --git a/parse.py b/parse.py
index 33462f8..1869319 100644
--- a/parse.py
+++ b/parse.py
@@ -1,6 +1,6 @@
 from __future__ import absolute_import
 
-__version__ = "1.19.1"
+__version__ = "1.20.0"
 
 # yes, I now have two problems
 import re
@@ -135,7 +135,7 @@ class FixedTzOffset(tzinfo):
 
     def __eq__(self, other):
         if not isinstance(other, FixedTzOffset):
-            return False
+            return NotImplemented
         return self._name == other._name and self._offset == other._offset
 
 
@@ -271,6 +271,67 @@ def date_convert(
     return d
 
 
+def strf_date_convert(x, _, type):
+
+    is_date = any("%" + x in type for x in "aAwdbBmyYjUW")
+    is_time = any("%" + x in type for x in "HIpMSfz")
+
+    dt = datetime.strptime(x, type)
+    if "%y" not in type and "%Y" not in type:  # year not specified
+        dt = dt.replace(year=datetime.today().year)
+
+    if is_date and is_time:
+        return dt
+    elif is_date:
+        return dt.date()
+    elif is_time:
+        return dt.time()
+    else:
+        ValueError("Datetime not a date nor a time?")
+
+
+# ref: https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
+dt_format_to_regex = {
+    "%a": "(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)",
+    "%A": "(?:Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)",
+    "%w": "[0-6]",
+    "%d": "[0-9]{1,2}",
+    "%b": "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)",
+    "%B": "(?:January|February|March|April|May|June|July|August|September|October|November|December)",
+    "%m": "[0-9]{1,2}",
+    "%y": "[0-9]{2}",
+    "%Y": "[0-9]{4}",
+    "%H": "[0-9]{1,2}",
+    "%I": "[0-9]{1,2}",
+    "%p": "(?:AM|PM)",
+    "%M": "[0-9]{2}",
+    "%S": "[0-9]{2}",
+    "%f": "[0-9]{6}",
+    "%z": "[+|-][0-9]{2}(:?[0-9]{2})?(:?[0-9]{2})?",
+    # "%Z": punt
+    "%j": "[0-9]{1,3}",
+    "%U": "[0-9]{1,2}",
+    "%W": "[0-9]{1,2}",
+}
+
+# Compile a regular expression pattern that matches any date/time format symbol.
+dt_format_symbols_re = re.compile("|".join(dt_format_to_regex))
+
+
+def get_regex_for_datetime_format(format_):
+    """
+    Generate a regex pattern for a given datetime format string.
+
+    Parameters:
+        format_ (str): The datetime format string.
+
+    Returns:
+        str: A regex pattern corresponding to the datetime format string.
+    """
+    # Replace all format symbols with their regex patterns.
+    return dt_format_symbols_re.sub(lambda m: dt_format_to_regex[m.group(0)], format_)
+
+
 class TooManyFields(ValueError):
     pass
 
@@ -326,7 +387,12 @@ def extract_format(format, extra_types):
 
     # the rest is the type, if present
     type = format
-    if type and type not in ALLOWED_TYPES and type not in extra_types:
+    if (
+        type
+        and type not in ALLOWED_TYPES
+        and type not in extra_types
+        and not any(k in type for k in dt_format_to_regex)
+    ):
         raise ValueError("format spec %r not recognised" % type)
 
     return locals()
@@ -576,7 +642,7 @@ class Parser(object):
         format = ""
 
         if ":" in field:
-            name, format = field.split(":")
+            name, format = field.split(":", 1)
         else:
             name = field
 
@@ -617,6 +683,7 @@ class Parser(object):
         # figure type conversions, if any
         type = format["type"]
         is_numeric = type and type in "n%fegdobx"
+        conv = self._type_conversions
         if type in self._extra_types:
             type_converter = self._extra_types[type]
             s = getattr(type_converter, "pattern", r".+?")
@@ -624,40 +691,40 @@ class Parser(object):
             if regex_group_count is None:
                 regex_group_count = 0
             self._group_index += regex_group_count
-            self._type_conversions[group] = convert_first(type_converter)
+            conv[group] = convert_first(type_converter)
         elif type == "n":
             s = r"\d{1,3}([,.]\d{3})*"
             self._group_index += 1
-            self._type_conversions[group] = int_convert(10)
+            conv[group] = int_convert(10)
         elif type == "b":
             s = r"(0[bB])?[01]+"
-            self._type_conversions[group] = int_convert(2)
+            conv[group] = int_convert(2)
             self._group_index += 1
         elif type == "o":
             s = r"(0[oO])?[0-7]+"
-            self._type_conversions[group] = int_convert(8)
+            conv[group] = int_convert(8)
             self._group_index += 1
         elif type == "x":
             s = r"(0[xX])?[0-9a-fA-F]+"
-            self._type_conversions[group] = int_convert(16)
+            conv[group] = int_convert(16)
             self._group_index += 1
         elif type == "%":
             s = r"\d+(\.\d+)?%"
             self._group_index += 1
-            self._type_conversions[group] = percentage
+            conv[group] = percentage
         elif type == "f":
             s = r"\d*\.\d+"
-            self._type_conversions[group] = convert_first(float)
+            conv[group] = convert_first(float)
         elif type == "F":
             s = r"\d*\.\d+"
-            self._type_conversions[group] = convert_first(Decimal)
+            conv[group] = convert_first(Decimal)
         elif type == "e":
             s = r"\d*\.\d+[eE][-+]?\d+|nan|NAN|[-+]?inf|[-+]?INF"
-            self._type_conversions[group] = convert_first(float)
+            conv[group] = convert_first(float)
         elif type == "g":
             s = r"\d+(\.\d+)?([eE][-+]?\d+)?|nan|NAN|[-+]?inf|[-+]?INF"
             self._group_index += 2
-            self._type_conversions[group] = convert_first(float)
+            conv[group] = convert_first(float)
         elif type == "d":
             if format.get("width"):
                 width = r"{1,%s}" % int(format["width"])
@@ -666,85 +733,60 @@ class Parser(object):
             s = r"\d{w}|[-+ ]?0[xX][0-9a-fA-F]{w}|[-+ ]?0[bB][01]{w}|[-+ ]?0[oO][0-7]{w}".format(
                 w=width
             )
-            self._type_conversions[
-                group
-            ] = int_convert()  # do not specify number base, determine it automatically
+            conv[group] = int_convert()
+            # do not specify number base, determine it automatically
+        elif any(k in type for k in dt_format_to_regex):
+            s = get_regex_for_datetime_format(type)
+            conv[group] = partial(strf_date_convert, type=type)
         elif type == "ti":
             s = r"(\d{4}-\d\d-\d\d)((\s+|T)%s)?(Z|\s*[-+]\d\d:?\d\d)?" % TIME_PAT
             n = self._group_index
-            self._type_conversions[group] = partial(
-                date_convert, ymd=n + 1, hms=n + 4, tz=n + 7
-            )
+            conv[group] = partial(date_convert, ymd=n + 1, hms=n + 4, tz=n + 7)
             self._group_index += 7
         elif type == "tg":
-            s = r"(\d{1,2}[-/](\d{1,2}|%s)[-/]\d{4})(\s+%s)?%s?%s?" % (
-                ALL_MONTHS_PAT,
-                TIME_PAT,
-                AM_PAT,
-                TZ_PAT,
-            )
+            s = r"(\d{1,2}[-/](\d{1,2}|%s)[-/]\d{4})(\s+%s)?%s?%s?"
+            s %= (ALL_MONTHS_PAT, TIME_PAT, AM_PAT, TZ_PAT)
             n = self._group_index
-            self._type_conversions[group] = partial(
+            conv[group] = partial(
                 date_convert, dmy=n + 1, hms=n + 5, am=n + 8, tz=n + 9
             )
             self._group_index += 9
         elif type == "ta":
-            s = r"((\d{1,2}|%s)[-/]\d{1,2}[-/]\d{4})(\s+%s)?%s?%s?" % (
-                ALL_MONTHS_PAT,
-                TIME_PAT,
-                AM_PAT,
-                TZ_PAT,
-            )
+            s = r"((\d{1,2}|%s)[-/]\d{1,2}[-/]\d{4})(\s+%s)?%s?%s?"
+            s %= (ALL_MONTHS_PAT, TIME_PAT, AM_PAT, TZ_PAT)
             n = self._group_index
-            self._type_conversions[group] = partial(
+            conv[group] = partial(
                 date_convert, mdy=n + 1, hms=n + 5, am=n + 8, tz=n + 9
             )
             self._group_index += 9
         elif type == "te":
             # this will allow microseconds through if they're present, but meh
-            s = r"(%s,\s+)?(\d{1,2}\s+%s\s+\d{4})\s+%s%s" % (
-                DAYS_PAT,
-                MONTHS_PAT,
-                TIME_PAT,
-                TZ_PAT,
-            )
+            s = r"(%s,\s+)?(\d{1,2}\s+%s\s+\d{4})\s+%s%s"
+            s %= (DAYS_PAT, MONTHS_PAT, TIME_PAT, TZ_PAT)
             n = self._group_index
-            self._type_conversions[group] = partial(
-                date_convert, dmy=n + 3, hms=n + 5, tz=n + 8
-            )
+            conv[group] = partial(date_convert, dmy=n + 3, hms=n + 5, tz=n + 8)
             self._group_index += 8
         elif type == "th":
             # slight flexibility here from the stock Apache format
             s = r"(\d{1,2}[-/]%s[-/]\d{4}):%s%s" % (MONTHS_PAT, TIME_PAT, TZ_PAT)
             n = self._group_index
-            self._type_conversions[group] = partial(
-                date_convert, dmy=n + 1, hms=n + 3, tz=n + 6
-            )
+            conv[group] = partial(date_convert, dmy=n + 1, hms=n + 3, tz=n + 6)
             self._group_index += 6
         elif type == "tc":
-            s = r"(%s)\s+%s\s+(\d{1,2})\s+%s\s+(\d{4})" % (
-                DAYS_PAT,
-                MONTHS_PAT,
-                TIME_PAT,
-            )
+            s = r"(%s)\s+%s\s+(\d{1,2})\s+%s\s+(\d{4})"
+            s %= (DAYS_PAT, MONTHS_PAT, TIME_PAT)
             n = self._group_index
-            self._type_conversions[group] = partial(
-                date_convert, d_m_y=(n + 4, n + 3, n + 8), hms=n + 5
-            )
+            conv[group] = partial(date_convert, d_m_y=(n + 4, n + 3, n + 8), hms=n + 5)
             self._group_index += 8
         elif type == "tt":
             s = r"%s?%s?%s?" % (TIME_PAT, AM_PAT, TZ_PAT)
             n = self._group_index
-            self._type_conversions[group] = partial(
-                date_convert, hms=n + 1, am=n + 4, tz=n + 5
-            )
+            conv[group] = partial(date_convert, hms=n + 1, am=n + 4, tz=n + 5)
             self._group_index += 5
         elif type == "ts":
             s = r"%s(\s+)(\d+)(\s+)(\d{1,2}:\d{1,2}:\d{1,2})?" % MONTHS_PAT
             n = self._group_index
-            self._type_conversions[group] = partial(
-                date_convert, mm=n + 1, dd=n + 3, hms=n + 5
-            )
+            conv[group] = partial(date_convert, mm=n + 1, dd=n + 3, hms=n + 5)
             self._group_index += 5
         elif type == "l":
             s = r"[A-Za-z]+"


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f4643cc..3353d72 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -31,9 +31,9 @@ jobs:
           pip install -r tests/requirements.txt --editable .
           pytest
       - name: Upload coverage to Codecov
-        uses: "codecov/codecov-action@main"
-        env:
-          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+        uses: "codecov/codecov-action@v3"
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
 
   tests-27:
     name: "Python 2.7 on ubuntu-20.04"
@@ -47,6 +47,6 @@ jobs:
           pip install -r tests/requirements.txt
           python -m pytest
       - name: Upload coverage to Codecov
-        uses: "codecov/codecov-action@main"
-        env:
-          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+        uses: "codecov/codecov-action@v3"
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/tests/test_parse.py b/tests/test_parse.py
index 84071c6..3c5035a 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -4,9 +4,9 @@
 This code is copyright 2011 eKit.com Inc (http://www.ekit.com/)
 See the end of the source file for the license of use.
 """
-
+import sys
 import unittest
-from datetime import datetime, time
+from datetime import datetime, time, date
 from decimal import Decimal
 import pickle
 import re
@@ -444,6 +444,78 @@ class TestParse(unittest.TestCase):
         self.assertEqual(r[0], datetime(1997, 7, 16))
         self.assertEqual(r[1], datetime(2012, 8, 1))
 
+    def test_flexible_datetimes(self):
+        r = parse.parse("a {:%Y-%m-%d} b", "a 1997-07-16 b")
+        self.assertEqual(len(r.fixed), 1)
+        self.assertEqual(r[0], date(1997, 7, 16))
+
+        r = parse.parse("a {:%Y-%b-%d} b", "a 1997-Feb-16 b")
+        self.assertEqual(len(r.fixed), 1)
+        self.assertEqual(r[0], date(1997, 2, 16))
+
+        r = parse.parse("a {:%Y-%b-%d} {:d} b", "a 1997-Feb-16 8 b")
+        self.assertEqual(len(r.fixed), 2)
+        self.assertEqual(r[0], date(1997, 2, 16))
+
+        r = parse.parse("a {my_date:%Y-%b-%d} {num:d} b", "a 1997-Feb-16 8 b")
+        self.assertEqual((r.named["my_date"]), date(1997, 2, 16))
+        self.assertEqual((r.named["num"]), 8)
+
+        r = parse.parse("a {:%Y-%B-%d} b", "a 1997-February-16 b")
+        self.assertEqual(r[0], date(1997, 2, 16))
+
+        r = parse.parse("a {:%Y%m%d} b", "a 19970716 b")
+        self.assertEqual(r[0], date(1997, 7, 16))
+
+    def test_flexible_datetime_with_colon(self):
+        r = parse.parse("{dt:%Y-%m-%d %H:%M:%S}", "2023-11-21 13:23:27")
+        self.assertEqual(r.named["dt"], datetime(2023, 11, 21, 13, 23, 27))
+
+    @unittest.skipIf(sys.version_info[0] < 3, "Python 3+ required for timezone support")
+    def test_flexible_datetime_with_timezone(self):
+        from datetime import timezone
+
+        r = parse.parse("{dt:%Y-%m-%d %H:%M:%S %z}", "2023-11-21 13:23:27 +0000")
+        self.assertEqual(
+            r.named["dt"], datetime(2023, 11, 21, 13, 23, 27, tzinfo=timezone.utc)
+        )
+
+    @unittest.skipIf(sys.version_info[0] < 3, "Python 3+ required for timezone support")
+    def test_flexible_datetime_with_timezone_that_has_colons(self):
+        from datetime import timezone
+
+        r = parse.parse("{dt:%Y-%m-%d %H:%M:%S %z}", "2023-11-21 13:23:27 +00:00:00")
+        self.assertEqual(
+            r.named["dt"], datetime(2023, 11, 21, 13, 23, 27, tzinfo=timezone.utc)
+        )
+
+    def test_flexible_time(self):
+        r = parse.parse("a {time:%H:%M:%S} b", "a 13:23:27 b")
+        self.assertEqual(r.named["time"], time(13, 23, 27))
+
+    def test_flexible_time_no_hour(self):
+        r = parse.parse("a {time:%M:%S} b", "a 23:27 b")
+        self.assertEqual(r.named["time"], time(0, 23, 27))
+
+    def test_flexible_time_ms(self):
+        r = parse.parse("a {time:%M:%S:%f} b", "a 23:27:123456 b")
+        self.assertEqual(r.named["time"], time(0, 23, 27, 123456))
+
+    def test_flexible_dates_single_digit(self):
+        r = parse.parse("{dt:%Y/%m/%d}", "2023/1/1")
+        self.assertEqual(r.named["dt"], date(2023, 1, 1))
+
+    def test_flexible_dates_j(self):
+        r = parse.parse("{dt:%Y/%j}", "2023/9")
+        self.assertEqual(r.named["dt"], date(2023, 1, 9))
+
+        r = parse.parse("{dt:%Y/%j}", "2023/009")
+        self.assertEqual(r.named["dt"], date(2023, 1, 9))
+
+    def test_flexible_dates_year_current_year_inferred(self):
+        r = parse.parse("{dt:%j}", "9")
+        self.assertEqual(r.named["dt"], date(datetime.today().year, 1, 9))
+
     def test_datetimes(self):
         def y(fmt, s, e, tz=None):
             p = parse.compile(fmt)
@@ -1098,6 +1170,14 @@ class TestParseType(unittest.TestCase):
         self.assertEqual(parser.parse("0b1011")[0], 0b1011)
 
 
+def test_strftime_strptime_roundtrip():
+    dt = datetime.now()
+    fmt = "_".join([k for k in parse.dt_format_to_regex if k != "%z"])
+    s = dt.strftime(fmt)
+    [res] = parse.parse("{:" + fmt + "}", s)
+    assert res == dt
+
+
 if __name__ == "__main__":
     unittest.main()