Repository: HypothesisWorks/hypothesis
Pull request: Add feature for multiple unique_by functions
Fixes #1916, each callable passed in the `unique_by` tuple has it's own respective `seen` set

Problem statement:
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?

Hints from issue 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

Commit message: Add failing tests for feature checking

Patch to review:
diff --git a/hypothesis-python/tests/cover/test_simple_collections.py b/hypothesis-python/tests/cover/test_simple_collections.py
index 5fc8518223..7bc8f2192c 100644
--- 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)


Code review comments:
diff_hunk: @@ -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 function or tuple of functions
+    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 functions the uniqueness will be respective to
path: hypothesis-python/src/hypothesis/_strategies.py
text: @user1:
Use the monospace format for `unique_by`; callables instead of functions again.

diff_hunk: @@ -144,21 +144,27 @@ def do_draw(self, data):
             max_size=self.max_size,
             average_size=self.average_size,
         )
-        seen = set()
+        seens = tuple(set() for _ in range(len(self.keys)))
path: hypothesis-python/src/hypothesis/searchstrategy/collections.py
text: @user1:
`tuple(set() for _ in self.keys)` is sufficient here, as we're not using the index at all.

I'd also prefer to call the variable `seen_sets`.