Skip to content

Commit

Permalink
New core strategies for IP addresses (#2316)
Browse files Browse the repository at this point in the history
New core strategies for IP addresses
  • Loading branch information
Zac-HD authored Jan 21, 2020
2 parents 0b9f04c + 55ac6fe commit c25dcbf
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 34 deletions.
12 changes: 12 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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).

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.
18 changes: 11 additions & 7 deletions hypothesis-python/src/hypothesis/extra/django/_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()]


Expand All @@ -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)


Expand Down
27 changes: 14 additions & 13 deletions hypothesis-python/src/hypothesis/provisional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "$-_.+!*'(),")
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions hypothesis-python/src/hypothesis/strategies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,6 +90,7 @@
"frozensets",
"functions",
"integers",
"ip_addresses",
"iterables",
"just",
"lists",
Expand Down
124 changes: 124 additions & 0 deletions hypothesis-python/src/hypothesis/strategies/_internal/ipaddress.py
Original file line number Diff line number Diff line change
@@ -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
# ([email protected]), 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 <https://www.iana.org/assignments/iana-ipv4-special-registry/>`__
`registries <https://www.iana.org/assignments/iana-ipv6-special-registry/>`__.
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
34 changes: 32 additions & 2 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import functools
import inspect
import io
import ipaddress
import numbers
import typing
import uuid
Expand All @@ -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

Expand Down Expand Up @@ -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(),
Expand All @@ -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),
Expand All @@ -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!
}

Expand Down
33 changes: 33 additions & 0 deletions hypothesis-python/tests/cover/test_direct_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import fractions
import math
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Network, IPv6Network

import pytest

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Loading

0 comments on commit c25dcbf

Please sign in to comment.