url-normalize 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {url_normalize-2.1.0 → url_normalize-2.2.0}/PKG-INFO +18 -1
- {url_normalize-2.1.0 → url_normalize-2.2.0}/README.md +17 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/pyproject.toml +1 -1
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_cli.py +61 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_path.py +3 -1
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_query.py +2 -0
- url_normalize-2.2.0/tests/test_provide_url_domain.py +31 -0
- url_normalize-2.2.0/tests/test_url_normalize.py +163 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/__init__.py +1 -1
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/cli.py +7 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_path.py +4 -4
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_query.py +2 -2
- url_normalize-2.2.0/url_normalize/provide_url_domain.py +28 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/provide_url_scheme.py +11 -4
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/url_normalize.py +7 -2
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/PKG-INFO +18 -1
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/SOURCES.txt +2 -0
- url_normalize-2.1.0/tests/test_url_normalize.py +0 -127
- {url_normalize-2.1.0 → url_normalize-2.2.0}/LICENSE +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/setup.cfg +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_deconstruct_url.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_generic_url_cleanup.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_fragment.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_host.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_port.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_query_filters.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_scheme.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_userinfo.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_provide_url_scheme.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_reconstruct_url.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_tools.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/generic_url_cleanup.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_fragment.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_host.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_port.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_scheme.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_userinfo.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/param_allowlist.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/tools.py +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/dependency_links.txt +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/entry_points.txt +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/requires.txt +0 -0
- {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: url-normalize
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: URL normalization for Python
|
|
5
5
|
Author-email: Nikolay Panov <github@npanov.com>
|
|
6
6
|
License: MIT
|
|
@@ -50,6 +50,7 @@ Inspired by Sam Ruby's [urlnorm.py](<http://intertwingly.net/blog/2004/08/04/Url
|
|
|
50
50
|
|
|
51
51
|
* IDN (Internationalized Domain Name) support
|
|
52
52
|
* Configurable default scheme (https by default)
|
|
53
|
+
* Configurable default domain for absolute paths
|
|
53
54
|
* Query parameter filtering with allowlists
|
|
54
55
|
* Support for various URL formats including:
|
|
55
56
|
* Empty string URLs
|
|
@@ -97,6 +98,14 @@ print(url_normalize(
|
|
|
97
98
|
param_allowlist=["page", "id"]
|
|
98
99
|
))
|
|
99
100
|
# Output: https://example.com?page=1&id=123
|
|
101
|
+
|
|
102
|
+
# With default domain for absolute paths
|
|
103
|
+
print(url_normalize("/images/logo.png", default_domain="example.com"))
|
|
104
|
+
# Output: https://example.com/images/logo.png
|
|
105
|
+
|
|
106
|
+
# With default domain and custom scheme
|
|
107
|
+
print(url_normalize("/images/logo.png", default_scheme="http", default_domain="example.com"))
|
|
108
|
+
# Output: http://example.com/images/logo.png
|
|
100
109
|
```
|
|
101
110
|
|
|
102
111
|
### Command-line usage
|
|
@@ -119,6 +128,14 @@ $ url-normalize -f "www.google.com/search?q=test&utm_source=test"
|
|
|
119
128
|
$ url-normalize -f -p page,id "example.com?page=1&id=123&ref=test"
|
|
120
129
|
# Output: https://example.com/?page=1&id=123
|
|
121
130
|
|
|
131
|
+
# With default domain for absolute paths
|
|
132
|
+
$ url-normalize -d example.com "/images/logo.png"
|
|
133
|
+
# Output: https://example.com/images/logo.png
|
|
134
|
+
|
|
135
|
+
# With default domain and custom scheme
|
|
136
|
+
$ url-normalize -d example.com -s http "/images/logo.png"
|
|
137
|
+
# Output: http://example.com/images/logo.png
|
|
138
|
+
|
|
122
139
|
# Via uv tool/uvx
|
|
123
140
|
$ uvx url-normalize www.foo.com:80/foo
|
|
124
141
|
# Output: https://www.foo.com:80/foo
|
|
@@ -24,6 +24,7 @@ Inspired by Sam Ruby's [urlnorm.py](<http://intertwingly.net/blog/2004/08/04/Url
|
|
|
24
24
|
|
|
25
25
|
* IDN (Internationalized Domain Name) support
|
|
26
26
|
* Configurable default scheme (https by default)
|
|
27
|
+
* Configurable default domain for absolute paths
|
|
27
28
|
* Query parameter filtering with allowlists
|
|
28
29
|
* Support for various URL formats including:
|
|
29
30
|
* Empty string URLs
|
|
@@ -71,6 +72,14 @@ print(url_normalize(
|
|
|
71
72
|
param_allowlist=["page", "id"]
|
|
72
73
|
))
|
|
73
74
|
# Output: https://example.com?page=1&id=123
|
|
75
|
+
|
|
76
|
+
# With default domain for absolute paths
|
|
77
|
+
print(url_normalize("/images/logo.png", default_domain="example.com"))
|
|
78
|
+
# Output: https://example.com/images/logo.png
|
|
79
|
+
|
|
80
|
+
# With default domain and custom scheme
|
|
81
|
+
print(url_normalize("/images/logo.png", default_scheme="http", default_domain="example.com"))
|
|
82
|
+
# Output: http://example.com/images/logo.png
|
|
74
83
|
```
|
|
75
84
|
|
|
76
85
|
### Command-line usage
|
|
@@ -93,6 +102,14 @@ $ url-normalize -f "www.google.com/search?q=test&utm_source=test"
|
|
|
93
102
|
$ url-normalize -f -p page,id "example.com?page=1&id=123&ref=test"
|
|
94
103
|
# Output: https://example.com/?page=1&id=123
|
|
95
104
|
|
|
105
|
+
# With default domain for absolute paths
|
|
106
|
+
$ url-normalize -d example.com "/images/logo.png"
|
|
107
|
+
# Output: https://example.com/images/logo.png
|
|
108
|
+
|
|
109
|
+
# With default domain and custom scheme
|
|
110
|
+
$ url-normalize -d example.com -s http "/images/logo.png"
|
|
111
|
+
# Output: http://example.com/images/logo.png
|
|
112
|
+
|
|
96
113
|
# Via uv tool/uvx
|
|
97
114
|
$ uvx url-normalize www.foo.com:80/foo
|
|
98
115
|
# Output: https://www.foo.com:80/foo
|
|
@@ -231,3 +231,64 @@ def test_cli_charset() -> None:
|
|
|
231
231
|
assert result_charset_short.returncode == 0
|
|
232
232
|
assert result_charset_short.stdout.strip() == expected_idn
|
|
233
233
|
assert not result_charset_short.stderr
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_cli_default_domain() -> None:
|
|
237
|
+
"""Test adding default domain to absolute path via CLI."""
|
|
238
|
+
url = "/path/to/image.png"
|
|
239
|
+
expected = "https://example.com/path/to/image.png"
|
|
240
|
+
|
|
241
|
+
result = run_cli("--default-domain", "example.com", url)
|
|
242
|
+
|
|
243
|
+
assert result.returncode == 0
|
|
244
|
+
assert result.stdout.strip() == expected
|
|
245
|
+
assert not result.stderr
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_cli_default_domain_short_arg() -> None:
|
|
249
|
+
"""Test adding default domain using short argument."""
|
|
250
|
+
url = "/path/to/image.png"
|
|
251
|
+
expected = "https://example.com/path/to/image.png"
|
|
252
|
+
|
|
253
|
+
result = run_cli("-d", "example.com", url)
|
|
254
|
+
|
|
255
|
+
assert result.returncode == 0
|
|
256
|
+
assert result.stdout.strip() == expected
|
|
257
|
+
assert not result.stderr
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_cli_default_domain_with_scheme() -> None:
|
|
261
|
+
"""Test adding default domain with custom scheme."""
|
|
262
|
+
url = "/path/to/image.png"
|
|
263
|
+
expected = "http://example.com/path/to/image.png"
|
|
264
|
+
|
|
265
|
+
result = run_cli("-d", "example.com", "-s", "http", url)
|
|
266
|
+
|
|
267
|
+
assert result.returncode == 0
|
|
268
|
+
assert result.stdout.strip() == expected
|
|
269
|
+
assert not result.stderr
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_cli_default_domain_no_effect_on_absolute_urls() -> None:
|
|
273
|
+
"""Test default domain has no effect on absolute URLs."""
|
|
274
|
+
url = "http://original-domain.com/path"
|
|
275
|
+
expected = "http://original-domain.com/path"
|
|
276
|
+
|
|
277
|
+
result = run_cli("-d", "example.com", url)
|
|
278
|
+
|
|
279
|
+
assert result.returncode == 0
|
|
280
|
+
assert result.stdout.strip() == expected
|
|
281
|
+
assert not result.stderr
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_cli_default_domain_no_effect_on_relative_paths() -> None:
|
|
285
|
+
"""Test default domain has no effect on relative paths."""
|
|
286
|
+
url = "path/to/file.html"
|
|
287
|
+
# This becomes a regular URL with the default scheme
|
|
288
|
+
expected = "https://path/to/file.html"
|
|
289
|
+
|
|
290
|
+
result = run_cli("-d", "example.com", url)
|
|
291
|
+
|
|
292
|
+
assert result.returncode == 0
|
|
293
|
+
assert result.stdout.strip() == expected
|
|
294
|
+
assert not result.stderr
|
|
@@ -29,10 +29,12 @@ EXPECTED_DATA = {
|
|
|
29
29
|
"/foo/bar/../baz": "/foo/baz",
|
|
30
30
|
"/foo/bar/.": "/foo/bar/",
|
|
31
31
|
"/foo/bar/./": "/foo/bar/",
|
|
32
|
+
# Issue #25: we should preserve ? in the path
|
|
33
|
+
"/More+Tea+Vicar%3F/discussion": "/More+Tea+Vicar%3F/discussion",
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
def
|
|
37
|
+
def test_normalize_path_result_is_expected():
|
|
36
38
|
"""Assert we got expected results from the normalize_path function."""
|
|
37
39
|
for url, expected in EXPECTED_DATA.items():
|
|
38
40
|
result = normalize_path(url, "http")
|
|
@@ -14,6 +14,8 @@ from url_normalize.url_normalize import normalize_query
|
|
|
14
14
|
("Ç=Ç", "%C3%87=%C3%87"),
|
|
15
15
|
("%C3%87=%C3%87", "%C3%87=%C3%87"),
|
|
16
16
|
("q=C%CC%A7", "q=%C3%87"),
|
|
17
|
+
("q=%23test", "q=%23test"), # Preserve encoded # in value, #31
|
|
18
|
+
("where=code%3D123", "where=code%3D123"), # Preserve encoded = in value, #25
|
|
17
19
|
],
|
|
18
20
|
)
|
|
19
21
|
def test_normalize_query_result_is_expected(query, expected):
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Tests for provide_url_domain function."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from url_normalize.provide_url_domain import provide_url_domain
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
("url", "expected"),
|
|
10
|
+
[
|
|
11
|
+
("", ""),
|
|
12
|
+
("-", "-"),
|
|
13
|
+
("http://example.com/", "http://example.com/"),
|
|
14
|
+
("/file/path", "//example.com/file/path"),
|
|
15
|
+
("site/page", "site/page"), # No change for relative paths
|
|
16
|
+
],
|
|
17
|
+
)
|
|
18
|
+
def test_provide_url_domain_result_is_expected(url: str, expected: str) -> None:
|
|
19
|
+
"""Assert we get expected results from provide_url_domain function."""
|
|
20
|
+
result = provide_url_domain(url, default_domain="example.com")
|
|
21
|
+
assert result == expected
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_provide_url_domain_accept_different_domains():
|
|
25
|
+
"""Assert we could provide different default_domain values."""
|
|
26
|
+
url = "/file/path"
|
|
27
|
+
expected = "//custom-domain.org/file/path"
|
|
28
|
+
|
|
29
|
+
actual = provide_url_domain(url, default_domain="custom-domain.org")
|
|
30
|
+
|
|
31
|
+
assert actual == expected
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Integrations tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from url_normalize import url_normalize
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
"value",
|
|
12
|
+
[
|
|
13
|
+
"-",
|
|
14
|
+
"",
|
|
15
|
+
"/..foo",
|
|
16
|
+
"/.foo",
|
|
17
|
+
"/foo..",
|
|
18
|
+
"/foo.",
|
|
19
|
+
"ftp://user:pass@ftp.foo.net/foo/bar",
|
|
20
|
+
"http://127.0.0.1/",
|
|
21
|
+
"http://example.com:8080/",
|
|
22
|
+
"http://example.com/?a&b",
|
|
23
|
+
"http://example.com/?q=%5C",
|
|
24
|
+
"http://example.com/?q=%C3%87",
|
|
25
|
+
"http://example.com/?q=%E2%85%A0",
|
|
26
|
+
"http://example.com/",
|
|
27
|
+
"http://example.com/~jane",
|
|
28
|
+
"http://example.com/a/b",
|
|
29
|
+
"http://example.com/FOO",
|
|
30
|
+
"http://user:password@example.com/",
|
|
31
|
+
"http://www.foo.com:8000/foo",
|
|
32
|
+
# from rfc2396bis
|
|
33
|
+
"ftp://ftp.is.co.za/rfc/rfc1808.txt",
|
|
34
|
+
"http://www.ietf.org/rfc/rfc2396.txt",
|
|
35
|
+
"ldap://[2001:db8::7]/c=GB?objectClass?one",
|
|
36
|
+
"mailto:John.Doe@example.com",
|
|
37
|
+
"news:comp.infosystems.www.servers.unix",
|
|
38
|
+
"tel:+1-816-555-1212",
|
|
39
|
+
"telnet://192.0.2.16:80/",
|
|
40
|
+
"urn:oasis:names:specification:docbook:dtd:xml:4.1.2",
|
|
41
|
+
# Issue #36: Fragment with '=' should not be encoded
|
|
42
|
+
"https://docs.google.com/spreadsheets/d/abcd/edit#gid=1234",
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
def test_url_normalize_no_changes_expected(value: str) -> None:
|
|
46
|
+
"""Assert url_normalize do not change URI if not required.
|
|
47
|
+
|
|
48
|
+
http://www.intertwingly.net/wiki/pie/PaceCanonicalIds
|
|
49
|
+
"""
|
|
50
|
+
assert url_normalize(value) == value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize(
|
|
54
|
+
("value", "expected"),
|
|
55
|
+
[
|
|
56
|
+
("/../foo", "/foo"),
|
|
57
|
+
("/./../foo", "/foo"),
|
|
58
|
+
("/./foo", "/foo"),
|
|
59
|
+
("/./foo/.", "/foo/"),
|
|
60
|
+
("//www.foo.com/", "https://www.foo.com/"),
|
|
61
|
+
("/foo/../bar", "/bar"),
|
|
62
|
+
("/foo/./bar", "/foo/bar"),
|
|
63
|
+
("/foo//", "/foo/"),
|
|
64
|
+
("/foo///bar//", "/foo/bar/"),
|
|
65
|
+
("/foo/bar/..", "/foo/"),
|
|
66
|
+
("/foo/bar/../..", "/"),
|
|
67
|
+
("/foo/bar/../../../../baz", "/baz"),
|
|
68
|
+
("/foo/bar/../../../baz", "/baz"),
|
|
69
|
+
("/foo/bar/../../", "/"),
|
|
70
|
+
("/foo/bar/../../baz", "/baz"),
|
|
71
|
+
("/foo/bar/../", "/foo/"),
|
|
72
|
+
("/foo/bar/../baz", "/foo/baz"),
|
|
73
|
+
("/foo/bar/.", "/foo/bar/"),
|
|
74
|
+
("/foo/bar/./", "/foo/bar/"),
|
|
75
|
+
("http://:@example.com/", "http://example.com/"),
|
|
76
|
+
("http://@example.com/", "http://example.com/"),
|
|
77
|
+
("http://127.0.0.1:80/", "http://127.0.0.1/"),
|
|
78
|
+
("http://example.com:081/", "http://example.com:81/"),
|
|
79
|
+
("http://example.com:80/", "http://example.com/"),
|
|
80
|
+
("http://example.com", "http://example.com/"),
|
|
81
|
+
("http://example.com/?b&a", "http://example.com/?b&a"),
|
|
82
|
+
("http://example.com/?q=%5c", "http://example.com/?q=%5C"),
|
|
83
|
+
("http://example.com/?q=%C7", "http://example.com/?q=%EF%BF%BD"),
|
|
84
|
+
("http://example.com/?q=C%CC%A7", "http://example.com/?q=%C3%87"),
|
|
85
|
+
("http://EXAMPLE.COM/", "http://example.com/"),
|
|
86
|
+
("http://example.com/%7Ejane", "http://example.com/~jane"),
|
|
87
|
+
("http://example.com/a/../a/b", "http://example.com/a/b"),
|
|
88
|
+
("http://example.com/a/./b", "http://example.com/a/b"),
|
|
89
|
+
(
|
|
90
|
+
"http://example.com/#!5753509/hello-world",
|
|
91
|
+
"http://example.com/?_escaped_fragment_=5753509/hello-world",
|
|
92
|
+
),
|
|
93
|
+
(
|
|
94
|
+
"http://USER:pass@www.Example.COM/foo/bar",
|
|
95
|
+
"http://USER:pass@www.example.com/foo/bar",
|
|
96
|
+
),
|
|
97
|
+
("http://www.example.com./", "http://www.example.com/"),
|
|
98
|
+
("http://www.foo.com:80/foo", "http://www.foo.com/foo"),
|
|
99
|
+
("http://www.foo.com.:81/foo", "http://www.foo.com:81/foo"),
|
|
100
|
+
("http://www.foo.com./foo/bar.html", "http://www.foo.com/foo/bar.html"),
|
|
101
|
+
("http://www.foo.com/%7Ebar", "http://www.foo.com/~bar"),
|
|
102
|
+
("http://www.foo.com/%7ebar", "http://www.foo.com/~bar"),
|
|
103
|
+
(
|
|
104
|
+
"пример.испытание/Служебная:Search/Test",
|
|
105
|
+
"https://xn--e1afmkfd.xn--80akhbyknj4f/%D0%A1%D0%BB%D1%83%D0%B6%D0%B5%D0%B1%D0%BD%D0%B0%D1%8F:Search/Test",
|
|
106
|
+
),
|
|
107
|
+
# Issue #19: http:example.com
|
|
108
|
+
("http:example.com", "http://example.com/"),
|
|
109
|
+
("http:example.com/path", "http://example.com/path"),
|
|
110
|
+
("ftp:test.com/files", "ftp://test.com/files"),
|
|
111
|
+
("https:www.example.com", "https://www.example.com/"),
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
def test_url_normalize_expected_changes(value: str, expected: str) -> None:
|
|
115
|
+
"""Assert url_normalize return expected results."""
|
|
116
|
+
assert url_normalize(value) == expected
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_url_normalize_filtered() -> None:
|
|
120
|
+
"""Assert url_normalize return expected results."""
|
|
121
|
+
url = "/?a&b"
|
|
122
|
+
expected = "/"
|
|
123
|
+
|
|
124
|
+
actual = url_normalize(url, filter_params=True)
|
|
125
|
+
|
|
126
|
+
assert actual == expected
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_url_normalize_with_http_scheme() -> None:
|
|
130
|
+
"""Assert we could use http scheme as default."""
|
|
131
|
+
url = "//www.foo.com/"
|
|
132
|
+
expected = "http://www.foo.com/"
|
|
133
|
+
|
|
134
|
+
actual = url_normalize(url, default_scheme="http")
|
|
135
|
+
|
|
136
|
+
assert actual == expected
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
("url", "expected"),
|
|
141
|
+
[
|
|
142
|
+
("/foo.png", "https://example.com/foo.png"),
|
|
143
|
+
("//google.com", "https://google.com/"),
|
|
144
|
+
("//example.com:80/foo.png", "https://example.com:80/foo.png"),
|
|
145
|
+
("//example.com/foo.png?foo=bar", "https://example.com/foo.png?foo=bar"),
|
|
146
|
+
("http://google.com", "http://google.com/"),
|
|
147
|
+
],
|
|
148
|
+
)
|
|
149
|
+
def test_url_normalize_with_default_domain(url: str, expected: str) -> None:
|
|
150
|
+
"""Assert we could use default_domain parameter."""
|
|
151
|
+
actual = url_normalize(url, default_domain="example.com")
|
|
152
|
+
|
|
153
|
+
assert actual == expected
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_url_normalize_with_default_domain_and_scheme() -> None:
|
|
157
|
+
"""Assert we can use both default_domain and default_scheme together."""
|
|
158
|
+
url = "/foo.png"
|
|
159
|
+
expected = "http://example.com/foo.png"
|
|
160
|
+
|
|
161
|
+
actual = url_normalize(url, default_scheme="http", default_domain="example.com")
|
|
162
|
+
|
|
163
|
+
assert actual == expected
|
|
@@ -36,6 +36,12 @@ def main() -> None:
|
|
|
36
36
|
action="store_true",
|
|
37
37
|
help="Filter common tracking parameters.",
|
|
38
38
|
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"-d",
|
|
41
|
+
"--default-domain",
|
|
42
|
+
type=str,
|
|
43
|
+
help="Default domain to use for absolute paths (starting with '/').",
|
|
44
|
+
)
|
|
39
45
|
parser.add_argument(
|
|
40
46
|
"-p",
|
|
41
47
|
"--param-allowlist",
|
|
@@ -52,6 +58,7 @@ def main() -> None:
|
|
|
52
58
|
args.url,
|
|
53
59
|
charset=args.charset,
|
|
54
60
|
default_scheme=args.default_scheme,
|
|
61
|
+
default_domain=args.default_domain,
|
|
55
62
|
filter_params=args.filter_params,
|
|
56
63
|
param_allowlist=allowlist,
|
|
57
64
|
)
|
|
@@ -21,9 +21,9 @@ def normalize_path(path: str, scheme: str) -> str:
|
|
|
21
21
|
# Only perform percent-encoding where it is essential.
|
|
22
22
|
# Always use uppercase A-through-F characters when percent-encoding.
|
|
23
23
|
# All portions of the URI must be utf-8 encoded NFC from Unicode strings
|
|
24
|
-
path = quote(unquote(path), "
|
|
24
|
+
path = quote(unquote(path), "~:/#[]@!$&'()*+,;=")
|
|
25
25
|
# Prevent dot-segments appearing in non-relative URI paths.
|
|
26
|
-
if scheme in
|
|
26
|
+
if scheme in {"", "http", "https", "ftp", "file"}:
|
|
27
27
|
output: list[str] = []
|
|
28
28
|
for part in path.split("/"):
|
|
29
29
|
if part == "":
|
|
@@ -38,11 +38,11 @@ def normalize_path(path: str, scheme: str) -> str:
|
|
|
38
38
|
output.append(part)
|
|
39
39
|
# The part variable is used in the final check
|
|
40
40
|
last_part = part
|
|
41
|
-
if last_part in
|
|
41
|
+
if last_part in {"", ".", ".."}:
|
|
42
42
|
output.append("")
|
|
43
43
|
path = "/".join(output)
|
|
44
44
|
# For schemes that define an empty path to be equivalent to a path of "/",
|
|
45
45
|
# use "/".
|
|
46
|
-
if not path and scheme in
|
|
46
|
+
if not path and scheme in {"http", "https", "ftp", "file"}:
|
|
47
47
|
path = "/"
|
|
48
48
|
return path
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from .param_allowlist import get_allowed_params
|
|
6
6
|
from .tools import quote, unquote
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
QUERY_PARAM_SAFE_CHARS = "~:/?[]@!$'()*+,;"
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def process_query_param(param: str) -> str:
|
|
@@ -23,7 +23,7 @@ def process_query_param(param: str) -> str:
|
|
|
23
23
|
"""
|
|
24
24
|
if not param:
|
|
25
25
|
return ""
|
|
26
|
-
return quote(unquote(param),
|
|
26
|
+
return quote(unquote(param), QUERY_PARAM_SAFE_CHARS)
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def normalize_query(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""URL domain validation and attachment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def provide_url_domain(url: str, default_domain: str | None = None) -> str:
|
|
7
|
+
"""Add default domain to URL if needed.
|
|
8
|
+
|
|
9
|
+
For absolute paths (starting with '/'), adds the specified default domain.
|
|
10
|
+
|
|
11
|
+
Params:
|
|
12
|
+
url : str : the URL
|
|
13
|
+
default_domain : str | None : default domain to use, e.g. 'example.com'
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
str : URL with domain added if applicable
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
# Skip processing if no default domain provided or URL is empty or stdout
|
|
20
|
+
if not default_domain or not url or url == "-":
|
|
21
|
+
return url
|
|
22
|
+
|
|
23
|
+
# Only apply to absolute paths (starting with '/')
|
|
24
|
+
# but not scheme-relative URLs ('//')
|
|
25
|
+
if url.startswith("/") and not url.startswith("//"):
|
|
26
|
+
return "//" + default_domain + url
|
|
27
|
+
|
|
28
|
+
return url
|
|
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from .normalize_scheme import DEFAULT_SCHEME
|
|
6
6
|
|
|
7
|
+
# Schemes that require authority component reconstruction with //
|
|
8
|
+
AUTHORITY_SCHEMES = frozenset(["http", "https", "ftp", "ftps"])
|
|
9
|
+
|
|
7
10
|
|
|
8
11
|
def provide_url_scheme(url: str, default_scheme: str = DEFAULT_SCHEME) -> str:
|
|
9
12
|
"""Make sure we have valid url scheme.
|
|
@@ -19,8 +22,12 @@ def provide_url_scheme(url: str, default_scheme: str = DEFAULT_SCHEME) -> str:
|
|
|
19
22
|
has_scheme = ":" in url[:7]
|
|
20
23
|
is_universal_scheme = url.startswith("//")
|
|
21
24
|
is_file_path = url == "-" or (url.startswith("/") and not is_universal_scheme)
|
|
22
|
-
if not url or
|
|
25
|
+
if not url or is_file_path:
|
|
26
|
+
return url
|
|
27
|
+
if not has_scheme:
|
|
28
|
+
return f"{default_scheme}://{url.lstrip('/')}"
|
|
29
|
+
scheme_part, rest = url.split(":", 1)
|
|
30
|
+
if scheme_part.lower() not in AUTHORITY_SCHEMES:
|
|
31
|
+
# handle cases like tel:, mailto:, etc.
|
|
23
32
|
return url
|
|
24
|
-
|
|
25
|
-
return default_scheme + ":" + url
|
|
26
|
-
return default_scheme + "://" + url
|
|
33
|
+
return f"{scheme_part}://{rest.lstrip('/')}"
|
|
@@ -16,15 +16,17 @@ from .normalize_port import normalize_port
|
|
|
16
16
|
from .normalize_query import normalize_query
|
|
17
17
|
from .normalize_scheme import DEFAULT_SCHEME, normalize_scheme
|
|
18
18
|
from .normalize_userinfo import normalize_userinfo
|
|
19
|
+
from .provide_url_domain import provide_url_domain
|
|
19
20
|
from .provide_url_scheme import provide_url_scheme
|
|
20
21
|
from .tools import deconstruct_url, reconstruct_url
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
def url_normalize(
|
|
24
|
+
def url_normalize( # noqa: PLR0913
|
|
24
25
|
url: str | None,
|
|
26
|
+
*, # Force keyword-only arguments
|
|
25
27
|
charset: str = DEFAULT_CHARSET,
|
|
26
28
|
default_scheme: str = DEFAULT_SCHEME,
|
|
27
|
-
|
|
29
|
+
default_domain: str | None = None,
|
|
28
30
|
filter_params: bool = False,
|
|
29
31
|
param_allowlist: dict | list | None = None,
|
|
30
32
|
) -> str | None:
|
|
@@ -43,6 +45,8 @@ def url_normalize(
|
|
|
43
45
|
charset : str : optional
|
|
44
46
|
The target charset for the URL if the url was given as unicode string
|
|
45
47
|
default_scheme : str : default scheme to use if none present
|
|
48
|
+
default_domain : str | None : optional
|
|
49
|
+
Default domain to use for absolute paths (starting with '/')
|
|
46
50
|
filter_params : bool : optional
|
|
47
51
|
Whether to filter non-allowlisted parameters (False by default)
|
|
48
52
|
param_allowlist : dict | list | None : optional
|
|
@@ -54,6 +58,7 @@ def url_normalize(
|
|
|
54
58
|
"""
|
|
55
59
|
if not url:
|
|
56
60
|
return url
|
|
61
|
+
url = provide_url_domain(url, default_domain)
|
|
57
62
|
url = provide_url_scheme(url, default_scheme)
|
|
58
63
|
url = generic_url_cleanup(url)
|
|
59
64
|
url_elements = deconstruct_url(url)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: url-normalize
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: URL normalization for Python
|
|
5
5
|
Author-email: Nikolay Panov <github@npanov.com>
|
|
6
6
|
License: MIT
|
|
@@ -50,6 +50,7 @@ Inspired by Sam Ruby's [urlnorm.py](<http://intertwingly.net/blog/2004/08/04/Url
|
|
|
50
50
|
|
|
51
51
|
* IDN (Internationalized Domain Name) support
|
|
52
52
|
* Configurable default scheme (https by default)
|
|
53
|
+
* Configurable default domain for absolute paths
|
|
53
54
|
* Query parameter filtering with allowlists
|
|
54
55
|
* Support for various URL formats including:
|
|
55
56
|
* Empty string URLs
|
|
@@ -97,6 +98,14 @@ print(url_normalize(
|
|
|
97
98
|
param_allowlist=["page", "id"]
|
|
98
99
|
))
|
|
99
100
|
# Output: https://example.com?page=1&id=123
|
|
101
|
+
|
|
102
|
+
# With default domain for absolute paths
|
|
103
|
+
print(url_normalize("/images/logo.png", default_domain="example.com"))
|
|
104
|
+
# Output: https://example.com/images/logo.png
|
|
105
|
+
|
|
106
|
+
# With default domain and custom scheme
|
|
107
|
+
print(url_normalize("/images/logo.png", default_scheme="http", default_domain="example.com"))
|
|
108
|
+
# Output: http://example.com/images/logo.png
|
|
100
109
|
```
|
|
101
110
|
|
|
102
111
|
### Command-line usage
|
|
@@ -119,6 +128,14 @@ $ url-normalize -f "www.google.com/search?q=test&utm_source=test"
|
|
|
119
128
|
$ url-normalize -f -p page,id "example.com?page=1&id=123&ref=test"
|
|
120
129
|
# Output: https://example.com/?page=1&id=123
|
|
121
130
|
|
|
131
|
+
# With default domain for absolute paths
|
|
132
|
+
$ url-normalize -d example.com "/images/logo.png"
|
|
133
|
+
# Output: https://example.com/images/logo.png
|
|
134
|
+
|
|
135
|
+
# With default domain and custom scheme
|
|
136
|
+
$ url-normalize -d example.com -s http "/images/logo.png"
|
|
137
|
+
# Output: http://example.com/images/logo.png
|
|
138
|
+
|
|
122
139
|
# Via uv tool/uvx
|
|
123
140
|
$ uvx url-normalize www.foo.com:80/foo
|
|
124
141
|
# Output: https://www.foo.com:80/foo
|
|
@@ -12,6 +12,7 @@ tests/test_normalize_query.py
|
|
|
12
12
|
tests/test_normalize_query_filters.py
|
|
13
13
|
tests/test_normalize_scheme.py
|
|
14
14
|
tests/test_normalize_userinfo.py
|
|
15
|
+
tests/test_provide_url_domain.py
|
|
15
16
|
tests/test_provide_url_scheme.py
|
|
16
17
|
tests/test_reconstruct_url.py
|
|
17
18
|
tests/test_tools.py
|
|
@@ -27,6 +28,7 @@ url_normalize/normalize_query.py
|
|
|
27
28
|
url_normalize/normalize_scheme.py
|
|
28
29
|
url_normalize/normalize_userinfo.py
|
|
29
30
|
url_normalize/param_allowlist.py
|
|
31
|
+
url_normalize/provide_url_domain.py
|
|
30
32
|
url_normalize/provide_url_scheme.py
|
|
31
33
|
url_normalize/tools.py
|
|
32
34
|
url_normalize/url_normalize.py
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
"""Integrations tests."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Final
|
|
6
|
-
|
|
7
|
-
from url_normalize import url_normalize
|
|
8
|
-
|
|
9
|
-
EXPECTED_RESULTS: Final[dict[str, str]] = {
|
|
10
|
-
"/../foo": "/foo",
|
|
11
|
-
"/./../foo": "/foo",
|
|
12
|
-
"/./foo": "/foo",
|
|
13
|
-
"/./foo/.": "/foo/",
|
|
14
|
-
"//www.foo.com/": "https://www.foo.com/",
|
|
15
|
-
"/foo/../bar": "/bar",
|
|
16
|
-
"/foo/./bar": "/foo/bar",
|
|
17
|
-
"/foo//": "/foo/",
|
|
18
|
-
"/foo///bar//": "/foo/bar/",
|
|
19
|
-
"/foo/bar/..": "/foo/",
|
|
20
|
-
"/foo/bar/../..": "/",
|
|
21
|
-
"/foo/bar/../../../../baz": "/baz",
|
|
22
|
-
"/foo/bar/../../../baz": "/baz",
|
|
23
|
-
"/foo/bar/../../": "/",
|
|
24
|
-
"/foo/bar/../../baz": "/baz",
|
|
25
|
-
"/foo/bar/../": "/foo/",
|
|
26
|
-
"/foo/bar/../baz": "/foo/baz",
|
|
27
|
-
"/foo/bar/.": "/foo/bar/",
|
|
28
|
-
"/foo/bar/./": "/foo/bar/",
|
|
29
|
-
"http://:@example.com/": "http://example.com/",
|
|
30
|
-
"http://@example.com/": "http://example.com/",
|
|
31
|
-
"http://127.0.0.1:80/": "http://127.0.0.1/",
|
|
32
|
-
"http://example.com:081/": "http://example.com:81/",
|
|
33
|
-
"http://example.com:80/": "http://example.com/",
|
|
34
|
-
"http://example.com": "http://example.com/",
|
|
35
|
-
"http://example.com/?b&a": "http://example.com/?b&a",
|
|
36
|
-
"http://example.com/?q=%5c": "http://example.com/?q=%5C",
|
|
37
|
-
"http://example.com/?q=%C7": "http://example.com/?q=%EF%BF%BD",
|
|
38
|
-
"http://example.com/?q=C%CC%A7": "http://example.com/?q=%C3%87",
|
|
39
|
-
"http://EXAMPLE.COM/": "http://example.com/",
|
|
40
|
-
"http://example.com/%7Ejane": "http://example.com/~jane",
|
|
41
|
-
"http://example.com/a/../a/b": "http://example.com/a/b",
|
|
42
|
-
"http://example.com/a/./b": "http://example.com/a/b",
|
|
43
|
-
"http://example.com/#!5753509/hello-world": (
|
|
44
|
-
"http://example.com/?_escaped_fragment_=5753509/hello-world"
|
|
45
|
-
),
|
|
46
|
-
"http://USER:pass@www.Example.COM/foo/bar": (
|
|
47
|
-
"http://USER:pass@www.example.com/foo/bar"
|
|
48
|
-
),
|
|
49
|
-
"http://www.example.com./": "http://www.example.com/",
|
|
50
|
-
"http://www.foo.com:80/foo": "http://www.foo.com/foo",
|
|
51
|
-
"http://www.foo.com.:81/foo": "http://www.foo.com:81/foo",
|
|
52
|
-
"http://www.foo.com./foo/bar.html": "http://www.foo.com/foo/bar.html",
|
|
53
|
-
"http://www.foo.com/%7Ebar": "http://www.foo.com/~bar",
|
|
54
|
-
"http://www.foo.com/%7ebar": "http://www.foo.com/~bar",
|
|
55
|
-
"пример.испытание/Служебная:Search/Test": (
|
|
56
|
-
"https://xn--e1afmkfd.xn--80akhbyknj4f"
|
|
57
|
-
"/%D0%A1%D0%BB%D1%83%D0%B6%D0%B5%D0%B1%D0%BD%D0%B0%D1%8F:Search/Test"
|
|
58
|
-
),
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
NO_CHANGES_EXPECTED: Final[tuple[str, ...]] = (
|
|
62
|
-
"-",
|
|
63
|
-
"",
|
|
64
|
-
"/..foo",
|
|
65
|
-
"/.foo",
|
|
66
|
-
"/foo..",
|
|
67
|
-
"/foo.",
|
|
68
|
-
"ftp://user:pass@ftp.foo.net/foo/bar",
|
|
69
|
-
"http://127.0.0.1/",
|
|
70
|
-
"http://example.com:8080/",
|
|
71
|
-
"http://example.com/?a&b",
|
|
72
|
-
"http://example.com/?q=%5C",
|
|
73
|
-
"http://example.com/?q=%C3%87",
|
|
74
|
-
"http://example.com/?q=%E2%85%A0",
|
|
75
|
-
"http://example.com/",
|
|
76
|
-
"http://example.com/~jane",
|
|
77
|
-
"http://example.com/a/b",
|
|
78
|
-
"http://example.com/FOO",
|
|
79
|
-
"http://user:password@example.com/",
|
|
80
|
-
"http://www.foo.com:8000/foo",
|
|
81
|
-
# from rfc2396bis
|
|
82
|
-
"ftp://ftp.is.co.za/rfc/rfc1808.txt",
|
|
83
|
-
"http://www.ietf.org/rfc/rfc2396.txt",
|
|
84
|
-
"ldap://[2001:db8::7]/c=GB?objectClass?one",
|
|
85
|
-
"mailto:John.Doe@example.com",
|
|
86
|
-
"news:comp.infosystems.www.servers.unix",
|
|
87
|
-
"tel:+1-816-555-1212",
|
|
88
|
-
"telnet://192.0.2.16:80/",
|
|
89
|
-
"urn:oasis:names:specification:docbook:dtd:xml:4.1.2",
|
|
90
|
-
# Issue #36: Fragment with '=' should not be encoded
|
|
91
|
-
"https://docs.google.com/spreadsheets/d/abcd/edit#gid=1234",
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def test_url_normalize_changes() -> None:
|
|
96
|
-
"""Assert url_normalize do not change URI if not required.
|
|
97
|
-
|
|
98
|
-
http://www.intertwingly.net/wiki/pie/PaceCanonicalIds
|
|
99
|
-
"""
|
|
100
|
-
for value in NO_CHANGES_EXPECTED:
|
|
101
|
-
assert url_normalize(value) == value
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_url_normalize_results() -> None:
|
|
105
|
-
"""Assert url_normalize return expected results."""
|
|
106
|
-
for value, expected in EXPECTED_RESULTS.items():
|
|
107
|
-
assert expected == url_normalize(value), value
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def test_url_normalize_filtered() -> None:
|
|
111
|
-
"""Assert url_normalize return expected results."""
|
|
112
|
-
url = "/?a&b"
|
|
113
|
-
expected = "/"
|
|
114
|
-
|
|
115
|
-
actual = url_normalize(url, filter_params=True)
|
|
116
|
-
|
|
117
|
-
assert actual == expected
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def test_url_normalize_with_http_scheme() -> None:
|
|
121
|
-
"""Assert we could use http scheme as default."""
|
|
122
|
-
url = "//www.foo.com/"
|
|
123
|
-
expected = "http://www.foo.com/"
|
|
124
|
-
|
|
125
|
-
actual = url_normalize(url, default_scheme="http")
|
|
126
|
-
|
|
127
|
-
assert actual == expected
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|