Repository: HypothesisWorks/hypothesis

Issue:
Use a timedelta for the deadline setting
The [`deadline` setting](https://hypothesis.readthedocs.io/en/latest/settings.html#hypothesis.settings.deadline) is given as a number of milliseconds, but users could mistake this number for seconds or microseconds.  Fortunately, Python has a `timedelta` type which can easily express durations - it would be good to accept timedeltas as values for the `deadline` setting, and convert numeric deadlines to timedeltas for unambiguous display.

That would mean:

- Modifying the `hypothesis._settings._validate_deadline` function to accept timedeltas as well as numbers, and to return a timedelta
- Make the default value for the `deadline` setting a timedelta
- Calculate `runtime` in `hypothesis/core.py` as a timedelta, instead of a number of milliseconds, so that the comparison works (ctrl-f for `deadline` to find this)
- Add a test in `hypothesis-python/tests/cover/test_settings.py` that passes a timedelta as a deadline, to check that it works and get full coverage
- [optionally] create a subclass of `datetime.timedelta` that gives the `__repr__` in terms of milliseconds, and use it in `hypothesis._settings._validate_deadline` (only).  e.g.:

```python
class timedelta_ms(datetime.timedelta):
    def __repr__(self):
        t = self.total_seconds() * 1000
        if t == int(t):
            return "timedelta(milliseconds=%d)" % int(t)
        return datetime.timedelta.__repr__(self)
```


Discussion:
I would like to take a shot at this.
Go for it!  Per our usual policy, we'll reserve this issue for you for the next week 😄 

Make sure you check the contributing guide - you'll need to add your name to the list of authors, and add a `RELEASE.rst` file, then go through the points above.  If in doubt, open a work-in-progress pull request and I'll be happy to review it and help out!

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/_settings.py b/hypothesis-python/src/hypothesis/_settings.py
--- a/hypothesis-python/src/hypothesis/_settings.py
+++ b/hypothesis-python/src/hypothesis/_settings.py
@@ -37,7 +37,7 @@
     InvalidArgument,
     InvalidState,
 )
-from hypothesis.internal.compat import integer_types, string_types
+from hypothesis.internal.compat import integer_types, quiet_raise, string_types
 from hypothesis.internal.reflection import get_pretty_function_description, proxies
 from hypothesis.internal.validation import check_type, try_convert
 from hypothesis.utils.conventions import UniqueIdentifier, not_set
@@ -673,6 +673,13 @@ def validate_health_check_suppressions(suppressions):
 )
 
 
+class duration(datetime.timedelta):
+    """A timedelta specifically measured in milliseconds."""
+
+    def __repr__(self):
+        return "timedelta(milliseconds=%r)" % (self.total_seconds() * 1000,)
+
+
 def _validate_deadline(x):
     if isinstance(x, bool):
         note_deprecation(
@@ -680,26 +687,38 @@ def _validate_deadline(x):
             "  Boolean deadlines are treated as ints, and deprecated." % (x,),
             since="2019-03-06",
         )
-    if x is None or isinstance(x, integer_types + (float,)):
-        if x is not None and x <= 0:
+    if x is None:
+        return x
+    if isinstance(x, integer_types + (float,)):
+        try:
+            x = duration(milliseconds=x)
+        except OverflowError:
+            quiet_raise(
+                InvalidArgument(
+                    "deadline=%r is invalid, because it is too large to represent "
+                    "as a timedelta. Use deadline=None to disable deadlines." % (x,)
+                )
+            )
+    if isinstance(x, datetime.timedelta):
+        if x <= datetime.timedelta(0):
             raise InvalidArgument(
                 "deadline=%r is invalid, because it is impossible to meet a "
-                "deadline <= 0.  Use deadline=None to disable deadlines." % (x,)
+                "deadline <= 0. Use deadline=None to disable deadlines." % (x,)
             )
-        return x
+        return duration(seconds=x.total_seconds())
     raise InvalidArgument(
-        "deadline=%r (type %s) must be an integer or float number of milliseconds, "
+        "deadline=%r (type %s) must be a timedelta object, an integer or float number of milliseconds, "
         "or None to disable the per-test-case deadline." % (x, type(x).__name__)
     )
 
 
 settings._define_setting(
     "deadline",
-    default=200,
+    default=duration(milliseconds=200),
     validator=_validate_deadline,
     description=u"""
-If set, a time in milliseconds (which may be a float to express
-smaller units of time) that each individual example (i.e. each time your test
+If set, a duration (as timedelta, or integer or float number of milliseconds)
+that each individual example (i.e. each time your test
 function is called, not the whole decorated test) within a test is not
 allowed to exceed. Tests which take longer than that may be converted into
 errors (but will not necessarily be if close to the deadline, to allow some
diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py
--- a/hypothesis-python/src/hypothesis/core.py
+++ b/hypothesis-python/src/hypothesis/core.py
@@ -23,6 +23,7 @@
 import ast
 import base64
 import contextlib
+import datetime
 import inspect
 import os
 import random as rnd_module
@@ -529,11 +530,13 @@ def test(*args, **kwargs):
                 result = self.test(*args, **kwargs)
                 finish = benchmark_time()
                 internal_draw_time = sum(data.draw_times[initial_draws:])
-                runtime = (finish - start - internal_draw_time) * 1000
+                runtime = datetime.timedelta(
+                    seconds=finish - start - internal_draw_time
+                )
                 self.__test_runtime = runtime
                 current_deadline = self.settings.deadline
                 if not is_final:
-                    current_deadline *= 1.25
+                    current_deadline = (current_deadline // 4) * 5
                 if runtime >= current_deadline:
                     raise DeadlineExceeded(runtime, self.settings.deadline)
                 return result
@@ -581,7 +584,11 @@ def run(data):
                         "variability in your test timings, consider turning "
                         "deadlines off for this test by setting deadline=None."
                     )
-                    % (exception.runtime, self.settings.deadline, self.__test_runtime)
+                    % (
+                        exception.runtime.total_seconds() * 1000,
+                        self.settings.deadline.total_seconds() * 1000,
+                        self.__test_runtime.total_seconds() * 1000,
+                    )
                 )
             else:
                 report("Failed to reproduce exception. Expected: \n" + traceback)
diff --git a/hypothesis-python/src/hypothesis/errors.py b/hypothesis-python/src/hypothesis/errors.py
--- a/hypothesis-python/src/hypothesis/errors.py
+++ b/hypothesis-python/src/hypothesis/errors.py
@@ -146,7 +146,7 @@ class DeadlineExceeded(HypothesisException):
     def __init__(self, runtime, deadline):
         super(DeadlineExceeded, self).__init__(
             "Test took %.2fms, which exceeds the deadline of %.2fms"
-            % (runtime, deadline)
+            % (runtime.total_seconds() * 1000, deadline.total_seconds() * 1000)
         )
         self.runtime = runtime
         self.deadline = deadline


Test patch:
diff --git a/hypothesis-python/tests/cover/test_settings.py b/hypothesis-python/tests/cover/test_settings.py
--- a/hypothesis-python/tests/cover/test_settings.py
+++ b/hypothesis-python/tests/cover/test_settings.py
@@ -17,6 +17,7 @@
 
 from __future__ import absolute_import, division, print_function
 
+import datetime
 import re
 import subprocess
 import sys
@@ -324,6 +325,45 @@ def test_setattr_on_settings_singleton_is_error():
         settings.max_examples = 10
 
 
+def test_deadline_given_none():
+    x = settings(deadline=None).deadline
+    assert x is None
+
+
+def test_deadline_given_valid_int():
+    x = settings(deadline=1000).deadline
+    assert isinstance(x, datetime.timedelta)
+    assert x.days == 0 and x.seconds == 1 and x.microseconds == 0
+
+
+def test_deadline_given_valid_float():
+    x = settings(deadline=2050.25).deadline
+    assert isinstance(x, datetime.timedelta)
+    assert x.days == 0 and x.seconds == 2 and x.microseconds == 50250
+
+
+def test_deadline_given_valid_timedelta():
+    x = settings(deadline=datetime.timedelta(days=1, microseconds=15030000)).deadline
+    assert isinstance(x, datetime.timedelta)
+    assert x.days == 1 and x.seconds == 15 and x.microseconds == 30000
+
+
+@pytest.mark.parametrize(
+    "x",
+    [
+        0,
+        -0.7,
+        -1,
+        86400000000000000.2,
+        datetime.timedelta(microseconds=-1),
+        datetime.timedelta(0),
+    ],
+)
+def test_invalid_deadline(x):
+    with pytest.raises(InvalidArgument):
+        settings(deadline=x)
+
+
 @pytest.mark.parametrize(
     ("value", "replacement", "suggestion"),
     [