Repository: python-babel/babel

Issue:
Parsing time and date must be enhanced
# Overview Description

parse_time can not handle missing day times or 12 hour clock as well. 

Both parse_time and parse_date raise IndexError when date/time is not well format, making error handling hard
# Steps to Reproduce
1. All of above time should be parsed:

``` python
    assert dates.parse_time('15:30', locale='en_US') == time(15, 30)
    assert dates.parse_time('3:30', locale='en_US') == time(3, 30)
    assert dates.parse_time('00:30', locale='en_US') == time(0, 30)
    assert dates.parse_time('03:30 PM', locale='en_US') == time(15, 30)
    assert dates.parse_time('03:30 pM', locale='en_US') == time(15, 30)
    assert dates.parse_time('03:30 pm', locale='en_US') == time(15, 30)
    assert dates.parse_time('03:30 Pm', locale='en_US') == time(15, 30)
    assert dates.parse_time('03:30:21 AM', locale='en_US') == time(3, 30, 21)
    assert dates.parse_time('03:30:00 AM', locale='en_US') == time(3, 30)
    assert dates.parse_time('03:30:00 AM', locale='en_US') == time(3, 30)
```
1. Try parsing invalid time: ('', 'a', 'aaa', '15', '15:')
2. Try parsing invalid date: ('', 'a', 'aaa', '15', '15/15/')
# Actual Results
1. 

``` python
>>> from babel import dates
>>> dates.parse_time('15:30', locale='en_US')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1186, in parse_time
    second = int(numbers[indexes['S']])
IndexError: list index out of range
>>> dates.parse_time('03:30:21 AM', locale='en_US')
datetime.time(3, 30, 21)
>>> dates.parse_time('03:30 Pm', locale='en_US')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1186, in parse_time
    second = int(numbers[indexes['S']])
IndexError: list index out of range
>>> dates.parse_time('')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1184, in parse_time
    hour = int(numbers[indexes['H']])
IndexError: list index out of range
>>> dates.parse_time('a')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1184, in parse_time
    hour = int(numbers[indexes['H']])
IndexError: list index out of range
>>> dates.parse_time('aa')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1184, in parse_time
    hour = int(numbers[indexes['H']])
IndexError: list index out of range
>>> dates.parse_time('15:')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1185, in parse_time
    minute = int(numbers[indexes['M']])
IndexError: list index out of range
>>> dates.parse_date('15/15/15')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1150, in parse_date
    return date(year, month, day)
ValueError: month must be in 1..12
>>> dates.parse_date('15/15/')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1141, in parse_date
    year = numbers[indexes['Y']]
IndexError: list index out of range
>>> dates.parse_date('15/')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1141, in parse_date
    year = numbers[indexes['Y']]
IndexError: list index out of range
>>> dates.parse_date('')

Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1141, in parse_date
    year = numbers[indexes['Y']]
IndexError: list index out of range
```
# Expected Results
1. 

``` python
>>> from babel import dates
>>> dates.parse_time('15:30', locale='en_US')
datetime.time(15, 30)
>>> dates.parse_time('03:30:21 AM', locale='en_US')
datetime.time(3, 30, 21)
>>> dates.parse_time('03:30 Pm', locale='en_US')
datetime.time(15, 30)
>>> dates.parse_time('')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1201, in parse_time
    raise ParseTimeException('At least hour and minute should be present')
ParseTimeException: At least hour and minute should be present
>>> dates.parse_time('a')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1201, in parse_time
    raise ParseTimeException('At least hour and minute should be present')
ParseTimeException: At least hour and minute should be present
>>> dates.parse_time('aa')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1201, in parse_time
    raise ParseTimeException('At least hour and minute should be present')
ParseTimeException: At least hour and minute should be present
>>> dates.parse_time('15:')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1201, in parse_time
    raise ParseTimeException('At least hour and minute should be present')
ParseTimeException: At least hour and minute should be present
>>> dates.parse_date('15/15/15')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1178, in parse_date
    return date(year, month, day)
ValueError: month must be in 1..12
>>> dates.parse_date('15/15/')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1155, in parse_date
    raise ParseDateException('Year not present')
ParseDateException: Year not present
>>> dates.parse_date('15/')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1155, in parse_date
    raise ParseDateException('Year not present')
ParseDateException: Year not present
>>> dates.parse_date('')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Users/renzo/PycharmProjects/babel/babel/dates.py", line 1155, in parse_date
    raise ParseDateException('Year not present')
ParseDateException: Year not present

```
# Reproducibility

Always
# Additional Information:

No


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) Report
> Merging [#834](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) (f420e96) into [master](https://codecov.io/gh/python-babel/babel/commit/d3cea2adda7688e56a3cbfbfe98f8003a299a56e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) (d3cea2a) will **increase** coverage by `0.02%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/python-babel/babel/pull/834/graphs/tree.svg?width=650&height=150&src=pr&token=lEYaWCjCnu&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel)](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel)

```diff
@@            Coverage Diff             @@
##           master     #834      +/-   ##
==========================================
+ Coverage   91.09%   91.12%   +0.02%     
==========================================
  Files          23       23              
  Lines        4123     4136      +13     
==========================================
+ Hits         3756     3769      +13     
  Misses        367      367              
```


| [Impacted Files](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | Coverage Δ | |
|---|---|---|
| [babel/dates.py](https://codecov.io/gh/python-babel/babel/pull/834/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel#diff-YmFiZWwvZGF0ZXMucHk=) | `92.55% <100.00%> (+0.14%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel). Last update [d3cea2a...f420e96](https://codecov.io/gh/python-babel/babel/pull/834?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).


Patch:
diff --git a/babel/dates.py b/babel/dates.py
index f94c60a..1323503 100644
--- a/babel/dates.py
+++ b/babel/dates.py
@@ -1138,7 +1138,11 @@ def get_period_id(time, tzinfo=None, type=None, locale=LC_TIME):
         return "pm"
 
 
-def parse_date(string, locale=LC_TIME):
+class ParseError(ValueError):
+    pass
+
+
+def parse_date(string, locale=LC_TIME, format='medium'):
     """Parse a date from a string.
 
     This function uses the date format for the locale as a hint to determine
@@ -1151,14 +1155,19 @@ def parse_date(string, locale=LC_TIME):
 
     :param string: the string containing the date
     :param locale: a `Locale` object or a locale identifier
+    :param format: the format to use (see ``get_date_format``)
     """
+    numbers = re.findall(r'(\d+)', string)
+    if not numbers:
+        raise ParseError("No numbers were found in input")
+
     # TODO: try ISO format first?
-    format = get_date_format(locale=locale).pattern.lower()
-    year_idx = format.index('y')
-    month_idx = format.index('m')
+    format_str = get_date_format(format=format, locale=locale).pattern.lower()
+    year_idx = format_str.index('y')
+    month_idx = format_str.index('m')
     if month_idx < 0:
-        month_idx = format.index('l')
-    day_idx = format.index('d')
+        month_idx = format_str.index('l')
+    day_idx = format_str.index('d')
 
     indexes = [(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')]
     indexes.sort()
@@ -1167,7 +1176,6 @@ def parse_date(string, locale=LC_TIME):
     # FIXME: this currently only supports numbers, but should also support month
     #        names, both in the requested locale, and english
 
-    numbers = re.findall(r'(\d+)', string)
     year = numbers[indexes['Y']]
     if len(year) == 2:
         year = 2000 + int(year)
@@ -1180,7 +1188,7 @@ def parse_date(string, locale=LC_TIME):
     return date(year, month, day)
 
 
-def parse_time(string, locale=LC_TIME):
+def parse_time(string, locale=LC_TIME, format='medium'):
     """Parse a time from a string.
 
     This function uses the time format for the locale as a hint to determine
@@ -1191,29 +1199,42 @@ def parse_time(string, locale=LC_TIME):
 
     :param string: the string containing the time
     :param locale: a `Locale` object or a locale identifier
+    :param format: the format to use (see ``get_time_format``)
     :return: the parsed time
     :rtype: `time`
     """
+    numbers = re.findall(r'(\d+)', string)
+    if not numbers:
+        raise ParseError("No numbers were found in input")
+
     # TODO: try ISO format first?
-    format = get_time_format(locale=locale).pattern.lower()
-    hour_idx = format.index('h')
+    format_str = get_time_format(format=format, locale=locale).pattern.lower()
+    hour_idx = format_str.index('h')
     if hour_idx < 0:
-        hour_idx = format.index('k')
-    min_idx = format.index('m')
-    sec_idx = format.index('s')
+        hour_idx = format_str.index('k')
+    min_idx = format_str.index('m')
+    sec_idx = format_str.index('s')
 
     indexes = [(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')]
     indexes.sort()
     indexes = dict([(item[1], idx) for idx, item in enumerate(indexes)])
 
-    # FIXME: support 12 hour clock, and 0-based hour specification
-    #        and seconds should be optional, maybe minutes too
-    #        oh, and time-zones, of course
-
-    numbers = re.findall(r'(\d+)', string)
-    hour = int(numbers[indexes['H']])
-    minute = int(numbers[indexes['M']])
-    second = int(numbers[indexes['S']])
+    # TODO: support time zones
+
+    # Check if the format specifies a period to be used;
+    # if it does, look for 'pm' to figure out an offset.
+    hour_offset = 0
+    if 'a' in format_str:
+        if 'pm' in string.lower():
+            hour_offset = 12
+
+    # Parse up to three numbers from the string.
+    minute = second = 0
+    hour = int(numbers[indexes['H']]) + hour_offset
+    if len(numbers) > 1:
+        minute = int(numbers[indexes['M']])
+        if len(numbers) > 2:
+            second = int(numbers[indexes['S']])
     return time(hour, minute, second)
 
 


Test patch:
diff --git a/tests/test_dates.py b/tests/test_dates.py
index d11758c..2a883ab 100644
--- a/tests/test_dates.py
+++ b/tests/test_dates.py
@@ -775,10 +775,37 @@ def test_format_timedelta():
 def test_parse_date():
     assert dates.parse_date('4/1/04', locale='en_US') == date(2004, 4, 1)
     assert dates.parse_date('01.04.2004', locale='de_DE') == date(2004, 4, 1)
-
-
-def test_parse_time():
-    assert dates.parse_time('15:30:00', locale='en_US') == time(15, 30)
+    assert dates.parse_date('2004-04-01', locale='sv_SE', format='short') == date(2004, 4, 1)
+
+
+@pytest.mark.parametrize('input, expected', [
+    # base case, fully qualified time
+    ('15:30:00', time(15, 30)),
+    # test digits
+    ('15:30', time(15, 30)),
+    ('3:30', time(3, 30)),
+    ('00:30', time(0, 30)),
+    # test am parsing
+    ('03:30 am', time(3, 30)),
+    ('3:30:21 am', time(3, 30, 21)),
+    ('3:30 am', time(3, 30)),
+    # test pm parsing
+    ('03:30 pm', time(15, 30)),
+    ('03:30 pM', time(15, 30)),
+    ('03:30 Pm', time(15, 30)),
+    ('03:30 PM', time(15, 30)),
+    # test hour-only parsing
+    ('4 pm', time(16, 0)),
+])
+def test_parse_time(input, expected):
+    assert dates.parse_time(input, locale='en_US') == expected
+
+
+@pytest.mark.parametrize('case', ['', 'a', 'aaa'])
+@pytest.mark.parametrize('func', [dates.parse_date, dates.parse_time])
+def test_parse_errors(case, func):
+    with pytest.raises(dates.ParseError):
+        func(case, locale='en_US')
 
 
 def test_datetime_format_get_week_number():