Repository: HypothesisWorks/hypothesis

Issue:
Default strategies for more Django model field types
There are [several types of fields](https://docs.djangoproject.com/en/1.11/ref/models/fields/#field-types) that we don't have a default strategy for, and it would be good to fix that.

Notes: some fields can just be added to the default mapping; others have extra metadata (eg length of strings, digits of decimals) to exploit.  All exceptions should inherit from `HypothesisException`.  We shouldn't try to generate autofields or file-related fields.  There's some scope for reorganising the code (eg exception classes at the top, alphabetise fields, etc).


Discussion:

Resolving patch:
diff --git a/src/hypothesis/extra/django/models.py b/src/hypothesis/extra/django/models.py
--- a/src/hypothesis/extra/django/models.py
+++ b/src/hypothesis/extra/django/models.py
@@ -19,6 +19,7 @@
 
 import string
 from decimal import Decimal
+from datetime import timedelta
 
 import django.db.models as dm
 from django.db import IntegrityError
@@ -26,32 +27,18 @@
 from django.core.exceptions import ValidationError
 
 import hypothesis.strategies as st
+from hypothesis import reject
 from hypothesis.errors import InvalidArgument
 from hypothesis.extra.pytz import timezones
+from hypothesis.provisional import emails, ip4_addr_strings, \
+    ip6_addr_strings
 from hypothesis.utils.conventions import UniqueIdentifier
-from hypothesis.searchstrategy.strategies import SearchStrategy
 
 
-class ModelNotSupported(Exception):
-    pass
-
-
-def referenced_models(model, seen=None):
-    if seen is None:
-        seen = set()
-    for f in model._meta.concrete_fields:
-        if isinstance(f, dm.ForeignKey):
-            t = f.rel.to
-            if t not in seen:
-                seen.add(t)
-                referenced_models(t, seen)
-    return seen
-
-
-def get_datetime_strat():
+def get_tz_strat():
     if getattr(django_settings, 'USE_TZ', False):
-        return st.datetimes(timezones=timezones())
-    return st.datetimes()
+        return timezones()
+    return st.none()
 
 
 __default_field_mappings = None
@@ -61,6 +48,8 @@ def field_mappings():
     global __default_field_mappings
 
     if __default_field_mappings is None:
+        # Sized fields are handled in _get_strategy_for_field()
+        # URL fields are not yet handled
         __default_field_mappings = {
             dm.SmallIntegerField: st.integers(-32768, 32767),
             dm.IntegerField: st.integers(-2147483648, 2147483647),
@@ -70,10 +59,26 @@ def field_mappings():
             dm.PositiveSmallIntegerField: st.integers(0, 32767),
             dm.BinaryField: st.binary(),
             dm.BooleanField: st.booleans(),
-            dm.DateTimeField: get_datetime_strat(),
+            dm.DateField: st.dates(),
+            dm.DateTimeField: st.datetimes(timezones=get_tz_strat()),
+            dm.DurationField: st.timedeltas(),
+            dm.EmailField: emails(),
             dm.FloatField: st.floats(),
             dm.NullBooleanField: st.one_of(st.none(), st.booleans()),
+            dm.TimeField: st.times(timezones=get_tz_strat()),
+            dm.UUIDField: st.uuids(),
         }
+
+        # SQLite does not support timezone-aware times, or timedeltas that
+        # don't fit in six bytes of microseconds, so we override those
+        db = getattr(django_settings, 'DATABASES', {}).get('default', {})
+        if db.get('ENGINE', '').endswith('.sqlite3'):  # pragma: no branch
+            sqlite_delta = timedelta(microseconds=2 ** 47 - 1)
+            __default_field_mappings.update({
+                dm.TimeField: st.times(),
+                dm.DurationField: st.timedeltas(-sqlite_delta, sqlite_delta),
+            })
+
     return __default_field_mappings
 
 
@@ -84,10 +89,6 @@ def add_default_field_mapping(field_type, strategy):
 default_value = UniqueIdentifier(u'default_value')
 
 
-class UnmappedFieldError(Exception):
-    pass
-
-
 def validator_to_filter(f):
     """Converts the field run_validators method to something suitable for use
     in filter."""
@@ -102,61 +103,34 @@ def validate(value):
     return validate
 
 
-safe_letters = string.ascii_letters + string.digits + '_-'
-
-domains = st.builds(
-    lambda x, y: '.'.join(x + [y]),
-    st.lists(st.text(safe_letters, min_size=1), min_size=1), st.sampled_from([
-        'com', 'net', 'org', 'biz', 'info',
-    ])
-)
-
-
-email_domains = st.one_of(
-    domains,
-    st.sampled_from(['gmail.com', 'yahoo.com', 'hotmail.com'])
-)
-
-base_emails = st.text(safe_letters, min_size=1)
-
-emails_with_plus = st.builds(
-    lambda x, y: '%s+%s' % (x, y), base_emails, base_emails
-)
-
-emails = st.builds(
-    lambda x, y: '%s@%s' % (x, y),
-    st.one_of(base_emails, emails_with_plus), email_domains
-)
-
-
 def _get_strategy_for_field(f):
-    if isinstance(f, dm.AutoField):
-        return default_value
-    elif f.choices:
-        choices = [value for (value, name) in f.choices]
+    if f.choices:
+        choices = []
+        for value, name_or_optgroup in f.choices:
+            if isinstance(name_or_optgroup, (list, tuple)):
+                choices.extend(key for key, _ in name_or_optgroup)
+            else:
+                choices.append(value)
         if isinstance(f, (dm.CharField, dm.TextField)) and f.blank:
-            choices.append(u'')
+            choices.insert(0, u'')
         strategy = st.sampled_from(choices)
-    elif isinstance(f, dm.EmailField):
-        return emails
+    elif type(f) == dm.SlugField:
+        strategy = st.text(alphabet=string.ascii_letters + string.digits,
+                           min_size=(None if f.blank else 1),
+                           max_size=f.max_length)
+    elif type(f) == dm.GenericIPAddressField:
+        lookup = {'both': ip4_addr_strings() | ip6_addr_strings(),
+                  'ipv4': ip4_addr_strings(), 'ipv6': ip6_addr_strings()}
+        strategy = lookup[f.protocol.lower()]
     elif type(f) in (dm.TextField, dm.CharField):
         strategy = st.text(min_size=(None if f.blank else 1),
                            max_size=f.max_length)
     elif type(f) == dm.DecimalField:
-        m = 10 ** f.max_digits - 1
-        div = 10 ** f.decimal_places
-        q = Decimal('1.' + ('0' * f.decimal_places))
-        strategy = (
-            st.integers(min_value=-m, max_value=m)
-            .map(lambda n: (Decimal(n) / div).quantize(q)))
+        bound = Decimal(10 ** f.max_digits - 1) / (10 ** f.decimal_places)
+        strategy = st.decimals(min_value=-bound, max_value=bound,
+                               places=f.decimal_places)
     else:
-        try:
-            strategy = field_mappings()[type(f)]
-        except KeyError:
-            if f.null:
-                return None
-            else:
-                raise UnmappedFieldError(f)
+        strategy = field_mappings().get(type(f), st.nothing())
     if f.validators:
         strategy = strategy.filter(validator_to_filter(f))
     if f.null:
@@ -165,45 +139,25 @@ def _get_strategy_for_field(f):
 
 
 def models(model, **extra):
-    result = {}
-    mandatory = set()
+    """Return a strategy for instances of a model."""
+    result = {k: v for k, v in extra.items() if v is not default_value}
+    missed = []
     for f in model._meta.concrete_fields:
-        try:
-            strategy = _get_strategy_for_field(f)
-        except UnmappedFieldError:
-            mandatory.add(f.name)
-            continue
-        if strategy is not None:
-            result[f.name] = strategy
-    missed = {x for x in mandatory if x not in extra}
+        if not (f.name in extra or isinstance(f, dm.AutoField)):
+            result[f.name] = _get_strategy_for_field(f)
+            if result[f.name].is_empty:
+                missed.append(f.name)
     if missed:
-        raise InvalidArgument((
-            u'Missing arguments for mandatory field%s %s for model %s' % (
-                u's' if len(missed) > 1 else u'',
-                u', '.join(missed),
-                model.__name__,
-            )))
-    result.update(extra)
-    # Remove default_values so we don't try to generate anything for those.
-    result = {k: v for k, v in result.items() if v is not default_value}
-    return ModelStrategy(model, result)
-
-
-class ModelStrategy(SearchStrategy):
-
-    def __init__(self, model, mappings):
-        super(ModelStrategy, self).__init__()
-        self.model = model
-        self.arg_strategy = st.fixed_dictionaries(mappings)
-
-    def __repr__(self):
-        return u'ModelStrategy(%s)' % (self.model.__name__,)
-
-    def do_draw(self, data):
-        try:
-            result, _ = self.model.objects.get_or_create(
-                **self.arg_strategy.do_draw(data)
-            )
-            return result
-        except IntegrityError:
-            data.mark_invalid()
+        raise InvalidArgument(
+            u'Missing arguments for mandatory field%s %s for model %s'
+            % (u's' if missed else u'', u', '.join(missed), model.__name__))
+    return _models_impl(st.builds(model.objects.get_or_create, **result))
+
+
+@st.composite
+def _models_impl(draw, strat):
+    """Handle the nasty part of drawing a value for models()"""
+    try:
+        return draw(strat)[0]
+    except IntegrityError:
+        reject()
diff --git a/src/hypothesis/provisional.py b/src/hypothesis/provisional.py
new file mode 100644
--- /dev/null
+++ b/src/hypothesis/provisional.py
@@ -0,0 +1,84 @@
+# coding=utf-8
+#
+# This file is part of Hypothesis, which may be found at
+# https://github.com/HypothesisWorks/hypothesis-python
+#
+# Most of this work is copyright (C) 2013-2017 David R. MacIver
+# (david@drmaciver.com), but it contains contributions by others. See
+# CONTRIBUTING.rst for a full list of people who may hold copyright, and
+# consult the git log if you need to determine who owns an individual
+# contribution.
+#
+# This Source Code Form is subject to the terms of the Mozilla Public License,
+# v. 2.0. If a copy of the MPL was not distributed with this file, You can
+# obtain one at http://mozilla.org/MPL/2.0/.
+#
+# END HEADER
+
+"""This module contains various provisional APIs and strategies.
+
+It is intended for internal use, to ease code reuse, and is not stable.
+Point releases may move or break the contents at any time!
+
+Internet strategies should conform to https://tools.ietf.org/html/rfc3696 or
+the authoritative definitions it links to.  If not, report the bug!
+
+"""
+
+from __future__ import division, print_function, absolute_import
+
+import string
+
+import hypothesis.strategies as st
+
+
+@st.defines_strategy_with_reusable_values
+def domains():
+    """A strategy for :rfc:`1035` fully qualified domain names."""
+    atoms = st.text(string.ascii_letters + '0123456789-',
+                    min_size=1, max_size=63
+                    ).filter(lambda s: '-' not in s[0] + s[-1])
+    return st.builds(
+        lambda x, y: '.'.join(x + [y]),
+        st.lists(atoms, min_size=1),
+        # TODO: be more devious about top-level domains
+        st.sampled_from(['com', 'net', 'org', 'biz', 'info'])
+    ).filter(lambda url: len(url) <= 255)
+
+
+@st.defines_strategy_with_reusable_values
+def emails():
+    """A strategy for email addresses.
+
+    See https://github.com/HypothesisWorks/hypothesis-python/issues/162
+    for work on a permanent replacement.
+
+    """
+    local_chars = string.ascii_letters + string.digits + "!#$%&'*+-/=^_`{|}~"
+    local_part = st.text(local_chars, min_size=1, max_size=64)
+    # TODO: include dot-atoms, quoted strings, escaped chars, etc in local part
+    return st.builds('{}@{}'.format, local_part, domains()).filter(
+        lambda addr: len(addr) <= 255)
+
+
+@st.defines_strategy_with_reusable_values
+def ip4_addr_strings():
+    """A strategy for IPv4 address strings.
+
+    This consists of four strings representing integers [0..255],
+    without zero-padding, joined by dots.
+
+    """
+    return st.builds('{}.{}.{}.{}'.format, *(4 * [st.integers(0, 255)]))
+
+
+@st.defines_strategy_with_reusable_values
+def ip6_addr_strings():
+    """A strategy for IPv6 address strings.
+
+    This consists of sixteen quads of hex digits (0000 .. FFFF), joined
+    by colons.  Values do not currently have zero-segments collapsed.
+
+    """
+    part = st.integers(0, 2**16 - 1).map(u'{:04x}'.format)
+    return st.tuples(*[part] * 8).map(lambda a: u':'.join(a).upper())


Test patch:
diff --git a/tests/cover/test_provisional_strategies.py b/tests/cover/test_provisional_strategies.py
new file mode 100644
--- /dev/null
+++ b/tests/cover/test_provisional_strategies.py
@@ -0,0 +1,51 @@
+# coding=utf-8
+#
+# This file is part of Hypothesis, which may be found at
+# https://github.com/HypothesisWorks/hypothesis-python
+#
+# Most of this work is copyright (C) 2013-2017 David R. MacIver
+# (david@drmaciver.com), but it contains contributions by others. See
+# CONTRIBUTING.rst for a full list of people who may hold copyright, and
+# consult the git log if you need to determine who owns an individual
+# contribution.
+#
+# This Source Code Form is subject to the terms of the Mozilla Public License,
+# v. 2.0. If a copy of the MPL was not distributed with this file, You can
+# obtain one at http://mozilla.org/MPL/2.0/.
+#
+# END HEADER
+
+from __future__ import division, print_function, absolute_import
+
+from binascii import unhexlify
+
+from hypothesis import given
+from hypothesis.provisional import emails, ip4_addr_strings, \
+    ip6_addr_strings
+
+
+@given(emails())
+def test_is_valid_email(address):
+    local, at_, domain = address.rpartition('@')
+    assert at_ == '@'
+    assert local
+    assert domain
+
+
+@given(ip4_addr_strings())
+def test_is_IP4_addr(address):
+    as_num = [int(n) for n in address.split('.')]
+    assert len(as_num) == 4
+    assert all(0 <= n <= 255 for n in as_num)
+
+
+@given(ip6_addr_strings())
+def test_is_IP6_addr(address):
+    # Works for non-normalised addresses produced by this strategy, but not
+    # a particularly general test
+    assert address == address.upper()
+    as_hex = address.split(':')
+    assert len(as_hex) == 8
+    assert all(len(part) == 4 for part in as_hex)
+    raw = unhexlify(address.replace(u':', u'').encode('ascii'))
+    assert len(raw) == 16
diff --git a/tests/django/toystore/models.py b/tests/django/toystore/models.py
--- a/tests/django/toystore/models.py
+++ b/tests/django/toystore/models.py
@@ -85,6 +85,19 @@ class ManyNumerics(models.Model):
     d = models.DecimalField(decimal_places=2, max_digits=5)
 
 
+class ManyTimes(models.Model):
+    time = models.TimeField()
+    date = models.DateField()
+    duration = models.DurationField()
+
+
+class OddFields(models.Model):
+    uuid = models.UUIDField()
+    slug = models.SlugField()
+    ipv4 = models.GenericIPAddressField(protocol='IPv4')
+    ipv6 = models.GenericIPAddressField(protocol='IPv6')
+
+
 class CustomishDefault(models.Model):
     customish = CustomishField(default=u'b')
 
@@ -119,6 +132,11 @@ class RestrictedFields(models.Model):
         choices=((1, 'First'), (2, 'Second')),
         null=True, blank=True
     )
+    choice_field_grouped = models.TextField(choices=(
+        ('Audio', (('vinyl', 'Vinyl'), ('cd', 'CD'),)),
+        ('Video', (('vhs', 'VHS Tape'), ('dvd', 'DVD'),)),
+        ('unknown', 'Unknown'),
+    ))
     even_number_field = models.IntegerField(
         validators=[validate_even]
     )
diff --git a/tests/django/toystore/test_email_strategies.py b/tests/django/toystore/test_email_strategies.py
deleted file mode 100644
--- a/tests/django/toystore/test_email_strategies.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# coding=utf-8
-#
-# This file is part of Hypothesis, which may be found at
-# https://github.com/HypothesisWorks/hypothesis-python
-#
-# Most of this work is copyright (C) 2013-2017 David R. MacIver
-# (david@drmaciver.com), but it contains contributions by others. See
-# CONTRIBUTING.rst for a full list of people who may hold copyright, and
-# consult the git log if you need to determine who owns an individual
-# contribution.
-#
-# This Source Code Form is subject to the terms of the Mozilla Public License,
-# v. 2.0. If a copy of the MPL was not distributed with this file, You can
-# obtain one at http://mozilla.org/MPL/2.0/.
-#
-# END HEADER
-
-from __future__ import division, print_function, absolute_import
-
-from unittest import TestCase
-
-from hypothesis import given
-from hypothesis.extra.django.models import emails, domains
-
-
-class TestBasicValidation(TestCase):
-
-    @given(domains)
-    def test_is_never_bare_tld(self, d):
-        assert '.' in d
-
-    @given(emails)
-    def test_is_valid_email(self, e):
-        assert '@' in e
diff --git a/tests/django/toystore/test_given_models.py b/tests/django/toystore/test_given_models.py
--- a/tests/django/toystore/test_given_models.py
+++ b/tests/django/toystore/test_given_models.py
@@ -17,15 +17,20 @@
 
 from __future__ import division, print_function, absolute_import
 
+import datetime as dt
+from uuid import UUID
+
 from django.conf import settings as django_settings
 
 from hypothesis import given, assume
 from hypothesis.errors import InvalidArgument
 from hypothesis.strategies import just, lists
 from hypothesis.extra.django import TestCase, TransactionTestCase
+from hypothesis.internal.compat import text_type
 from tests.django.toystore.models import Store, Company, Customer, \
-    SelfLoop, Customish, ManyNumerics, CustomishField, CouldBeCharming, \
-    CustomishDefault, RestrictedFields, MandatoryComputed
+    SelfLoop, Customish, ManyTimes, OddFields, ManyNumerics, \
+    CustomishField, CouldBeCharming, CustomishDefault, RestrictedFields, \
+    MandatoryComputed
 from hypothesis.extra.django.models import models, default_value, \
     add_default_field_mapping
 
@@ -105,6 +110,23 @@ def test_customish_default_generated(self, x):
     def test_customish_default_not_generated(self, x):
         assert x.customish == u'b'
 
+    @given(models(OddFields))
+    def test_odd_fields(self, x):
+        assert isinstance(x.uuid, UUID)
+        assert isinstance(x.slug, text_type)
+        assert u' ' not in x.slug
+        assert isinstance(x.ipv4, str)
+        assert len(x.ipv4.split('.')) == 4
+        assert all(int(i) in range(256) for i in x.ipv4.split('.'))
+        assert isinstance(x.ipv6, text_type)
+        assert set(x.ipv6).issubset(set(u'0123456789abcdefABCDEF:.'))
+
+    @given(models(ManyTimes))
+    def test_time_fields(self, x):
+        assert isinstance(x.time, dt.time)
+        assert isinstance(x.date, dt.date)
+        assert isinstance(x.duration, dt.timedelta)
+
 
 class TestsNeedingRollback(TransactionTestCase):
 
@@ -124,5 +146,7 @@ def test_constructs_valid_instance(self, instance):
         self.assertIn(instance.choice_field_text, ('foo', 'bar'))
         self.assertIn(instance.choice_field_int, (1, 2))
         self.assertIn(instance.null_choice_field_int, (1, 2, None))
+        self.assertEqual(instance.choice_field_grouped,
+                         instance.choice_field_grouped.lower())
         self.assertEqual(instance.even_number_field % 2, 0)
         self.assertTrue(instance.non_blank_text_field)