Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/source/release-notes/unreleased.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
2.x.y - 202z-aa-bb
------------------

- *Add Items here*
- Preserve an explicitly empty authority when parsing, normalizing, and
reassembling URI and IRI references, including ``file:///`` references.

.. links below here
2 changes: 1 addition & 1 deletion src/rfc3986/_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ def unsplit(self) -> str:
result_list: list[str] = []
if self.scheme:
result_list.extend([self.scheme, ":"])
if self.authority:
if self.authority is not None:
result_list.extend(["//", self.authority])
if self.path:
result_list.append(self.path)
Expand Down
2 changes: 1 addition & 1 deletion src/rfc3986/iri.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def __new__(
ref = super().__new__(
cls,
scheme or None,
authority or None,
authority,
path or None,
query,
fragment,
Expand Down
8 changes: 4 additions & 4 deletions src/rfc3986/parseresult.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def from_parts(
authority += f":{port}"
uri_ref = uri.URIReference(
scheme=scheme,
authority=authority,
authority=authority if authority or host == "" else None,
path=path,
query=query,
fragment=fragment,
Expand Down Expand Up @@ -344,7 +344,7 @@ def from_parts(
authority += f":{int(port)}"
uri_ref = uri.URIReference(
scheme=scheme,
authority=authority,
authority=authority if authority or host == "" else None,
path=path,
query=query,
fragment=fragment,
Expand Down Expand Up @@ -404,9 +404,9 @@ def from_string(
)

@property
def authority(self) -> bytes:
def authority(self) -> t.Optional[bytes]:
"""Return the normalized authority."""
return self.reference.authority.encode(self.encoding)
return compat.to_bytes(self.reference.authority, self.encoding)

def copy_with(
self,
Expand Down
14 changes: 10 additions & 4 deletions src/rfc3986/uri.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def __new__(
ref = super().__new__(
cls,
scheme or None,
authority or None,
authority,
path or None,
query,
fragment,
Expand Down Expand Up @@ -136,11 +136,17 @@ def normalize(self) -> "URIReference":
"""
# See http://tools.ietf.org/html/rfc3986#section-6.2.2 for logic in
# this method.
authority = self.authority
if authority:
authority = (
normalizers.normalize_authority(
(self.userinfo, self.host, self.port)
)
or None
)
return URIReference(
normalizers.normalize_scheme(self.scheme or ""),
normalizers.normalize_authority(
(self.userinfo, self.host, self.port)
),
authority,
normalizers.normalize_path(self.path or ""),
normalizers.normalize_query(self.query),
normalizers.normalize_fragment(self.fragment),
Expand Down
62 changes: 62 additions & 0 deletions tests/test_empty_authority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pytest

from rfc3986 import iri_reference
from rfc3986 import uri_reference
from rfc3986 import urlparse
from rfc3986.parseresult import ParseResult
from rfc3986.parseresult import ParseResultBytes


@pytest.mark.parametrize("parse", [uri_reference, iri_reference, urlparse])
@pytest.mark.parametrize(
"text", ["foo:///", "file:///tmp/a", "//", "///a?x#y"]
)
def test_empty_authority_round_trip(parse, text):
reference = parse(text)
assert reference.authority == ""
assert reference.unsplit() == text
assert (
reference.copy_with(fragment="new").unsplit()
== text.split("#")[0] + "#new"
)


@pytest.mark.parametrize("text", ["foo:///", "file:///tmp/a", "//"])
def test_normalization_and_encoding_preserve_empty_authority(text):
assert uri_reference(text).normalize().unsplit() == text
assert iri_reference(text).encode().unsplit() == text
assert urlparse(text).encode().unsplit() == text.encode()


@pytest.mark.parametrize("text", ["foo:/", "/tmp/a", "a", ""])
def test_absent_authority_remains_absent(text):
reference = uri_reference(text)
assert reference.authority is None
assert reference.normalize().authority is None
assert reference.unsplit() == text


@pytest.mark.parametrize("cls", [ParseResult, ParseResultBytes])
def test_from_parts_distinguishes_empty_and_absent_host(cls):
absent = cls.from_parts(scheme="foo", path="/a")
empty = cls.from_parts(scheme="foo", host="", path="/a")

def as_string(value):
return value.decode() if isinstance(value, bytes) else value

assert as_string(absent.unsplit()) == "foo:/a"
assert as_string(empty.unsplit()) == "foo:///a"


@pytest.mark.parametrize(
"relative, base, expected",
[
("///new", "foo://example.com/old", "foo:///new"),
("new", "foo://", "foo:///new"),
("", "foo://", "foo://"),
],
)
def test_resolve_with_empty_authority(relative, base, expected):
result = uri_reference(relative).resolve_with(base)
assert result.authority == ""
assert result.unsplit() == expected