Skip to content

Commit c0f6d6c

Browse files
committed
Preserve explicitly empty URI authorities
1 parent 3bd3832 commit c0f6d6c

6 files changed

Lines changed: 80 additions & 11 deletions

File tree

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
2.x.y - 202z-aa-bb
22
------------------
33

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

67
.. links below here

‎src/rfc3986/_mixin.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def unsplit(self) -> str:
360360
result_list: list[str] = []
361361
if self.scheme:
362362
result_list.extend([self.scheme, ":"])
363-
if self.authority:
363+
if self.authority is not None:
364364
result_list.extend(["//", self.authority])
365365
if self.path:
366366
result_list.append(self.path)

‎src/rfc3986/iri.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def __new__(
5555
ref = super().__new__(
5656
cls,
5757
scheme or None,
58-
authority or None,
58+
authority,
5959
path or None,
6060
query,
6161
fragment,

‎src/rfc3986/parseresult.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def from_parts(
158158
authority += f":{port}"
159159
uri_ref = uri.URIReference(
160160
scheme=scheme,
161-
authority=authority,
161+
authority=authority if authority or host == "" else None,
162162
path=path,
163163
query=query,
164164
fragment=fragment,
@@ -344,7 +344,7 @@ def from_parts(
344344
authority += f":{int(port)}"
345345
uri_ref = uri.URIReference(
346346
scheme=scheme,
347-
authority=authority,
347+
authority=authority if authority or host == "" else None,
348348
path=path,
349349
query=query,
350350
fragment=fragment,
@@ -404,9 +404,9 @@ def from_string(
404404
)
405405

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

411411
def copy_with(
412412
self,

‎src/rfc3986/uri.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def __new__(
9696
ref = super().__new__(
9797
cls,
9898
scheme or None,
99-
authority or None,
99+
authority,
100100
path or None,
101101
query,
102102
fragment,
@@ -136,11 +136,17 @@ def normalize(self) -> "URIReference":
136136
"""
137137
# See http://tools.ietf.org/html/rfc3986#section-6.2.2 for logic in
138138
# this method.
139+
authority = self.authority
140+
if authority:
141+
authority = (
142+
normalizers.normalize_authority(
143+
(self.userinfo, self.host, self.port)
144+
)
145+
or None
146+
)
139147
return URIReference(
140148
normalizers.normalize_scheme(self.scheme or ""),
141-
normalizers.normalize_authority(
142-
(self.userinfo, self.host, self.port)
143-
),
149+
authority,
144150
normalizers.normalize_path(self.path or ""),
145151
normalizers.normalize_query(self.query),
146152
normalizers.normalize_fragment(self.fragment),

‎tests/test_empty_authority.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import pytest
2+
3+
from rfc3986 import iri_reference
4+
from rfc3986 import uri_reference
5+
from rfc3986 import urlparse
6+
from rfc3986.parseresult import ParseResult
7+
from rfc3986.parseresult import ParseResultBytes
8+
9+
10+
@pytest.mark.parametrize("parse", [uri_reference, iri_reference, urlparse])
11+
@pytest.mark.parametrize(
12+
"text", ["foo:///", "file:///tmp/a", "//", "///a?x#y"]
13+
)
14+
def test_empty_authority_round_trip(parse, text):
15+
reference = parse(text)
16+
assert reference.authority == ""
17+
assert reference.unsplit() == text
18+
assert (
19+
reference.copy_with(fragment="new").unsplit()
20+
== text.split("#")[0] + "#new"
21+
)
22+
23+
24+
@pytest.mark.parametrize("text", ["foo:///", "file:///tmp/a", "//"])
25+
def test_normalization_and_encoding_preserve_empty_authority(text):
26+
assert uri_reference(text).normalize().unsplit() == text
27+
assert iri_reference(text).encode().unsplit() == text
28+
assert urlparse(text).encode().unsplit() == text.encode()
29+
30+
31+
@pytest.mark.parametrize("text", ["foo:/", "/tmp/a", "a", ""])
32+
def test_absent_authority_remains_absent(text):
33+
reference = uri_reference(text)
34+
assert reference.authority is None
35+
assert reference.normalize().authority is None
36+
assert reference.unsplit() == text
37+
38+
39+
@pytest.mark.parametrize("cls", [ParseResult, ParseResultBytes])
40+
def test_from_parts_distinguishes_empty_and_absent_host(cls):
41+
absent = cls.from_parts(scheme="foo", path="/a")
42+
empty = cls.from_parts(scheme="foo", host="", path="/a")
43+
44+
def as_string(value):
45+
return value.decode() if isinstance(value, bytes) else value
46+
47+
assert as_string(absent.unsplit()) == "foo:/a"
48+
assert as_string(empty.unsplit()) == "foo:///a"
49+
50+
51+
@pytest.mark.parametrize(
52+
"relative, base, expected",
53+
[
54+
("///new", "foo://example.com/old", "foo:///new"),
55+
("new", "foo://", "foo:///new"),
56+
("", "foo://", "foo://"),
57+
],
58+
)
59+
def test_resolve_with_empty_authority(relative, base, expected):
60+
result = uri_reference(relative).resolve_with(base)
61+
assert result.authority == ""
62+
assert result.unsplit() == expected

0 commit comments

Comments
 (0)