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.
Files changed (43) hide show
  1. {url_normalize-2.1.0 → url_normalize-2.2.0}/PKG-INFO +18 -1
  2. {url_normalize-2.1.0 → url_normalize-2.2.0}/README.md +17 -0
  3. {url_normalize-2.1.0 → url_normalize-2.2.0}/pyproject.toml +1 -1
  4. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_cli.py +61 -0
  5. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_path.py +3 -1
  6. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_query.py +2 -0
  7. url_normalize-2.2.0/tests/test_provide_url_domain.py +31 -0
  8. url_normalize-2.2.0/tests/test_url_normalize.py +163 -0
  9. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/__init__.py +1 -1
  10. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/cli.py +7 -0
  11. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_path.py +4 -4
  12. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_query.py +2 -2
  13. url_normalize-2.2.0/url_normalize/provide_url_domain.py +28 -0
  14. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/provide_url_scheme.py +11 -4
  15. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/url_normalize.py +7 -2
  16. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/PKG-INFO +18 -1
  17. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/SOURCES.txt +2 -0
  18. url_normalize-2.1.0/tests/test_url_normalize.py +0 -127
  19. {url_normalize-2.1.0 → url_normalize-2.2.0}/LICENSE +0 -0
  20. {url_normalize-2.1.0 → url_normalize-2.2.0}/setup.cfg +0 -0
  21. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_deconstruct_url.py +0 -0
  22. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_generic_url_cleanup.py +0 -0
  23. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_fragment.py +0 -0
  24. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_host.py +0 -0
  25. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_port.py +0 -0
  26. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_query_filters.py +0 -0
  27. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_scheme.py +0 -0
  28. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_normalize_userinfo.py +0 -0
  29. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_provide_url_scheme.py +0 -0
  30. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_reconstruct_url.py +0 -0
  31. {url_normalize-2.1.0 → url_normalize-2.2.0}/tests/test_tools.py +0 -0
  32. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/generic_url_cleanup.py +0 -0
  33. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_fragment.py +0 -0
  34. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_host.py +0 -0
  35. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_port.py +0 -0
  36. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_scheme.py +0 -0
  37. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/normalize_userinfo.py +0 -0
  38. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/param_allowlist.py +0 -0
  39. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize/tools.py +0 -0
  40. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/dependency_links.txt +0 -0
  41. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/entry_points.txt +0 -0
  42. {url_normalize-2.1.0 → url_normalize-2.2.0}/url_normalize.egg-info/requires.txt +0 -0
  43. {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.1.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "url-normalize"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "URL normalization for Python"
5
5
  authors = [{ name = "Nikolay Panov", email = "github@npanov.com" }]
6
6
  license = { text = "MIT" }
@@ -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 test_normalize_host_result_is_expected():
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
@@ -8,6 +8,6 @@ SPDX-License-Identifier: MIT
8
8
  from .url_normalize import url_normalize
9
9
 
10
10
  __license__ = "MIT"
11
- __version__ = "2.1.0"
11
+ __version__ = "2.2.0"
12
12
 
13
13
  __all__ = ["url_normalize"]
@@ -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 ["", "http", "https", "ftp", "file"]:
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 ["http", "https", "ftp", "file"]:
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
- QUERY_PARAM_RESERVED_CHARS = "~:/?#[]@!$'()*+,;="
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), QUERY_PARAM_RESERVED_CHARS)
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 has_scheme or is_file_path:
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
- if is_universal_scheme:
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
- *, # Force keyword-only arguments
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.1.0
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