Repository: HypothesisWorks/hypothesis

Issue:
Show falsifying keyword arguments in the same order as the arguments
Falsifying examples are shown with positional arguments converted to keyword arguments. However, these are sorted in alphabetical order. This can be confusing. It would be better if the arguments were shown in the original order.

For example:

```py
from hypothesis import given
from hypothesis.strategies import integers

@given(integers(), integers(), integers())
def test(b, a, c):
    assert a == b == c
```

gives

```
Falsifying example: test(
    a=0, b=0, c=1,
)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in test
  File "/Users/aaronmeurer/anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/core.py", line 1171, in wrapped_test
    raise the_error_hypothesis_found
  File "<stdin>", line 6, in test
AssertionError
```

I dug into it. Hypothesis internally converts the entire argument spec into a single strategy, and converts positional arguments into keyword arguments where possible.  

The issue seems to be that fixed_dictionaries does not maintain key order for normal dictionaries. I'm not sure why this is the case, since dictionaries are supposed to be ordered since Python 3.6. Supposing that isn't going to change, however, I think this can be fixed by explicitly passing an OrderedDict 

If this weren't the case, I believe this would just work as expected, except there is also an oddity where the keyword arguments are actually reversed for some reason [here](https://github.com/HypothesisWorks/hypothesis/blob/3d5761ed9254b39260f39cf52bad1293ccf733ab/hypothesis-python/src/hypothesis/core.py#L975). I'm not entirely clear about that logic, but I guess it is supposed to handle the case where one argument to the `zip` is longer than the other (if so, it can easily be fixed by using an additional reversed on the zip). 

In short, this patch fixes the issue

```patch
diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py
index 6089b3efb..606c5d1e2 100644
--- a/hypothesis-python/src/hypothesis/core.py
+++ b/hypothesis-python/src/hypothesis/core.py
@@ -27,7 +27,7 @@ import traceback
 import types
 import warnings
 import zlib
-from collections import defaultdict
+from collections import defaultdict, OrderedDict
 from inspect import getfullargspec
 from io import StringIO
 from random import Random
@@ -436,7 +436,7 @@ def process_arguments_to_given(wrapped_test, arguments, kwargs, given_kwargs, ar
     search_strategy = TupleStrategy(
         (
             st.just(arguments),
-            st.fixed_dictionaries(given_kwargs).map(lambda args: dict(args, **kwargs)),
+            st.fixed_dictionaries(OrderedDict(given_kwargs)).map(lambda args: dict(args, **kwargs)),
         )
     )
 
@@ -971,9 +971,9 @@ def given(
         # positional arguments into keyword arguments for simplicity.
         if given_arguments:
             assert not given_kwargs
-            for name, strategy in zip(
+            for name, strategy in reversed(list(zip(
                 reversed(original_argspec.args), reversed(given_arguments)
-            ):
+            ))):
                 given_kwargs[name] = strategy
         # These have been converted, so delete them to prevent accidental use.
         del given_arguments
```

Although (to me at least), making fixed_dictionaries not sort normal dicts would be preferable. 


Discussion:
I'd be happy to accept a patch the patch above, as well as stopping `st.fixed_dictionaries()` from sorting the keys:

https://github.com/HypothesisWorks/hypothesis/blob/3d5761ed9254b39260f39cf52bad1293ccf733ab/hypothesis-python/src/hypothesis/strategies/_internal/collections.py#L233-L239

IMO this made sense when we supported earlier versions of Python for consistency across versions, as the database requires a consistent order of keys, but as definition order is reliably preserved on Python 3.6+ we can unconditionally `self.keys = tuple(strategy_dict.keys())`.
OK. Should these be separate pull requests?
I think combining them makes sense; with `RELEASE_TYPE: minor` in the change notes.

Resolving patch:
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
@@ -971,10 +971,8 @@ def run_test_as_given(test):
         # positional arguments into keyword arguments for simplicity.
         if given_arguments:
             assert not given_kwargs
-            for name, strategy in zip(
-                reversed(original_argspec.args), reversed(given_arguments)
-            ):
-                given_kwargs[name] = strategy
+            nargs = len(given_arguments)
+            given_kwargs.update(zip(original_argspec.args[-nargs:], given_arguments))
         # These have been converted, so delete them to prevent accidental use.
         del given_arguments
 
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/collections.py b/hypothesis-python/src/hypothesis/strategies/_internal/collections.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/collections.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/collections.py
@@ -13,8 +13,6 @@
 #
 # END HEADER
 
-from collections import OrderedDict
-
 from hypothesis.errors import InvalidArgument
 from hypothesis.internal.conjecture import utils as cu
 from hypothesis.internal.conjecture.junkdrawer import LazySequenceCopy
@@ -229,14 +227,7 @@ class FixedKeysDictStrategy(MappedSearchStrategy):
 
     def __init__(self, strategy_dict):
         self.dict_type = type(strategy_dict)
-
-        if isinstance(strategy_dict, OrderedDict):
-            self.keys = tuple(strategy_dict.keys())
-        else:
-            try:
-                self.keys = tuple(sorted(strategy_dict.keys()))
-            except TypeError:
-                self.keys = tuple(sorted(strategy_dict.keys(), key=repr))
+        self.keys = tuple(strategy_dict.keys())
         super().__init__(strategy=TupleStrategy(strategy_dict[k] for k in self.keys))
 
     def calc_is_empty(self, recur):
@@ -262,14 +253,6 @@ def __init__(self, strategy_dict, optional):
         self.fixed = FixedKeysDictStrategy(strategy_dict)
         self.optional = optional
 
-        if isinstance(self.optional, OrderedDict):
-            self.optional_keys = tuple(self.optional.keys())
-        else:
-            try:
-                self.optional_keys = tuple(sorted(self.optional.keys()))
-            except TypeError:
-                self.optional_keys = tuple(sorted(self.optional.keys(), key=repr))
-
     def calc_is_empty(self, recur):
         return recur(self.fixed)
 
@@ -278,7 +261,7 @@ def __repr__(self):
 
     def do_draw(self, data):
         result = data.draw(self.fixed)
-        remaining = [k for k in self.optional_keys if not self.optional[k].is_empty]
+        remaining = [k for k, v in self.optional.items() if not v.is_empty]
         should_draw = cu.many(
             data, min_size=0, max_size=len(remaining), average_size=len(remaining) / 2
         )


Test patch:
diff --git a/hypothesis-python/tests/nocover/test_testdecorators.py b/hypothesis-python/tests/nocover/test_testdecorators.py
--- a/hypothesis-python/tests/nocover/test_testdecorators.py
+++ b/hypothesis-python/tests/nocover/test_testdecorators.py
@@ -13,9 +13,8 @@
 #
 # END HEADER
 
-from hypothesis import HealthCheck, given, reject, settings
+from hypothesis import HealthCheck, given, reject, settings, strategies as st
 from hypothesis.errors import InvalidArgument, Unsatisfiable
-from hypothesis.strategies import integers
 
 from tests.common.utils import raises
 
@@ -23,7 +22,7 @@
 def test_contains_the_test_function_name_in_the_exception_string():
     look_for_one = settings(max_examples=1, suppress_health_check=HealthCheck.all())
 
-    @given(integers())
+    @given(st.integers())
     @look_for_one
     def this_has_a_totally_unique_name(x):
         reject()
@@ -33,7 +32,7 @@ def this_has_a_totally_unique_name(x):
     assert this_has_a_totally_unique_name.__name__ in e.value.args[0]
 
     class Foo:
-        @given(integers())
+        @given(st.integers())
         @look_for_one
         def this_has_a_unique_name_and_lives_on_a_class(self, x):
             reject()
@@ -47,7 +46,7 @@ def test_signature_mismatch_error_message():
     # Regression test for issue #1978
 
     @settings(max_examples=2)
-    @given(x=integers())
+    @given(x=st.integers())
     def bad_test():
         pass
 
@@ -58,3 +57,9 @@ def bad_test():
             str(e) == "bad_test() got an unexpected keyword argument 'x', "
             "from `x=integers()` in @given"
         )
+
+
+@given(data=st.data(), keys=st.lists(st.integers(), unique=True))
+def test_fixed_dict_preserves_iteration_order(data, keys):
+    d = data.draw(st.fixed_dictionaries({k: st.none() for k in keys}))
+    assert all(a == b for a, b in zip(keys, d)), f"keys={keys}, d.keys()={d.keys()}"