From 7aded5521bf6b6428986f616abf9fa77fd50cf97 Mon Sep 17 00:00:00 2001 From: gfyoung Date: Mon, 25 Feb 2019 00:20:51 +0000 Subject: [PATCH 1/4] Use custom ParserError class in parser This gives a nicer string representation while retaining access to the input string in the exception arguments. --- AUTHORS.md | 1 + changelog.d/881.bugfix.rst | 3 +++ dateutil/parser/__init__.py | 3 ++- dateutil/parser/_parser.py | 21 +++++++++++++++++---- 4 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 changelog.d/881.bugfix.rst 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..dbd5ab404 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,10 +646,10 @@ 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) @@ -1588,6 +1588,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 From 3dc323bc6090cf487c6125239e70df36eebd2bd2 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 23 Apr 2019 08:41:48 -0400 Subject: [PATCH 2/4] Add xfailing test for ParserError --- dateutil/test/test_parser.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dateutil/test/test_parser.py b/dateutil/test/test_parser.py index bf46dcecd..f31efa5ed 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 @@ -721,6 +722,17 @@ def test_era_trailing_year(self): res = parse(dstr) assert res.year == 2001, res + @pytest.mark.xfail + 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): From 31e8c66d76b1a53ab692decf4330b6b5706bf08c Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 23 Apr 2019 08:54:11 -0400 Subject: [PATCH 3/4] Wrap ValueError in ParserError Ensures that ValueErrors raised when building the datetime from an already-parsed string will be raised as ParserError as well. --- dateutil/parser/_parser.py | 5 ++++- dateutil/test/test_parser.py | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dateutil/parser/_parser.py b/dateutil/parser/_parser.py index dbd5ab404..9ab896f3f 100644 --- a/dateutil/parser/_parser.py +++ b/dateutil/parser/_parser.py @@ -651,7 +651,10 @@ def parse(self, timestr, default=None, if len(res) == 0: 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) diff --git a/dateutil/test/test_parser.py b/dateutil/test/test_parser.py index f31efa5ed..8c08c684d 100644 --- a/dateutil/test/test_parser.py +++ b/dateutil/test/test_parser.py @@ -722,7 +722,6 @@ def test_era_trailing_year(self): res = parse(dstr) assert res.year == 2001, res - @pytest.mark.xfail def test_includes_timestr(self): timestr = "2020-13-97T44:61:83" From f764c8a91b8ed2eeeecbd0b6db4c0a33a350e22f Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 23 Apr 2019 09:02:49 -0400 Subject: [PATCH 4/4] Convert all parser raises tests to use ParserError ParserError is a subclass of ValueError, so this just makes the tests stricter, and this can be considered a backwards-compatible change. --- dateutil/test/test_parser.py | 40 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/dateutil/test/test_parser.py b/dateutil/test/test_parser.py index 8c08c684d..25d585a50 100644 --- a/dateutil/test/test_parser.py +++ b/dateutil/test/test_parser.py @@ -292,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): @@ -479,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): @@ -565,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): @@ -714,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): @@ -736,31 +736,31 @@ def test_includes_timestr(self): 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) @@ -813,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 @@ -935,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)