Repository: crsmithdev/arrow
Pull request #722 (2019-11-26 15:41:16)

Support for multiple granularities in humanize
This implements a feature in #716 to use multiple granularities as an input to humanize. 

For example, 
```
>>> later140 = self.now.shift(seconds=8400)
>>> later140.humanize(granularity="minute")
"in 140 minutes" 
>>> later140.humanize(granularity=["hour", "minute"])
"in 2 hours and 20 minutes"
```


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 34e139b..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-name: "🐞 Bug Report"
-about: Find a bug? Create a report to help us improve.
-title: ''
-labels: 'bug'
-assignees: ''
----
-
-<!--
-Thanks for taking the time to submit this bug report.
-
-Please provide us with a detailed description of the
-bug and a bit of information about your system.
--->
-
-## Issue Description
-
-<!--
-Replace with a description of the bug.
-
-Be sure to include details such as the expected
-and actual outcomes. Happy bug reporting 🐞!
--->
-
-## System Info
-
-- 🖥 **OS name and version**: <!-- Replace with OS name and version (e.g. macOS 10.15.1) -->
-- 🐍 **Python version**: <!-- Replace with Python version (e.g. Python 3.7.3). -->
-- 🏹 **Arrow version**: <!-- Replace with Arrow version. You can run arrow.__version__ to find out! -->
diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md
deleted file mode 100644
index c6b85c8..0000000
--- a/.github/ISSUE_TEMPLATE/documentation.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-name: "📚 Documentation"
-about: Find errors or problems in the docs (https://arrow.readthedocs.io)?
-title: ''
-labels: 'documentation'
-assignees: ''
----
-
-<!--
-Thanks for taking the time to submit documentation feedback.
-
-Please provide us with a detailed description of the documentation issue.
--->
-
-## Issue Description
-
-<!-- Replace with a description of the documentation issue. -->
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 4202e81..0000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-name: "💡 Feature Request"
-about: Have an idea for a new feature or improvement?
-title: ''
-labels: 'enhancement'
-assignees: ''
----
-
-<!--
-Thanks for taking the time to submit this feature request.
-
-Please provide us with a detailed description of the
-potential improvement. Happy feature requesting 💡!
--->
-
-## Feature Request
-
-<!-- Replace with a description of the potential improvement. -->
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
deleted file mode 100644
index 552b9a9..0000000
--- a/.github/pull_request_template.md
+++ /dev/null
@@ -1,23 +0,0 @@
-## Pull Request Checklist
-
-Thank you for taking the time to improve Arrow! Before submitting your pull request, please check all *appropriate* boxes:
-
-<!-- Check boxes by placing an x in the brackets: [x] -->
-- [ ] 🧪 Added **tests** for changed code.
-- [ ] 🛠️ All tests **pass** when run locally (run `tox` or `make test` to find out!).
-- [ ] 📚 Updated **documentation** for changed code.
-- [ ] ⏩ Code is **up-to-date** with the `master` branch.
-
-If you have *any* questions about your code changes or any of the points above, please submit your questions along with the pull request and we will try our best to help!
-
-## Description of Changes
-
-<!--
-Replace with description of code changes.
-
-If your PR has an associated issue, insert the issue number (e.g. #703) or directly link to the GitHub issue (e.g. https://github.com/crsmithdev/arrow/issues/703).
-
-For example, doing the following will automatically close issue #703 when this PR is merged:
-
-Closes: #703
--->
diff --git a/arrow/arrow.py b/arrow/arrow.py
index 2ed9b1b..cc9d041 100644
--- a/arrow/arrow.py
+++ b/arrow/arrow.py
@@ -56,6 +56,12 @@ class Arrow(object):
     _ATTRS = ["year", "month", "day", "hour", "minute", "second", "microsecond"]
     _ATTRS_PLURAL = ["{}s".format(a) for a in _ATTRS]
     _MONTHS_PER_QUARTER = 3
+    _SECS_PER_MINUTE = float(60)
+    _SECS_PER_HOUR = float(60 * 60)
+    _SECS_PER_DAY = float(60 * 60 * 24)
+    _SECS_PER_WEEK = float(60 * 60 * 24 * 7)
+    _SECS_PER_MONTH = float(60 * 60 * 24 * 30.5)
+    _SECS_PER_YEAR = float(60 * 60 * 24 * 365.25)
 
     def __init__(
         self, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None
@@ -156,7 +162,7 @@ class Arrow(object):
                 "The provided timestamp '{}' is invalid.".format(timestamp)
             )
 
-        dt = util.safe_fromtimestamp(float(timestamp), tzinfo)
+        dt = datetime.fromtimestamp(float(timestamp), tzinfo)
 
         return cls(
             dt.year,
@@ -182,7 +188,7 @@ class Arrow(object):
                 "The provided timestamp '{}' is invalid.".format(timestamp)
             )
 
-        dt = util.safe_utcfromtimestamp(float(timestamp))
+        dt = datetime.utcfromtimestamp(float(timestamp))
 
         return cls(
             dt.year,
@@ -351,7 +357,7 @@ class Arrow(object):
             )
 
     @classmethod
-    def span_range(cls, frame, start, end, tz=None, limit=None, bounds="[)"):
+    def span_range(cls, frame, start, end, tz=None, limit=None):
         """ Returns an iterator of tuples, each :class:`Arrow <arrow.arrow.Arrow>` objects,
         representing a series of timespans between two inputs.
 
@@ -361,10 +367,6 @@ class Arrow(object):
         :param tz: (optional) A :ref:`timezone expression <tz-expr>`.  Defaults to
             ``start``'s timezone, or UTC if ``start`` is naive.
         :param limit: (optional) A maximum number of tuples to return.
-        :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies
-            whether to include or exclude the start and end values in each span in the range. '(' excludes
-            the start, '[' includes the start, ')' excludes the end, and ']' includes the end.
-            If the bounds are not specified, the default bound '[)' is used.
 
         **NOTE**: The ``end`` or ``limit`` must be provided.  Call with ``end`` alone to
         return the entire range.  Call with ``limit`` alone to return a maximum # of results from
@@ -403,10 +405,10 @@ class Arrow(object):
         tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz)
         start = cls.fromdatetime(start, tzinfo).span(frame)[0]
         _range = cls.range(frame, start, end, tz, limit)
-        return (r.span(frame, bounds=bounds) for r in _range)
+        return (r.span(frame) for r in _range)
 
     @classmethod
-    def interval(cls, frame, start, end, interval=1, tz=None, bounds="[)"):
+    def interval(cls, frame, start, end, interval=1, tz=None):
         """ Returns an iterator of tuples, each :class:`Arrow <arrow.arrow.Arrow>` objects,
         representing a series of intervals between two inputs.
 
@@ -415,10 +417,6 @@ class Arrow(object):
         :param end: (optional) A datetime expression, the end of the range.
         :param interval: (optional) Time interval for the given time frame.
         :param tz: (optional) A timezone expression.  Defaults to UTC.
-        :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies
-            whether to include or exclude the start and end values in the intervals. '(' excludes
-            the start, '[' includes the start, ')' excludes the end, and ']' includes the end.
-            If the bounds are not specified, the default bound '[)' is used.
 
         Supported frame values: year, quarter, month, week, day, hour, minute, second
 
@@ -448,7 +446,7 @@ class Arrow(object):
         if interval < 1:
             raise ValueError("interval has to be a positive integer")
 
-        spanRange = iter(cls.span_range(frame, start, end, tz, bounds=bounds))
+        spanRange = iter(cls.span_range(frame, start, end, tz))
         while True:
             try:
                 intvlStart, intvlEnd = next(spanRange)
@@ -728,23 +726,12 @@ class Arrow(object):
             dt.tzinfo,
         )
 
-    @classmethod
-    def _validate_bounds(cls, bounds):
-        if bounds != "()" and bounds != "(]" and bounds != "[)" and bounds != "[]":
-            raise AttributeError(
-                'Invalid bounds. Please select between "()", "(]", "[)", or "[]".'
-            )
-
-    def span(self, frame, count=1, bounds="[)"):
+    def span(self, frame, count=1):
         """ Returns two new :class:`Arrow <arrow.arrow.Arrow>` objects, representing the timespan
         of the :class:`Arrow <arrow.arrow.Arrow>` object in a given timeframe.
 
         :param frame: the timeframe.  Can be any ``datetime`` property (day, hour, minute...).
         :param count: (optional) the number of frames to span.
-        :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies
-            whether to include or exclude the start and end values in the span. '(' excludes
-            the start, '[' includes the start, ')' excludes the end, and ']' includes the end.
-            If the bounds are not specified, the default bound '[)' is used.
 
         Supported frame values: year, quarter, month, week, day, hour, minute, second.
 
@@ -762,13 +749,8 @@ class Arrow(object):
             >>> arrow.utcnow().span('day', count=2)
             (<Arrow [2013-05-09T00:00:00+00:00]>, <Arrow [2013-05-10T23:59:59.999999+00:00]>)
 
-            >>> arrow.utcnow().span('day', bounds='[]')
-            (<Arrow [2013-05-09T00:00:00+00:00]>, <Arrow [2013-05-10T00:00:00+00:00]>)
-
         """
 
-        self._validate_bounds(bounds)
-
         frame_absolute, frame_relative, relative_steps = self._get_frames(frame)
 
         if frame_absolute == "week":
@@ -793,13 +775,11 @@ class Arrow(object):
         elif frame_absolute == "quarter":
             floor = floor + relativedelta(months=-((self.month - 1) % 3))
 
-        ceil = floor + relativedelta(**{frame_relative: count * relative_steps})
-
-        if bounds[0] == "(":
-            floor += relativedelta(microseconds=1)
-
-        if bounds[1] == ")":
-            ceil += relativedelta(microseconds=-1)
+        ceil = (
+            floor
+            + relativedelta(**{frame_relative: count * relative_steps})
+            + relativedelta(microseconds=-1)
+        )
 
         return floor, ceil
 
@@ -870,7 +850,8 @@ class Arrow(object):
             Defaults to now in the current :class:`Arrow <arrow.arrow.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.
-        :param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'.
+        :param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute',
+                           'hour', 'day', 'week', 'month' or 'year' or a list of any combination of these strings
 
         Usage::
 
@@ -903,6 +884,9 @@ class Arrow(object):
         else:
             raise TypeError()
 
+        if isinstance(granularity, list) and len(granularity) == 1:
+            granularity = granularity[0]
+
         delta = int(round(util.total_seconds(self._datetime - dt)))
         sign = -1 if delta < 0 else 1
         diff = abs(delta)
@@ -963,23 +947,23 @@ class Arrow(object):
                     years = sign * int(max(delta / 31536000, 2))
                     return locale.describe("years", years, only_distance=only_distance)
 
-            else:
+            elif util.isstr(granularity):
                 if granularity == "second":
                     delta = sign * delta
                     if abs(delta) < 2:
                         return locale.describe("now", only_distance=only_distance)
                 elif granularity == "minute":
-                    delta = sign * delta / float(60)
+                    delta = sign * delta / self._SECS_PER_MINUTE
                 elif granularity == "hour":
-                    delta = sign * delta / float(60 * 60)
+                    delta = sign * delta / self._SECS_PER_HOUR
                 elif granularity == "day":
-                    delta = sign * delta / float(60 * 60 * 24)
+                    delta = sign * delta / self._SECS_PER_DAY
                 elif granularity == "week":
-                    delta = sign * delta / float(60 * 60 * 24 * 7)
+                    delta = sign * delta / self._SECS_PER_WEEK
                 elif granularity == "month":
-                    delta = sign * delta / float(60 * 60 * 24 * 30.5)
+                    delta = sign * delta / self._SECS_PER_MONTH
                 elif granularity == "year":
-                    delta = sign * delta / float(60 * 60 * 24 * 365.25)
+                    delta = sign * delta / self._SECS_PER_YEAR
                 else:
                     raise AttributeError(
                         "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'"
@@ -988,6 +972,53 @@ class Arrow(object):
                 if trunc(abs(delta)) != 1:
                     granularity += "s"
                 return locale.describe(granularity, delta, only_distance=only_distance)
+
+            else:
+                timeframes = []
+                if "year" in granularity:
+                    years = sign * delta / self._SECS_PER_YEAR
+                    delta %= self._SECS_PER_YEAR
+                    timeframes.append(["year", years])
+
+                if "month" in granularity:
+                    months = sign * delta / self._SECS_PER_MONTH
+                    delta %= self._SECS_PER_MONTH
+                    timeframes.append(["month", months])
+
+                if "week" in granularity:
+                    weeks = sign * delta / self._SECS_PER_WEEK
+                    delta %= self._SECS_PER_WEEK
+                    timeframes.append(["week", weeks])
+
+                if "day" in granularity:
+                    days = sign * delta / self._SECS_PER_DAY
+                    delta %= self._SECS_PER_DAY
+                    timeframes.append(["day", days])
+
+                if "hour" in granularity:
+                    hours = sign * delta / self._SECS_PER_HOUR
+                    delta %= self._SECS_PER_HOUR
+                    timeframes.append(["hour", hours])
+
+                if "minute" in granularity:
+                    minutes = sign * delta / self._SECS_PER_MINUTE
+                    delta %= self._SECS_PER_MINUTE
+                    timeframes.append(["minute", minutes])
+
+                if "second" in granularity:
+                    seconds = sign * delta
+                    timeframes.append(["second", seconds])
+
+                if len(timeframes) < len(granularity):
+                    raise AttributeError(
+                        "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'"
+                    )
+
+                for index, (_, delta) in enumerate(timeframes):
+                    if trunc(abs(delta)) != 1:
+                        timeframes[index][0] += "s"
+                return locale.describe_multi(timeframes, only_distance=only_distance)
+
         except KeyError as e:
             raise ValueError(
                 "Humanization of the {} granularity is not currently translated in the '{}' locale. Please consider making a contribution to this locale.".format(
@@ -1027,7 +1058,10 @@ class Arrow(object):
 
         """
 
-        self._validate_bounds(bounds)
+        if bounds != "()" and bounds != "(]" and bounds != "[)" and bounds != "[]":
+            raise AttributeError(
+                'Invalid bounds. Please select between "()", "(]", "[)", or "[]".'
+            )
 
         if not isinstance(start, Arrow):
             raise TypeError(
diff --git a/arrow/locales.py b/arrow/locales.py
index 1a50662..9b1f617 100644
--- a/arrow/locales.py
+++ b/arrow/locales.py
@@ -51,6 +51,7 @@ class Locale(object):
 
     past = None
     future = None
+    and_word = None
 
     month_names = []
     month_abbreviations = []
@@ -78,6 +79,26 @@ class Locale(object):
 
         return humanized
 
+    def describe_multi(self, timeframes, only_distance=False):
+        """ Describes a delta within multiple timeframes in plain language.
+
+        :param timeframes: a list of string, quantity pairs each representing a timeframe and delta.
+        :param only_distance: return only distance eg: "2 hours 11 seconds" without "in" or "ago" keywords
+        """
+
+        humanized = ""
+        for index, (timeframe, delta) in enumerate(timeframes):
+            humanized += self._format_timeframe(timeframe, delta)
+            if index == len(timeframes) - 2 and self.and_word:
+                humanized += " " + self.and_word + " "
+            elif index < len(timeframes) - 1:
+                humanized += " "
+
+        if not only_distance:
+            humanized = self._format_relative(humanized, timeframe, delta)
+
+        return humanized
+
     def day_name(self, day):
         """ Returns the day name for a specified day of the week.
 
@@ -200,6 +221,7 @@ class EnglishLocale(Locale):
 
     past = "{0} ago"
     future = "in {0}"
+    and_word = "and"
 
     timeframes = {
         "now": "just now",
@@ -295,6 +317,7 @@ class ItalianLocale(Locale):
     names = ["it", "it_it"]
     past = "{0} fa"
     future = "tra {0}"
+    and_word = "e"
 
     timeframes = {
         "now": "adesso",
@@ -364,6 +387,7 @@ class SpanishLocale(Locale):
     names = ["es", "es_es"]
     past = "hace {0}"
     future = "en {0}"
+    and_word = "y"
 
     timeframes = {
         "now": "ahora",
@@ -437,6 +461,7 @@ class FrenchLocale(Locale):
     names = ["fr", "fr_fr"]
     past = "il y a {0}"
     future = "dans {0}"
+    and_word = "et"
 
     timeframes = {
         "now": "maintenant",
@@ -514,6 +539,7 @@ class GreekLocale(Locale):
 
     past = "{0} πριν"
     future = "σε {0}"
+    and_word = "και"
 
     timeframes = {
         "now": "τώρα",
@@ -639,6 +665,7 @@ class SwedishLocale(Locale):
 
     past = "för {0} sen"
     future = "om {0}"
+    and_word = "och"
 
     timeframes = {
         "now": "just nu",
@@ -1527,6 +1554,7 @@ class DeutschBaseLocale(Locale):
 
     past = "vor {0}"
     future = "in {0}"
+    and_word = "und"
 
     timeframes = {
         "now": "gerade eben",
@@ -1778,6 +1806,7 @@ class PortugueseLocale(Locale):
 
     past = "há {0}"
     future = "em {0}"
+    and_word = "e"
 
     timeframes = {
         "now": "agora",
@@ -2495,6 +2524,7 @@ class DanishLocale(Locale):
 
     past = "for {0} siden"
     future = "efter {0}"
+    and_word = "og"
 
     timeframes = {
         "now": "lige nu",
@@ -2802,6 +2832,7 @@ class SlovakLocale(Locale):
 
     past = "Pred {0}"
     future = "O {0}"
+    and_word = "a"
 
     month_names = [
         "",
@@ -3101,6 +3132,7 @@ class CatalanLocale(Locale):
     names = ["ca", "ca_es", "ca_ad", "ca_fr", "ca_it"]
     past = "Fa {0}"
     future = "En {0}"
+    and_word = "i"
 
     timeframes = {
         "now": "Ara mateix",
@@ -3685,6 +3717,7 @@ class RomanianLocale(Locale):
 
     past = "{0} în urmă"
     future = "peste {0}"
+    and_word = "și"
 
     timeframes = {
         "now": "acum",
@@ -3750,6 +3783,7 @@ class SlovenianLocale(Locale):
 
     past = "pred {0}"
     future = "čez {0}"
+    and_word = "in"
 
     timeframes = {
         "now": "zdaj",
@@ -3820,6 +3854,7 @@ class IndonesianLocale(Locale):
 
     past = "{0} yang lalu"
     future = "dalam {0}"
+    and_word = "dan"
 
     timeframes = {
         "now": "baru saja",
@@ -3957,6 +3992,7 @@ class EstonianLocale(Locale):
 
     past = "{0} tagasi"
     future = "{0} pärast"
+    and_word = "ja"
 
     timeframes = {
         "now": {"past": "just nüüd", "future": "just nüüd"},
diff --git a/arrow/parser.py b/arrow/parser.py
index 5f8c07b..a13b715 100644
--- a/arrow/parser.py
+++ b/arrow/parser.py
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
 
 from dateutil import tz
 
-from arrow import locales, util
+from arrow import locales
 from arrow.constants import MAX_TIMESTAMP, MAX_TIMESTAMP_MS, MAX_TIMESTAMP_US
 
 try:
@@ -218,7 +218,6 @@ class DateTimeParser(object):
         fmt_tokens, fmt_pattern_re = self._generate_pattern_re(fmt)
 
         match = fmt_pattern_re.search(datetime_string)
-
         if match is None:
             raise ParserMatchError(
                 "Failed to match '{}' when parsing '{}'".format(fmt, datetime_string)
@@ -293,19 +292,9 @@ class DateTimeParser(object):
         # and time string in a natural language sentence. Therefore, searching
         # for a string of the form YYYY-MM-DD in "blah 1998-09-12 blah" will
         # work properly.
-        # Certain punctuation before or after the target pattern such as
-        # "1998-09-12," is permitted. For the full list of valid punctuation,
-        # see the documentation.
-
-        starting_word_boundary = (
-            r"(?<!\S\S)"  # Don't have two consecutive non-whitespace characters. This ensures that we allow cases like .11.25.2019 but not 1.11.25.2019 (for pattern MM.DD.YYYY)
-            r"(?<![^\,\.\;\:\?\!\"\'\`\[\]\{\}\(\)<>\s])"  # This is the list of punctuation that is ok before the pattern (i.e. "It can't not be these characters before the pattern")
-            r"(\b|^)"  # The \b is to block cases like 1201912 but allow 201912 for pattern YYYYMM. The ^ was necessary to allow a negative number through i.e. before epoch numbers
-        )
-        ending_word_boundary = (
-            r"(?=[\,\.\;\:\?\!\"\'\`\[\]\{\}\(\)\<\>]?"  # Positive lookahead stating that these punctuation marks can appear after the pattern at most 1 time
-            r"(?!\S))"  # Don't allow any non-whitespace character after the punctuation
-        )
+        # Reference: https://stackoverflow.com/q/14232931/3820660
+        starting_word_boundary = r"(?<![\S])"
+        ending_word_boundary = r"(?![\S])"
         bounded_fmt_pattern = r"{}{}{}".format(
             starting_word_boundary, final_fmt_pattern, ending_word_boundary
         )
@@ -383,7 +372,7 @@ class DateTimeParser(object):
         timestamp = parts.get("timestamp")
 
         if timestamp is not None:
-            return util.safe_fromtimestamp(timestamp, tz=tz.tzutc())
+            return datetime.fromtimestamp(timestamp, tz=tz.tzutc())
 
         expanded_timestamp = parts.get("expanded_timestamp")
 
@@ -401,7 +390,7 @@ class DateTimeParser(object):
                         )
                     )
 
-            return util.safe_fromtimestamp(expanded_timestamp, tz=tz.tzutc())
+            return datetime.fromtimestamp(expanded_timestamp, tz=tz.tzutc())
 
         day_of_year = parts.get("day_of_year")
 
diff --git a/arrow/util.py b/arrow/util.py
index cf3bf5b..62f1a05 100644
--- a/arrow/util.py
+++ b/arrow/util.py
@@ -2,11 +2,6 @@
 from __future__ import absolute_import
 
 import datetime
-import math
-import time
-from os import name as os_name
-
-import dateutil
 
 
 def total_seconds(td):  # pragma: no cover
@@ -28,44 +23,6 @@ def is_timestamp(value):
         return False
 
 
-def windows_datetime_from_timestamp(timestamp, tz=None):
-    """Computes datetime from timestamp. Supports negative timestamps on Windows platform."""
-    sec_frac, sec = math.modf(timestamp)
-    dt = datetime.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc()) + datetime.timedelta(
-        seconds=sec, microseconds=sec_frac * 1000000
-    )
-    if tz is None:
-        tz = dateutil.tz.tzlocal()
-
-    if tz == dateutil.tz.tzlocal():
-        # because datetime.astimezone does not work on Windows for tzlocal() and dates before the 1970-01-01
-        # take timestamp from appropriate time of the year, because of daylight saving time changes
-        ts = time.mktime(dt.replace(year=1970).timetuple())
-        dt += datetime.datetime.fromtimestamp(ts) - datetime.datetime.utcfromtimestamp(
-            ts
-        )
-        dt = dt.replace(tzinfo=dateutil.tz.tzlocal())
-    else:
-        dt = dt.astimezone(tz)
-    return dt
-
-
-def safe_utcfromtimestamp(timestamp):
-    """ datetime.utcfromtimestamp alternative which supports negative timestamps on Windows platform."""
-    if os_name == "nt" and timestamp < 0:
-        return windows_datetime_from_timestamp(timestamp, dateutil.tz.tzutc())
-    else:
-        return datetime.datetime.utcfromtimestamp(timestamp)
-
-
-def safe_fromtimestamp(timestamp, tz=None):
-    """ datetime.fromtimestamp alternative which supports negative timestamps on Windows platform."""
-    if os_name == "nt" and timestamp < 0:
-        return windows_datetime_from_timestamp(timestamp, tz)
-    else:
-        return datetime.datetime.fromtimestamp(timestamp, tz)
-
-
 # Credit to https://stackoverflow.com/a/1700069
 def iso_to_gregorian(iso_year, iso_week, iso_day):
     """Converts an ISO week date tuple into a datetime object."""
@@ -100,12 +57,4 @@ except NameError:  # pragma: no cover
         return isinstance(s, str)
 
 
-__all__ = [
-    "total_seconds",
-    "is_timestamp",
-    "isstr",
-    "iso_to_gregorian",
-    "windows_datetime_from_timestamp",
-    "safe_utcfromtimestamp",
-    "safe_fromtimestamp",
-]
+__all__ = ["total_seconds", "is_timestamp", "isstr", "iso_to_gregorian"]
diff --git a/docs/index.rst b/docs/index.rst
index 5a83d74..d3371eb 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -208,10 +208,36 @@ Or another Arrow, or datetime:
     >>> future.humanize(present)
     'in 2 hours'
 
-Support for a growing number of locales (see ``locales.py`` for supported languages):
+Indicate time as relative or include only the distance
+
+.. code-block:: python
+
+    >>> present = arrow.utcnow()
+    >>> future = present.shift(hours=2)
+    >>> future.humanize(present)
+    'in 2 hours'
+    >>> future.humanize(present, only_distance=True)
+    '2 hours'
+
+
+Indicate a specific time granularity (or multiple):
 
 .. code-block:: python
 
+    >>> present = arrow.utcnow()
+    >>> future = present.shift(minutes=66)
+    >>> future.humanize(present, granularity="minute")
+    'in 66 minutes'
+    >>> future.humanize(present, granularity=["hour", "minute"])
+    'in an hour and 6 minutes'
+    >>> present.humanize(future, granularity=["hour", "minute"])
+    'an hour and 6 minutes ago'
+    >>> future.humanize(present, only_distance=True, granularity=["hour", "minute"])
+    'an hour and 6 minutes'
+
+Support for a growing number of locales (see ``locales.py`` for supported languages):
+
+.. code-block:: python
 
     >>> future = arrow.utcnow().shift(hours=1)
     >>> future.humanize(a, locale='ru')
@@ -428,22 +454,6 @@ You can also escape regular expressions by enclosing them within square brackets
     >>> arrow.get("Mon Sep 08   16:41:45   2014", fmt)
     <Arrow [2014-09-08T16:41:45+00:00]>
 
-Punctuation
-~~~~~~~~~~~
-
-Date formats may be fenced on either side by one punctuation character from the following list: :literal:`, . ; : ? ! " \` ' [ ] { } ( ) < >`
-
-.. code-block:: python
-
-    >>> arrow.get("Tomorrow (2019-10-31) is Halloween!", "YYYY-MM-DD")
-    <Arrow [2019-10-31T00:00:00+00:00]>
-
-    >>> arrow.get("Halloween is on 2019.10.31.", "YYYY.MM.DD")
-    <Arrow [2019-10-31T00:00:00+00:00]>
-
-    >>> arrow.get("It's Halloween tomorrow (2019-10-31)!", "YYYY-MM-DD")
-    # Raises exception because there are multiple punctuation marks following the date
-
 API Guide
 ---------
 


Test patch:
diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py
index 1beec26..5ab01f4 100644
--- a/tests/arrow_tests.py
+++ b/tests/arrow_tests.py
@@ -1207,22 +1207,6 @@ class ArrowSpanRangeTests(Chai):
             self.assertEqual(f.tzinfo, tz.gettz("US/Central"))
             self.assertEqual(c.tzinfo, tz.gettz("US/Central"))
 
-    def test_bounds_param_is_passed(self):
-
-        result = list(
-            arrow.Arrow.span_range(
-                "quarter", datetime(2013, 2, 2), datetime(2013, 5, 15), bounds="[]"
-            )
-        )
-
-        self.assertEqual(
-            result,
-            [
-                (arrow.Arrow(2013, 1, 1), arrow.Arrow(2013, 4, 1)),
-                (arrow.Arrow(2013, 4, 1), arrow.Arrow(2013, 7, 1)),
-            ],
-        )
-
 
 class ArrowIntervalTests(Chai):
     def test_incorrect_input(self):
@@ -1263,26 +1247,6 @@ class ArrowIntervalTests(Chai):
             ],
         )
 
-    def test_bounds_param_is_passed(self):
-        result = list(
-            arrow.Arrow.interval(
-                "hour",
-                datetime(2013, 5, 5, 12, 30),
-                datetime(2013, 5, 5, 17, 15),
-                2,
-                bounds="[]",
-            )
-        )
-
-        self.assertEqual(
-            result,
-            [
-                (arrow.Arrow(2013, 5, 5, 12), arrow.Arrow(2013, 5, 5, 14)),
-                (arrow.Arrow(2013, 5, 5, 14), arrow.Arrow(2013, 5, 5, 16)),
-                (arrow.Arrow(2013, 5, 5, 16), arrow.Arrow(2013, 5, 5, 18)),
-            ],
-        )
-
 
 class ArrowSpanTests(Chai):
     def setUp(self):
@@ -1404,34 +1368,6 @@ class ArrowSpanTests(Chai):
         self.assertEqual(floor, self.arrow.floor("month"))
         self.assertEqual(ceil, self.arrow.ceil("month"))
 
-    def test_span_inclusive_inclusive(self):
-
-        floor, ceil = self.arrow.span("hour", bounds="[]")
-
-        self.assertEqual(floor, datetime(2013, 2, 15, 3, tzinfo=tz.tzutc()))
-        self.assertEqual(ceil, datetime(2013, 2, 15, 4, tzinfo=tz.tzutc()))
-
-    def test_span_exclusive_inclusive(self):
-
-        floor, ceil = self.arrow.span("hour", bounds="(]")
-
-        self.assertEqual(floor, datetime(2013, 2, 15, 3, 0, 0, 1, tzinfo=tz.tzutc()))
-        self.assertEqual(ceil, datetime(2013, 2, 15, 4, tzinfo=tz.tzutc()))
-
-    def test_span_exclusive_exclusive(self):
-
-        floor, ceil = self.arrow.span("hour", bounds="()")
-
-        self.assertEqual(floor, datetime(2013, 2, 15, 3, 0, 0, 1, tzinfo=tz.tzutc()))
-        self.assertEqual(
-            ceil, datetime(2013, 2, 15, 3, 59, 59, 999999, tzinfo=tz.tzutc())
-        )
-
-    def test_bounds_are_validated(self):
-
-        with self.assertRaises(AttributeError):
-            floor, ceil = self.arrow.span("hour", bounds="][")
-
 
 class ArrowHumanizeTests(Chai):
     def setUp(self):
@@ -1499,6 +1435,12 @@ class ArrowHumanizeTests(Chai):
         self.assertEqual(
             later105.humanize(self.now, granularity="month"), "in 0 months"
         )
+        self.assertEqual(
+            self.now.humanize(later105, granularity=["month"]), "0 months ago"
+        )
+        self.assertEqual(
+            later105.humanize(self.now, granularity=["month"]), "in 0 months"
+        )
 
         later106 = self.now.shift(seconds=3 * 10 ** 6)
         self.assertEqual(self.now.humanize(later106, granularity="day"), "34 days ago")
@@ -1543,9 +1485,70 @@ class ArrowHumanizeTests(Chai):
             ),
             "3 years",
         )
+
         with self.assertRaises(AttributeError):
             self.now.humanize(later108, granularity="years")
 
+    def test_multiple_granularity(self):
+        self.assertEqual(self.now.humanize(granularity="second"), "just now")
+        self.assertEqual(self.now.humanize(granularity=["second"]), "just now")
+        self.assertEqual(
+            self.now.humanize(granularity=["year", "month", "day", "hour", "second"]),
+            "in 0 years 0 months 0 days 0 hours and seconds",
+        )
+
+        later4000 = self.now.shift(seconds=4000)
+        self.assertEqual(
+            later4000.humanize(self.now, granularity=["hour", "minute"]),
+            "in an hour and 6 minutes",
+        )
+        self.assertEqual(
+            self.now.humanize(later4000, granularity=["hour", "minute"]),
+            "an hour and 6 minutes ago",
+        )
+        self.assertEqual(
+            later4000.humanize(
+                self.now, granularity=["hour", "minute"], only_distance=True
+            ),
+            "an hour and 6 minutes",
+        )
+        self.assertEqual(
+            later4000.humanize(self.now, granularity=["day", "hour", "minute"]),
+            "in 0 days an hour and 6 minutes",
+        )
+        self.assertEqual(
+            self.now.humanize(later4000, granularity=["day", "hour", "minute"]),
+            "0 days an hour and 6 minutes ago",
+        )
+
+        later105 = self.now.shift(seconds=10 ** 5)
+        self.assertEqual(
+            self.now.humanize(later105, granularity=["hour", "day", "minute"]),
+            "a day 3 hours and 46 minutes ago",
+        )
+        with self.assertRaises(AttributeError):
+            self.now.humanize(later105, granularity=["error", "second"])
+
+        later108onlydistance = self.now.shift(seconds=10 ** 8)
+        self.assertEqual(
+            self.now.humanize(
+                later108onlydistance, only_distance=True, granularity=["year"]
+            ),
+            "3 years",
+        )
+        self.assertEqual(
+            self.now.humanize(
+                later108onlydistance, only_distance=True, granularity=["month", "week"]
+            ),
+            "37 months and 4 weeks",
+        )
+        self.assertEqual(
+            self.now.humanize(
+                later108onlydistance, only_distance=True, granularity=["year", "second"]
+            ),
+            "3 years and seconds",
+        )
+
     def test_seconds(self):
 
         later = self.now.shift(seconds=10)
diff --git a/tests/factory_tests.py b/tests/factory_tests.py
index 9f7e67a..98d9eca 100644
--- a/tests/factory_tests.py
+++ b/tests/factory_tests.py
@@ -323,14 +323,6 @@ class GetTests(Chai):
             result._datetime, datetime(2010, 1, 1, 0, 0, 0, 0, tzinfo=tz.tzutc())
         )
 
-        # regression test for issue #701
-        result = self.factory.get(
-            "Montag, 9. September 2019, 16:15-20:00", "dddd, D. MMMM YYYY", locale="de"
-        )
-        self.assertEqual(
-            result._datetime, datetime(2019, 9, 9, 0, 0, 0, 0, tzinfo=tz.tzutc())
-        )
-
     def test_locale_kwarg_only(self):
         res = self.factory.get(locale="ja")
         self.assertEqual(res.tzinfo, tz.tzutc())
diff --git a/tests/parser_tests.py b/tests/parser_tests.py
index 08c20d1..93eefc5 100644
--- a/tests/parser_tests.py
+++ b/tests/parser_tests.py
@@ -2,13 +2,14 @@
 from __future__ import unicode_literals
 
 import calendar
+import os
 import time
 from datetime import datetime
 
 from chai import Chai
 from dateutil import tz
 
-from arrow import parser, util
+from arrow import parser
 from arrow.constants import MAX_TIMESTAMP_US
 from arrow.parser import DateTimeParser, ParserError, ParserMatchError
 
@@ -227,19 +228,23 @@ class DateTimeParserParseTests(Chai):
             self.parser.parse("{:f}123456".format(float_timestamp), "X"), self.expected
         )
 
-        # regression test for issue #662
-        negative_int_timestamp = -int_timestamp
-        self.expected = util.safe_fromtimestamp(negative_int_timestamp, tz=tz_utc)
-        self.assertEqual(
-            self.parser.parse("{:d}".format(negative_int_timestamp), "X"), self.expected
-        )
+        # NOTE: negative timestamps cannot be handled by datetime on Window
+        # Must use timedelta to handle them. ref: https://stackoverflow.com/questions/36179914
+        if os.name != "nt":
+            # regression test for issue #662
+            negative_int_timestamp = -int_timestamp
+            self.expected = datetime.fromtimestamp(negative_int_timestamp, tz=tz_utc)
+            self.assertEqual(
+                self.parser.parse("{:d}".format(negative_int_timestamp), "X"),
+                self.expected,
+            )
 
-        negative_float_timestamp = -float_timestamp
-        self.expected = util.safe_fromtimestamp(negative_float_timestamp, tz=tz_utc)
-        self.assertEqual(
-            self.parser.parse("{:f}".format(negative_float_timestamp), "X"),
-            self.expected,
-        )
+            negative_float_timestamp = -float_timestamp
+            self.expected = datetime.fromtimestamp(negative_float_timestamp, tz=tz_utc)
+            self.assertEqual(
+                self.parser.parse("{:f}".format(negative_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)
@@ -467,10 +472,7 @@ class DateTimeParserParseTests(Chai):
 
     def test_parse_with_extra_words_at_start_and_end_valid(self):
         # Spaces surrounding the parsable date are ok because we
-        # allow the parsing of natural language input. Additionally, a single
-        # character of specific punctuation before or after the date is okay.
-        # See docs for full list of valid punctuation.
-
+        # allow the parsing of natural language input
         self.assertEqual(
             self.parser.parse("blah 2016 blah", "YYYY"), datetime(2016, 1, 1)
         )
@@ -525,38 +527,6 @@ class DateTimeParserParseTests(Chai):
             datetime(2016, 5, 16, 4, 5, 6, 789120),
         )
 
-    # regression test for issue #701
-    # tests cases of a partial match surrounded by punctuation
-    # for the list of valid punctuation, see documentation
-    def test_parse_with_punctuation_fences(self):
-        self.assertEqual(
-            self.parser.parse(
-                "Meet me at my house on Halloween (2019-31-10)", "YYYY-DD-MM"
-            ),
-            datetime(2019, 10, 31),
-        )
-
-        self.assertEqual(
-            self.parser.parse(
-                "Monday, 9. September 2019, 16:15-20:00", "dddd, D. MMMM YYYY"
-            ),
-            datetime(2019, 9, 9),
-        )
-
-        self.assertEqual(
-            self.parser.parse("A date is 11.11.2011.", "DD.MM.YYYY"),
-            datetime(2011, 11, 11),
-        )
-
-        with self.assertRaises(ParserMatchError):
-            self.parser.parse("11.11.2011.1 is not a valid date.", "DD.MM.YYYY")
-
-        with self.assertRaises(ParserMatchError):
-            self.parser.parse(
-                "This date has too many punctuation marks following it (11.11.2011).",
-                "DD.MM.YYYY",
-            )
-
     def test_parse_with_leading_and_trailing_whitespace(self):
         self.assertEqual(self.parser.parse("      2016", "YYYY"), datetime(2016, 1, 1))
 
diff --git a/tests/util_tests.py b/tests/util_tests.py
index 980a0a9..bb84c44 100644
--- a/tests/util_tests.py
+++ b/tests/util_tests.py
@@ -1,10 +1,7 @@
 # -*- coding: utf-8 -*-
 import time
-from datetime import datetime
 
 from chai import Chai
-from dateutil import tz
-from mock import patch
 
 from arrow import util
 
@@ -36,63 +33,3 @@ class UtilTests(Chai):
 
         with self.assertRaises(ValueError):
             util.iso_to_gregorian(2013, 8, 0)
-
-    def test_windows_datetime_from_timestamp(self):
-        timestamp = 1572204340.6460679
-        result = util.windows_datetime_from_timestamp(timestamp)
-        expected = datetime.fromtimestamp(timestamp).replace(tzinfo=tz.tzlocal())
-        self.assertEqual(result, expected)
-
-    def test_windows_datetime_from_timestamp_utc(self):
-        timestamp = 1572204340.6460679
-        result = util.windows_datetime_from_timestamp(timestamp, tz.tzutc())
-        expected = datetime.utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
-        self.assertEqual(result, expected)
-
-    def test_safe_utcfromtimestamp(self):
-        timestamp = 1572204340.6460679
-        result = util.safe_utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
-        expected = datetime.utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
-        self.assertEqual(result, expected)
-
-    def test_safe_fromtimestamp_default_tz(self):
-        timestamp = 1572204340.6460679
-        result = util.safe_fromtimestamp(timestamp).replace(tzinfo=tz.tzlocal())
-        expected = datetime.fromtimestamp(timestamp).replace(tzinfo=tz.tzlocal())
-        self.assertEqual(result, expected)
-
-    def test_safe_fromtimestamp_paris_tz(self):
-        timestamp = 1572204340.6460679
-        result = util.safe_fromtimestamp(timestamp, tz.gettz("Europe/Paris"))
-        expected = datetime.fromtimestamp(timestamp, tz.gettz("Europe/Paris"))
-        self.assertEqual(result, expected)
-
-    def test_safe_utcfromtimestamp_negative(self):
-        timestamp = -1572204340.6460679
-        result = util.safe_utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
-        expected = datetime(1920, 3, 7, 4, 34, 19, 353932, tzinfo=tz.tzutc())
-        self.assertEqual(result, expected)
-
-    def test_safe_fromtimestamp_negative(self):
-        timestamp = -1572204340.6460679
-        result = util.safe_fromtimestamp(timestamp, tz.gettz("Europe/Paris"))
-        expected = datetime(
-            1920, 3, 7, 5, 34, 19, 353932, tzinfo=tz.gettz("Europe/Paris")
-        )
-        self.assertEqual(result, expected)
-
-    @patch.object(util, "os_name", "nt")
-    def test_safe_utcfromtimestamp_negative_nt(self):
-        timestamp = -1572204340.6460679
-        result = util.safe_utcfromtimestamp(timestamp).replace(tzinfo=tz.tzutc())
-        expected = datetime(1920, 3, 7, 4, 34, 19, 353932, tzinfo=tz.tzutc())
-        self.assertEqual(result, expected)
-
-    @patch.object(util, "os_name", "nt")
-    def test_safe_fromtimestamp_negative_nt(self):
-        timestamp = -1572204340.6460679
-        result = util.safe_fromtimestamp(timestamp, tz.gettz("Europe/Paris"))
-        expected = datetime(
-            1920, 3, 7, 5, 34, 19, 353932, tzinfo=tz.gettz("Europe/Paris")
-        )
-        self.assertEqual(result, expected)