From aef1ce018c60bfeb2e0910dd293cfc3131fa87a9 Mon Sep 17 00:00:00 2001 From: Andrew Elkins Date: Wed, 16 Nov 2016 16:35:08 -0800 Subject: [PATCH 1/6] Remove duplicate code This will close #306 --- arrow/arrow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index b17c435cc..b0de6035b 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -718,8 +718,6 @@ def __eq__(self, other): if not isinstance(other, (Arrow, datetime)): return False - other = self._get_datetime(other) - return self._datetime == self._get_datetime(other) def __ne__(self, other): From bc03b0b5337642c6766cda4f483074e0ecc88455 Mon Sep 17 00:00:00 2001 From: Buck Evan Date: Wed, 16 Nov 2016 09:52:52 -0800 Subject: [PATCH 2/6] fix parsing of iso-8601 dates as emitted by GNU date -Ins --- arrow/parser.py | 37 ++++++++-------------- tests/parser_tests.py | 72 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/arrow/parser.py b/arrow/parser.py index 6e94a10c4..480738216 100644 --- a/arrow/parser.py +++ b/arrow/parser.py @@ -14,13 +14,10 @@ class ParserError(RuntimeError): class DateTimeParser(object): - _FORMAT_RE = re.compile('(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X)') + _FORMAT_RE = re.compile('(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|X)') _ESCAPE_RE = re.compile('\[[^\[\]]*\]') - _ONE_THROUGH_SIX_DIGIT_RE = re.compile('\d{1,6}') - _ONE_THROUGH_FIVE_DIGIT_RE = re.compile('\d{1,5}') - _ONE_THROUGH_FOUR_DIGIT_RE = re.compile('\d{1,4}') - _ONE_TWO_OR_THREE_DIGIT_RE = re.compile('\d{1,3}') + _ONE_OR_MORE_DIGIT_RE = re.compile('\d+') _ONE_OR_TWO_DIGIT_RE = re.compile('\d{1,2}') _FOUR_DIGIT_RE = re.compile('\d{4}') _TWO_DIGIT_RE = re.compile('\d{2}') @@ -47,12 +44,7 @@ class DateTimeParser(object): 'ZZZ': _TZ_NAME_RE, 'ZZ': _TZ_RE, 'Z': _TZ_RE, - 'SSSSSS': _ONE_THROUGH_SIX_DIGIT_RE, - 'SSSSS': _ONE_THROUGH_FIVE_DIGIT_RE, - 'SSSS': _ONE_THROUGH_FOUR_DIGIT_RE, - 'SSS': _ONE_TWO_OR_THREE_DIGIT_RE, - 'SS': _ONE_OR_TWO_DIGIT_RE, - 'S': re.compile('\d'), + 'S': _ONE_OR_MORE_DIGIT_RE, } MARKERS = ['YYYY', 'MM', 'DD'] @@ -92,11 +84,10 @@ def parse_iso(self, string): time_parts = re.split('[+-]', time_string, 1) has_tz = len(time_parts) > 1 has_seconds = time_parts[0].count(':') > 1 - has_subseconds = '.' in time_parts[0] + has_subseconds = re.search('[.,]', time_parts[0]) if has_subseconds: - subseconds_token = 'S' * min(len(re.split('\D+', time_parts[0].split('.')[1], 1)[0]), 6) - formats = ['YYYY-MM-DDTHH:mm:ss.%s' % subseconds_token] + formats = ['YYYY-MM-DDTHH:mm:ss%sS' % has_subseconds.group()] elif has_seconds: formats = ['YYYY-MM-DDTHH:mm:ss'] else: @@ -132,6 +123,8 @@ def parse(self, string, fmt): # Extract the bracketed expressions to be reinserted later. escaped_fmt = re.sub(self._ESCAPE_RE, "#" , fmt) + # Any number of S is the same as one. + escaped_fmt = re.sub('S+', 'S', escaped_fmt) escaped_data = re.findall(self._ESCAPE_RE, fmt) fmt_pattern = escaped_fmt @@ -202,18 +195,12 @@ def _parse_token(self, token, value, parts): elif token in ['ss', 's']: parts['second'] = int(value) - elif token == 'SSSSSS': - parts['microsecond'] = int(value) - elif token == 'SSSSS': - parts['microsecond'] = int(value) * 10 - elif token == 'SSSS': - parts['microsecond'] = int(value) * 100 - elif token == 'SSS': - parts['microsecond'] = int(value) * 1000 - elif token == 'SS': - parts['microsecond'] = int(value) * 10000 elif token == 'S': - parts['microsecond'] = int(value) * 100000 + # We have the *most significant* digits of an arbitrary-precision integer. + # We want the six most significant digits as an integer, rounded. + # FIXME: add nanosecond support somehow? + value = value.ljust(7, '0') + parts['microsecond'] = int(value[:6]) elif token == 'X': parts['timestamp'] = int(value) diff --git a/tests/parser_tests.py b/tests/parser_tests.py index 59a84dbc9..5eb6da10a 100644 --- a/tests/parser_tests.py +++ b/tests/parser_tests.py @@ -188,17 +188,34 @@ def test_parse_subsecond(self): assertEqual(self.parser.parse('2013-01-01 12:30:45.987654', 'YYYY-MM-DD HH:mm:ss.SSSSSS'), expected) assertEqual(self.parser.parse_iso('2013-01-01 12:30:45.987654'), expected) + def test_parse_subsecond_rounding(self): + """currently, we've decided there's no rounding""" + format = 'YYYY-MM-DD HH:mm:ss.S' + + # round up + string = '2013-01-01 12:30:45.9876539' + expected = datetime(2013, 1, 1, 12, 30, 45, 987653) + assertEqual(self.parser.parse(string, format), expected) + assertEqual(self.parser.parse_iso(string), expected) + + # round down + string = '2013-01-01 12:30:45.98765432' expected = datetime(2013, 1, 1, 12, 30, 45, 987654) - assertEqual(self.parser.parse('2013-01-01 12:30:45.9876543', 'YYYY-MM-DD HH:mm:ss.SSSSSS'), expected) - assertEqual(self.parser.parse_iso('2013-01-01 12:30:45.9876543'), expected) - - expected = datetime(2013, 1, 1, 12, 30, 45, 987654) - assertEqual(self.parser.parse('2013-01-01 12:30:45.98765432', 'YYYY-MM-DD HH:mm:ss.SSSSSS'), expected) - assertEqual(self.parser.parse_iso('2013-01-01 12:30:45.98765432'), expected) - + assertEqual(self.parser.parse(string, format), expected) + #import pudb; pudb.set_trace() + assertEqual(self.parser.parse_iso(string), expected) + + # round half-up + string = '2013-01-01 12:30:45.987653521' + expected = datetime(2013, 1, 1, 12, 30, 45, 987653) + assertEqual(self.parser.parse(string, format), expected) + assertEqual(self.parser.parse_iso(string), expected) + + # round half-down + string = '2013-01-01 12:30:45.9876545210' expected = datetime(2013, 1, 1, 12, 30, 45, 987654) - assertEqual(self.parser.parse('2013-01-01 12:30:45.987654321', 'YYYY-MM-DD HH:mm:ss.SSSSSS'), expected) - assertEqual(self.parser.parse_iso('2013-01-01 12:30:45.987654321'), expected) + assertEqual(self.parser.parse(string, format), expected) + assertEqual(self.parser.parse_iso(string), expected) def test_map_lookup_keyerror(self): @@ -398,6 +415,21 @@ def test_YYYY_MM_DDTHH_mm_ss_S(self): datetime(2013, 2, 3, 4, 5, 6, 789120) ) + # ISO 8601:2004(E), ISO, 2004-12-01, 4.2.2.4 ... the decimal fraction + # shall be divided from the integer part by the decimal sign specified + # in ISO 31-0, i.e. the comma [,] or full stop [.]. Of these, the comma + # is the preferred sign. + assertEqual( + self.parser.parse_iso('2013-02-03T04:05:06,789123678'), + datetime(2013, 2, 3, 4, 5, 6, 789123) + ) + + # there is no limit on the number of decimal places + assertEqual( + self.parser.parse_iso('2013-02-03T04:05:06.789123678'), + datetime(2013, 2, 3, 4, 5, 6, 789123) + ) + def test_YYYY_MM_DDTHH_mm_ss_SZ(self): assertEqual( @@ -431,6 +463,28 @@ def test_YYYY_MM_DDTHH_mm_ss_SZ(self): datetime(2013, 2, 3, 4, 5, 6, 789120) ) + def test_gnu_date(self): + """ + regression tests for parsing output from GNU date(1) + """ + # date -Ins + assertEqual( + self.parser.parse_iso('2016-11-16T09:46:30,895636557-0800'), + datetime( + 2016, 11, 16, 9, 46, 30, 895636, + tzinfo=tz.tzoffset(None, -3600 * 8), + ) + ) + + # date --rfc-3339=ns + assertEqual( + self.parser.parse_iso('2016-11-16 09:51:14.682141526-08:00'), + datetime( + 2016, 11, 16, 9, 51, 14, 682141, + tzinfo=tz.tzoffset(None, -3600 * 8), + ) + ) + def test_isoformat(self): dt = datetime.utcnow() From 3288883a975f5ffaddd1fbe3fca31b6bf7f90898 Mon Sep 17 00:00:00 2001 From: Buck Evan Date: Wed, 16 Nov 2016 13:34:26 -0800 Subject: [PATCH 3/6] check coverage for test code too this is useful; i was able to find two tests that weren't even running also, show the lines that are missing coverage, if any --- .coveragerc | 8 +++++++- setup.cfg | 6 +++--- tests/arrow_tests.py | 2 +- tests/factory_tests.py | 7 ++++++- tests/util_tests.py | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.coveragerc b/.coveragerc index ad3153683..1e8b639e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,9 @@ [run] branch = True -source = arrow +source = + tests + arrow + +[report] +show_missing = True +fail_under = 100 diff --git a/setup.cfg b/setup.cfg index 6af300877..53e78d1e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,10 +5,10 @@ verbosity = 2 all-modules = true with-coverage = true cover-min-percentage = 100 -cover-package = arrow +cover-package = + arrow + tests cover-erase = true -cover-inclusive = true -cover-branches = true [bdist_wheel] universal=1 diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py index 7735b20e6..aada4f9fd 100644 --- a/tests/arrow_tests.py +++ b/tests/arrow_tests.py @@ -900,7 +900,7 @@ def test_span_second(self): assertEqual(floor, datetime(2013, 2, 15, 3, 41, 22, tzinfo=tz.tzutc())) assertEqual(ceil, datetime(2013, 2, 15, 3, 41, 22, 999999, tzinfo=tz.tzutc())) - def test_span_hour(self): + def test_span_microsecond(self): floor, ceil = self.arrow.span('microsecond') diff --git a/tests/factory_tests.py b/tests/factory_tests.py index 7de47d6d5..603ac799e 100644 --- a/tests/factory_tests.py +++ b/tests/factory_tests.py @@ -180,7 +180,12 @@ def test_three_args(self): assertEqual(self.factory.get(2013, 1, 1), datetime(2013, 1, 1, tzinfo=tz.tzutc())) -def UtcNowTests(Chai): +class UtcNowTests(Chai): + + def setUp(self): + super(UtcNowTests, self).setUp() + + self.factory = factory.ArrowFactory() def test_utcnow(self): diff --git a/tests/util_tests.py b/tests/util_tests.py index 7be123014..c3d059fdd 100644 --- a/tests/util_tests.py +++ b/tests/util_tests.py @@ -18,7 +18,7 @@ def test_total_seconds_26(self): assertEqual(util._total_seconds_26(td), 30) - if util.version >= '2.7': + if util.version >= '2.7': # pragma: no cover def test_total_seconds_27(self): From 9355b404c9745b1b697ff4efc955aabb8324ea93 Mon Sep 17 00:00:00 2001 From: Buck Evan Date: Wed, 16 Nov 2016 14:05:27 -0800 Subject: [PATCH 4/6] fix py2, too, and don't swallow unexpected exceptions --- arrow/parser.py | 4 ++-- tests/parser_tests.py | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/arrow/parser.py b/arrow/parser.py index 480738216..0805f5ce3 100644 --- a/arrow/parser.py +++ b/arrow/parser.py @@ -199,7 +199,7 @@ def _parse_token(self, token, value, parts): # We have the *most significant* digits of an arbitrary-precision integer. # We want the six most significant digits as an integer, rounded. # FIXME: add nanosecond support somehow? - value = value.ljust(7, '0') + value = value.ljust(7, str('0')) parts['microsecond'] = int(value[:6]) elif token == 'X': @@ -250,7 +250,7 @@ def _parse_multiformat(self, string, formats): try: _datetime = self.parse(string, fmt) break - except: + except ParserError: pass if _datetime is None: diff --git a/tests/parser_tests.py b/tests/parser_tests.py index 5eb6da10a..1dd7d64ea 100644 --- a/tests/parser_tests.py +++ b/tests/parser_tests.py @@ -22,7 +22,7 @@ def test_parse_multiformat(self): mock_datetime = mock() - expect(self.parser.parse).args('str', 'fmt_a').raises(Exception) + expect(self.parser.parse).args('str', 'fmt_a').raises(ParserError) expect(self.parser.parse).args('str', 'fmt_b').returns(mock_datetime) result = self.parser._parse_multiformat('str', ['fmt_a', 'fmt_b']) @@ -31,10 +31,20 @@ def test_parse_multiformat(self): def test_parse_multiformat_all_fail(self): - expect(self.parser.parse).args('str', 'fmt_a').raises(Exception) - expect(self.parser.parse).args('str', 'fmt_b').raises(Exception) + expect(self.parser.parse).args('str', 'fmt_a').raises(ParserError) + expect(self.parser.parse).args('str', 'fmt_b').raises(ParserError) - with assertRaises(Exception): + with assertRaises(ParserError): + self.parser._parse_multiformat('str', ['fmt_a', 'fmt_b']) + + def test_parse_multiformat_unexpected_fail(self): + + class UnexpectedError(Exception): + pass + + expect(self.parser.parse).args('str', 'fmt_a').raises(UnexpectedError) + + with assertRaises(UnexpectedError): self.parser._parse_multiformat('str', ['fmt_a', 'fmt_b']) def test_parse_token_nonsense(self): From 8ec8f89632527a8510ae2f227dae12eb37312cac Mon Sep 17 00:00:00 2001 From: Buck Evan Date: Wed, 16 Nov 2016 16:30:13 -0800 Subject: [PATCH 5/6] fix all (seven) sphinx warnings mainly these are misnaming of classes' modules --- arrow/api.py | 2 +- arrow/arrow.py | 1 + arrow/locales.py | 4 ++-- docs/conf.py | 4 ++-- docs/index.rst | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/arrow/api.py b/arrow/api.py index 495eef490..16de39fea 100644 --- a/arrow/api.py +++ b/arrow/api.py @@ -51,5 +51,5 @@ def factory(type): return ArrowFactory(type) -__all__ = ['get', 'utcnow', 'now', 'factory', 'iso'] +__all__ = ['get', 'utcnow', 'now', 'factory'] diff --git a/arrow/arrow.py b/arrow/arrow.py index b17c435cc..256d2e398 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -598,6 +598,7 @@ def humanize(self, other=None, locale='en_us', only_distance=False): Defaults to now in the current :class:`Arrow ` object's timezone. :param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'. :param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part. + Usage:: >>> earlier = arrow.utcnow().replace(hours=-2) diff --git a/arrow/locales.py b/arrow/locales.py index 5af426746..9a68d2dc9 100644 --- a/arrow/locales.py +++ b/arrow/locales.py @@ -7,8 +7,8 @@ def get_locale(name): - '''Returns an appropriate :class:`Locale ` corresponding - to an inpute locale name. + '''Returns an appropriate :class:`Locale ` + corresponding to an inpute locale name. :param name: the name of the locale. diff --git a/docs/conf.py b/docs/conf.py index 92fa464ee..95305aab7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -64,7 +64,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ['_build', '_themes'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None @@ -123,7 +123,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/index.rst b/docs/index.rst index a4d2475bb..976abc933 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -205,7 +205,7 @@ Call datetime functions that return properties: Replace & shift =============== -Get a new :class:`Arrow ` object, with altered attributes, just as you would with a datetime: +Get a new :class:`Arrow ` object, with altered attributes, just as you would with a datetime: .. code-block:: python From 66eec78128adde75a65b0f0167bc6c691987a45a Mon Sep 17 00:00:00 2001 From: Buck Evan Date: Wed, 16 Nov 2016 16:42:56 -0800 Subject: [PATCH 6/6] implement, test, and document half-even rounding for fractional seconds --- arrow/parser.py | 12 +++++++++++- docs/index.rst | 11 ++++------- tests/parser_tests.py | 12 ++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/arrow/parser.py b/arrow/parser.py index 0805f5ce3..69ed5acb9 100644 --- a/arrow/parser.py +++ b/arrow/parser.py @@ -200,7 +200,17 @@ def _parse_token(self, token, value, parts): # We want the six most significant digits as an integer, rounded. # FIXME: add nanosecond support somehow? value = value.ljust(7, str('0')) - parts['microsecond'] = int(value[:6]) + + # floating-point (IEEE-754) defaults to half-to-even rounding + seventh_digit = int(value[6]) + if seventh_digit == 5: + rounding = int(value[5]) % 2 + elif seventh_digit > 5: + rounding = 1 + else: + rounding = 0 + + parts['microsecond'] = int(value[:6]) + rounding elif token == 'X': parts['timestamp'] = int(value) diff --git a/docs/index.rst b/docs/index.rst index 976abc933..d579803b0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -429,13 +429,9 @@ Use the following tokens in parsing and formatting. Note that they're not the s +--------------------------------+--------------+-------------------------------------------+ | |s |0, 1, 2 ... 58, 59 | +--------------------------------+--------------+-------------------------------------------+ -|**Sub-second** |SSS |000, 001, 002 ... 998, 999 | +|**Sub-second** |S... |0, 02, 003, 000006, 123123123123... [#t3]_ | +--------------------------------+--------------+-------------------------------------------+ -| |SS |00, 01, 02 ... 98, 99 | -+--------------------------------+--------------+-------------------------------------------+ -| |S |0, 1, 2 ... 8, 9 | -+--------------------------------+--------------+-------------------------------------------+ -|**Timezone** |ZZZ |Asia/Baku, Europe/Warsaw, GMT ... [#t3]_ | +|**Timezone** |ZZZ |Asia/Baku, Europe/Warsaw, GMT ... [#t4]_ | +--------------------------------+--------------+-------------------------------------------+ | |ZZ |-07:00, -06:00 ... +06:00, +07:00 | +--------------------------------+--------------+-------------------------------------------+ @@ -448,7 +444,8 @@ Use the following tokens in parsing and formatting. Note that they're not the s .. [#t1] localization support for parsing and formatting .. [#t2] localization support only for formatting -.. [#t3] timezone names from `tz database `_ provided via dateutil package +.. [#t3] the result is truncated to microseconds, with `half-to-even rounding `_. +.. [#t4] timezone names from `tz database `_ provided via dateutil package --------- API Guide diff --git a/tests/parser_tests.py b/tests/parser_tests.py index 1dd7d64ea..67cf32f79 100644 --- a/tests/parser_tests.py +++ b/tests/parser_tests.py @@ -199,31 +199,27 @@ def test_parse_subsecond(self): assertEqual(self.parser.parse_iso('2013-01-01 12:30:45.987654'), expected) def test_parse_subsecond_rounding(self): - """currently, we've decided there's no rounding""" + expected = datetime(2013, 1, 1, 12, 30, 45, 987654) format = 'YYYY-MM-DD HH:mm:ss.S' # round up string = '2013-01-01 12:30:45.9876539' - expected = datetime(2013, 1, 1, 12, 30, 45, 987653) assertEqual(self.parser.parse(string, format), expected) assertEqual(self.parser.parse_iso(string), expected) # round down string = '2013-01-01 12:30:45.98765432' - expected = datetime(2013, 1, 1, 12, 30, 45, 987654) assertEqual(self.parser.parse(string, format), expected) #import pudb; pudb.set_trace() assertEqual(self.parser.parse_iso(string), expected) # round half-up string = '2013-01-01 12:30:45.987653521' - expected = datetime(2013, 1, 1, 12, 30, 45, 987653) assertEqual(self.parser.parse(string, format), expected) assertEqual(self.parser.parse_iso(string), expected) # round half-down string = '2013-01-01 12:30:45.9876545210' - expected = datetime(2013, 1, 1, 12, 30, 45, 987654) assertEqual(self.parser.parse(string, format), expected) assertEqual(self.parser.parse_iso(string), expected) @@ -431,13 +427,13 @@ def test_YYYY_MM_DDTHH_mm_ss_S(self): # is the preferred sign. assertEqual( self.parser.parse_iso('2013-02-03T04:05:06,789123678'), - datetime(2013, 2, 3, 4, 5, 6, 789123) + datetime(2013, 2, 3, 4, 5, 6, 789124) ) # there is no limit on the number of decimal places assertEqual( self.parser.parse_iso('2013-02-03T04:05:06.789123678'), - datetime(2013, 2, 3, 4, 5, 6, 789123) + datetime(2013, 2, 3, 4, 5, 6, 789124) ) def test_YYYY_MM_DDTHH_mm_ss_SZ(self): @@ -490,7 +486,7 @@ def test_gnu_date(self): assertEqual( self.parser.parse_iso('2016-11-16 09:51:14.682141526-08:00'), datetime( - 2016, 11, 16, 9, 51, 14, 682141, + 2016, 11, 16, 9, 51, 14, 682142, tzinfo=tz.tzoffset(None, -3600 * 8), ) )