From 68a473c30dde0488e544e5dd2261235f255b16ff Mon Sep 17 00:00:00 2001 From: Zac-HD Date: Tue, 21 Jan 2020 17:18:12 +1100 Subject: [PATCH 1/3] New core strategy for ipaddresses --- hypothesis-python/RELEASE.rst | 7 + .../src/hypothesis/extra/django/_fields.py | 18 ++- .../src/hypothesis/strategies/__init__.py | 2 + .../strategies/_internal/ipaddress.py | 124 ++++++++++++++++++ .../tests/cover/test_direct_strategies.py | 33 +++++ 5 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst create mode 100644 hypothesis-python/src/hypothesis/strategies/_internal/ipaddress.py diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..19cf65bd4c --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,7 @@ +RELEASE_TYPE: minor + +The standard library :mod:`ipaddress` module is new in Python 3, and this release +adds the new :func:`~hypothesis.strategies.ip_addresses` strategy to generate +:class:`~python:ipaddress.IPv4Address`\ es and/or +:class:`~python:ipaddress.IPv6Address`\ es (depending on the ``v`` and ``network`` +arguments). diff --git a/hypothesis-python/src/hypothesis/extra/django/_fields.py b/hypothesis-python/src/hypothesis/extra/django/_fields.py index 31f1a499b3..d2c5ef4ea6 100644 --- a/hypothesis-python/src/hypothesis/extra/django/_fields.py +++ b/hypothesis-python/src/hypothesis/extra/django/_fields.py @@ -32,7 +32,7 @@ from hypothesis.errors import InvalidArgument from hypothesis.extra.pytz import timezones from hypothesis.internal.validation import check_type -from hypothesis.provisional import ip4_addr_strings, ip6_addr_strings, urls +from hypothesis.provisional import urls from hypothesis.strategies import emails try: @@ -70,6 +70,10 @@ df.UUIDField: st.uuids(), } # type: Dict[Type[AnyField], Union[st.SearchStrategy, Callable[[Any], st.SearchStrategy]]] +_ipv6_strings = st.one_of( + st.ip_addresses(v=6).map(str), st.ip_addresses(v=6).map(lambda addr: addr.exploded), +) + def register_for(field_type): def inner(func): @@ -139,9 +143,9 @@ def _for_slug(field): @register_for(dm.GenericIPAddressField) def _for_model_ip(field): return { - "ipv4": ip4_addr_strings(), - "ipv6": ip6_addr_strings(), - "both": ip4_addr_strings() | ip6_addr_strings(), + "ipv4": st.ip_addresses(v=4).map(str), + "ipv6": _ipv6_strings, + "both": st.ip_addresses(v=4).map(str) | _ipv6_strings, }[field.protocol.lower()] @@ -151,11 +155,11 @@ def _for_form_ip(field): # of address they want, so direct comparison with the validator # function has to be used instead. Sorry for the potato logic here if validate_ipv46_address in field.default_validators: - return ip4_addr_strings() | ip6_addr_strings() + return st.ip_addresses(v=4).map(str) | _ipv6_strings if validate_ipv4_address in field.default_validators: - return ip4_addr_strings() + return st.ip_addresses(v=4).map(str) if validate_ipv6_address in field.default_validators: - return ip6_addr_strings() + return _ipv6_strings raise InvalidArgument("No IP version validator on field=%r" % field) diff --git a/hypothesis-python/src/hypothesis/strategies/__init__.py b/hypothesis-python/src/hypothesis/strategies/__init__.py index 551da26ec4..8d1aade8ce 100644 --- a/hypothesis-python/src/hypothesis/strategies/__init__.py +++ b/hypothesis-python/src/hypothesis/strategies/__init__.py @@ -60,6 +60,7 @@ tuples, uuids, ) +from hypothesis.strategies._internal.ipaddress import ip_addresses # The implementation of all of these lives in `_strategies.py`, but we # re-export them via this module to avoid exposing implementation details @@ -89,6 +90,7 @@ "frozensets", "functions", "integers", + "ip_addresses", "iterables", "just", "lists", diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/ipaddress.py b/hypothesis-python/src/hypothesis/strategies/_internal/ipaddress.py new file mode 100644 index 0000000000..2f8dc23823 --- /dev/null +++ b/hypothesis-python/src/hypothesis/strategies/_internal/ipaddress.py @@ -0,0 +1,124 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Most of this work is copyright (C) 2013-2020 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 https://mozilla.org/MPL/2.0/. +# +# END HEADER + +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network +from typing import Union + +from hypothesis.errors import InvalidArgument +from hypothesis.internal.validation import check_type +from hypothesis.strategies._internal.core import ( + SearchStrategy, + binary, + defines_strategy_with_reusable_values, + integers, + sampled_from, +) + +# See https://www.iana.org/assignments/iana-ipv4-special-registry/ +SPECIAL_IPv4_RANGES = ( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.0.0/29", + "192.0.0.8/32", + "192.0.0.9/32", + "192.0.0.10/32", + "192.0.0.170/32", + "192.0.0.171/32", + "192.0.2.0/24", + "192.31.196.0/24", + "192.52.193.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "192.175.48.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "240.0.0.0/4", + "255.255.255.255/32", +) +# and https://www.iana.org/assignments/iana-ipv6-special-registry/ +SPECIAL_IPv6_RANGES = ( + "::1/128", + "::/128", + "::ffff:0:0/96", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "2001::/23", + "2001::/32", + "2001:1::1/128", + "2001:1::2/128", + "2001:2::/48", + "2001:3::/32", + "2001:4:112::/48", + "2001:10::/28", + "2001:20::/28", + "2001:db8::/32", + "2002::/16", + "2620:4f:8000::/48", + "fc00::/7", + "fe80::/10", +) + + +@defines_strategy_with_reusable_values +def ip_addresses( + *, v: int = None, network: Union[str, IPv4Network, IPv6Network] = None +) -> SearchStrategy[Union[IPv4Address, IPv6Address]]: + r"""Generate IP addresses - ``v=4`` for :class:`~python:ipaddress.IPv4Address`\ es, + ``v=6`` for :class:`~python:ipaddress.IPv6Address`\ es, or leave unspecified + to allow both versions. + + ``network`` may be an :class:`~python:ipaddress.IPv4Network` or + :class:`~python:ipaddress.IPv6Network`, or a string representing a network such as + ``"127.0.0.0/24"`` or ``"2001:db8::/32"``. As well as generating addresses within + a particular routable network, this can be used to generate addresses from a + reserved range listed in the + `IANA `__ + `registries `__. + + If you pass both ``v`` and ``network``, they must be for the same version. + """ + if v is not None: + check_type(int, v, "v") + if v != 4 and v != 6: + raise InvalidArgument("v=%r, but only v=4 or v=6 are valid" % (v,)) + if network is None: + # We use the reserved-address registries to boost the chance + # of generating one of the various special types of address. + four = binary(4, 4).map(IPv4Address) | sampled_from( + SPECIAL_IPv4_RANGES + ).flatmap(lambda network: ip_addresses(network=network)) + six = binary(16, 16).map(IPv6Address) | sampled_from( + SPECIAL_IPv6_RANGES + ).flatmap(lambda network: ip_addresses(network=network)) + if v == 4: + return four + if v == 6: + return six + return four | six + if isinstance(network, str): + network = ip_network(network) + check_type((IPv4Network, IPv6Network), network, "network") + assert isinstance(network, (IPv4Network, IPv6Network)) # for Mypy + if v not in (None, network.version): + raise InvalidArgument("v=%r is incompatible with network=%r" % (v, network)) + addr_type = IPv4Address if network.version == 4 else IPv6Address + return integers(int(network[0]), int(network[-1])).map(addr_type) # type: ignore diff --git a/hypothesis-python/tests/cover/test_direct_strategies.py b/hypothesis-python/tests/cover/test_direct_strategies.py index 2dac428095..6abe857f0d 100644 --- a/hypothesis-python/tests/cover/test_direct_strategies.py +++ b/hypothesis-python/tests/cover/test_direct_strategies.py @@ -18,6 +18,7 @@ import fractions import math from datetime import date, datetime, time, timedelta +from ipaddress import IPv4Network, IPv6Network import pytest @@ -175,6 +176,13 @@ def fn_ktest(*fnkwargs): (ds.slices, {"size": -1}), (ds.slices, {"size": 2.3}), (ds.sampled_from, {"elements": ()}), + (ds.ip_addresses, {"v": "4"}), + (ds.ip_addresses, {"v": 4.0}), + (ds.ip_addresses, {"v": 5}), + (ds.ip_addresses, {"v": 4, "network": "::/64"}), + (ds.ip_addresses, {"v": 6, "network": "127.0.0.0/8"}), + (ds.ip_addresses, {"network": b"127.0.0.0/8"}), # only unicode strings are valid + (ds.ip_addresses, {"network": b"::/64"}), ) def test_validates_keyword_arguments(fn, kwargs): with pytest.raises(InvalidArgument): @@ -243,6 +251,17 @@ def test_validates_keyword_arguments(fn, kwargs): (ds.text, {"alphabet": ds.sampled_from("abc")}), (ds.characters, {"whitelist_categories": ["N"]}), (ds.characters, {"blacklist_categories": []}), + (ds.ip_addresses, {}), + (ds.ip_addresses, {"v": 4}), + (ds.ip_addresses, {"v": 6}), + (ds.ip_addresses, {"network": "127.0.0.0/8"}), + (ds.ip_addresses, {"network": "::/64"}), + (ds.ip_addresses, {"v": 4, "network": "127.0.0.0/8"}), + (ds.ip_addresses, {"v": 6, "network": "::/64"}), + (ds.ip_addresses, {"network": IPv4Network("127.0.0.0/8")}), + (ds.ip_addresses, {"network": IPv6Network("::/64")}), + (ds.ip_addresses, {"v": 4, "network": IPv4Network("127.0.0.0/8")}), + (ds.ip_addresses, {"v": 6, "network": IPv6Network("::/64")}), ) def test_produces_valid_examples_from_keyword(fn, kwargs): fn(**kwargs).example() @@ -458,3 +477,17 @@ def test_chained_filter(x): def test_chained_filter_tracks_all_conditions(): s = ds.integers().filter(bool).filter(lambda x: x % 3) assert len(s.flat_conditions) == 2 + + +@pytest.mark.parametrize("version", [4, 6]) +@given(data=ds.data()) +def test_ipaddress_from_network_is_always_correct_version(data, version): + ip = data.draw(ds.ip_addresses(v=version), label="address") + assert ip.version == version + + +@given(data=ds.data(), network=ds.from_type(IPv4Network) | ds.from_type(IPv6Network)) +def test_ipaddress_from_network_is_always_in_network(data, network): + ip = data.draw(ds.ip_addresses(network=network), label="address") + assert ip in network + assert ip.version == network.version From cd9d7b3a54d89fb3dc8617f225fd976e6ea14d8e Mon Sep 17 00:00:00 2001 From: Zac-HD Date: Tue, 21 Jan 2020 17:18:12 +1100 Subject: [PATCH 2/3] Register strategies for ipaddress types --- hypothesis-python/RELEASE.rst | 3 ++ .../hypothesis/strategies/_internal/types.py | 34 +++++++++++++++++-- .../tests/cover/test_type_lookup.py | 1 + 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst index 19cf65bd4c..fae286f1ac 100644 --- a/hypothesis-python/RELEASE.rst +++ b/hypothesis-python/RELEASE.rst @@ -5,3 +5,6 @@ adds the new :func:`~hypothesis.strategies.ip_addresses` strategy to generate :class:`~python:ipaddress.IPv4Address`\ es and/or :class:`~python:ipaddress.IPv6Address`\ es (depending on the ``v`` and ``network`` arguments). + +If you use them in type annotations, :func:`~hypothesis.strategies.from_type` now +has strategies registered for :mod:`ipaddress` address, network, and interface types. diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/types.py b/hypothesis-python/src/hypothesis/strategies/_internal/types.py index ef9e04f785..b90121951a 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/types.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/types.py @@ -20,6 +20,7 @@ import functools import inspect import io +import ipaddress import numbers import typing import uuid @@ -28,6 +29,11 @@ import hypothesis.strategies as st from hypothesis.errors import InvalidArgument, ResolutionFailed from hypothesis.internal.compat import ForwardRef, typing_root_type +from hypothesis.strategies._internal.ipaddress import ( + SPECIAL_IPv4_RANGES, + SPECIAL_IPv6_RANGES, + ip_addresses, +) from hypothesis.strategies._internal.lazy import unwrap_strategies from hypothesis.strategies._internal.strategies import OneOfStrategy @@ -223,12 +229,25 @@ def can_cast(type, value): return False +def _networks(bits): + return st.tuples(st.integers(0, 2 ** bits - 1), st.integers(-bits, 0).map(abs)) + + utc_offsets = st.builds( datetime.timedelta, minutes=st.integers(0, 59), hours=st.integers(-23, 23) ) +# These builtin and standard-library types have Hypothesis strategies, +# seem likely to appear in type annotations, or are otherwise notable. +# +# The strategies below must cover all possible values from the type, because +# many users treat them as comprehensive and one of Hypothesis' design goals +# is to avoid testing less than expected. +# +# As a general rule, we try to limit this to scalars because from_type() +# would have to decide on arbitrary collection elements, and we'd rather +# not (with typing module generic types and some builtins as exceptions). _global_type_lookup = { - # Types with core Hypothesis strategies type(None): st.none(), bool: st.booleans(), int: st.integers(), @@ -252,7 +271,6 @@ def can_cast(type, value): frozenset: st.builds(frozenset), dict: st.builds(dict), FunctionType: st.functions(), - # Built-in types type(Ellipsis): st.just(Ellipsis), type(NotImplemented): st.just(NotImplemented), bytearray: st.binary().map(bytearray), @@ -273,6 +291,18 @@ def can_cast(type, value): st.builds(range, st.integers(), st.integers()), st.builds(range, st.integers(), st.integers(), st.integers().filter(bool)), ), + ipaddress.IPv4Address: ip_addresses(v=4), + ipaddress.IPv6Address: ip_addresses(v=6), + ipaddress.IPv4Interface: _networks(32).map(ipaddress.IPv4Interface), + ipaddress.IPv6Interface: _networks(128).map(ipaddress.IPv6Interface), + ipaddress.IPv4Network: st.one_of( + _networks(32).map(lambda x: ipaddress.IPv4Network(x, strict=False)), + st.sampled_from(SPECIAL_IPv4_RANGES).map(ipaddress.IPv4Network), + ), + ipaddress.IPv6Network: st.one_of( + _networks(128).map(lambda x: ipaddress.IPv6Network(x, strict=False)), + st.sampled_from(SPECIAL_IPv6_RANGES).map(ipaddress.IPv6Network), + ), # Pull requests with more types welcome! } diff --git a/hypothesis-python/tests/cover/test_type_lookup.py b/hypothesis-python/tests/cover/test_type_lookup.py index ba39aa5d5a..51dfade47c 100644 --- a/hypothesis-python/tests/cover/test_type_lookup.py +++ b/hypothesis-python/tests/cover/test_type_lookup.py @@ -31,6 +31,7 @@ # Build a set of all types output by core strategies blacklist = [ "builds", + "ip_addresses", "iterables", "permutations", "random_module", From 55ac6fe6b20019612314b02be83d28fcb86a8208 Mon Sep 17 00:00:00 2001 From: Zac-HD Date: Tue, 21 Jan 2020 17:18:12 +1100 Subject: [PATCH 3/3] Deprecate provisional IP address strategies --- hypothesis-python/RELEASE.rst | 2 ++ .../src/hypothesis/provisional.py | 27 ++++++++++--------- .../cover/test_provisional_strategies.py | 20 ++++++-------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst index fae286f1ac..d228a6f46f 100644 --- a/hypothesis-python/RELEASE.rst +++ b/hypothesis-python/RELEASE.rst @@ -8,3 +8,5 @@ arguments). If you use them in type annotations, :func:`~hypothesis.strategies.from_type` now has strategies registered for :mod:`ipaddress` address, network, and interface types. + +The provisional strategies for IP address strings are therefore deprecated. diff --git a/hypothesis-python/src/hypothesis/provisional.py b/hypothesis-python/src/hypothesis/provisional.py index 6c926ad744..8a70080206 100644 --- a/hypothesis-python/src/hypothesis/provisional.py +++ b/hypothesis-python/src/hypothesis/provisional.py @@ -28,7 +28,9 @@ import hypothesis.internal.conjecture.utils as cu import hypothesis.strategies._internal.core as st +from hypothesis._settings import note_deprecation from hypothesis.errors import InvalidArgument +from hypothesis.strategies._internal.ipaddress import ip_addresses from hypothesis.strategies._internal.strategies import SearchStrategy URL_SAFE_CHARACTERS = frozenset(string.ascii_letters + string.digits + "$-_.+!*'(),") @@ -159,20 +161,19 @@ def url_encode(s): @st.defines_strategy_with_reusable_values def ip4_addr_strings() -> SearchStrategy[str]: - """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)])) + note_deprecation( + "Use `ip_addresses(v=4).map(str)` instead of `ip4_addr_strings()`; " + "the provisional strategy is less flexible and will be removed.", + since="RELEASEDAY", + ) + return ip_addresses(v=4).map(str) @st.defines_strategy_with_reusable_values def ip6_addr_strings() -> SearchStrategy[str]: - """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("{:04x}".format) - return st.tuples(*[part] * 8).map(lambda a: ":".join(a).upper()) + note_deprecation( + "Use `ip_addresses(v=6).map(str)` instead of `ip6_addr_strings()`; " + "the provisional strategy is less flexible and will be removed.", + since="RELEASEDAY", + ) + return ip_addresses(v=6).map(str) diff --git a/hypothesis-python/tests/cover/test_provisional_strategies.py b/hypothesis-python/tests/cover/test_provisional_strategies.py index 3a15247da6..fad31cd3a5 100644 --- a/hypothesis-python/tests/cover/test_provisional_strategies.py +++ b/hypothesis-python/tests/cover/test_provisional_strategies.py @@ -13,9 +13,9 @@ # # END HEADER +import ipaddress import re import string -from binascii import unhexlify import pytest @@ -23,6 +23,7 @@ from hypothesis.errors import InvalidArgument from hypothesis.provisional import domains, ip4_addr_strings, ip6_addr_strings, urls from tests.common.debug import find_any +from tests.common.utils import checks_deprecated_behaviour @given(urls()) @@ -36,6 +37,7 @@ def test_is_URL(url): ) +@checks_deprecated_behaviour @given(ip4_addr_strings()) def test_is_IP4_addr(address): as_num = [int(n) for n in address.split(".")] @@ -43,16 +45,12 @@ def test_is_IP4_addr(address): assert all(0 <= n <= 255 for n in as_num) +@checks_deprecated_behaviour @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(":", "").encode("ascii")) - assert len(raw) == 16 + # The IPv6Address constructor does all the validation we could need + assert isinstance(address, str) + ipaddress.IPv6Address(address) @pytest.mark.parametrize("max_length", [-1, 0, 3, 4.0, 256]) @@ -68,8 +66,6 @@ def test_valid_domains_arguments(max_length, max_element_length): domains(max_length=max_length, max_element_length=max_element_length).example() -@pytest.mark.parametrize( - "strategy", [domains(), ip4_addr_strings(), ip6_addr_strings(), urls()] -) +@pytest.mark.parametrize("strategy", [domains(), urls()]) def test_find_any_non_empty(strategy): find_any(strategy, lambda s: len(s) > 0)