Repository: HypothesisWorks/hypothesis

Issue:
Parameter name clash in extra.django.from_model
`from_model`'s first argument is `model`:

https://github.com/HypothesisWorks/hypothesis/blob/d159096999e9e65c77039a25f539ea0717a9f1de/hypothesis-python/src/hypothesis/extra/django/_impl.py#L57-L59

This leads to an error when trying to write a strategy for a class that has a field named `model`. For example:

```python
from django.db.models import Model, CharField
class Car(Model):
    model = CharField(help_text="The model of the car.")

car_strategy = from_model(Car, model='Mustang')
```

This will result in the following error:

```
TypeError: from_model() got multiple values for argument 'model'
```

Perhaps the first argument to `from_model` could be named `_model` or `_hypothesis_model` or something like that?


Discussion:
Ah, we had the same problem with `builds()` (#1106), which was fixed by manual validation of `*args` - it's inelegant, but fixes the problem.

In Python 3.8 (or later), we can instead use a named positional-only argument... but that's years away.  Until then we can patch the `__signature__` to fix our docs, like #2345, and get better introspection on new Pythons for free.

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/extra/django/_impl.py b/hypothesis-python/src/hypothesis/extra/django/_impl.py
--- a/hypothesis-python/src/hypothesis/extra/django/_impl.py
+++ b/hypothesis-python/src/hypothesis/extra/django/_impl.py
@@ -13,8 +13,10 @@
 #
 # END HEADER
 
+import sys
 import unittest
 from functools import partial
+from inspect import Parameter, signature
 from typing import Type, Union
 
 import django.db.models as dm
@@ -25,6 +27,7 @@
 
 import hypothesis.strategies._internal.core as st
 from hypothesis import reject
+from hypothesis._settings import note_deprecation
 from hypothesis.errors import InvalidArgument
 from hypothesis.extra.django._fields import from_field
 from hypothesis.utils.conventions import InferType, infer
@@ -55,7 +58,7 @@ class TransactionTestCase(HypothesisTestCase, dt.TransactionTestCase):
 
 @st.defines_strategy
 def from_model(
-    model: Type[dm.Model], **field_strategies: Union[st.SearchStrategy, InferType]
+    *model: Type[dm.Model], **field_strategies: Union[st.SearchStrategy, InferType]
 ) -> st.SearchStrategy:
     """Return a strategy for examples of ``model``.
 
@@ -66,7 +69,8 @@ def from_model(
 
     ``model`` must be an subclass of :class:`~django:django.db.models.Model`.
     Strategies for fields may be passed as keyword arguments, for example
-    ``is_staff=st.just(False)``.
+    ``is_staff=st.just(False)``.  In order to support models with fields named
+    "model", this is a positional-only parameter.
 
     Hypothesis can often infer a strategy based the field type and validators,
     and will attempt to do so for any required fields.  No strategy will be
@@ -81,10 +85,25 @@ def from_model(
     :obj:`~hypothesis.infer` as a keyword argument to infer a strategy for
     a field which has a default value instead of using the default.
     """
-    if not issubclass(model, dm.Model):
+    if len(model) == 1:
+        m_type = model[0]
+    elif len(model) > 1:
+        raise TypeError("Too many positional arguments")
+    else:
+        try:
+            m_type = field_strategies.pop("model")  # type: ignore
+        except KeyError:
+            raise TypeError("Missing required positional argument `model`") from None
+        else:
+            note_deprecation(
+                "The `model` argument will be positional-only in a future version",
+                since="RELEASEDAY",
+            )
+
+    if not issubclass(m_type, dm.Model):
         raise InvalidArgument("model=%r must be a subtype of Model" % (model,))
 
-    fields_by_name = {f.name: f for f in model._meta.concrete_fields}
+    fields_by_name = {f.name: f for f in m_type._meta.concrete_fields}
     for name, value in sorted(field_strategies.items()):
         if value is infer:
             field_strategies[name] = from_field(fields_by_name[name])
@@ -97,18 +116,27 @@ def from_model(
             field_strategies[name] = from_field(field)
 
     for field in field_strategies:
-        if model._meta.get_field(field).primary_key:
+        if m_type._meta.get_field(field).primary_key:
             # The primary key is generated as part of the strategy. We
             # want to find any existing row with this primary key and
             # overwrite its contents.
             kwargs = {field: field_strategies.pop(field)}
             kwargs["defaults"] = st.fixed_dictionaries(field_strategies)  # type: ignore
-            return _models_impl(st.builds(model.objects.update_or_create, **kwargs))
+            return _models_impl(st.builds(m_type.objects.update_or_create, **kwargs))
 
     # The primary key is not generated as part of the strategy, so we
     # just match against any row that has the same value for all
     # fields.
-    return _models_impl(st.builds(model.objects.get_or_create, **field_strategies))
+    return _models_impl(st.builds(m_type.objects.get_or_create, **field_strategies))
+
+
+if sys.version_info[:2] >= (3, 8):  # pragma: no cover
+    # See notes above definition of st.builds() - this signature is compatible
+    # and better matches the semantics of the function.  Great for documentation!
+    sig = signature(from_model)
+    params = list(sig.parameters.values())
+    params[0] = params[0].replace(kind=Parameter.POSITIONAL_ONLY)
+    from_model.__signature__ = sig.replace(parameters=params)
 
 
 @st.composite


Test patch:
diff --git a/hypothesis-python/tests/django/toystore/models.py b/hypothesis-python/tests/django/toystore/models.py
--- a/hypothesis-python/tests/django/toystore/models.py
+++ b/hypothesis-python/tests/django/toystore/models.py
@@ -26,6 +26,11 @@ class Store(models.Model):
     company = models.ForeignKey(Company, null=False, on_delete=models.CASCADE)
 
 
+class Car(models.Model):
+    # See https://github.com/HypothesisWorks/hypothesis/issues/2369
+    model = models.CharField(max_length=100, unique=True)
+
+
 class CharmField(models.Field):
     def db_type(self, connection):
         return "char(1)"
diff --git a/hypothesis-python/tests/django/toystore/test_given_models.py b/hypothesis-python/tests/django/toystore/test_given_models.py
--- a/hypothesis-python/tests/django/toystore/test_given_models.py
+++ b/hypothesis-python/tests/django/toystore/test_given_models.py
@@ -21,7 +21,11 @@
 
 from hypothesis import HealthCheck, assume, given, infer, settings
 from hypothesis.control import reject
-from hypothesis.errors import HypothesisException, InvalidArgument
+from hypothesis.errors import (
+    HypothesisDeprecationWarning,
+    HypothesisException,
+    InvalidArgument,
+)
 from hypothesis.extra.django import (
     TestCase,
     TransactionTestCase,
@@ -31,6 +35,7 @@
 from hypothesis.internal.conjecture.data import ConjectureData
 from hypothesis.strategies import binary, just, lists
 from tests.django.toystore.models import (
+    Car,
     Company,
     CompanyExtension,
     CouldBeCharming,
@@ -187,3 +192,16 @@ class TestValidatorInference(TestCase):
     @given(from_model(User))
     def test_user_issue_1112_regression(self, user):
         assert user.username
+
+
+class TestPosOnlyArg(TestCase):
+    @given(from_model(Car))
+    def test_user_issue_2369_regression(self, val):
+        pass
+
+    def test_from_model_argspec(self):
+        self.assertRaises(TypeError, from_model().example)
+        self.assertRaises(TypeError, from_model(Car, None).example)
+        self.assertWarns(
+            HypothesisDeprecationWarning, from_model(model=Customer).example
+        )