diff --git a/.gitignore b/.gitignore index 0ef6fd4d..35a65f26 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ tmp.py htmlcov/ .coverage.* *.py[cod] +.mypy_cache # emacs *~ @@ -33,7 +34,7 @@ pip-log.txt # Unit test / coverage reports .coverage -.tox +.tox/ nosetests.xml # Translations diff --git a/setup.py b/setup.py index 85d29b57..db3b4214 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,10 @@ zip_safe=False, license=__license__, platforms='any', - install_requires=['idna>=2.5'], + install_requires=[ + 'idna>=2.5', + 'typing ; python_version<"3.5"', + ], classifiers=[ 'Topic :: Utilities', 'Intended Audience :: Developers', diff --git a/src/hyperlink/_socket.py b/src/hyperlink/_socket.py new file mode 100644 index 00000000..769b9d54 --- /dev/null +++ b/src/hyperlink/_socket.py @@ -0,0 +1,46 @@ +try: + from socket import inet_pton +except ImportError: + from typing import TYPE_CHECKING + if TYPE_CHECKING: # pragma: no cover + pass + else: + # based on https://gist.github.com/nnemkin/4966028 + # this code only applies on Windows Python 2.7 + import ctypes + import socket + + class SockAddr(ctypes.Structure): + _fields_ = [ + ("sa_family", ctypes.c_short), + ("__pad1", ctypes.c_ushort), + ("ipv4_addr", ctypes.c_byte * 4), + ("ipv6_addr", ctypes.c_byte * 16), + ("__pad2", ctypes.c_ulong), + ] + + WSAStringToAddressA = ctypes.windll.ws2_32.WSAStringToAddressA + WSAAddressToStringA = ctypes.windll.ws2_32.WSAAddressToStringA + + def inet_pton(address_family, ip_string): + # type: (int, str) -> bytes + addr = SockAddr() + ip_string_bytes = ip_string.encode('ascii') + addr.sa_family = address_family + addr_size = ctypes.c_int(ctypes.sizeof(addr)) + + try: + attribute, size = { + socket.AF_INET: ("ipv4_addr", 4), + socket.AF_INET6: ("ipv6_addr", 16), + }[address_family] + except KeyError: + raise socket.error("unknown address family") + + if WSAStringToAddressA( + ip_string_bytes, address_family, None, + ctypes.byref(addr), ctypes.byref(addr_size) + ) != 0: + raise socket.error(ctypes.FormatError()) + + return ctypes.string_at(getattr(addr, attribute), size) diff --git a/src/hyperlink/_url.py b/src/hyperlink/_url.py index 7f94a234..2d4e712a 100644 --- a/src/hyperlink/_url.py +++ b/src/hyperlink/_url.py @@ -19,11 +19,9 @@ import sys import string import socket +from typing import Callable, Text, Type from unicodedata import normalize -try: - from socket import inet_pton -except ImportError: - inet_pton = None # defined below +from ._socket import inet_pton try: from collections.abc import Mapping except ImportError: # Python 2 @@ -32,47 +30,13 @@ from idna import encode as idna_encode, decode as idna_decode -if inet_pton is None: - # based on https://gist.github.com/nnemkin/4966028 - # this code only applies on Windows Python 2.7 - import ctypes - - class SockAddr(ctypes.Structure): - _fields_ = [("sa_family", ctypes.c_short), - ("__pad1", ctypes.c_ushort), - ("ipv4_addr", ctypes.c_byte * 4), - ("ipv6_addr", ctypes.c_byte * 16), - ("__pad2", ctypes.c_ulong)] - - WSAStringToAddressA = ctypes.windll.ws2_32.WSAStringToAddressA - WSAAddressToStringA = ctypes.windll.ws2_32.WSAAddressToStringA - - def inet_pton(address_family, ip_string): - addr = SockAddr() - ip_string = ip_string.encode('ascii') - addr.sa_family = address_family - addr_size = ctypes.c_int(ctypes.sizeof(addr)) - - if WSAStringToAddressA( - ip_string, address_family, None, - ctypes.byref(addr), ctypes.byref(addr_size) - ) != 0: - raise socket.error(ctypes.FormatError()) - - if address_family == socket.AF_INET: - return ctypes.string_at(addr.ipv4_addr, 4) - if address_family == socket.AF_INET6: - return ctypes.string_at(addr.ipv6_addr, 16) - raise socket.error('unknown address family') - - PY2 = (sys.version_info[0] == 2) unicode = type(u'') try: unichr -except NameError: - unichr = chr # py3 -NoneType = type(None) +except NameError: # Py3 + unichr = chr # type: Callable[[int], Text] +NoneType = type(None) # type: Type[None] # from boltons.typeutils diff --git a/src/hyperlink/test/common.py b/src/hyperlink/test/common.py index c2674d8f..c9696fcf 100644 --- a/src/hyperlink/test/common.py +++ b/src/hyperlink/test/common.py @@ -1,3 +1,4 @@ +from typing import Any, Callable, Optional, Type from unittest import TestCase @@ -5,8 +6,11 @@ class HyperlinkTestCase(TestCase): """This type mostly exists to provide a backwards-compatible assertRaises method for Python 2.6 testing. """ - def assertRaises(self, excClass, callableObj=None, *args, **kwargs): - """Fail unless an exception of class excClass is raised + def assertRaises( # type: ignore[override] Doesn't match superclass, meh + self, expected_exception, callableObj=None, *args, **kwargs + ): + # type: (Type[BaseException], Optional[Callable], Any, Any) -> Any + """Fail unless an exception of class expected_exception is raised by callableObj when invoked with arguments args and keyword arguments kwargs. If a different type of exception is raised, it will not be caught, and the test case will be @@ -28,7 +32,7 @@ def assertRaises(self, excClass, callableObj=None, *args, **kwargs): the_exception = cm.exception self.assertEqual(the_exception.error_code, 3) """ - context = _AssertRaisesContext(excClass, self) + context = _AssertRaisesContext(expected_exception, self) if callableObj is None: return context with context: @@ -39,13 +43,16 @@ class _AssertRaisesContext(object): "A context manager used to implement HyperlinkTestCase.assertRaises." def __init__(self, expected, test_case): + # type: (Type[BaseException], TestCase) -> None self.expected = expected self.failureException = test_case.failureException def __enter__(self): + # type: () -> "_AssertRaisesContext" return self def __exit__(self, exc_type, exc_value, tb): + # type: (Optional[Type[BaseException]], Any, Any) -> bool if exc_type is None: exc_name = self.expected.__name__ raise self.failureException("%s not raised" % (exc_name,)) diff --git a/src/hyperlink/test/test_common.py b/src/hyperlink/test/test_common.py index 1d61583c..ac8e9b3c 100644 --- a/src/hyperlink/test/test_common.py +++ b/src/hyperlink/test/test_common.py @@ -1,6 +1,7 @@ """ Tests for hyperlink.test.common """ +from typing import Any from unittest import TestCase from .common import HyperlinkTestCase @@ -21,9 +22,11 @@ class TestHyperlink(TestCase): """Tests for HyperlinkTestCase""" def setUp(self): + # type: () -> None self.hyperlink_test = HyperlinkTestCase("run") def test_assertRaisesWithCallable(self): + # type: () -> None """HyperlinkTestCase.assertRaises does not raise an AssertionError when given a callable that, when called with the provided arguments, raises the expected exception. @@ -32,6 +35,7 @@ def test_assertRaisesWithCallable(self): called_with = [] def raisesExpected(*args, **kwargs): + # type: (Any, Any) -> None called_with.append((args, kwargs)) raise _ExpectedException @@ -40,12 +44,14 @@ def raisesExpected(*args, **kwargs): self.assertEqual(called_with, [((1,), {"keyword": True})]) def test_assertRaisesWithCallableUnexpectedException(self): + # type: () -> None """When given a callable that raises an unexpected exception, HyperlinkTestCase.assertRaises raises that exception. """ def doesNotRaiseExpected(*args, **kwargs): + # type: (Any, Any) -> None raise _UnexpectedException try: @@ -55,13 +61,15 @@ def doesNotRaiseExpected(*args, **kwargs): pass def test_assertRaisesWithCallableDoesNotRaise(self): + # type: () -> None """HyperlinkTestCase.assertRaises raises an AssertionError when given a callable that, when called, does not raise any exception. """ def doesNotRaise(*args, **kwargs): - return True + # type: (Any, Any) -> None + pass try: self.hyperlink_test.assertRaises(_ExpectedException, @@ -70,6 +78,7 @@ def doesNotRaise(*args, **kwargs): pass def test_assertRaisesContextManager(self): + # type: () -> None """HyperlinkTestCase.assertRaises does not raise an AssertionError when used as a context manager with a suite that raises the expected exception. The context manager stores the exception @@ -79,9 +88,12 @@ def test_assertRaisesContextManager(self): with self.hyperlink_test.assertRaises(_ExpectedException) as cm: raise _ExpectedException - self.assertTrue(isinstance(cm.exception, _ExpectedException)) + self.assertTrue( # type: ignore[misc] unreachable + isinstance(cm.exception, _ExpectedException) + ) def test_assertRaisesContextManagerUnexpectedException(self): + # type: () -> None """When used as a context manager with a block that raises an unexpected exception, HyperlinkTestCase.assertRaises raises that unexpected exception. @@ -94,6 +106,7 @@ def test_assertRaisesContextManagerUnexpectedException(self): pass def test_assertRaisesContextManagerDoesNotRaise(self): + # type: () -> None """HyperlinkTestcase.assertRaises raises an AssertionError when used as a context manager with a block that does not raise any exception. diff --git a/src/hyperlink/test/test_decoded_url.py b/src/hyperlink/test/test_decoded_url.py index f44f8862..17f7f19b 100644 --- a/src/hyperlink/test/test_decoded_url.py +++ b/src/hyperlink/test/test_decoded_url.py @@ -16,6 +16,7 @@ class TestURL(HyperlinkTestCase): def test_durl_basic(self): + # type: () -> None bdurl = DecodedURL.from_text(BASIC_URL) assert bdurl.scheme == 'http' assert bdurl.host == 'example.com' @@ -36,6 +37,8 @@ def test_durl_basic(self): assert durl.userinfo == ('user', '\0\0\0\0') def test_passthroughs(self): + # type: () -> None + # just basic tests for the methods that more or less pass straight # through to the underlying URL @@ -72,10 +75,12 @@ def test_passthroughs(self): assert durl != 1 def test_repr(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) assert repr(durl) == 'DecodedURL(url=' + repr(durl._url) + ')' def test_query_manipulation(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) assert durl.get('zot') == ['23%'] @@ -115,6 +120,7 @@ def test_query_manipulation(self): ) def test_equality_and_hashability(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) durl2 = DecodedURL.from_text(TOTAL_URL) burl = DecodedURL.from_text(BASIC_URL) @@ -141,6 +147,7 @@ def test_equality_and_hashability(self): assert len(durl_map) == 3 def test_replace_roundtrip(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) durl2 = durl.replace(scheme=durl.scheme, @@ -156,12 +163,14 @@ def test_replace_roundtrip(self): assert durl == durl2 def test_replace_userinfo(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) with self.assertRaises(ValueError): durl.replace(userinfo=['user', 'pw', 'thiswillcauseafailure']) return def test_twisted_compat(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) assert durl == DecodedURL.fromText(TOTAL_URL) @@ -170,9 +179,12 @@ def test_twisted_compat(self): assert durl.to_text() == durl.asText() def test_percent_decode_bytes(self): + # type: () -> None assert _percent_decode('%00', subencoding=False) == b'\0' def test_percent_decode_mixed(self): + # type: () -> None + # See https://github.com/python-hyper/hyperlink/pull/59 for a # nice discussion of the possibilities assert _percent_decode('abcdé%C3%A9éfg') == 'abcdéééfg' @@ -191,6 +203,7 @@ def test_percent_decode_mixed(self): assert _percent_decode('é%25é', subencoding='ascii') == 'é%25é' def test_click_decoded_url(self): + # type: () -> None durl = DecodedURL.from_text(TOTAL_URL) durl_dest = DecodedURL.from_text('/tëst') diff --git a/src/hyperlink/test/test_parse.py b/src/hyperlink/test/test_parse.py index d151f4ee..8fdbf351 100644 --- a/src/hyperlink/test/test_parse.py +++ b/src/hyperlink/test/test_parse.py @@ -18,6 +18,7 @@ class TestURL(HyperlinkTestCase): def test_parse(self): + # type: () -> None purl = parse(TOTAL_URL) assert isinstance(purl, DecodedURL) assert purl.user == 'user' @@ -35,5 +36,3 @@ def test_parse(self): with self.assertRaises(UnicodeDecodeError): purl3.fragment - - return diff --git a/src/hyperlink/test/test_scheme_registration.py b/src/hyperlink/test/test_scheme_registration.py index d344353c..a8bbbef5 100644 --- a/src/hyperlink/test/test_scheme_registration.py +++ b/src/hyperlink/test/test_scheme_registration.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from typing import cast from .. import _url @@ -10,14 +11,17 @@ class TestSchemeRegistration(HyperlinkTestCase): def setUp(self): + # type: () -> None self._orig_scheme_port_map = dict(_url.SCHEME_PORT_MAP) self._orig_no_netloc_schemes = set(_url.NO_NETLOC_SCHEMES) def tearDown(self): + # type: () -> None _url.SCHEME_PORT_MAP = self._orig_scheme_port_map _url.NO_NETLOC_SCHEMES = self._orig_no_netloc_schemes def test_register_scheme_basic(self): + # type: () -> None register_scheme('deltron', uses_netloc=True, default_port=3030) u1 = URL.from_text('deltron://example.com') @@ -40,25 +44,30 @@ def test_register_scheme_basic(self): assert u4.to_text() == 'nonetron://example.com' def test_register_no_netloc_scheme(self): + # type: () -> None register_scheme('noloctron', uses_netloc=False) u4 = URL(scheme='noloctron') u4 = u4.replace(path=("example", "path")) assert u4.to_text() == 'noloctron:example/path' def test_register_no_netloc_with_port(self): + # type: () -> None with self.assertRaises(ValueError): register_scheme('badnetlocless', uses_netloc=False, default_port=7) def test_invalid_uses_netloc(self): + # type: () -> None with self.assertRaises(ValueError): - register_scheme('badnetloc', uses_netloc=None) + register_scheme('badnetloc', uses_netloc=cast(bool, None)) with self.assertRaises(ValueError): - register_scheme('badnetloc', uses_netloc=object()) + register_scheme('badnetloc', uses_netloc=cast(bool, object())) def test_register_invalid_uses_netloc(self): + # type: () -> None with self.assertRaises(ValueError): - register_scheme('lol', uses_netloc=lambda: 'nope') + register_scheme('lol', uses_netloc=cast(bool, object())) def test_register_invalid_port(self): + # type: () -> None with self.assertRaises(ValueError): - register_scheme('nope', default_port=lambda: 'lol') + register_scheme('nope', default_port=cast(bool, object())) diff --git a/src/hyperlink/test/test_socket.py b/src/hyperlink/test/test_socket.py new file mode 100644 index 00000000..a66e1b05 --- /dev/null +++ b/src/hyperlink/test/test_socket.py @@ -0,0 +1,46 @@ +# mypy: always-true=inet_pton + +try: + from socket import inet_pton +except ImportError: + inet_pton = None # type: ignore[assignment] + +if not inet_pton: + import socket + + from .common import HyperlinkTestCase + from .._socket import inet_pton + + + class TestSocket(HyperlinkTestCase): + def test_inet_pton_ipv4_valid(self): + # type: () -> None + data = inet_pton(socket.AF_INET, "127.0.0.1") + assert isinstance(data, bytes) + + def test_inet_pton_ipv4_bogus(self): + # type: () -> None + with self.assertRaises(socket.error): + inet_pton(socket.AF_INET, "blah") + + def test_inet_pton_ipv6_valid(self): + # type: () -> None + data = inet_pton(socket.AF_INET6, "::1") + assert isinstance(data, bytes) + + def test_inet_pton_ipv6_bogus(self): + # type: () -> None + with self.assertRaises(socket.error): + inet_pton(socket.AF_INET6, "blah") + + def test_inet_pton_bogus_family(self): + # type: () -> None + # Find an integer not associated with a known address family + i = int(socket.AF_INET6) + while True: + if i != socket.AF_INET and i != socket.AF_INET6: + break + i += 100 + + with self.assertRaises(socket.error): + inet_pton(i, "127.0.0.1") diff --git a/src/hyperlink/test/test_url.py b/src/hyperlink/test/test_url.py index 73cbd936..511515c8 100644 --- a/src/hyperlink/test/test_url.py +++ b/src/hyperlink/test/test_url.py @@ -7,6 +7,7 @@ import sys import socket +from typing import Any, Iterable, Optional, Text, Tuple from .common import HyperlinkTestCase from .. import URL, URLParseError @@ -149,6 +150,7 @@ class TestURL(HyperlinkTestCase): """ def assertUnicoded(self, u): + # type: (URL) -> None """ The given L{URL}'s components should be L{unicode}. @@ -165,8 +167,18 @@ def assertUnicoded(self, u): self.assertTrue(v is None or isinstance(v, unicode), repr(u)) self.assertEqual(type(u.fragment), unicode, repr(u)) - def assertURL(self, u, scheme, host, path, query, - fragment, port, userinfo=''): + def assertURL( + self, + u, # type: URL + scheme, # type: Text + host, # type: Text + path, # type: Iterable[Text] + query, # type: Iterable[Tuple[Text, Optional[Text]]] + fragment, # type: Text + port, # type: Optional[int] + userinfo='', # type: Text + ): + # type: (...) -> None """ The given L{URL} should have the given components. @@ -193,10 +205,12 @@ def assertURL(self, u, scheme, host, path, query, self.assertEqual(actual, expected) def test_initDefaults(self): + # type: () -> None """ L{URL} should have appropriate default values. """ def check(u): + # type: (URL) -> None self.assertUnicoded(u) self.assertURL(u, 'http', '', [], [], '', 80, '') @@ -205,6 +219,7 @@ def check(u): check(URL('http', '', [], [], '')) def test_init(self): + # type: () -> None """ L{URL} should accept L{unicode} parameters. """ @@ -219,6 +234,7 @@ def test_init(self): [('\u03bb', '\u03c0')], '\u22a5', 80) def test_initPercent(self): + # type: () -> None """ L{URL} should accept (and not interpret) percent characters. """ @@ -231,6 +247,7 @@ def test_initPercent(self): '%66', None) def test_repr(self): + # type: () -> None """ L{URL.__repr__} will display the canonical form of the URL, wrapped in a L{URL.from_text} invocation, so that it is C{eval}-able but still @@ -244,6 +261,7 @@ def test_repr(self): ) def test_from_text(self): + # type: () -> None """ Round-tripping L{URL.from_text} with C{str} results in an equivalent URL. @@ -252,6 +270,7 @@ def test_from_text(self): self.assertEqual(BASIC_URL, urlpath.to_text()) def test_roundtrip(self): + # type: () -> None """ L{URL.to_text} should invert L{URL.from_text}. """ @@ -260,6 +279,7 @@ def test_roundtrip(self): self.assertEqual(test, result) def test_roundtrip_double_iri(self): + # type: () -> None for test in ROUNDTRIP_TESTS: url = URL.from_text(test) iri = url.to_iri() @@ -272,6 +292,7 @@ def test_roundtrip_double_iri(self): return def test_equality(self): + # type: () -> None """ Two URLs decoded using L{URL.from_text} will be equal (C{==}) if they decoded same URL string, and unequal (C{!=}) if they decoded different @@ -286,6 +307,7 @@ def test_equality(self): ) def test_fragmentEquality(self): + # type: () -> None """ An URL created with the empty string for a fragment compares equal to an URL created with an unspecified fragment. @@ -295,6 +317,7 @@ def test_fragmentEquality(self): URL.from_text(u"http://localhost/")) def test_child(self): + # type: () -> None """ L{URL.child} appends a new path segment, but does not affect the query or fragment. @@ -314,6 +337,7 @@ def test_child(self): ) def test_multiChild(self): + # type: () -> None """ L{URL.child} receives multiple segments as C{*args} and appends each in turn. @@ -323,6 +347,7 @@ def test_multiChild(self): 'http://example.com/a/b/c/d/e') def test_childInitRoot(self): + # type: () -> None """ L{URL.child} of a L{URL} without a path produces a L{URL} with a single path segment. @@ -332,6 +357,7 @@ def test_childInitRoot(self): self.assertEqual("http://www.foo.com/c", childURL.to_text()) def test_emptyChild(self): + # type: () -> None """ L{URL.child} without any new segments returns the original L{URL}. """ @@ -339,6 +365,7 @@ def test_emptyChild(self): self.assertEqual(url.child(), url) def test_sibling(self): + # type: () -> None """ L{URL.sibling} of a L{URL} replaces the last path segment, but does not affect the query or fragment. @@ -357,6 +384,7 @@ def test_sibling(self): ) def test_click(self): + # type: () -> None """ L{URL.click} interprets the given string as a relative URI-reference and returns a new L{URL} interpreting C{self} as the base absolute URI. @@ -406,6 +434,7 @@ def test_click(self): self.assertEqual(u3.to_text(), 'http://localhost/foo/bar') def test_clickRFC3986(self): + # type: () -> None """ L{URL.click} should correctly resolve the examples in RFC 3986. """ @@ -414,6 +443,7 @@ def test_clickRFC3986(self): self.assertEqual(base.click(ref).to_text(), expected) def test_clickSchemeRelPath(self): + # type: () -> None """ L{URL.click} should not accept schemes with relative paths. """ @@ -422,6 +452,7 @@ def test_clickSchemeRelPath(self): self.assertRaises(NotImplementedError, base.click, 'http:h') def test_cloneUnchanged(self): + # type: () -> None """ Verify that L{URL.replace} doesn't change any of the arguments it is passed. @@ -437,6 +468,7 @@ def test_cloneUnchanged(self): self.assertEqual(urlpath.replace(), urlpath) def test_clickCollapse(self): + # type: () -> None """ L{URL.click} collapses C{.} and C{..} according to RFC 3986 section 5.2.4. @@ -472,6 +504,7 @@ def test_clickCollapse(self): ) def test_queryAdd(self): + # type: () -> None """ L{URL.add} adds query parameters. """ @@ -504,6 +537,7 @@ def test_queryAdd(self): .to_text()) def test_querySet(self): + # type: () -> None """ L{URL.set} replaces query parameters by name. """ @@ -524,6 +558,7 @@ def test_querySet(self): ) def test_queryRemove(self): + # type: () -> None """ L{URL.remove} removes instances of a query parameter. """ @@ -549,6 +584,7 @@ def test_queryRemove(self): ) def test_parseEqualSignInParamValue(self): + # type: () -> None """ Every C{=}-sign after the first in a query parameter is simply included in the value of the parameter. @@ -569,12 +605,14 @@ def test_parseEqualSignInParamValue(self): self.assertEqual(iri.to_uri().get('operator'), ['=']) def test_empty(self): + # type: () -> None """ An empty L{URL} should serialize as the empty string. """ self.assertEqual(URL().to_text(), '') def test_justQueryText(self): + # type: () -> None """ An L{URL} with query text should serialize as just query text. """ @@ -582,6 +620,7 @@ def test_justQueryText(self): self.assertEqual(u.to_text(), '?hello=world') def test_identicalEqual(self): + # type: () -> None """ L{URL} compares equal to itself. """ @@ -589,6 +628,7 @@ def test_identicalEqual(self): self.assertEqual(u, u) def test_similarEqual(self): + # type: () -> None """ URLs with equivalent components should compare equal. """ @@ -597,6 +637,7 @@ def test_similarEqual(self): self.assertEqual(u1, u2) def test_differentNotEqual(self): + # type: () -> None """ L{URL}s that refer to different resources are both unequal (C{!=}) and also not equal (not C{==}). @@ -607,6 +648,7 @@ def test_differentNotEqual(self): self.assertNotEqual(u1, u2) def test_otherTypesNotEqual(self): + # type: () -> None """ L{URL} is not equal (C{==}) to other types. """ @@ -617,6 +659,7 @@ def test_otherTypesNotEqual(self): self.assertNotEqual(u, object()) def test_identicalNotUnequal(self): + # type: () -> None """ Identical L{URL}s are not unequal (C{!=}) to each other. """ @@ -624,6 +667,7 @@ def test_identicalNotUnequal(self): self.assertFalse(u != u, "%r == itself" % u) def test_similarNotUnequal(self): + # type: () -> None """ Structurally similar L{URL}s are not unequal (C{!=}) to each other. """ @@ -632,6 +676,7 @@ def test_similarNotUnequal(self): self.assertFalse(u1 != u2, "%r == %r" % (u1, u2)) def test_differentUnequal(self): + # type: () -> None """ Structurally different L{URL}s are unequal (C{!=}) to each other. """ @@ -640,6 +685,7 @@ def test_differentUnequal(self): self.assertTrue(u1 != u2, "%r == %r" % (u1, u2)) def test_otherTypesUnequal(self): + # type: () -> None """ L{URL} is unequal (C{!=}) to other types. """ @@ -648,6 +694,7 @@ def test_otherTypesUnequal(self): self.assertTrue(u != object(), "URL must be differ from an object.") def test_asURI(self): + # type: () -> None """ L{URL.asURI} produces an URI which converts any URI unicode encoding into pure US-ASCII and returns a new L{URL}. @@ -669,6 +716,7 @@ def test_asURI(self): '%r != %r' % (actualURI, expectedURI)) def test_asIRI(self): + # type: () -> None """ L{URL.asIRI} decodes any percent-encoded text in the URI, making it more suitable for reading by humans, and returns a new L{URL}. @@ -689,6 +737,7 @@ def test_asIRI(self): '%r != %r' % (actualIRI, expectedIRI)) def test_badUTF8AsIRI(self): + # type: () -> None """ Bad UTF-8 in a path segment, query parameter, or fragment results in that portion of the URI remaining percent-encoded in the IRI. @@ -704,6 +753,7 @@ def test_badUTF8AsIRI(self): '%r != %r' % (actualIRI, expectedIRI)) def test_alreadyIRIAsIRI(self): + # type: () -> None """ A L{URL} composed of non-ASCII text will result in non-ASCII text. """ @@ -717,6 +767,7 @@ def test_alreadyIRIAsIRI(self): self.assertEqual(alsoIRI.to_text(), unicodey) def test_alreadyURIAsURI(self): + # type: () -> None """ A L{URL} composed of encoded text will remain encoded. """ @@ -726,6 +777,7 @@ def test_alreadyURIAsURI(self): self.assertEqual(actualURI, expectedURI) def test_userinfo(self): + # type: () -> None """ L{URL.from_text} will parse the C{userinfo} portion of the URI separately from the host and port. @@ -746,6 +798,7 @@ def test_userinfo(self): ) def test_portText(self): + # type: () -> None """ L{URL.from_text} parses custom port numbers as integers. """ @@ -754,6 +807,7 @@ def test_portText(self): self.assertEqual(portURL.to_text(), u"http://www.example.com:8080/") def test_mailto(self): + # type: () -> None """ Although L{URL} instances are mainly for dealing with HTTP, other schemes (such as C{mailto:}) should work as well. For example, @@ -764,6 +818,7 @@ def test_mailto(self): u"mailto:user@example.com") def test_queryIterable(self): + # type: () -> None """ When a L{URL} is created with a C{query} argument, the C{query} argument is converted into an N-tuple of 2-tuples, sensibly @@ -776,6 +831,7 @@ def test_queryIterable(self): self.assertEqual(url.query, expected) def test_pathIterable(self): + # type: () -> None """ When a L{URL} is created with a C{path} argument, the C{path} is converted into a tuple. @@ -784,6 +840,7 @@ def test_pathIterable(self): self.assertEqual(url.path, ('hello', 'world')) def test_invalidArguments(self): + # type: () -> None """ Passing an argument of the wrong type to any of the constructor arguments of L{URL} will raise a descriptive L{TypeError}. @@ -795,20 +852,24 @@ def test_invalidArguments(self): """ class Unexpected(object): def __str__(self): + # type: () -> str return "wrong" def __repr__(self): + # type: () -> str return "" defaultExpectation = "unicode" if bytes is str else "str" def assertRaised(raised, expectation, name): + # type: (Any, Text, Text) -> None self.assertEqual(str(raised.exception), "expected {0} for {1}, got {2}".format( expectation, name, "")) def check(param, expectation=defaultExpectation): + # type: (Any, str) -> None with self.assertRaises(TypeError) as raised: URL(**{param: Unexpected()}) @@ -860,6 +921,7 @@ def check(param, expectation=defaultExpectation): assertRaised(raised, defaultExpectation, "relative URL") def test_technicallyTextIsIterableBut(self): + # type: () -> None """ Technically, L{str} (or L{unicode}, as appropriate) is iterable, but C{URL(path="foo")} resulting in C{URL.from_text("f/o/o")} is never what @@ -873,6 +935,7 @@ def test_technicallyTextIsIterableBut(self): ) def test_netloc(self): + # type: () -> None url = URL(scheme='https') self.assertEqual(url.uses_netloc, True) @@ -892,6 +955,7 @@ def test_netloc(self): self.assertEqual(url.uses_netloc, False) def test_ipv6_with_port(self): + # type: () -> None t = 'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:80/' url = URL.from_text(t) assert url.host == '2001:0db8:85a3:0000:0000:8a2e:0370:7334' @@ -899,6 +963,7 @@ def test_ipv6_with_port(self): assert SCHEME_PORT_MAP[url.scheme] != url.port def test_basic(self): + # type: () -> None text = 'https://user:pass@example.com/path/to/here?k=v#nice' url = URL.from_text(text) assert url.scheme == 'https' @@ -922,12 +987,15 @@ def test_basic(self): assert url.path == ('path', 'to', 'here') def test_invalid_url(self): + # type: () -> None self.assertRaises(URLParseError, URL.from_text, '#\n\n') def test_invalid_authority_url(self): + # type: () -> None self.assertRaises(URLParseError, URL.from_text, 'http://abc:\n\n/#') def test_invalid_ipv6(self): + # type: () -> None invalid_ipv6_ips = ['2001::0234:C1ab::A0:aabc:003F', '2001::1::3F', ':', @@ -939,6 +1007,7 @@ def test_invalid_ipv6(self): self.assertRaises(URLParseError, URL.from_text, url_text) def test_invalid_port(self): + # type: () -> None self.assertRaises( URLParseError, URL.from_text, 'ftp://portmouth:smash' ) @@ -946,6 +1015,7 @@ def test_invalid_port(self): 'http://reader.googlewebsite.com:neverforget') def test_idna(self): + # type: () -> None u1 = URL.from_text('http://bücher.ch') self.assertEqual(u1.host, 'bücher.ch') self.assertEqual(u1.to_text(), 'http://bücher.ch') @@ -957,6 +1027,8 @@ def test_idna(self): self.assertEqual(u2.to_iri().to_text(), u'https://bücher.ch') def test_netloc_slashes(self): + # type: () -> None + # basic sanity checks url = URL.from_text('mailto:mahmoud@hatnote.com') self.assertEqual(url.scheme, 'mailto') @@ -1010,6 +1082,7 @@ def test_netloc_slashes(self): return def test_wrong_constructor(self): + # type: () -> None with self.assertRaises(ValueError): # whole URL not allowed URL(BASIC_URL) @@ -1018,6 +1091,7 @@ def test_wrong_constructor(self): URL('HTTP_____more_like_imHoTTeP') def test_encoded_userinfo(self): + # type: () -> None url = URL.from_text('http://user:pass@example.com') assert url.userinfo == 'user:pass' url = url.replace(userinfo='us%20her:pass') @@ -1036,6 +1110,7 @@ def test_encoded_userinfo(self): ) def test_hash(self): + # type: () -> None url_map = {} url1 = URL.from_text('http://blog.hatnote.com/ask?utm_source=geocity') assert hash(url1) == hash(url1) # sanity @@ -1053,6 +1128,7 @@ def test_hash(self): assert hash(URL()) == hash(URL()) # slightly more sanity def test_dir(self): + # type: () -> None url = URL() res = dir(url) @@ -1064,12 +1140,15 @@ def test_dir(self): assert 'asIRI' not in res def test_twisted_compat(self): + # type: () -> None url = URL.fromText(u'http://example.com/a%20té%C3%A9st') assert url.asText() == 'http://example.com/a%20té%C3%A9st' assert url.asURI().asText() == 'http://example.com/a%20t%C3%A9%C3%A9st' # TODO: assert url.asIRI().asText() == u'http://example.com/a%20téést' def test_set_ordering(self): + # type: () -> None + # TODO url = URL.from_text('http://example.com/?a=b&c') url = url.set(u'x', u'x') @@ -1079,6 +1158,7 @@ def test_set_ordering(self): # assert url.to_text() == u'http://example.com/?a=b&c&x=x&x=y' def test_schemeless_path(self): + # type: () -> None "See issue #4" u1 = URL.from_text("urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob") u2 = URL.from_text(u1.to_text()) @@ -1097,6 +1177,7 @@ def test_schemeless_path(self): assert u5 == u6 # colons stay decoded bc they're not in the first seg def test_emoji_domain(self): + # type: () -> None "See issue #7, affecting only narrow builds (2.6-3.3)" url = URL.from_text('https://xn--vi8hiv.ws') iri = url.to_iri() @@ -1104,6 +1185,7 @@ def test_emoji_domain(self): # as long as we don't get ValueErrors, we're good def test_delim_in_param(self): + # type: () -> None "Per issue #6 and #8" self.assertRaises(ValueError, URL, scheme=u'http', host=u'a/c') self.assertRaises(ValueError, URL, path=(u"?",)) @@ -1111,6 +1193,7 @@ def test_delim_in_param(self): self.assertRaises(ValueError, URL, query=((u"&", "test"))) def test_empty_paths_eq(self): + # type: () -> None u1 = URL.from_text('http://example.com/') u2 = URL.from_text('http://example.com') @@ -1132,11 +1215,14 @@ def test_empty_paths_eq(self): assert u1 == u2 def test_from_text_type(self): + # type: () -> None assert URL.from_text(u'#ok').fragment == u'ok' # sanity self.assertRaises(TypeError, URL.from_text, b'bytes://x.y.z') self.assertRaises(TypeError, URL.from_text, object()) def test_from_text_bad_authority(self): + # type: () -> None + # bad ipv6 brackets self.assertRaises(URLParseError, URL.from_text, 'http://[::1/') self.assertRaises(URLParseError, URL.from_text, 'http://::1]/') @@ -1151,6 +1237,7 @@ def test_from_text_bad_authority(self): self.assertRaises(URLParseError, URL.from_text, 'http://127.0.0.1::80') def test_normalize(self): + # type: () -> None url = URL.from_text('HTTP://Example.com/A%61/./../A%61?B%62=C%63#D%64') assert url.get('Bb') == [] assert url.get('B%62') == ['C%63'] @@ -1205,6 +1292,8 @@ def test_normalize(self): ) def test_str(self): + # type: () -> None + # see also issue #49 text = u'http://example.com/á/y%20a%20y/?b=%25' url = URL.from_text(text) @@ -1219,6 +1308,7 @@ def test_str(self): assert isinstance(bytes(url), bytes) def test_idna_corners(self): + # type: () -> None url = URL.from_text(u'http://abé.com/') assert url.to_iri().host == u'abé.com' assert url.to_uri().host == u'xn--ab-cja.com' diff --git a/tox.ini b/tox.ini index b8a2bc26..52c6e393 100644 --- a/tox.ini +++ b/tox.ini @@ -31,6 +31,7 @@ basepython = deps = test: coverage==4.5.4 test: idna==2.8 + test: typing==3.7.4.1 test: {py26,py27,py34}: pytest==4.6.6 test: {py35,py36,py37,py38}: pytest==5.2.1 test: pytest-cov==2.8.1 @@ -97,6 +98,9 @@ doctests = True # Codes: http://flake8.pycqa.org/en/latest/user/error-codes.html ignore = + # syntax error in type comment + F723, + # function name should be lowercase N802, @@ -122,6 +126,59 @@ ignore = application-import-names = deploy +## +# Mypy static type checking +## + +[testenv:mypy] + +description = run Mypy (static type checker) + +basepython = python3.7 + +skip_install = True + + +deps = + mypy==0.730 + + +commands = + mypy \ + --config-file="{toxinidir}/tox.ini" \ + --cache-dir="{toxworkdir}/mypy_cache" \ + {tty:--pretty:} \ + {posargs:src} + + +[mypy] + +# Global settings + +disallow_incomplete_defs = True +disallow_untyped_defs = True +no_implicit_optional = True +show_column_numbers = True +show_error_codes = True +strict_optional = True +warn_no_return = True +warn_redundant_casts = True +warn_return_any = True +warn_unreachable = True +warn_unused_ignores = True + +# Enable these over time +check_untyped_defs = False + +# Disable some checks until effected files fully adopt mypy + +[mypy-hyperlink._url] +allow_untyped_defs = True + +[mypy-idna] +ignore_missing_imports = True + + ## # Coverage report ##