diff --git a/AUTHORS.md b/AUTHORS.md index fe02288c6..2baa5e326 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -112,6 +112,7 @@ switch, and thus all their contributions are dual-licensed. - bachmann - bjv (@bjamesvERT) - gl +- gfyoung **D** - labrys (gh: @labrys) **R** - ms-boom - ryanss (gh: @ryanss) **R** diff --git a/changelog.d/881.bugfix.rst b/changelog.d/881.bugfix.rst new file mode 100644 index 000000000..d171861fd --- /dev/null +++ b/changelog.d/881.bugfix.rst @@ -0,0 +1,3 @@ +Parsing errors will now raise ``ParserError``, a subclass of ``ValueError``, +which has a nicer string representation. +Patch by @gfyoung (gh pr #881) diff --git a/dateutil/parser/__init__.py b/dateutil/parser/__init__.py index 216762c09..d174b0e4d 100644 --- a/dateutil/parser/__init__.py +++ b/dateutil/parser/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from ._parser import parse, parser, parserinfo +from ._parser import parse, parser, parserinfo, ParserError from ._parser import DEFAULTPARSER, DEFAULTTZPARSER from ._parser import UnknownTimezoneWarning @@ -9,6 +9,7 @@ __all__ = ['parse', 'parser', 'parserinfo', 'isoparse', 'isoparser', + 'ParserError', 'UnknownTimezoneWarning'] diff --git a/dateutil/parser/_parser.py b/dateutil/parser/_parser.py index 2ed091150..9ab896f3f 100644 --- a/dateutil/parser/_parser.py +++ b/dateutil/parser/_parser.py @@ -49,7 +49,7 @@ from .. import relativedelta from .. import tz -__all__ = ["parse", "parserinfo"] +__all__ = ["parse", "parserinfo", "ParserError"] # TODO: pandas.core.tools.datetimes imports this explicitly. Might be worth @@ -626,7 +626,7 @@ def parse(self, timestr, default=None, first element being a :class:`datetime.datetime` object, the second a tuple containing the fuzzy tokens. - :raises ValueError: + :raises ParserError: Raised for invalid or unknown string format, if the provided :class:`tzinfo` is not in a valid format, or if an invalid date would be created. @@ -646,12 +646,15 @@ def parse(self, timestr, default=None, res, skipped_tokens = self._parse(timestr, **kwargs) if res is None: - raise ValueError("Unknown string format:", timestr) + raise ParserError("Unknown string format: %s", timestr) if len(res) == 0: - raise ValueError("String does not contain a date:", timestr) + raise ParserError("String does not contain a date: %s", timestr) - ret = self._build_naive(res, default) + try: + ret = self._build_naive(res, default) + except ValueError as e: + six.raise_from(ParserError(e.args[0] + ": %s", timestr), e) if not ignoretz: ret = self._build_tzaware(ret, res, tzinfos) @@ -1588,6 +1591,19 @@ def parse(self, tzstr): def _parsetz(tzstr): return DEFAULTTZPARSER.parse(tzstr) + +class ParserError(ValueError): + """Error class for representing failure to parse a datetime string.""" + def __str__(self): + try: + return self.args[0] % self.args[1:] + except (TypeError, IndexError): + return super(ParserError, self).__str__() + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, str(self)) + + class UnknownTimezoneWarning(RuntimeWarning): """Raised when the parser finds a timezone it cannot parse into a tzinfo""" # vim:ts=4:sw=4:et diff --git a/dateutil/test/test_parser.py b/dateutil/test/test_parser.py index bf46dcecd..25d585a50 100644 --- a/dateutil/test/test_parser.py +++ b/dateutil/test/test_parser.py @@ -9,6 +9,7 @@ from dateutil import tz from dateutil.tz import tzoffset from dateutil.parser import parse, parserinfo +from dateutil.parser import ParserError from dateutil.parser import UnknownTimezoneWarning from ._common import TZEnvContext @@ -291,7 +292,7 @@ def test_strftime_formats_2003Sep25(self, fmt, dstr): class TestInputTypes(object): def test_empty_string_invalid(self): - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse('') def test_none_invalid(self): @@ -478,17 +479,17 @@ def testISOStrippedFormatStrip2(self): tzinfo=tzoffset(None, 10800))) def testAMPMNoHour(self): - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse("AM") - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse("Jan 20, 2015 PM") def testAMPMRange(self): - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse("13:44 AM") - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse("January 25, 1921 23:13 PM") def testPertain(self): @@ -564,15 +565,15 @@ def testUnspecifiedDayFallbackFebLeapYear(self): datetime(2008, 2, 29)) def testErrorType01(self): - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse('shouldfail') def testCorrectErrorOnFuzzyWithTokens(self): - assertRaisesRegex(self, ValueError, 'Unknown string format', + assertRaisesRegex(self, ParserError, 'Unknown string format', parse, '04/04/32/423', fuzzy_with_tokens=True) - assertRaisesRegex(self, ValueError, 'Unknown string format', + assertRaisesRegex(self, ParserError, 'Unknown string format', parse, '04/04/04 +32423', fuzzy_with_tokens=True) - assertRaisesRegex(self, ValueError, 'Unknown string format', + assertRaisesRegex(self, ParserError, 'Unknown string format', parse, '04/04/0d4', fuzzy_with_tokens=True) def testIncreasingCTime(self): @@ -713,7 +714,7 @@ def test_hmBY(self): def test_validate_hour(self): # See GH353 invalid = "201A-01-01T23:58:39.239769+03:00" - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse(invalid) def test_era_trailing_year(self): @@ -721,35 +722,45 @@ def test_era_trailing_year(self): res = parse(dstr) assert res.year == 2001, res + def test_includes_timestr(self): + timestr = "2020-13-97T44:61:83" + + try: + parse(timestr) + except ParserError as e: + assert e.args[1] == timestr + else: + pytest.fail("Failed to raise ParserError") + class TestOutOfBounds(object): def test_no_year_zero(self): - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse("0000 Jun 20") def test_out_of_bound_day(self): - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse("Feb 30, 2007") def test_day_sanity(self, fuzzy): dstr = "2014-15-25" - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse(dstr, fuzzy=fuzzy) def test_minute_sanity(self, fuzzy): dstr = "2014-02-28 22:64" - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse(dstr, fuzzy=fuzzy) def test_hour_sanity(self, fuzzy): dstr = "2014-02-28 25:16 PM" - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse(dstr, fuzzy=fuzzy) def test_second_sanity(self, fuzzy): dstr = "2014-02-28 22:14:64" - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse(dstr, fuzzy=fuzzy) @@ -802,7 +813,7 @@ def test_four_letter_day(self): @pytest.mark.xfail def test_non_date_number(self): dstr = '1,700' - with pytest.raises(ValueError): + with pytest.raises(ParserError): parse(dstr) @pytest.mark.xfail @@ -924,7 +935,7 @@ def test_rounding_floatlike_strings(dtstr, dt): @pytest.mark.parametrize('value', ['1: test', 'Nan']) def test_decimal_error(value): - # GH 632, GH 662 - decimal.Decimal raises some non-ValueError exception when - # constructed with an invalid value - with pytest.raises(ValueError): + # GH 632, GH 662 - decimal.Decimal raises some non-ParserError exception + # when constructed with an invalid value + with pytest.raises(ParserError): parse(value)