diff --git a/src/hyperlink/_url.py b/src/hyperlink/_url.py index 7b2b27c0..244406f2 100644 --- a/src/hyperlink/_url.py +++ b/src/hyperlink/_url.py @@ -815,9 +815,18 @@ class URL(object): that starts with a slash. userinfo (Text): The username or colon-separated username:password pair. - uses_netloc (bool): Indicates whether two slashes appear - between the scheme and the host (``http://eg.com`` vs - ``mailto:e@g.com``). Set automatically based on scheme. + uses_netloc (Optional[bool]): Indicates whether ``://`` (the "netloc + separator") will appear to separate the scheme from the *path* in + cases where no host is present. Setting this to ``True`` is a + non-spec-compliant affordance for the common practice of having URIs + that are *not* URLs (cannot have a 'host' part) but nevertheless use + the common ``://`` idiom that most people associate with URLs; + e.g. ``message:`` URIs like ``message://message-id`` being + equivalent to ``message:message-id``. This may be inferred based on + the scheme depending on whether :func:`register_scheme` has been + used to register the scheme and should not be passed directly unless + you know the scheme works like this and you know it has not been + registered. All of these parts are also exposed as read-only attributes of URL instances, along with several useful methods. @@ -882,15 +891,28 @@ def __init__( self._rooted = _typecheck("rooted", rooted, bool) self._userinfo = _textcheck("userinfo", userinfo, '/?#@') - uses_netloc = scheme_uses_netloc(self._scheme, uses_netloc) + if uses_netloc is None: + uses_netloc = scheme_uses_netloc(self._scheme, uses_netloc) self._uses_netloc = _typecheck("uses_netloc", uses_netloc, bool, NoneType) - # fixup for rooted consistency - if self._host: + will_have_authority = ( + self._host or + (self._port and self._port != SCHEME_PORT_MAP.get(scheme)) + ) + if will_have_authority: + # fixup for rooted consistency; if there's any 'authority' + # represented in the textual URL, then the path must be rooted, and + # we're definitely using a netloc (there must be a ://). self._rooted = True - if (not self._rooted) and self._path and self._path[0] == '': + self._uses_netloc = True + if (not self._rooted) and self.path[:1] == (u'',): self._rooted = True self._path = self._path[1:] + if not will_have_authority and self._path and not self._rooted: + # If, after fixing up the path, there *is* a path and it *isn't* + # rooted, then we are definitely not using a netloc; if we did, it + # would make the path (erroneously) look like a hostname. + self._uses_netloc = False def get_decoded_url(self, lazy=False): # type: (bool) -> DecodedURL @@ -1006,6 +1028,8 @@ def userinfo(self): def uses_netloc(self): # type: () -> Optional[bool] """ + Indicates whether ``://`` (the "netloc separator") will appear to + separate the scheme from the *path* in cases where no host is present. """ return self._uses_netloc @@ -1134,14 +1158,28 @@ def replace( slash. userinfo (Text): The username or colon-separated username:password pair. - uses_netloc (bool): Indicates whether two slashes appear between - the scheme and the host - (``http://eg.com`` vs ``mailto:e@g.com``) + uses_netloc (bool): Indicates whether ``://`` (the "netloc + separator") will appear to separate the scheme from the *path* + in cases where no host is present. Setting this to ``True`` is + a non-spec-compliant affordance for the common practice of + having URIs that are *not* URLs (cannot have a 'host' part) but + nevertheless use the common ``://`` idiom that most people + associate with URLs; e.g. ``message:`` URIs like + ``message://message-id`` being equivalent to + ``message:message-id``. This may be inferred based on the + scheme depending on whether :func:`register_scheme` has been + used to register the scheme and should not be passed directly + unless you know the scheme works like this and you know it has + not been registered. Returns: URL: A copy of the current :class:`URL`, with new values for parameters passed. """ + if scheme is not _UNSET and scheme != self.scheme: + # when changing schemes, reset the explicit uses_netloc preference + # to honor the new scheme. + uses_netloc = None return self.__class__( scheme=_optional(scheme, self.scheme), host=_optional(host, self.host), diff --git a/src/hyperlink/test/test_url.py b/src/hyperlink/test/test_url.py index fa5c7bf1..6952e42f 100644 --- a/src/hyperlink/test/test_url.py +++ b/src/hyperlink/test/test_url.py @@ -817,6 +817,18 @@ def test_mailto(self): self.assertEqual(URL.from_text(u"mailto:user@example.com").to_text(), u"mailto:user@example.com") + def test_httpWithoutHost(self): + # type: () -> None + """ + An HTTP URL without a hostname, but with a path, should also round-trip + cleanly. + """ + without_host = URL.from_text(u"http:relative-path") + self.assertEqual(without_host.host, u'') + self.assertEqual(without_host.path, (u'relative-path',)) + self.assertEqual(without_host.uses_netloc, False) + self.assertEqual(without_host.to_text(), u"http:relative-path") + def test_queryIterable(self): # type: () -> None """ @@ -938,15 +950,29 @@ def test_netloc(self): # type: () -> None url = URL(scheme='https') self.assertEqual(url.uses_netloc, True) + self.assertEqual(url.to_text(), u'https://') + # scheme, no host, no path, no netloc hack + self.assertEqual(URL.from_text('https:').uses_netloc, False) + # scheme, no host, absolute path, no netloc hack + self.assertEqual(URL.from_text('https:/').uses_netloc, False) + # scheme, no host, no path, netloc hack to indicate :// syntax + self.assertEqual(URL.from_text('https://').uses_netloc, True) + + url = URL(scheme='https', uses_netloc=False) + self.assertEqual(url.uses_netloc, False) + self.assertEqual(url.to_text(), u'https:') url = URL(scheme='git+https') self.assertEqual(url.uses_netloc, True) + self.assertEqual(url.to_text(), u'git+https://') url = URL(scheme='mailto') self.assertEqual(url.uses_netloc, False) + self.assertEqual(url.to_text(), u'mailto:') url = URL(scheme='ztp') self.assertEqual(url.uses_netloc, None) + self.assertEqual(url.to_text(), u'ztp:') url = URL.from_text('ztp://test.com') self.assertEqual(url.uses_netloc, True) @@ -1116,6 +1142,34 @@ def test_autorooted(self): self.assertEqual(normal_absolute.rooted, True) self.assertEqual(attempt_unrooted_absolute.rooted, True) + def test_rooted_with_port_but_no_host(self): + # type: () -> None + """ + URLs which include a ``://`` netloc-separator for any reason are + inherently rooted, regardless of the value or presence of the + ``rooted`` constructor argument. + + They may include a netloc-separator because their constructor was + directly invoked with an explicit host or port, or because they were + parsed from a string which included the literal ``://`` separator. + """ + directly_constructed = URL(scheme='udp', port=4900, rooted=False) + directly_constructed_implict = URL(scheme='udp', port=4900) + directly_constructed_rooted = URL(scheme=u'udp', port=4900, + rooted=True) + self.assertEqual(directly_constructed.rooted, True) + self.assertEqual(directly_constructed_implict.rooted, True) + self.assertEqual(directly_constructed_rooted.rooted, True) + parsed = URL.from_text('udp://:4900') + self.assertEqual(str(directly_constructed), str(parsed)) + self.assertEqual(str(directly_constructed_implict), str(parsed)) + self.assertEqual(directly_constructed.asText(), parsed.asText()) + self.assertEqual(directly_constructed, parsed) + self.assertEqual(directly_constructed, directly_constructed_implict) + self.assertEqual(directly_constructed, directly_constructed_rooted) + self.assertEqual(directly_constructed_implict, parsed) + self.assertEqual(directly_constructed_rooted, parsed) + def test_wrong_constructor(self): # type: () -> None with self.assertRaises(ValueError):