Repository: crsmithdev/arrow
Pull request #639 (2019-08-17 12:39:06)

Updated regular expressions, fixed a few bugs, added tests for v0.15.0
- `X` token can now properly parse float timestamp strings
- Time strings in basic and extended format are now parsed using a regular expression
- "Z" is no longer stripped from the end of the string to prevent mutating the passed in datetime string. I changed it to now split on the "Z" as well as the + and -, so the parsing logic now understands that a `tz` is present and will parse it accordingly (rather than going on the assumption that the logic defaults to UTC and ignores the Z, it is now more explicit with a `tzutc` object).
- Removed stripping of trailing whitespace
- Added a number of tests for regular expressions and the basic format
- `ParserError` exceptions that were thrown when the `day_of_year` logic encountered an error were not shown to the user because they were being prematurely caught. I fixed this by adding a new `ParserMatchError` that inherits from `ParserError` that can be caught by the internal logic and allow the `ParserErrors` to propagate.
- `ParserError` now inherits from `ValueError`, which makes more sense then a `RuntimeError`.
- Removed warnings and incremented version.


Patch:
diff --git a/arrow/arrow.py b/arrow/arrow.py
index 08b9b57..37c49fe 100644
--- a/arrow/arrow.py
+++ b/arrow/arrow.py
@@ -1335,7 +1335,7 @@ class Arrow(object):
         if end is None:
 
             if limit is None:
-                raise Exception("one of 'end' or 'limit' is required")
+                raise ValueError("one of 'end' or 'limit' is required")
 
             return cls.max, limit
 
diff --git a/arrow/factory.py b/arrow/factory.py
index 246ab5c..ef8e897 100644
--- a/arrow/factory.py
+++ b/arrow/factory.py
@@ -9,7 +9,6 @@ construction scenarios.
 from __future__ import absolute_import
 
 import calendar
-import warnings
 from datetime import date, datetime, tzinfo
 from time import struct_time
 
@@ -20,21 +19,6 @@ from arrow.arrow import Arrow
 from arrow.util import is_timestamp, isstr
 
 
-class ArrowParseWarning(DeprecationWarning):
-    """Raised when arrow.get() is passed a string with no formats and matches incorrectly
-    on one of the default formats.
-
-    e.g.
-    arrow.get('blabla2016') -> <Arrow [2016-01-01T00:00:00+00:00]>
-    arrow.get('13/4/2045') -> <Arrow [2045-01-01T00:00:00+00:00]>
-
-    In version 0.15.0 this warning will become a ParserError.
-    """
-
-
-warnings.simplefilter("always", ArrowParseWarning)
-
-
 class ArrowFactory(object):
     """ A factory for generating :class:`Arrow <arrow.arrow.Arrow>` objects.
 
@@ -195,11 +179,6 @@ class ArrowFactory(object):
 
             # (str) -> parse.
             elif isstr(arg):
-                warnings.warn(
-                    "The .get() parsing method without a format string will parse more strictly in version 0.15.0."
-                    "See https://github.com/crsmithdev/arrow/issues/612 for more details.",
-                    ArrowParseWarning,
-                )
                 dt = parser.DateTimeParser(locale).parse_iso(arg)
                 return self.type.fromdatetime(dt, tz)
 
@@ -242,11 +221,6 @@ class ArrowFactory(object):
 
             # (str, format) -> parse.
             elif isstr(arg_1) and (isstr(arg_2) or isinstance(arg_2, list)):
-                warnings.warn(
-                    "The .get() parsing method with a format string will parse more strictly in version 0.15.0."
-                    "See https://github.com/crsmithdev/arrow/issues/612 for more details.",
-                    ArrowParseWarning,
-                )
                 dt = parser.DateTimeParser(locale).parse(args[0], args[1])
                 return self.type.fromdatetime(dt, tzinfo=tz)
 
diff --git a/arrow/parser.py b/arrow/parser.py
index f73448c..952131f 100644
--- a/arrow/parser.py
+++ b/arrow/parser.py
@@ -14,7 +14,16 @@ except ImportError:  # pragma: no cover
     from backports.functools_lru_cache import lru_cache  # pragma: no cover
 
 
-class ParserError(RuntimeError):
+class ParserError(ValueError):
+    pass
+
+
+# Allows for ParserErrors to be propagated from _build_datetime()
+# when day_of_year errors occur.
+# Before this, the ParserErrors were caught by the try/except in
+# _parse_multiformat() and the appropriate error message was not
+# transmitted to the user.
+class ParserMatchError(ParserError):
     pass
 
 
@@ -25,18 +34,20 @@ class DateTimeParser(object):
     )
     _ESCAPE_RE = re.compile(r"\[[^\[\]]*\]")
 
-    _ONE_OR_MORE_DIGIT_RE = re.compile(r"\d+")
     _ONE_OR_TWO_DIGIT_RE = re.compile(r"\d{1,2}")
     _ONE_OR_TWO_OR_THREE_DIGIT_RE = re.compile(r"\d{1,3}")
+    _ONE_OR_MORE_DIGIT_RE = re.compile(r"\d+")
+    _TWO_DIGIT_RE = re.compile(r"\d{2}")
     _THREE_DIGIT_RE = re.compile(r"\d{3}")
     _FOUR_DIGIT_RE = re.compile(r"\d{4}")
-    _TWO_DIGIT_RE = re.compile(r"\d{2}")
-    _TZ_RE = re.compile(r"[+\-]?\d{2}:?(\d{2})?|Z")
+    _TZ_Z_RE = re.compile(r"([\+\-])(\d{2})(?:(\d{2}))?|Z")
+    _TZ_ZZ_RE = re.compile(r"([\+\-])(\d{2})(?:\:(\d{2}))?|Z")
     _TZ_NAME_RE = re.compile(r"\w[\w+\-/]+")
+    # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will
+    # break cases like "15 Jul 2000" and a format list (see issue #447)
     _TIMESTAMP_RE = re.compile(r"^\d+\.?\d+$")
-    # TODO: test timestamp thoroughly
+    _TIME_RE = re.compile(r"^(\d{2})(?:\:?(\d{2}))?(?:\:?(\d{2}))?(?:([\.\,])(\d+))?$")
 
-    # TODO: test new regular expressions
     _BASE_INPUT_RE_MAP = {
         "YYYY": _FOUR_DIGIT_RE,
         "YY": _TWO_DIGIT_RE,
@@ -56,8 +67,8 @@ class DateTimeParser(object):
         "s": _ONE_OR_TWO_DIGIT_RE,
         "X": _TIMESTAMP_RE,
         "ZZZ": _TZ_NAME_RE,
-        "ZZ": _TZ_RE,
-        "Z": _TZ_RE,
+        "ZZ": _TZ_ZZ_RE,
+        "Z": _TZ_Z_RE,
         "S": _ONE_OR_MORE_DIGIT_RE,
     }
 
@@ -95,23 +106,14 @@ class DateTimeParser(object):
     # TODO: since we support more than ISO-8601, we should rename this function
     # IDEA: break into multiple functions
     def parse_iso(self, datetime_string):
-        # strip leading and trailing whitespace
-        datetime_string = datetime_string.strip()
-
+        # TODO: add a flag to normalize whitespace (useful in logs, ref issue #421)
         has_space_divider = " " in datetime_string
         has_t_divider = "T" in datetime_string
 
         num_spaces = datetime_string.count(" ")
-        if has_space_divider and num_spaces != 1:
+        if has_space_divider and num_spaces != 1 or has_t_divider and num_spaces > 0:
             raise ParserError(
-                "Expected an ISO 8601-like string, but was given '{}' which contains multiple spaces. Try passing in a format string to resolve this.".format(
-                    datetime_string
-                )
-            )
-
-        if has_t_divider and num_spaces > 0:
-            raise ParserError(
-                "Expected an ISO 8601-like string, but was given '{}' which contains \"T\" separator and spaces. Try passing in a format string to resolve this.".format(
+                "Expected an ISO 8601-like string, but was given '{}'. Try passing in a format string to resolve this.".format(
                     datetime_string
                 )
             )
@@ -120,6 +122,7 @@ class DateTimeParser(object):
         has_tz = False
 
         # TODO: add tests for all the new formats, especially basic format
+        # NOTE: YYYYMM is omitted to avoid confusion with YYMMDD (no longer part of ISO 8601, but is still often used)
         # date formats (ISO-8601 and others) to test against
         formats = [
             "YYYY-MM-DD",
@@ -141,53 +144,59 @@ class DateTimeParser(object):
         ]
 
         if has_time:
-            # Z is ignored entirely because fromdatetime defaults to UTC in arrow.py
-            if datetime_string[-1] == "Z":
-                datetime_string = datetime_string[:-1]
 
             if has_space_divider:
                 date_string, time_string = datetime_string.split(" ", 1)
             else:
                 date_string, time_string = datetime_string.split("T", 1)
 
-            time_parts = re.split("[+-]", time_string, 1)
-            colon_count = time_parts[0].count(":")
+            time_parts = re.split(r"[\+\-Z]", time_string, 1, re.IGNORECASE)
+
+            is_basic_time_format = ":" not in time_parts[0]
+            tz_format = "Z"
+
+            # use 'ZZ' token instead since tz offset is present in non-basic format
+            if len(time_parts) == 2 and ":" in time_parts[1]:
+                tz_format = "ZZ"
+
+            time_components = self._TIME_RE.match(time_parts[0])
 
-            is_basic_time_format = colon_count == 0
+            if time_components is None:
+                raise ParserError(
+                    "Invalid time component provided. Please specify a format or provide a valid time component in the basic or extended ISO 8601 time format."
+                )
+
+            hours, minutes, seconds, subseconds_sep, subseconds = (
+                time_components.groups()
+            )
 
-            has_tz = len(time_parts) > 1
-            has_hours = len(time_parts[0]) == 2
-            has_minutes = colon_count == 1 or len(time_parts[0]) == 4
-            has_seconds = colon_count == 2 or len(time_parts[0]) == 6
-            has_subseconds = re.search("[.,]", time_parts[0])
+            has_tz = len(time_parts) == 2
+            has_minutes = minutes is not None
+            has_seconds = seconds is not None
+            has_subseconds = subseconds is not None
+
+            time_sep = "" if is_basic_time_format else ":"
 
             if has_subseconds:
-                time_string = "HH:mm:ss{}S".format(has_subseconds.group())
+                time_string = "HH{time_sep}mm{time_sep}ss{subseconds_sep}S".format(
+                    time_sep=time_sep, subseconds_sep=subseconds_sep
+                )
             elif has_seconds:
-                time_string = "HH:mm:ss"
+                time_string = "HH{time_sep}mm{time_sep}ss".format(time_sep=time_sep)
             elif has_minutes:
-                time_string = "HH:mm"
-            elif has_hours:
-                time_string = "HH"
+                time_string = "HH{time_sep}mm".format(time_sep=time_sep)
             else:
-                raise ParserError(
-                    "Invalid time component provided. Please specify a format or provide a time in the form 'HH:mm:ss.S', 'HH:mm:ss', 'HH:mm', or 'HH'."
-                )
-
-            if is_basic_time_format:
-                time_string = time_string.replace(":", "")
+                time_string = "HH"
 
             if has_space_divider:
                 formats = ["{} {}".format(f, time_string) for f in formats]
             else:
                 formats = ["{}T{}".format(f, time_string) for f in formats]
 
-        # TODO: reduce set of date formats for basic? test earlier?
-
         if has_time and has_tz:
-            # Add "Z" to format strings to indicate to _parse_tokens
-            # that a timezone needs to be parsed
-            formats = ["{}Z".format(f) for f in formats]
+            # Add "Z" or "ZZ" to the format strings to indicate to
+            # _parse_token() that a timezone needs to be parsed
+            formats = ["{}{}".format(f, tz_format) for f in formats]
 
         return self._parse_multiformat(datetime_string, formats)
 
@@ -200,7 +209,7 @@ class DateTimeParser(object):
 
         match = fmt_pattern_re.search(datetime_string)
         if match is None:
-            raise ParserError(
+            raise ParserMatchError(
                 "Failed to match '{}' when parsing '{}'".format(fmt, datetime_string)
             )
 
@@ -231,7 +240,7 @@ class DateTimeParser(object):
 
         # Any number of S is the same as one.
         # TODO: allow users to specify the number of digits to parse
-        escaped_fmt = re.sub("S+", "S", escaped_fmt)
+        escaped_fmt = re.sub(r"S+", "S", escaped_fmt)
 
         escaped_data = re.findall(self._ESCAPE_RE, fmt)
 
@@ -276,13 +285,11 @@ class DateTimeParser(object):
         # Reference: https://stackoverflow.com/q/14232931/3820660
         starting_word_boundary = r"(?<![\S])"
         ending_word_boundary = r"(?![\S])"
-        final_fmt_pattern = r"{starting_word_boundary}{final_fmt_pattern}Z?{ending_word_boundary}".format(
-            starting_word_boundary=starting_word_boundary,
-            final_fmt_pattern=final_fmt_pattern,
-            ending_word_boundary=ending_word_boundary,
+        bounded_fmt_pattern = r"{}{}{}".format(
+            starting_word_boundary, final_fmt_pattern, ending_word_boundary
         )
 
-        return tokens, re.compile(final_fmt_pattern, flags=re.IGNORECASE)
+        return tokens, re.compile(bounded_fmt_pattern, flags=re.IGNORECASE)
 
     def _parse_token(self, token, value, parts):
 
@@ -335,7 +342,7 @@ class DateTimeParser(object):
             parts["microsecond"] = int(value[:6]) + rounding
 
         elif token == "X":
-            parts["timestamp"] = int(value)
+            parts["timestamp"] = float(value)
 
         elif token in ["ZZZ", "ZZ", "Z"]:
             parts["tzinfo"] = TzinfoParser.parse(value)
@@ -351,23 +358,22 @@ class DateTimeParser(object):
 
         timestamp = parts.get("timestamp")
 
-        if timestamp:
-            tz_utc = tz.tzutc()
-            return datetime.fromtimestamp(timestamp, tz=tz_utc)
+        if timestamp is not None:
+            return datetime.fromtimestamp(timestamp, tz=tz.tzutc())
 
         day_of_year = parts.get("day_of_year")
 
-        if day_of_year:
+        if day_of_year is not None:
             year = parts.get("year")
             month = parts.get("month")
             if year is None:
                 raise ParserError(
-                    "Year component is required with the DDD and DDDD tokens"
+                    "Year component is required with the DDD and DDDD tokens."
                 )
 
             if month is not None:
                 raise ParserError(
-                    "Month component is not allowed with the DDD and DDDD tokens"
+                    "Month component is not allowed with the DDD and DDDD tokens."
                 )
 
             date_string = "{}-{}".format(year, day_of_year)
@@ -375,9 +381,7 @@ class DateTimeParser(object):
                 dt = datetime.strptime(date_string, "%Y-%j")
             except ValueError:
                 raise ParserError(
-                    "Expected a valid day of year, but received '{}'".format(
-                        day_of_year
-                    )
+                    "The provided day of year '{}' is invalid.".format(day_of_year)
                 )
 
             parts["year"] = dt.year
@@ -411,12 +415,12 @@ class DateTimeParser(object):
             try:
                 _datetime = self.parse(string, fmt)
                 break
-            except ParserError:
+            except ParserMatchError:
                 pass
 
         if _datetime is None:
             raise ParserError(
-                "Could not match input '{}' to any of the formats provided: {}".format(
+                "Could not match input '{}' to any of the following formats: {}".format(
                     string, ", ".join(formats)
                 )
             )
@@ -429,23 +433,23 @@ class DateTimeParser(object):
 
 
 class TzinfoParser(object):
-
-    _TZINFO_RE = re.compile(r"([+\-])?(\d\d):?(\d\d)?")
+    # TODO: test against full timezone DB
+    _TZINFO_RE = re.compile(r"^([\+\-])?(\d{2})(?:\:?(\d{2}))?$")
 
     @classmethod
-    def parse(cls, string):
+    def parse(cls, tzinfo_string):
 
         tzinfo = None
 
-        if string == "local":
+        if tzinfo_string == "local":
             tzinfo = tz.tzlocal()
 
-        elif string in ["utc", "UTC", "Z"]:
+        elif tzinfo_string in ["utc", "UTC", "Z"]:
             tzinfo = tz.tzutc()
 
         else:
 
-            iso_match = cls._TZINFO_RE.match(string)
+            iso_match = cls._TZINFO_RE.match(tzinfo_string)
 
             if iso_match:
                 sign, hours, minutes = iso_match.groups()
@@ -459,9 +463,11 @@ class TzinfoParser(object):
                 tzinfo = tz.tzoffset(None, seconds)
 
             else:
-                tzinfo = tz.gettz(string)
+                tzinfo = tz.gettz(tzinfo_string)
 
         if tzinfo is None:
-            raise ParserError('Could not parse timezone expression "{}"'.format(string))
+            raise ParserError(
+                'Could not parse timezone expression "{}"'.format(tzinfo_string)
+            )
 
         return tzinfo
diff --git a/docs/index.rst b/docs/index.rst
index 8273a6f..78fcea5 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -361,11 +361,11 @@ Use the following tokens in parsing and formatting.  Note that they're not the s
 +--------------------------------+--------------+-------------------------------------------+
 |**Timezone**                    |ZZZ           |Asia/Baku, Europe/Warsaw, GMT ... [#t4]_   |
 +--------------------------------+--------------+-------------------------------------------+
-|                                |ZZ            |-07:00, -06:00 ... +06:00, +07:00          |
+|                                |ZZ            |-07:00, -06:00 ... +06:00, +07:00, +08, Z  |
 +--------------------------------+--------------+-------------------------------------------+
-|                                |Z             |-0700, -0600 ... +0600, +0700              |
+|                                |Z             |-0700, -0600 ... +0600, +0700, +08, Z      |
 +--------------------------------+--------------+-------------------------------------------+
-|**Timestamp**                   |X             |1381685817                                 |
+|**Timestamp**                   |X             |1381685817, 1381685817.915482 ... [#t5]_   |
 +--------------------------------+--------------+-------------------------------------------+
 
 .. rubric:: Footnotes
@@ -374,6 +374,7 @@ Use the following tokens in parsing and formatting.  Note that they're not the s
 .. [#t2] localization support only for formatting
 .. [#t3] the result is truncated to microseconds, with `half-to-even rounding <https://en.wikipedia.org/wiki/IEEE_floating_point#Roundings_to_nearest>`_.
 .. [#t4] timezone names from `tz database <https://www.iana.org/time-zones>`_ provided via dateutil package
+.. [#t5] this token cannot be used for parsing timestamps out of natural language strings due to compatibility reasons
 
 Escaping Formats
 ~~~~~~~~~~~~~~~~


Test patch:
diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py
index 740d849..a9f293b 100644
--- a/tests/arrow_tests.py
+++ b/tests/arrow_tests.py
@@ -5,7 +5,6 @@ import calendar
 import pickle
 import sys
 import time
-import warnings
 from datetime import date, datetime, timedelta
 
 import simplejson as json
@@ -1717,5 +1716,5 @@ class ArrowUtilTests(Chai):
         )
         self.assertEqual(arrow.Arrow._get_iteration_params(100, 120), (100, 120))
 
-        with self.assertRaises(Exception):
+        with self.assertRaises(ValueError):
             arrow.Arrow._get_iteration_params(None, None)
diff --git a/tests/factory_tests.py b/tests/factory_tests.py
index 8c02836..6857934 100644
--- a/tests/factory_tests.py
+++ b/tests/factory_tests.py
@@ -6,6 +6,7 @@ from chai import Chai
 from dateutil import tz
 
 from arrow import factory, util
+from arrow.parser import ParserError
 
 
 def assertDtEqual(dt1, dt2, within=10):
@@ -45,17 +46,27 @@ class GetTests(Chai):
 
     def test_one_arg_timestamp(self):
 
-        timestamp = 12345
-        timestamp_dt = datetime.utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
+        int_timestamp = int(time.time())
+        timestamp_dt = datetime.utcfromtimestamp(int_timestamp).replace(
+            tzinfo=tz.tzutc()
+        )
+
+        self.assertEqual(self.factory.get(int_timestamp), timestamp_dt)
 
-        self.assertEqual(self.factory.get(timestamp), timestamp_dt)
+        with self.assertRaises(ParserError):
+            self.factory.get(str(int_timestamp))
+
+        float_timestamp = time.time()
+        timestamp_dt = datetime.utcfromtimestamp(float_timestamp).replace(
+            tzinfo=tz.tzutc()
+        )
 
-        timestamp = 123.45
-        timestamp_dt = datetime.utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
+        self.assertEqual(self.factory.get(float_timestamp), timestamp_dt)
 
-        self.assertEqual(self.factory.get(timestamp), timestamp_dt)
+        with self.assertRaises(ParserError):
+            self.factory.get(str(float_timestamp))
 
-        # Issue 216
+        # Regression test for issue #216
         timestamp = 99999999999999999999999999
         # Python 3 raises `OverflowError`, Python 2 raises `ValueError`
         with self.assertRaises((OverflowError, ValueError)):
diff --git a/tests/parser_tests.py b/tests/parser_tests.py
index e90b5d9..bc6b0e6 100644
--- a/tests/parser_tests.py
+++ b/tests/parser_tests.py
@@ -9,7 +9,7 @@ from chai import Chai
 from dateutil import tz
 
 from arrow import parser
-from arrow.parser import DateTimeParser, ParserError
+from arrow.parser import DateTimeParser, ParserError, ParserMatchError
 
 
 class DateTimeParserTests(Chai):
@@ -22,7 +22,7 @@ class DateTimeParserTests(Chai):
 
         mock_datetime = self.mock()
 
-        self.expect(self.parser.parse).args("str", "fmt_a").raises(ParserError)
+        self.expect(self.parser.parse).args("str", "fmt_a").raises(ParserMatchError)
         self.expect(self.parser.parse).args("str", "fmt_b").returns(mock_datetime)
 
         result = self.parser._parse_multiformat("str", ["fmt_a", "fmt_b"])
@@ -31,8 +31,8 @@ class DateTimeParserTests(Chai):
 
     def test_parse_multiformat_all_fail(self):
 
-        self.expect(self.parser.parse).args("str", "fmt_a").raises(ParserError)
-        self.expect(self.parser.parse).args("str", "fmt_b").raises(ParserError)
+        self.expect(self.parser.parse).args("str", "fmt_a").raises(ParserMatchError)
+        self.expect(self.parser.parse).args("str", "fmt_b").raises(ParserMatchError)
 
         with self.assertRaises(ParserError):
             self.parser._parse_multiformat("str", ["fmt_a", "fmt_b"])
@@ -133,7 +133,7 @@ class DateTimeParserTests(Chai):
         )
 
     # regression test for issue #447
-    def test_parse_timestamp_token(self):
+    def test_timestamp_format_list(self):
         # should not match on the "X" token
         self.assertEqual(
             self.parser.parse(
@@ -174,12 +174,12 @@ class DateTimeParserParseTests(Chai):
 
     def test_parse_parse_no_match(self):
 
-        with self.assertRaises(parser.ParserError):
+        with self.assertRaises(ParserError):
             self.parser.parse("01-01", "YYYY-MM-DD")
 
     def test_parse_separators(self):
 
-        with self.assertRaises(parser.ParserError):
+        with self.assertRaises(ParserError):
             self.parser.parse("1403549231", "YYYY-MM-DD")
 
     def test_parse_numbers(self):
@@ -200,9 +200,43 @@ class DateTimeParserParseTests(Chai):
     def test_parse_timestamp(self):
 
         tz_utc = tz.tzutc()
-        timestamp = int(time.time())
-        self.expected = datetime.fromtimestamp(timestamp, tz=tz_utc)
-        self.assertEqual(self.parser.parse(str(timestamp), "X"), self.expected)
+        int_timestamp = int(time.time())
+        self.expected = datetime.fromtimestamp(int_timestamp, tz=tz_utc)
+        self.assertEqual(
+            self.parser.parse("{:d}".format(int_timestamp), "X"), self.expected
+        )
+
+        float_timestamp = time.time()
+        self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc)
+        self.assertEqual(
+            self.parser.parse("{:f}".format(float_timestamp), "X"), self.expected
+        )
+
+        # test handling of ns timestamp (arrow will round to 6 digits regardless)
+        self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc)
+        self.assertEqual(
+            self.parser.parse("{:f}123".format(float_timestamp), "X"), self.expected
+        )
+
+        # test ps timestamp (arrow will round to 6 digits regardless)
+        self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc)
+        self.assertEqual(
+            self.parser.parse("{:f}123456".format(float_timestamp), "X"), self.expected
+        )
+
+        # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will
+        # break cases like "15 Jul 2000" and a format list (see issue #447)
+        with self.assertRaises(ParserError):
+            natural_lang_string = "Meet me at {} at the restaurant.".format(
+                float_timestamp
+            )
+            self.parser.parse(natural_lang_string, "X")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse("1565982019.", "X")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse(".1565982019", "X")
 
     def test_parse_names(self):
 
@@ -269,7 +303,13 @@ class DateTimeParserParseTests(Chai):
 
         # note that offsets are not timezones
         with self.assertRaises(ParserError):
-            self.parser.parse("2013-01-01 +1000", "YYYY-MM-DD ZZZ")
+            self.parser.parse("2013-01-01 12:30:45.9+1000", "YYYY-MM-DDZZZ")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse("2013-01-01 12:30:45.9+10:00", "YYYY-MM-DDZZZ")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse("2013-01-01 12:30:45.9-10", "YYYY-MM-DDZZZ")
 
     def test_parse_subsecond(self):
         # TODO: make both test_parse_subsecond functions in Parse and ParseISO
@@ -407,7 +447,7 @@ class DateTimeParserParseTests(Chai):
 
         self.assertEqual(
             self.parser.parse(
-                "Meet me at 2016-05-16T04:05:06.789120 on Tuesday",
+                "Meet me at 2016-05-16T04:05:06.789120 at the restaurant.",
                 "YYYY-MM-DDThh:mm:ss.S",
             ),
             datetime(2016, 5, 16, 4, 5, 6, 789120),
@@ -415,7 +455,7 @@ class DateTimeParserParseTests(Chai):
 
         self.assertEqual(
             self.parser.parse(
-                "Meet me at 2016-05-16 04:05:06.789120 on Tuesday",
+                "Meet me at 2016-05-16 04:05:06.789120 at the restaurant.",
                 "YYYY-MM-DD hh:mm:ss.S",
             ),
             datetime(2016, 5, 16, 4, 5, 6, 789120),
@@ -471,6 +511,7 @@ class DateTimeParserParseTests(Chai):
         with self.assertRaises(ParserError):
             self.parser.parse("2015-01-009", "YYYY-MM-DDDD")
 
+    # year is required with the DDD and DDDD tokens
     def test_parse_DDD_only(self):
         with self.assertRaises(ParserError):
             self.parser.parse("5", "DDD")
@@ -533,6 +574,14 @@ class DateTimeParserRegexTests(Chai):
 
         self.assertEqual(self.format_regex.findall("X"), ["X"])
 
+    def test_escape(self):
+
+        escape_regex = parser.DateTimeParser._ESCAPE_RE
+
+        self.assertEqual(
+            escape_regex.findall("2018-03-09 8 [h] 40 [hello]"), ["[h]", "[hello]"]
+        )
+
     def test_month_names(self):
         p = parser.DateTimeParser("en_us")
 
@@ -553,15 +602,78 @@ class DateTimeParserRegexTests(Chai):
 
     def test_digits(self):
 
+        self.assertEqual(
+            parser.DateTimeParser._ONE_OR_TWO_DIGIT_RE.findall("4-56"), ["4", "56"]
+        )
+        self.assertEqual(
+            parser.DateTimeParser._ONE_OR_TWO_OR_THREE_DIGIT_RE.findall("4-56-789"),
+            ["4", "56", "789"],
+        )
+        self.assertEqual(
+            parser.DateTimeParser._ONE_OR_MORE_DIGIT_RE.findall("4-56-789-1234-12345"),
+            ["4", "56", "789", "1234", "12345"],
+        )
         self.assertEqual(
             parser.DateTimeParser._TWO_DIGIT_RE.findall("12-3-45"), ["12", "45"]
         )
+        self.assertEqual(
+            parser.DateTimeParser._THREE_DIGIT_RE.findall("123-4-56"), ["123"]
+        )
         self.assertEqual(
             parser.DateTimeParser._FOUR_DIGIT_RE.findall("1234-56"), ["1234"]
         )
+
+    def test_tz(self):
+        tz_z_re = parser.DateTimeParser._TZ_Z_RE
+        self.assertEqual(tz_z_re.findall("-0700"), [("-", "07", "00")])
+        self.assertEqual(tz_z_re.findall("+07"), [("+", "07", "")])
+        self.assertTrue(tz_z_re.search("15/01/2019T04:05:06.789120Z") is not None)
+        self.assertTrue(tz_z_re.search("15/01/2019T04:05:06.789120") is None)
+
+        tz_zz_re = parser.DateTimeParser._TZ_ZZ_RE
+        self.assertEqual(tz_zz_re.findall("-07:00"), [("-", "07", "00")])
+        self.assertEqual(tz_zz_re.findall("+07"), [("+", "07", "")])
+        self.assertTrue(tz_zz_re.search("15/01/2019T04:05:06.789120Z") is not None)
+        self.assertTrue(tz_zz_re.search("15/01/2019T04:05:06.789120") is None)
+
+        tz_name_re = parser.DateTimeParser._TZ_NAME_RE
+        self.assertEqual(tz_name_re.findall("Europe/Warsaw"), ["Europe/Warsaw"])
+        self.assertEqual(tz_name_re.findall("GMT"), ["GMT"])
+
+    def test_timestamp(self):
+        timestamp_re = parser.DateTimeParser._TIMESTAMP_RE
         self.assertEqual(
-            parser.DateTimeParser._ONE_OR_TWO_DIGIT_RE.findall("4-56"), ["4", "56"]
+            timestamp_re.findall("1565707550.452729"), ["1565707550.452729"]
         )
+        self.assertEqual(timestamp_re.findall("1565707550"), ["1565707550"])
+        self.assertEqual(timestamp_re.findall("1565707550."), [])
+        self.assertEqual(timestamp_re.findall(".1565707550"), [])
+
+    def test_time(self):
+        time_re = parser.DateTimeParser._TIME_RE
+        time_seperators = [":", ""]
+
+        for sep in time_seperators:
+            self.assertEqual(time_re.findall("12"), [("12", "", "", "", "")])
+            self.assertEqual(
+                time_re.findall("12{sep}35".format(sep=sep)), [("12", "35", "", "", "")]
+            )
+            self.assertEqual(
+                time_re.findall("12{sep}35{sep}46".format(sep=sep)),
+                [("12", "35", "46", "", "")],
+            )
+            self.assertEqual(
+                time_re.findall("12{sep}35{sep}46.952313".format(sep=sep)),
+                [("12", "35", "46", ".", "952313")],
+            )
+            self.assertEqual(
+                time_re.findall("12{sep}35{sep}46,952313".format(sep=sep)),
+                [("12", "35", "46", ",", "952313")],
+            )
+
+        self.assertEqual(time_re.findall("12:"), [])
+        self.assertEqual(time_re.findall("12:35:46."), [])
+        self.assertEqual(time_re.findall("12:35:46,"), [])
 
 
 class DateTimeParserISOTests(Chai):
@@ -582,9 +694,21 @@ class DateTimeParserISOTests(Chai):
         with self.assertRaises(ParserError):
             self.parser.parse_iso("1998-456")
 
-        # datetime.strptime("2015-366", "%Y-%j")
-        # Changes year: datetime.datetime(2016, 1, 1, 0, 0)
-        self.assertEqual(self.parser.parse_iso("2015-366"), datetime(2016, 1, 1))
+        # 2016 is a leap year, so Feb 29 exists (leap day)
+        self.assertEqual(self.parser.parse_iso("2016-059"), datetime(2016, 2, 28))
+        self.assertEqual(self.parser.parse_iso("2016-060"), datetime(2016, 2, 29))
+        self.assertEqual(self.parser.parse_iso("2016-061"), datetime(2016, 3, 1))
+
+        # 2017 is not a leap year, so Feb 29 does not exist
+        self.assertEqual(self.parser.parse_iso("2017-059"), datetime(2017, 2, 28))
+        self.assertEqual(self.parser.parse_iso("2017-060"), datetime(2017, 3, 1))
+        self.assertEqual(self.parser.parse_iso("2017-061"), datetime(2017, 3, 2))
+
+        # Since 2016 is a leap year, the 366th day falls in the same year
+        self.assertEqual(self.parser.parse_iso("2016-366"), datetime(2016, 12, 31))
+
+        # Since 2017 is not a leap year, the 366th day falls in the next year
+        self.assertEqual(self.parser.parse_iso("2017-366"), datetime(2018, 1, 1))
 
     def test_YYYY_DDDD_HH_mm_ssZ(self):
 
@@ -593,6 +717,11 @@ class DateTimeParserISOTests(Chai):
             datetime(2013, 2, 5, 4, 5, 6, tzinfo=tz.tzoffset(None, 3600)),
         )
 
+        self.assertEqual(
+            self.parser.parse_iso("2013-036 04:05:06Z"),
+            datetime(2013, 2, 5, 4, 5, 6, tzinfo=tz.tzutc()),
+        )
+
     def test_YYYY_MM_DDDD(self):
         with self.assertRaises(ParserError):
             self.parser.parse_iso("2014-05-125")
@@ -626,6 +755,19 @@ class DateTimeParserISOTests(Chai):
             self.parser.parse_iso("2013-02-03T04:05"), datetime(2013, 2, 3, 4, 5)
         )
 
+    def test_YYYY_MM_DDTHH(self):
+
+        self.assertEqual(
+            self.parser.parse_iso("2013-02-03T04"), datetime(2013, 2, 3, 4)
+        )
+
+    def test_YYYY_MM_DDTHHZ(self):
+
+        self.assertEqual(
+            self.parser.parse_iso("2013-02-03T04+01:00"),
+            datetime(2013, 2, 3, 4, tzinfo=tz.tzoffset(None, 3600)),
+        )
+
     def test_YYYY_MM_DDTHH_mm_ssZ(self):
 
         self.assertEqual(
@@ -751,14 +893,34 @@ class DateTimeParserISOTests(Chai):
             datetime(2013, 2, 3, 4, 5, 6, 789120, tzinfo=tz.tzoffset(None, 3600)),
         )
 
-        # parse_iso sets tzinfo to None if Z is passed, so a default datetime
-        # object is sufficient to compare against.
-        # Arrow adds +00:00 when get() is called directly and tzinfo is None
         self.assertEqual(
             self.parser.parse_iso("2013-02-03 04:05:06.78912Z"),
-            datetime(2013, 2, 3, 4, 5, 6, 789120),
+            datetime(2013, 2, 3, 4, 5, 6, 789120, tzinfo=tz.tzutc()),
         )
 
+    def test_invalid_Z(self):
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912z")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912zz")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912Zz")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912ZZ")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912+Z")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912-Z")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("2013-02-03T04:05:06.78912 Z")
+
     def test_parse_subsecond(self):
         # TODO: make both test_parse_subsecond functions in Parse and ParseISO
         # tests use the same expected objects (use pytest fixtures)
@@ -832,54 +994,56 @@ class DateTimeParserISOTests(Chai):
             "2016-05-16T04:05:06.789120ZblahZ",
             "2016-05-16T04:05:06.789120Zblah",
             "2016-05-16T04:05:06.789120blahZ",
-            "Meet me at 2016-05-16T04:05:06.789120 on Tuesday",
-            "Meet me at 2016-05-16 04:05:06.789120 on Tuesday",
+            "Meet me at 2016-05-16T04:05:06.789120 at the restaurant.",
+            "Meet me at 2016-05-16 04:05:06.789120 at the restaurant.",
         ]
 
         for ti in test_inputs:
             with self.assertRaises(ParserError):
                 self.parser.parse_iso(ti)
 
-    def test_parse_iso_with_leading_and_trailing_whitespace(self):
-        self.assertEqual(self.parser.parse_iso("      2016"), datetime(2016, 1, 1))
-
-        self.assertEqual(self.parser.parse_iso("2016      "), datetime(2016, 1, 1))
-
-        self.assertEqual(
-            self.parser.parse_iso("      2016      "), datetime(2016, 1, 1)
-        )
+    def test_iso8601_basic_format(self):
+        self.assertEqual(self.parser.parse_iso("20180517"), datetime(2018, 5, 17))
 
         self.assertEqual(
-            self.parser.parse_iso("      2016-05-16 04:05:06.789120      "),
-            datetime(2016, 5, 16, 4, 5, 6, 789120),
+            self.parser.parse_iso("20180517T10"), datetime(2018, 5, 17, 10)
         )
 
         self.assertEqual(
-            self.parser.parse_iso("      2016-05-16T04:05:06.789120      "),
-            datetime(2016, 5, 16, 4, 5, 6, 789120),
+            self.parser.parse_iso("20180517T105513.843456"),
+            datetime(2018, 5, 17, 10, 55, 13, 843456),
         )
 
-    def test_iso8601_basic_format(self):
-        self.assertEqual(self.parser.parse_iso("20180517"), datetime(2018, 5, 17))
-
         self.assertEqual(
-            self.parser.parse_iso("20180517T10"), datetime(2018, 5, 17, 10)
+            self.parser.parse_iso("20180517T105513Z"),
+            datetime(2018, 5, 17, 10, 55, 13, tzinfo=tz.tzutc()),
         )
 
         self.assertEqual(
-            self.parser.parse_iso("20180517T105513.84"),
-            datetime(2018, 5, 17, 10, 55, 13, 840000),
+            self.parser.parse_iso("20180517T105513.843456-0700"),
+            datetime(2018, 5, 17, 10, 55, 13, 843456, tzinfo=tz.tzoffset(None, -25200)),
         )
 
         self.assertEqual(
-            self.parser.parse_iso("20180517T105513Z"), datetime(2018, 5, 17, 10, 55, 13)
+            self.parser.parse_iso("20180517T105513-0700"),
+            datetime(2018, 5, 17, 10, 55, 13, tzinfo=tz.tzoffset(None, -25200)),
         )
 
         self.assertEqual(
-            self.parser.parse_iso("20180517T105513-0700"),
+            self.parser.parse_iso("20180517T105513-07"),
             datetime(2018, 5, 17, 10, 55, 13, tzinfo=tz.tzoffset(None, -25200)),
         )
 
+        # ordinal in basic format: YYYYDDDD
+        self.assertEqual(self.parser.parse_iso("1998136"), datetime(1998, 5, 16))
+
+        # timezone requires +- seperator
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("20180517T1055130700")
+
+        with self.assertRaises(ParserError):
+            self.parser.parse_iso("20180517T10551307")
+
         # too many digits in date
         with self.assertRaises(ParserError):
             self.parser.parse_iso("201860517T105513Z")
@@ -907,9 +1071,20 @@ class TzinfoParserTests(Chai):
     def test_parse_iso(self):
 
         self.assertEqual(self.parser.parse("01:00"), tz.tzoffset(None, 3600))
+        self.assertEqual(
+            self.parser.parse("11:35"), tz.tzoffset(None, 11 * 3600 + 2100)
+        )
         self.assertEqual(self.parser.parse("+01:00"), tz.tzoffset(None, 3600))
         self.assertEqual(self.parser.parse("-01:00"), tz.tzoffset(None, -3600))
 
+        self.assertEqual(self.parser.parse("0100"), tz.tzoffset(None, 3600))
+        self.assertEqual(self.parser.parse("+0100"), tz.tzoffset(None, 3600))
+        self.assertEqual(self.parser.parse("-0100"), tz.tzoffset(None, -3600))
+
+        self.assertEqual(self.parser.parse("01"), tz.tzoffset(None, 3600))
+        self.assertEqual(self.parser.parse("+01"), tz.tzoffset(None, 3600))
+        self.assertEqual(self.parser.parse("-01"), tz.tzoffset(None, -3600))
+
     def test_parse_str(self):
 
         self.assertEqual(self.parser.parse("US/Pacific"), tz.gettz("US/Pacific"))