Repository: crsmithdev/arrow
Pull request #576 (2019-04-14 15:08:36)

Improved humanize behavior for near-zero durations
Since @TroyStacer and I were already working on fixing issue #541 in Humanize, we thought we would tackle bug #416 as well since the PR and issue for it has been stale since mid 2017.

Fixes: https://github.com/crsmithdev/arrow/issues/416

The original PR for this issue (https://github.com/crsmithdev/arrow/pull/417) did not seem to fully address the issue and had failing tests, so we decided to open a new PR with fresh changes and updates.

codecov-io: # [Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=h1) Report
> Merging [#576](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=desc) into [master](https://codecov.io/gh/crsmithdev/arrow/commit/43ffd2ac65984ca90d6c44b6e6dc9769f5288117?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/crsmithdev/arrow/pull/576/graphs/tree.svg?width=650&token=4EkGnFsIGn&height=150&src=pr)](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #576   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          13     13           
  Lines        3399   3411   +12     
  Branches      238    239    +1     
=====================================
+ Hits         3399   3411   +12
```


| [Impacted Files](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [arrow/locales.py](https://codecov.io/gh/crsmithdev/arrow/pull/576/diff?src=pr&el=tree#diff-YXJyb3cvbG9jYWxlcy5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [tests/locales\_tests.py](https://codecov.io/gh/crsmithdev/arrow/pull/576/diff?src=pr&el=tree#diff-dGVzdHMvbG9jYWxlc190ZXN0cy5weQ==) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=footer). Last update [43ffd2a...8c38a29](https://codecov.io/gh/crsmithdev/arrow/pull/576?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

jadchaar: Thanks for the approval @systemcatch. One question though: would you like us to remove the docstrings comments from the inherited methods in the locales? Since describe is an internal method, it may not make much sense to keep the docstring comments. Would it be better to replace them with an inline comment explaining the rationale for the design decision?
systemcatch: hey @jadchaar, sure if you feel like it but no need to go to any great effort.
jadchaar: If I remove the docstrings from the two locales that override the describe method (English and German) to clean things up and reduce duplication, will Sphinx automatically copy over the parent classes docstring? I was reading online on a StackOverflow post that this seems to be the case, but I wanted to confirm :).

Patch:
diff --git a/arrow/arrow.py b/arrow/arrow.py
index 8db28be..08639a3 100644
--- a/arrow/arrow.py
+++ b/arrow/arrow.py
@@ -871,6 +871,65 @@ class Arrow(object):
             if(trunc(abs(delta)) != 1):
                 granularity += 's'
             return locale.describe(granularity, delta, only_distance=only_distance)
+
+    # query functions
+
+    def is_between(self, start, end, bounds='()'):
+        ''' Returns a boolean denoting whether the specified date and time is between
+        the start and end dates and times.
+
+        :param start: an :class:`Arrow <arrow.arrow.Arrow>`.
+        :param end: an :class:`Arrow <arrow.arrow.Arrow>`.
+        :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies
+            whether to include or exclude the start and end values 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.
+
+        Usage::
+
+            >>> start = arrow.get(datetime(2013, 5, 5, 12, 30, 10))
+            >>> end = arrow.get(datetime(2013, 5, 5, 12, 30, 36))
+            >>> arrow.get(datetime(2013, 5, 5, 12, 30, 27)).is_between(start, end)
+            True
+
+            >>> start = arrow.get(datetime(2013, 5, 5))
+            >>> end = arrow.get(datetime(2013, 5, 8))
+            >>> arrow.get(datetime(2013, 5, 8)).is_between(start, end, '[]')
+            True
+
+            >>> start = arrow.get(datetime(2013, 5, 5))
+            >>> end = arrow.get(datetime(2013, 5, 8))
+            >>> arrow.get(datetime(2013, 5, 8)).is_between(start, end, '[)')
+            False
+
+        '''
+
+        if bounds != '()' and bounds != '(]' and bounds != '[)' and bounds != '[]':
+            raise AttributeError('Error. Could not understand the specified bounds. Please select between \
+                "()", "(]", "[)", or "[]"')
+
+        if not isinstance(start, Arrow):
+            raise TypeError('Can\'t parse start date argument type of \'{}\''.format(type(start)))
+
+        if not isinstance(end, Arrow):
+            raise TypeError('Can\'t parse end date argument type of \'{}\''.format(type(end)))
+
+        include_start = (bounds[0] == '[')
+        include_end = (bounds[1] == ']')
+
+        target_timestamp = self.float_timestamp
+        start_timestamp = start.float_timestamp
+        end_timestamp = end.float_timestamp
+
+        if include_start and include_end:
+            return target_timestamp >= start_timestamp and target_timestamp <= end_timestamp
+        elif include_start and not include_end:
+            return target_timestamp >= start_timestamp and target_timestamp < end_timestamp
+        elif not include_start and include_end:
+            return target_timestamp > start_timestamp and target_timestamp <= end_timestamp
+        else:
+            return target_timestamp > start_timestamp and target_timestamp < end_timestamp
+
     # math
 
     def __add__(self, other):
diff --git a/arrow/locales.py b/arrow/locales.py
index 2efc859..db9433a 100644
--- a/arrow/locales.py
+++ b/arrow/locales.py
@@ -234,6 +234,19 @@ class EnglishLocale(Locale):
                 return '{}rd'.format(n)
         return '{}th'.format(n)
 
+    def describe(self, timeframe, delta=0, only_distance=False):
+        ''' Describes a delta within a timeframe in plain language.
+
+        :param timeframe: a string representing a timeframe.
+        :param delta: a quantity representing a delta in a timeframe.
+        :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords
+        '''
+
+        humanized = super(EnglishLocale, self).describe(timeframe, delta, only_distance)
+        if only_distance and timeframe == 'now':
+            humanized = 'instantly'
+
+        return humanized
 
 class ItalianLocale(Locale):
     names = ['it', 'it_it']
@@ -843,6 +856,7 @@ class _DeutschLocaleCommonMixin(object):
 
     def describe(self, timeframe, delta=0, only_distance=False):
         ''' Describes a delta within a timeframe in plain language.
+
         :param timeframe: a string representing a timeframe.
         :param delta: a quantity representing a delta in a timeframe.
         :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords


Test patch:
diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py
index afa3ca3..fa173c5 100644
--- a/tests/arrow_tests.py
+++ b/tests/arrow_tests.py
@@ -1360,6 +1360,82 @@ class ArrowHumanizeTestsWithLocale(Chai):
 
         assertEqual(result, '2 года назад')
 
+class ArrowIsBetweenTests(Chai):
+
+    def test_start_before_end(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 8))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 5))
+        result = target.is_between(start, end)
+        assertFalse(result)
+
+    def test_exclusive_exclusive_bounds(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 27))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 10))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 36))
+        result = target.is_between(start, end, '()')
+        assertTrue(result)
+        result = target.is_between(start, end)
+        assertTrue(result)
+
+    def test_exclusive_exclusive_bounds_same_date(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        result = target.is_between(start, end, '()')
+        assertFalse(result)
+
+    def test_inclusive_exclusive_bounds(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 6))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 4))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 6))
+        result = target.is_between(start, end, '[)')
+        assertFalse(result)
+
+    def test_exclusive_inclusive_bounds(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        result = target.is_between(start, end, '(]')
+        assertTrue(result)
+
+    def test_inclusive_inclusive_bounds_same_date(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        result = target.is_between(start, end, '[]')
+        assertTrue(result)
+
+    def test_type_error_exception(self):
+        with assertRaises(TypeError):
+            target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+            start = datetime(2013, 5, 5)
+            end = arrow.Arrow.fromdatetime(datetime(2013, 5, 8))
+            result = target.is_between(start, end)
+
+        with assertRaises(TypeError):
+            target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+            start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5))
+            end = datetime(2013, 5, 8)
+            result = target.is_between(start, end)
+
+        with assertRaises(TypeError):
+            result = target.is_between(None, None)
+
+    def test_attribute_error_exception(self):
+        target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7))
+        start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5))
+        end = arrow.Arrow.fromdatetime(datetime(2013, 5, 8))
+        with assertRaises(AttributeError):
+            result = target.is_between(start, end, "][")
+        with assertRaises(AttributeError):
+            result = target.is_between(start, end, "")
+        with assertRaises(AttributeError):
+            result = target.is_between(start, end, "]")
+        with assertRaises(AttributeError):
+            result = target.is_between(start, end, "[")
+        with assertRaises(AttributeError):
+            result = target.is_between(start, end, "hello")
 
 class ArrowUtilTests(Chai):
 
diff --git a/tests/locales_tests.py b/tests/locales_tests.py
index 82b31ef..adaab86 100644
--- a/tests/locales_tests.py
+++ b/tests/locales_tests.py
@@ -96,6 +96,18 @@ class LocaleTests(Chai):
         assertEqual(self.locale.meridian(7, 'NONSENSE'), None)
 
 
+class EnglishLocaleTests(Chai):
+
+    def setUp(self):
+        super(EnglishLocaleTests, self).setUp()
+
+        self.locale = locales.EnglishLocale()
+
+    def test_describe(self):
+        assertEqual(self.locale.describe('now', only_distance=True), 'instantly')
+        assertEqual(self.locale.describe('now', only_distance=False), 'just now')
+
+
 class ItalianLocalesTests(Chai):
 
     def test_ordinal_number(self):