Repository: HypothesisWorks/hypothesis

Issue:
Allow st.lists() to accept a tuple of functions for unique_by
Let's say I want to generate a list of users, but I want each user to be unique by `id` and `email` independently, not unique by the pair of properties.

For example, the strategy should not generate ```[{'id': 1, 'email': 'john@aol.com'}, {'id': 1, 'email': 'jane@aol.com'}]```, because they both have the same `id`. This might occur if I were to do the following:

```python
strategies.lists(_user_generator(), min_size=1, max_size=2, unique_by=lambda x: (x['id'], x['email']))
```

What I would really like is something along the lines of:

```python
strategies.lists(_user_generator(), min_size=1, max_size=2, unique_by=[lambda x: x['id'], lambda x: x['email']])
```

Am I missing a common approach here, or is something like this needed as a new feature?


Discussion:
Sounds like a useful feature request to me! Tagged as enhancement because it's upgrading an existing strategy rather than adding a new one.

You could currently approximate this by generating two unique lists, or by filtering, but that will be inefficient to shrink or to generate respectively. (check out the `@composite` docs if you don't mind that)

For anyone who wants to implement this: check out `hypothesis.searchstrategies.collections.UniqueListStrategy`, and think about using a list of key functions. As a design point I think we should stick to a single "seen" set for all the key functions; this is a slightly stronger uniqueness property in that it avoids possible collisions - for example `unique_by=(get_first_element, get_last_element)` nothing can be generated with a last element that is the same as any other last *or first* element.  It's much easier to relax this later than to tighten the uniqueness constraint!

Then thread that back through to the public interface. For validation, I think we'll insist on a single callable or a tuple of two or more callables, without allowing lists (because of mutation). 
Thank you, it sounds like `@composite` will be helpful.
I can have a go at this one

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/_strategies.py b/hypothesis-python/src/hypothesis/_strategies.py
--- a/hypothesis-python/src/hypothesis/_strategies.py
+++ b/hypothesis-python/src/hypothesis/_strategies.py
@@ -718,7 +718,7 @@ def lists(
     elements,  # type: SearchStrategy[Ex]
     min_size=0,  # type: int
     max_size=None,  # type: int
-    unique_by=None,  # type: Callable[..., Any]
+    unique_by=None,  # type: Union[Callable, Tuple[Callable, ...]]
     unique=False,  # type: bool
 ):
     # type: (...) -> SearchStrategy[List[Ex]]
@@ -731,10 +731,20 @@ def lists(
     object equality, as if unique_by was ``lambda x: x``. This comparison only
     works for hashable types.
 
-    if unique_by is not None it must be a function returning a hashable type
-    when given a value drawn from elements. The resulting list will satisfy the
-    condition that for ``i`` != ``j``, ``unique_by(result[i])`` !=
-    ``unique_by(result[j])``.
+    If unique_by is not None it must be a callable or tuple of callables
+    returning a hashable type when given a value drawn from elements. The
+    resulting list will satisfy the condition that for ``i`` != ``j``,
+    ``unique_by(result[i])`` != ``unique_by(result[j])``.
+
+    If ``unique_by`` is a tuple of callables the uniqueness will be respective
+    to each callable.
+
+    For example, the following will produce two columns of integers with both
+    columns being unique respectively.
+    .. code-block:: pycon
+
+        >>> twoints = st.tuples(st.integers(), st.integers())
+        >>> st.lists(twoints, unique_by=(lambda x: x[0], lambda x: x[1]))
 
     Examples from this strategy shrink by trying to remove elements from the
     list, and by shrinking each individual element of the list.
@@ -755,8 +765,19 @@ def unique_by(x):
     if max_size == 0:
         return builds(list)
     if unique_by is not None:
+        if not (callable(unique_by) or isinstance(unique_by, tuple)):
+            raise InvalidArgument(
+                "unique_by=%r is not a callable or tuple of callables" % (unique_by)
+            )
+        if callable(unique_by):
+            unique_by = (unique_by,)
+        if len(unique_by) == 0:
+            raise InvalidArgument("unique_by is empty")
+        for i, f in enumerate(unique_by):
+            if not callable(f):
+                raise InvalidArgument("unique_by[%i]=%r is not a callable" % (i, f))
         return UniqueListStrategy(
-            elements=elements, max_size=max_size, min_size=min_size, key=unique_by
+            elements=elements, max_size=max_size, min_size=min_size, keys=unique_by
         )
     return ListStrategy(elements, min_size=min_size, max_size=max_size)
 
diff --git a/hypothesis-python/src/hypothesis/searchstrategy/collections.py b/hypothesis-python/src/hypothesis/searchstrategy/collections.py
--- a/hypothesis-python/src/hypothesis/searchstrategy/collections.py
+++ b/hypothesis-python/src/hypothesis/searchstrategy/collections.py
@@ -129,9 +129,9 @@ def __repr__(self):
 
 
 class UniqueListStrategy(ListStrategy):
-    def __init__(self, elements, min_size, max_size, key):
+    def __init__(self, elements, min_size, max_size, keys):
         super(UniqueListStrategy, self).__init__(elements, min_size, max_size)
-        self.key = key
+        self.keys = keys
 
     def do_draw(self, data):
         if self.element_strategy.is_empty:
@@ -144,13 +144,17 @@ def do_draw(self, data):
             max_size=self.max_size,
             average_size=self.average_size,
         )
-        seen = set()
+        seen_sets = tuple(set() for _ in self.keys)
         result = []
 
         # We construct a filtered strategy here rather than using a check-and-reject
         # approach because some strategies have special logic for generation under a
         # filter, and FilteredStrategy can consolidate multiple filters.
-        filtered = self.element_strategy.filter(lambda val: self.key(val) not in seen)
+        filtered = self.element_strategy.filter(
+            lambda val: all(
+                key(val) not in seen for (key, seen) in zip(self.keys, seen_sets)
+            )
+        )
         while elements.more():
             value = filtered.filtered_strategy.do_filtered_draw(
                 data=data, filter_strategy=filtered
@@ -158,7 +162,8 @@ def do_draw(self, data):
             if value is filter_not_satisfied:
                 elements.reject()
             else:
-                seen.add(self.key(value))
+                for key, seen in zip(self.keys, seen_sets):
+                    seen.add(key(value))
                 result.append(value)
         assert self.max_size >= len(result) >= self.min_size
         return result


Test patch:
diff --git a/hypothesis-python/tests/cover/test_direct_strategies.py b/hypothesis-python/tests/cover/test_direct_strategies.py
--- a/hypothesis-python/tests/cover/test_direct_strategies.py
+++ b/hypothesis-python/tests/cover/test_direct_strategies.py
@@ -116,6 +116,9 @@ def fn_ktest(*fnkwargs):
     (ds.lists, {"elements": ds.integers(), "min_size": float("nan")}),
     (ds.lists, {"elements": ds.nothing(), "max_size": 1}),
     (ds.lists, {"elements": "hi"}),
+    (ds.lists, {"elements": ds.integers(), "unique_by": 1}),
+    (ds.lists, {"elements": ds.integers(), "unique_by": ()}),
+    (ds.lists, {"elements": ds.integers(), "unique_by": (1,)}),
     (ds.text, {"min_size": 10, "max_size": 9}),
     (ds.text, {"alphabet": [1]}),
     (ds.text, {"alphabet": ["abc"]}),
diff --git a/hypothesis-python/tests/cover/test_simple_collections.py b/hypothesis-python/tests/cover/test_simple_collections.py
--- a/hypothesis-python/tests/cover/test_simple_collections.py
+++ b/hypothesis-python/tests/cover/test_simple_collections.py
@@ -167,3 +167,16 @@ def test_minimize_dicts_with_incompatible_keys():
     assert minimal(
         fixed_dictionaries({1: booleans(), u"hi": lists(booleans())}), lambda x: True
     ) == {1: False, u"hi": []}
+
+
+@given(
+    lists(
+        tuples(integers(), integers()),
+        min_size=2,
+        unique_by=(lambda x: x[0], lambda x: x[1]),
+    )
+)
+def test_lists_unique_by_tuple_funcs(ls):
+    firstitems, seconditems = zip(*ls)
+    assert len(set(firstitems)) == len(firstitems)
+    assert len(set(seconditems)) == len(seconditems)