From c0f6d6c9b22dcce24f2862de7d053bdd26d031cd Mon Sep 17 00:00:00 2001 From: AC Date: Thu, 8 Oct 2026 13:33:57 +0800 Subject: [PATCH] Preserve explicitly empty URI authorities --- docs/source/release-notes/unreleased.rst | 3 +- src/rfc3986/_mixin.py | 2 +- src/rfc3986/iri.py | 2 +- src/rfc3986/parseresult.py | 8 +-- src/rfc3986/uri.py | 14 ++++-- tests/test_empty_authority.py | 62 ++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 tests/test_empty_authority.py diff --git a/docs/source/release-notes/unreleased.rst b/docs/source/release-notes/unreleased.rst index 09b03b4..0747c72 100644 --- a/docs/source/release-notes/unreleased.rst +++ b/docs/source/release-notes/unreleased.rst @@ -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 diff --git a/src/rfc3986/_mixin.py b/src/rfc3986/_mixin.py index b03b772..120caa3 100644 --- a/src/rfc3986/_mixin.py +++ b/src/rfc3986/_mixin.py @@ -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) diff --git a/src/rfc3986/iri.py b/src/rfc3986/iri.py index 205221e..8856fba 100644 --- a/src/rfc3986/iri.py +++ b/src/rfc3986/iri.py @@ -55,7 +55,7 @@ def __new__( ref = super().__new__( cls, scheme or None, - authority or None, + authority, path or None, query, fragment, diff --git a/src/rfc3986/parseresult.py b/src/rfc3986/parseresult.py index 551d1c6..7a869b0 100644 --- a/src/rfc3986/parseresult.py +++ b/src/rfc3986/parseresult.py @@ -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, @@ -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, @@ -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, diff --git a/src/rfc3986/uri.py b/src/rfc3986/uri.py index e382498..bd99efd 100644 --- a/src/rfc3986/uri.py +++ b/src/rfc3986/uri.py @@ -96,7 +96,7 @@ def __new__( ref = super().__new__( cls, scheme or None, - authority or None, + authority, path or None, query, fragment, @@ -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), diff --git a/tests/test_empty_authority.py b/tests/test_empty_authority.py new file mode 100644 index 0000000..e63a8b7 --- /dev/null +++ b/tests/test_empty_authority.py @@ -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