Skip to content

Commit 67098be

Browse files
committed
Add hypothesis strategies
1 parent 8ad6b22 commit 67098be

6 files changed

Lines changed: 2936 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ tmp.py
33
htmlcov/
44
.coverage.*
55
*.py[cod]
6-
.mypy_cache
6+
/.hypothesis/
77

88
# emacs
99
*~

src/hyperlink/idna-tables-properties.csv

Lines changed: 2471 additions & 0 deletions
Large diffs are not rendered by default.

src/hyperlink/strategies.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Hypothesis strategies.
4+
"""
5+
6+
from csv import reader as csv_reader
7+
from os.path import dirname, join
8+
from string import ascii_letters, digits
9+
from sys import maxunicode
10+
from typing import Callable, Iterable, Optional, Sequence, Text, TypeVar, cast
11+
12+
from . import DecodedURL, EncodedURL
13+
14+
from hypothesis import assume
15+
from hypothesis.strategies import (
16+
composite, integers, lists, sampled_from, text
17+
)
18+
19+
from idna import IDNAError, check_label, encode as idna_encode
20+
21+
22+
__all__ = ()
23+
24+
25+
T = TypeVar('T')
26+
DrawCallable = Callable[[Callable[..., T]], T]
27+
28+
29+
try:
30+
unichr
31+
except NameError: # Py3
32+
unichr = chr # type: Callable[[int], Text]
33+
34+
35+
def idna_characters():
36+
# type: () -> Text
37+
"""
38+
Returns a string containing IDNA characters.
39+
"""
40+
global _idnaCharacters
41+
42+
if not _idnaCharacters:
43+
result = []
44+
45+
# Data source "IDNA Derived Properties":
46+
# https://www.iana.org/assignments/idna-tables-6.3.0/
47+
# idna-tables-6.3.0.xhtml#idna-tables-properties
48+
dataFileName = join(dirname(__file__), "idna-tables-properties.csv")
49+
with open(dataFileName) as dataFile:
50+
reader = csv_reader(dataFile, delimiter=",")
51+
next(reader) # Skip header row
52+
for row in reader:
53+
codes, prop, description = row
54+
55+
if prop != "PVALID":
56+
# CONTEXTO or CONTEXTJ are also allowed, but they come with
57+
# rules, so we're punting on those here.
58+
# See: https://tools.ietf.org/html/rfc5892
59+
continue
60+
61+
startEnd = row[0].split("-", 1)
62+
if len(startEnd) == 1:
63+
# No end of range given; use start
64+
startEnd.append(startEnd[0])
65+
start, end = (int(i, 16) for i in startEnd)
66+
67+
for i in range(start, end + 1):
68+
assert i <= maxunicode
69+
result.append(unichr(i))
70+
71+
_idnaCharacters = u"".join(result)
72+
73+
return _idnaCharacters
74+
75+
76+
_idnaCharacters = "" # type: Text
77+
78+
79+
@composite
80+
def idna_text(draw, min_size=1, max_size=None):
81+
# type: (DrawCallable, int, Optional[int]) -> Text
82+
"""
83+
A strategy which generates IDNA-encodable text.
84+
85+
@param min_size: The minimum number of characters in the text.
86+
C{None} is treated as C{0}.
87+
88+
@param max_size: The maximum number of characters in the text.
89+
Use C{None} for an unbounded size.
90+
"""
91+
alphabet = idna_characters()
92+
93+
assert min_size >= 1
94+
95+
if max_size is not None:
96+
assert max_size >= 1
97+
98+
result = cast(
99+
Text,
100+
draw(text(
101+
min_size=min_size, max_size=max_size, alphabet=alphabet
102+
))
103+
)
104+
105+
# FIXME: There should be a more efficient way to ensure we produce valid
106+
# IDNA text.
107+
try:
108+
idna_encode(result)
109+
except IDNAError:
110+
assume(False)
111+
112+
return result
113+
114+
115+
@composite
116+
def port_numbers(draw, allow_zero=False):
117+
# type: (DrawCallable, bool) -> int
118+
"""
119+
A strategy which generates port numbers.
120+
121+
@param allow_zero: Whether to allow port C{0} as a possible value.
122+
"""
123+
if allow_zero:
124+
min_value = 0
125+
else:
126+
min_value = 1
127+
128+
return cast(
129+
int, draw(integers(min_value=min_value, max_value=65535))
130+
)
131+
132+
133+
@composite
134+
def hostname_labels(draw, allow_idn=True):
135+
# type: (DrawCallable, bool) -> Text
136+
"""
137+
A strategy which generates host name labels.
138+
139+
@param allow_idn: Whether to allow non-ASCII characters as allowed by
140+
internationalized domain names (IDNs).
141+
"""
142+
if allow_idn:
143+
label = cast(Text, draw(idna_text(min_size=1, max_size=63)))
144+
145+
try:
146+
label.encode("ascii")
147+
except UnicodeEncodeError:
148+
# If the label doesn't encode to ASCII, then we need to check the
149+
# length of the label after encoding to punycode and adding the
150+
# xn-- prefix.
151+
while (
152+
len(label.encode("punycode")) > 63 - len("xn--")
153+
): # pragma: no cover (not always drawn)
154+
# Rather than bombing out, just trim from the end until it is
155+
# short enough, so hypothesis doesn't have to generate new
156+
# data.
157+
label = label[:-1]
158+
159+
else:
160+
label = cast(
161+
Text,
162+
draw(text(
163+
min_size=1, max_size=63,
164+
alphabet=Text(ascii_letters + digits + u"-")
165+
))
166+
)
167+
168+
# Filter invalid labels.
169+
# It would be better to reliably avoid generation of bogus labels in the
170+
# first place, but it's hard...
171+
try:
172+
check_label(label)
173+
except UnicodeError: # pragma: no cover (not always drawn)
174+
assume(False)
175+
176+
return label
177+
178+
179+
@composite
180+
def hostnames(draw, allow_leading_digit=True, allow_idn=True):
181+
# type: (DrawCallable, bool, bool) -> Text
182+
"""
183+
A strategy which generates host names.
184+
185+
@param allow_leading_digit: Whether to allow a leading digit in host names;
186+
they were not allowed prior to RFC 1123.
187+
188+
@param allow_idn: Whether to allow non-ASCII characters as allowed by
189+
internationalized domain names (IDNs).
190+
"""
191+
labels = cast(
192+
Sequence[Text],
193+
draw(
194+
lists(hostname_labels(allow_idn=allow_idn), min_size=1, max_size=5)
195+
.filter(lambda ls: sum(len(l) for l in ls) + len(ls) - 1 <= 252)
196+
)
197+
)
198+
199+
if not allow_leading_digit:
200+
assume(labels[0][0] not in digits)
201+
202+
return u".".join(labels)
203+
204+
205+
def path_characters():
206+
# type: () -> str
207+
"""
208+
Returns a string containing valid URL path characters.
209+
"""
210+
global _path_characters
211+
212+
if _path_characters is None:
213+
def chars():
214+
# type: () -> Iterable[Text]
215+
for i in range(maxunicode):
216+
c = unichr(i)
217+
218+
# Exclude reserved characters
219+
if c in "#/?":
220+
continue
221+
222+
# Exclude anything not UTF-8 compatible
223+
try:
224+
c.encode("utf-8")
225+
except UnicodeEncodeError:
226+
continue
227+
228+
yield c
229+
230+
_path_characters = "".join(chars())
231+
232+
return _path_characters
233+
234+
235+
_path_characters = None # type: Optional[str]
236+
237+
238+
@composite
239+
def paths(draw):
240+
# type: (DrawCallable) -> Sequence[Text]
241+
return cast(
242+
Sequence[Text],
243+
draw(
244+
lists(text(min_size=1, alphabet=path_characters()), max_size=10)
245+
)
246+
)
247+
248+
249+
@composite
250+
def encoded_urls(draw):
251+
# type: (DrawCallable) -> EncodedURL
252+
"""
253+
A strategy which generates L{EncodedURL}s.
254+
Call the L{EncodedURL.to_uri} method on each URL to get an HTTP
255+
protocol-friendly URI.
256+
"""
257+
port = cast(Optional[int], draw(port_numbers(allow_zero=True)))
258+
host = cast(Text, draw(hostnames()))
259+
path = cast(Sequence[Text], draw(paths()))
260+
261+
if port == 0:
262+
port = None
263+
264+
args = dict(
265+
scheme=cast(Text, draw(sampled_from((u"http", u"https")))),
266+
host=host, port=port, path=path,
267+
)
268+
269+
return EncodedURL(**args)
270+
271+
272+
@composite
273+
def decoded_urls(draw):
274+
# type: (DrawCallable) -> DecodedURL
275+
"""
276+
A strategy which generates L{DecodedURL}s.
277+
Call the L{EncodedURL.to_uri} method on each URL to get an HTTP
278+
protocol-friendly URI.
279+
"""
280+
return DecodedURL(draw(encoded_urls()))

src/hyperlink/test/test_socket.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
try:
44
from socket import inet_pton
55
except ImportError:
6-
inet_pton = None # type: ignore[assignment]
6+
inet_pton = None # type: ignore[assignment] not optional
77

88
if not inet_pton:
99
import socket
1010

1111
from .common import HyperlinkTestCase
1212
from .._socket import inet_pton
1313

14-
1514
class TestSocket(HyperlinkTestCase):
1615
def test_inet_pton_ipv4_valid(self):
1716
# type: () -> None

0 commit comments

Comments
 (0)