Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-url-normalize for 
openSUSE:Factory checked in at 2026-10-26 10:02:45
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-url-normalize (Old)
 and      /work/SRC/openSUSE:Factory/.python-url-normalize.new.2252680 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-url-normalize"

Mon Oct 26 10:02:45 2026 rev:6 rq:1392535 version:3.0.1

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-url-normalize/python-url-normalize.changes    
    2026-04-26 21:14:28.345165712 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-url-normalize.new.2252680/python-url-normalize.changes
   2026-10-26 10:04:13.577342380 +0100
@@ -1,0 +2,12 @@
+Sun Oct 25 20:09:26 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 3.0.1:
+  * Normalization fixes change canonical output for some URLs,
+    including encoded delimiters, query plus signs, explicit
+    empty query values, and trailing spaces. If normalized URLs
+    are used as cache keys or deduplication identifiers, review
+    stored keys and re-normalize the original URLs where
+    available; information discarded by earlier normalization
+    cannot be recovered from those normalized values alone.
+
+-------------------------------------------------------------------

Old:
----
  url-normalize-3.0.0.tar.gz

New:
----
  url-normalize-3.0.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-url-normalize.spec ++++++
--- /var/tmp/diff_new_pack.pWA3b7/_old  2026-10-26 10:04:14.892397507 +0100
+++ /var/tmp/diff_new_pack.pWA3b7/_new  2026-10-26 10:04:14.895397633 +0100
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-url-normalize
-Version:        3.0.0
+Version:        3.0.1
 Release:        0
 Summary:        URL normalization for Python
 License:        MIT

++++++ url-normalize-3.0.0.tar.gz -> url-normalize-3.0.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/CHANGELOG.md 
new/url-normalize-3.0.1/CHANGELOG.md
--- old/url-normalize-3.0.0/CHANGELOG.md        2026-04-25 02:23:36.000000000 
+0200
+++ new/url-normalize-3.0.1/CHANGELOG.md        2026-09-22 23:31:41.000000000 
+0200
@@ -7,6 +7,36 @@
 
 ## [Unreleased]
 
+## [3.0.1] - 2026-09-22
+
+### Upgrade notes
+
+Normalization fixes change canonical output for some URLs, including encoded 
delimiters, query plus signs, explicit empty query values, and trailing spaces. 
If normalized URLs are used as cache keys or deduplication identifiers, review 
stored keys and re-normalize the original URLs where available; information 
discarded by earlier normalization cannot be recovered from those normalized 
values alone.
+
+### Fixed
+
+- Preserve invalid UTF-8 percent-encoded octets instead of replacing them with 
U+FFFD; valid UTF-8 still normalizes to NFC.
+- Preserve percent-encoded reserved characters in paths, keeping values such 
as `a%2Fb` distinct from `a/b`.
+- Preserve encoded plus signs (`%2B`) in query parameters and explicit empty 
values such as `flag=`.
+- Normalize Unicode and unsafe characters in URL credentials while preserving 
encoded delimiters; correctly separate credentials containing `@` from the host.
+- Correctly parse bracketed IPv6 hosts and ports, lowercase IPv6 addresses, 
and preserve zone identifier case.
+- Recognize long and custom URL schemes, protocol-relative URLs, and bare 
domains or `localhost` with numeric ports.
+- Remove leading whitespace and parser-ignored control characters before 
applying default domains and detecting URL schemes, preserving the intended 
destination.
+- Trim trailing authority whitespace without discarding trailing spaces or 
punctuation belonging to paths, query values, or fragments.
+- Convert hashbang fragments (`#!`) into a single escaped query parameter, 
preserving existing parameters and leaving ordinary fragments intact.
+- Match query parameter allowlists against normalized hosts and equivalent 
custom domain keys, including Unicode/Punycode aliases, case variants, and 
trailing dots. Exact canonical keys take precedence over aliases.
+- Decode IDNA host output as ASCII regardless of the `charset` argument.
+- Keep encoded backslashes and C0/C1 control characters encoded in 
`url_humanize()` output, and retain the normalized URL when a decoding 
candidate cannot be parsed.
+- Resolve query parameter allowlists once per URL to avoid repeated domain 
alias scans for each parameter.
+
+### Documentation
+
+- Correct normalization examples and clarify that parameter filtering uses 
allowlists, unknown domains have no default allowed parameters, and Unicode URL 
data uses UTF-8 percent encoding regardless of `charset`.
+
+### Internal
+
+- Enforce 100% statement coverage in the test suite and expand regression 
coverage for URL component handling.
+
 ## [3.0.0] - 2026-04-24
 
 ### Added
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/README.md 
new/url-normalize-3.0.1/README.md
--- old/url-normalize-3.0.0/README.md   2026-04-25 02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/README.md   2026-09-22 23:31:41.000000000 +0200
@@ -37,7 +37,7 @@
 - **Versatile URL Handling**: Handles empty strings, double-slash URLs 
(//domain.tld), and shebang (#!) URLs.
 - **Developer Friendly**:
   - Python 3.10+ compatibility.
-  - 100% test coverage.
+  - 100% statement coverage, enforced by the test suite.
   - Modern type hints and string handling.
 
 Inspired by Sam Ruby's 
[urlnorm.py](http://intertwingly.net/blog/2004/08/04/Urlnorm).
@@ -67,19 +67,23 @@
 
 # Basic normalization (uses https by default)
 print(url_normalize("www.foo.com:80/foo"))
-# Output: https://www.foo.com/foo
+# Output: https://www.foo.com:80/foo
 
 # With custom default scheme
 print(url_normalize("www.foo.com/foo", default_scheme="http"))
 # Output: http://www.foo.com/foo
 ```
 
+The `charset` argument remains for compatibility. Unicode characters use UTF-8 
percent encoding, regardless of this argument.
+
 #### Query Parameter Filtering
 
-You can strip out tracking parameters and only keep the ones you care about 
using allowlists.
+With `filter_params=True`, normalization retains only allowlisted query 
parameters. The built-in rules cover selected domains.
+
+Other domains have an empty default allowlist. Without a custom allowlist, 
filtering removes all query parameters from those domains.
 
 ```python
-# With query parameter filtering enabled (strips all params by default)
+# With the built-in Google allowlist
 print(url_normalize("www.google.com/search?q=test&utm_source=test", 
filter_params=True))
 # Output: https://www.google.com/search?q=test
 
@@ -89,7 +93,7 @@
     filter_params=True,
     param_allowlist=["page", "id"]
 ))
-# Output: https://example.com?page=1&id=123
+# Output: https://example.com/?page=1&id=123
 
 # With domain-specific parameter allowlists
 print(url_normalize(
@@ -97,7 +101,7 @@
     filter_params=True,
     param_allowlist={"example.com": ["page", "id"]}
 ))
-# Output: https://example.com?page=1&id=123
+# Output: https://example.com/?page=1&id=123
 ```
 
 #### Default Domain & Scheme
@@ -136,7 +140,7 @@
 
 ```bash
 $ url-normalize "www.foo.com:80/foo"
-# Output: https://www.foo.com/foo
+# Output: https://www.foo.com:80/foo
 
 # With custom default scheme
 $ url-normalize -s http "www.foo.com/foo"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/pyproject.toml 
new/url-normalize-3.0.1/pyproject.toml
--- old/url-normalize-3.0.0/pyproject.toml      2026-04-25 02:23:36.000000000 
+0200
+++ new/url-normalize-3.0.1/pyproject.toml      2026-09-22 23:31:41.000000000 
+0200
@@ -1,6 +1,6 @@
 [project]
 name = "url-normalize"
-version = "3.0.0"
+version = "3.0.1"
 description = "URL normalization for Python"
 authors = [{ name = "Nikolay Panov", email = "[email protected]" }]
 license = "MIT"
@@ -73,6 +73,7 @@
 addopts = [
   "--cov-report=term-missing:skip-covered",
   "--cov=url_normalize",
+  "--cov-fail-under=100",
   "--disable-socket",
   "-v",
 ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_cli.py 
new/url-normalize-3.0.1/tests/test_cli.py
--- old/url-normalize-3.0.0/tests/test_cli.py   2026-04-25 02:23:36.000000000 
+0200
+++ new/url-normalize-3.0.1/tests/test_cli.py   2026-09-22 23:31:41.000000000 
+0200
@@ -26,6 +26,38 @@
     )
 
 
+def test_cli_help_describes_allowlist_filtering() -> None:
+    """Describe filtering as an allowlist, not a tracking-parameter 
blocklist."""
+    result = run_cli("--help")
+    assert result.returncode == 0
+    help_text = " ".join(result.stdout.split())
+    assert "Keep only allowlisted query parameters." in help_text
+    assert "Unknown domains have an empty default allowlist." in help_text
+
+
+def test_cli_help_describes_fixed_utf8_encoding() -> None:
+    """Describe charset as a compatibility option for Unicode URL input."""
+    result = run_cli("--help")
+    assert result.returncode == 0
+    help_text = " ".join(result.stdout.split())
+    assert "Retained for compatibility." in help_text
+    assert "Unicode characters use UTF-8 percent encoding." in help_text
+    normalized = run_cli("--charset", "iso-8859-1", "https://example.com/é";)
+    assert normalized.returncode == 0
+    assert normalized.stdout.strip() == "https://example.com/%C3%A9";
+
+
+def test_cli_successful_main(capsys, monkeypatch):
+    """Exercise successful CLI output in the measured process."""
+    monkeypatch.setattr(
+        sys, "argv", ["url-normalize", "http://EXAMPLE.com/./path/../other/";]
+    )
+    main()
+    captured = capsys.readouterr()
+    assert captured.out == "http://example.com/other/\n";
+    assert not captured.err
+
+
 def test_cli_error_handling(capsys, monkeypatch):
     """Test CLI error handling when URL normalization fails."""
     with patch("url_normalize.cli.url_normalize") as mock_normalize:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_deconstruct_url.py 
new/url-normalize-3.0.1/tests/test_deconstruct_url.py
--- old/url-normalize-3.0.0/tests/test_deconstruct_url.py       2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_deconstruct_url.py       2026-09-22 
23:31:41.000000000 +0200
@@ -2,6 +2,7 @@
 
 import pytest
 
+from url_normalize import url_normalize
 from url_normalize.tools import URL, deconstruct_url
 
 
@@ -38,3 +39,49 @@
     """Assert we got expected results from the deconstruct_url function."""
     result = deconstruct_url(url)
     assert result == expected, url
+
+
[email protected](
+    ("authority", "userinfo", "host", "port", "normalized_authority"),
+    [
+        ("[2001:DB8::1]:080", "", "[2001:DB8::1]", "080", "[2001:db8::1]"),
+        ("[2001:DB8::1]:0081", "", "[2001:DB8::1]", "0081", 
"[2001:db8::1]:81"),
+        ("[::1]", "", "[::1]", "", "[::1]"),
+        ("[::1]:", "", "[::1]", "", "[::1]"),
+        ("user:pass@[::1]:080", "user:pass@", "[::1]", "080", 
"user:pass@[::1]"),
+        ("EXAMPLE.com:080", "", "EXAMPLE.com", "080", "example.com"),
+        ("127.0.0.1:0081", "", "127.0.0.1", "0081", "127.0.0.1:81"),
+    ],
+)
+def test_deconstruct_url_separates_bracketed_host_and_port(
+    authority, userinfo, host, port, normalized_authority
+):
+    """Parse the entire bracketed host before considering a port separator."""
+    url = f"http://{authority}/path?q=1#fragment";
+    assert deconstruct_url(url) == URL(
+        "http", userinfo, host, port, "/path", "q=1", "fragment"
+    )
+    assert url_normalize(url) == 
f"http://{normalized_authority}/path?q=1#fragment";
+
+
[email protected]("whitespace", [" ", "\u00a0", " \t\r\n"])
[email protected]("suffix", ["", "/path ", "?q=x ", "#part "])
[email protected](
+    ("authority", "normalized"),
+    [
+        ("EXAMPLE.com", "example.com"),
+        ("example.com:443", "example.com"),
+        ("user:[email protected]:443", "user:[email protected]"),
+        ("[FE80::1%25ethA]:443", "[fe80::1%25ethA]"),
+    ],
+)
+def test_deconstruct_url_trims_authority_whitespace_only(
+    authority, normalized, whitespace, suffix
+):
+    """Trim the authority without deleting whitespace from other components."""
+    value = f"https://{authority}{whitespace}{suffix}";
+    assert deconstruct_url(value) == 
deconstruct_url(f"https://{authority}{suffix}";)
+    path_suffix = suffix if suffix.startswith("/") else "/" + suffix
+    assert (
+        url_normalize(value) == f"https://{normalized}{path_suffix.replace(' 
', '%20')}"
+    )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/url-normalize-3.0.0/tests/test_generic_url_cleanup.py 
new/url-normalize-3.0.1/tests/test_generic_url_cleanup.py
--- old/url-normalize-3.0.0/tests/test_generic_url_cleanup.py   2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_generic_url_cleanup.py   2026-09-22 
23:31:41.000000000 +0200
@@ -2,9 +2,11 @@
 
 from __future__ import annotations
 
+from urllib.parse import parse_qsl, urlsplit
+
 import pytest
 
-from url_normalize.url_normalize import generic_url_cleanup
+from url_normalize.url_normalize import generic_url_cleanup, url_normalize
 
 
 @pytest.mark.parametrize(
@@ -12,10 +14,59 @@
     [
         ("//site/#!fragment", "//site/?_escaped_fragment_=fragment"),
         ("//site/page", "//site/page"),
-        ("//site/?& ", "//site/"),
+        ("//site/?&&", "//site/"),
+        ("//site/?& ", "//site/? "),
     ],
 )
 def test_generic_url_cleanup_result_is_expected(url: str, expected: str) -> 
None:
     """Assert we got expected results from the generic_url_cleanup function."""
     result = generic_url_cleanup(url)
     assert result == expected
+
+
[email protected](
+    ("suffix", "normalized_suffix"),
+    [
+        ("/a&", "/a&"),
+        ("/a ", "/a%20"),
+        ("/?q=why?", "/?q=why?"),
+        ("/?q=x ", "/?q=x%20"),
+        ("/#part&", "/#part%26"),
+        ("/#part?", "/#part%3F"),
+        ("/#part ", "/#part%20"),
+    ],
+)
+def test_generic_url_cleanup_preserves_component_data(suffix, 
normalized_suffix):
+    """Preserve trailing punctuation and whitespace belonging to components."""
+    url = f"https://example.com{suffix}";
+    assert generic_url_cleanup(url) == url
+    assert url_normalize(url) == f"https://example.com{normalized_suffix}";
+
+
[email protected]("query", ["", "?q=x", "?q=x&", "?q=why?"])
[email protected](
+    ("fragment", "payload"),
+    [
+        ("!/route", "/route"),
+        ("!/route&a=b", "/route&a=b"),
+        ("!/C++%2B%26%23", "/C+++&#"),
+        ("!", ""),
+        ("!a#!b", "a#!b"),
+        ("part#!nested", None),
+        ("ordinary", None),
+    ],
+)
+def test_generic_url_cleanup_encodes_hashbang_as_one_parameter(
+    query, fragment, payload
+):
+    """Append the decoded hashbang payload as one value after existing 
parameters."""
+    url = f"https://example.com/{query}#{fragment}";
+    if payload is None:
+        assert urlsplit(generic_url_cleanup(url)).fragment == fragment
+        return
+    expected = parse_qsl(query.removeprefix("?"), keep_blank_values=True)
+    expected.append(("_escaped_fragment_", payload))
+    for result in (generic_url_cleanup(url), url_normalize(url)):
+        parts = urlsplit(result)
+        assert parts.fragment == ""
+        assert parse_qsl(parts.query, keep_blank_values=True) == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_normalize_host.py 
new/url-normalize-3.0.1/tests/test_normalize_host.py
--- old/url-normalize-3.0.0/tests/test_normalize_host.py        2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_normalize_host.py        2026-09-22 
23:31:41.000000000 +0200
@@ -30,3 +30,29 @@
     """Assert we got expected results from the normalize_host function."""
     result = normalize_host(host)
     assert result == expected, host
+
+
[email protected]("charset", ["utf-8", "utf-16", "utf-32", 
"iso-8859-1"])
[email protected](
+    ("host", "expected"),
+    [
+        ("пример.рф", "xn--e1afmkfd.xn--p1ai"),
+        ("EXAMPLE.COM", "example.com"),
+        ("under_score.example", "under_score.example"),
+        ("[2001:DB8::1]", "[2001:db8::1]"),
+    ],
+)
+def test_normalize_host_decodes_idna_output_as_ascii(host, expected, charset):
+    """Decode ASCII IDNA output independently of the input byte encoding."""
+    assert normalize_host(host, charset) == expected
+    assert normalize_host(expected.encode(charset), charset) == expected
+
+
[email protected]("zone", ["%25ethA", "%ethA", "%25Interface.10", 
"%25ETH0"])
[email protected]("charset", ["utf-8", "utf-16"])
+def test_normalize_host_preserves_ipv6_zone_case(zone, charset):
+    """Lowercase the IPv6 address without changing its interface identifier."""
+    host = f"[FE80::ABCD{zone}]"
+    expected = f"[fe80::abcd{zone}]"
+    assert normalize_host(host, charset) == expected
+    assert normalize_host(host.encode(charset), charset) == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_normalize_path.py 
new/url-normalize-3.0.1/tests/test_normalize_path.py
--- old/url-normalize-3.0.0/tests/test_normalize_path.py        2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_normalize_path.py        2026-09-22 
23:31:41.000000000 +0200
@@ -2,7 +2,7 @@
 
 import pytest
 
-from url_normalize.url_normalize import normalize_path
+from url_normalize.url_normalize import normalize_path, url_normalize
 
 
 @pytest.mark.parametrize(
@@ -42,3 +42,17 @@
     """Assert we got expected results from the normalize_path function."""
     result = normalize_path(path, "http")
     assert result == expected, path
+
+
[email protected]("character", ":/?#[]@!$&'()*+,;=")
+def test_normalize_path_preserves_encoded_reserved_characters(character):
+    """Keep escaped delimiters distinct from literal path syntax."""
+    escaped = f"%{ord(character):02X}"
+    path = f"/a{escaped.lower()}b/%7e/e%CC%81/%25/%zz"
+    expected = f"/a{escaped}b/~/%C3%A9/%25/%25zz"
+    assert normalize_path(path, "https") == expected
+    url = f"https://example.com{expected}?x=1#end";
+    assert url_normalize(f"https://example.com{path}?x=1#end";) == url
+    assert url_normalize(url) == url
+    literal = "%23" if character == "#" else "%3F" if character == "?" else 
character
+    assert normalize_path(f"/a{character}b", "https") == f"/a{literal}b"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_normalize_query.py 
new/url-normalize-3.0.1/tests/test_normalize_query.py
--- old/url-normalize-3.0.0/tests/test_normalize_query.py       2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_normalize_query.py       2026-09-22 
23:31:41.000000000 +0200
@@ -1,5 +1,7 @@
 """Tests for normalize_query function."""
 
+from urllib.parse import parse_qsl
+
 import pytest
 
 from url_normalize.url_normalize import normalize_query
@@ -22,3 +24,34 @@
     """Assert we got expected results from the normalize_query function."""
     result = normalize_query(query)
     assert result == expected, query
+
+
[email protected](
+    ("query", "expected"),
+    [
+        ("q=C%2b%2B", "q=C%2B%2B"),
+        ("C%2b%2B=value", "C%2B%2B=value"),
+        ("q=C++", "q=C++"),
+        ("q=C%20%2B+", "q=C%20%2B+"),
+        ("q=%252B", "q=%252B"),
+    ],
+)
+def test_normalize_query_preserves_form_plus_meaning(query, expected):
+    """Distinguish encoded plus signs, literal plus signs, and spaces."""
+    actual = normalize_query(query)
+    assert actual == expected
+    assert parse_qsl(actual) == parse_qsl(query)
+
+
[email protected]("query", ["flag=&flag", "=", "=value", "flag=", 
"flag"])
[email protected]("filter_params", [False, True])
+def test_normalize_query_preserves_empty_value_separator(query, filter_params):
+    """Keep explicit empty values distinct from parameters without a value."""
+    actual = normalize_query(
+        query, filter_params=filter_params, param_allowlist=["flag", ""]
+    )
+    assert actual == query
+    if query == "flag=":
+        assert parse_qsl(actual, keep_blank_values=True, strict_parsing=True) 
== [
+            ("flag", "")
+        ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/url-normalize-3.0.0/tests/test_normalize_query_filters.py 
new/url-normalize-3.0.1/tests/test_normalize_query_filters.py
--- old/url-normalize-3.0.0/tests/test_normalize_query_filters.py       
2026-04-25 02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_normalize_query_filters.py       
2026-09-22 23:31:41.000000000 +0200
@@ -2,9 +2,12 @@
 
 from __future__ import annotations
 
+from unittest.mock import patch
+
 import pytest
 
 from url_normalize import url_normalize
+from url_normalize.param_allowlist import get_allowed_params
 
 
 def test_param_filtering_disabled_by_default():
@@ -96,3 +99,128 @@
 def test_parameter_filtering(url: str, expected: str):
     """Test URL parameter filtering functionality with various scenarios."""
     assert url_normalize(url, filter_params=True) == expected
+
+
[email protected](
+    ("host", "canonical", "allowlist"),
+    [
+        ("www.google.com.", "www.google.com", None),
+        ("WWW.GOOGLE.COM.", "www.google.com", None),
+        ("www。google.com", "www.google.com", None),
+        ("google.com", "google.com", None),
+        ("пример.рф", "xn--e1afmkfd.xn--p1ai", {"xn--e1afmkfd.xn--p1ai": 
["q"]}),
+        (
+            "xn--e1afmkfd.xn--p1ai.",
+            "xn--e1afmkfd.xn--p1ai",
+            {"xn--e1afmkfd.xn--p1ai": ["q"]},
+        ),
+        ("EXAMPLE.COM.", "example.com", ["q"]),
+    ],
+)
+def test_parameter_filtering_uses_normalized_host(host, canonical, allowlist):
+    """Use the output host's canonical spelling when choosing an allowlist."""
+    assert (
+        url_normalize(
+            f"https://{host}/?q=test&utm_source=x";,
+            filter_params=True,
+            param_allowlist=allowlist,
+        )
+        == f"https://{canonical}/?q=test";
+    )
+
+
[email protected]("port", ["", ":8080"])
+def test_parameter_filtering_preserves_bracketed_host(port):
+    """Keep IPv6 address colons intact when looking up a canonical host."""
+    assert (
+        url_normalize(
+            f"https://[2001:DB8::1]{port}/?q=test&utm_source=x";,
+            filter_params=True,
+            param_allowlist={"[2001:db8::1]": ["q"]},
+        )
+        == f"https://[2001:db8::1]{port}/?q=test";
+    )
+
+
[email protected]("host", ["пример.рф", "xn--e1afmkfd.xn--p1ai", 
"ПРИМЕР.РФ."])
[email protected](
+    "key",
+    [
+        "пример.рф",
+        "xn--e1afmkfd.xn--p1ai",
+        "ПРИМЕР.РФ.",
+        "www。пример.рф",
+        "пример.рф:8080",
+    ],
+)
+def test_custom_allowlist_normalizes_mapping_keys(host, key):
+    """Match equivalent domain spellings on both sides of an allowlist."""
+    allowlist = {key: ["q"]}
+    result = url_normalize(
+        f"https://{host}/?q=1&utm_source=x";,
+        filter_params=True,
+        param_allowlist=allowlist,
+    )
+    assert result == "https://xn--e1afmkfd.xn--p1ai/?q=1";
+    assert allowlist == {key: ["q"]}
+
+
[email protected]("key", ["[2001:DB8::1]", "[2001:DB8::1]:8080"])
+def test_custom_allowlist_normalizes_bracketed_keys(key):
+    """Preserve IPv6 colons when normalizing dictionary keys."""
+    assert (
+        url_normalize(
+            "https://[2001:db8::1]/?q=1&drop=2";,
+            filter_params=True,
+            param_allowlist={key: ["q"]},
+        )
+        == "https://[2001:db8::1]/?q=1";
+    )
+
+
[email protected]("allowed", [[], ["q"]])
[email protected]("reverse", [False, True])
+def test_custom_allowlist_canonical_key_takes_precedence(allowed, reverse):
+    """Keep canonical rules authoritative instead of merging conflicting 
aliases."""
+    entries = [("пример.рф", ["other"]), ("xn--e1afmkfd.xn--p1ai", allowed)]
+    allowlist = dict(reversed(entries) if reverse else entries)
+    expected = "https://xn--e1afmkfd.xn--p1ai/"; + ("?q=1" if allowed else "")
+    assert (
+        url_normalize(
+            "https://пример.рф/?q=1&other=2";,
+            filter_params=True,
+            param_allowlist=allowlist,
+        )
+        == expected
+    )
+
+
+def test_custom_allowlist_ignores_unrelated_invalid_domain():
+    """Keep a malformed unrelated key from breaking a valid domain lookup."""
+    assert (
+        url_normalize(
+            "https://пример.рф/?q=1";,
+            filter_params=True,
+            param_allowlist={"a" * 64 + ".example": [], "пример.рф": ["q"]},
+        )
+        == "https://xn--e1afmkfd.xn--p1ai/?q=1";
+    )
+
+
[email protected]("host", ["target.example", "unlisted.example"])
+def test_filtering_resolves_domain_rules_once_per_url(host):
+    """Bound domain lookups independently of how many parameters are 
supplied."""
+    allowlist = {f"d{index}.example": ["q"] for index in range(20)}
+    allowlist["TARGET.EXAMPLE."] = ["q"]
+    query = "&".join(f"q={index}&drop={index}" for index in range(50))
+    with patch(
+        "url_normalize.normalize_query.get_allowed_params", 
wraps=get_allowed_params
+    ) as lookup:
+        result = url_normalize(
+            f"https://{host}/?{query}";, filter_params=True, 
param_allowlist=allowlist
+        )
+    expected = f"https://{host}/";
+    if host == "target.example":
+        expected += "?" + "&".join(f"q={index}" for index in range(50))
+    assert result == expected
+    lookup.assert_called_once_with(host, allowlist)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_normalize_userinfo.py 
new/url-normalize-3.0.1/tests/test_normalize_userinfo.py
--- old/url-normalize-3.0.0/tests/test_normalize_userinfo.py    2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_normalize_userinfo.py    2026-09-22 
23:31:41.000000000 +0200
@@ -2,7 +2,7 @@
 
 import pytest
 
-from url_normalize.url_normalize import normalize_userinfo
+from url_normalize.url_normalize import normalize_userinfo, url_normalize
 
 
 @pytest.mark.parametrize(
@@ -19,3 +19,26 @@
     """Assert we got expected results from the normalize_userinfo function."""
     result = normalize_userinfo(userinfo)
     assert result == expected, userinfo
+
+
[email protected](
+    ("userinfo", "expected"),
+    [
+        ("us er:pa ss@", "us%20er:pa%20ss@"),
+        ("e\u0301:пароль@", "%C3%A9:%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8C@"),
+        ("user:name:!$&'()*+,;=@", "user:name:!$&'()*+,;=@"),
+        ("user%3aname:p%40ss@", "user%3Aname:p%40ss@"),
+        ("us%65r:%ff@", "user:%FF@"),
+        ("user:50%@", "user:50%25@"),
+        ("user:p@ss@", "user:p%40ss@"),
+        ('user:p"<>\\@', "user:p%22%3C%3E%5C@"),
+    ],
+)
+def test_normalize_userinfo_encodes_component_data(userinfo, expected):
+    """Encode unsafe data while preserving userinfo delimiters and escaped 
octets."""
+    assert normalize_userinfo(userinfo) == expected
+    assert (
+        url_normalize(f"http://{userinfo}example.com/";)
+        == f"http://{expected}example.com/";
+    )
+    assert normalize_userinfo(expected) == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_provide_url_scheme.py 
new/url-normalize-3.0.1/tests/test_provide_url_scheme.py
--- old/url-normalize-3.0.0/tests/test_provide_url_scheme.py    2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_provide_url_scheme.py    2026-09-22 
23:31:41.000000000 +0200
@@ -2,7 +2,7 @@
 
 import pytest
 
-from url_normalize.url_normalize import provide_url_scheme
+from url_normalize.url_normalize import provide_url_scheme, url_normalize
 
 
 @pytest.mark.parametrize(
@@ -30,3 +30,111 @@
     actual = provide_url_scheme(url, default_scheme="http")
 
     assert actual == expected
+
+
[email protected](
+    ("url", "expected"),
+    [
+        ("git+ssh://example.com/repo", "git+ssh://example.com/repo"),
+        ("postgresql://example.com/db", "postgresql://example.com/db"),
+        ("custom-long.scheme:opaque", "custom-long.scheme:opaque"),
+        ("TEL:123", "TEL:123"),
+        ("custom:123", "custom:123"),
+        ("//x:443/", "https://x:443/";),
+        ("//[::1]:443/", "https://[::1]:443/";),
+        ("x.co:8080/path", "https://x.co:8080/path";),
+        ("example.com:8080/path", "https://example.com:8080/path";),
+        ("localhost:8080/path", "https://localhost:8080/path";),
+        ("127.0.0.1:8080/", "https://127.0.0.1:8080/";),
+        ("[::1]:8080/", "https://[::1]:8080/";),
+        ("site/path:part", "https://site/path:part";),
+        ("1abc:thing", "https://1abc:thing";),
+        ("http:example.com", "http://example.com";),
+    ],
+)
+def test_provide_url_scheme_distinguishes_schemes_and_authorities(url, 
expected):
+    """Recognize schemes of any length without confusing host ports or 
paths."""
+    assert provide_url_scheme(url) == expected
+
+
[email protected]("prefix", [" ", "\t", "\r\n", "\u00a0", "  \t\n"])
[email protected](
+    ("url", "expected"),
+    [
+        ("http://example.com/path ", "http://example.com/path "),
+        ("https://example.com/?q=x ", "https://example.com/?q=x "),
+        ("//x:443/path ", "https://x:443/path "),
+        ("example.com/path ", "https://example.com/path "),
+        ("git+ssh://example.com/repo ", "git+ssh://example.com/repo "),
+        ("mailto:[email protected]";, "mailto:[email protected]";),
+        ("/path ", "/path "),
+        ("-", "-"),
+        ("", ""),
+    ],
+)
+def test_provide_url_scheme_ignores_only_leading_whitespace(prefix, url, 
expected):
+    """Remove leading whitespace before classifying the URL."""
+    assert provide_url_scheme(prefix + url) == expected
+
+
[email protected]("host", ["example.com", "localhost", "127.0.0.1"])
[email protected]("whitespace", [" ", "\u00a0", " \t\r\n"])
[email protected]("suffix", ["", "/path ", "?q=x ", "#part "])
+def test_provide_url_scheme_accepts_whitespace_after_bare_port(
+    host, whitespace, suffix
+):
+    """Recognize a bare authority with padding after its numeric port."""
+    value = f"{host}:443{whitespace}{suffix}"
+    cleaned = value.replace("\t", "").replace("\r", "").replace("\n", "")
+    assert provide_url_scheme(value) == f"https://{cleaned}";
+    path_suffix = suffix if suffix.startswith("/") else "/" + suffix
+    expected = f"https://{host}{path_suffix.replace(' ', '%20')}"
+    assert url_normalize(value) == expected
+    assert url_normalize(expected) == expected
+
+
[email protected]("control", ["\t", "\r", "\n"])
[email protected](
+    ("value", "expected"),
+    [
+        ("ht{control}tps://example.com/path", "https://example.com/path";),
+        ("https{control}://example.com/path", "https://example.com/path";),
+        ("git+{control}ssh://example.com/repo", "git+ssh://example.com/repo"),
+        ("mail{control}to:[email protected]", "mailto:[email protected]";),
+        ("/{control}/example.com/path", "https://example.com/path";),
+    ],
+)
+def test_scheme_detection_ignores_parser_control_characters(control, value, 
expected):
+    """Keep the destination when urlsplit would discard tabs or line breaks."""
+    value = value.format(control=control)
+    assert provide_url_scheme(value) == expected
+    assert url_normalize(value, default_domain="default.example") == expected
+
+
[email protected]("prefix", ["\x00", "\x1f", "\x00\u00a0\x01 "])
[email protected](
+    ("value", "expected"),
+    [
+        ("http://example.com/path";, "http://example.com/path";),
+        ("//example.com/path", "https://example.com/path";),
+        ("/path", "/path"),
+        ("-", "-"),
+        ("", ""),
+    ],
+)
+def test_url_classification_ignores_leading_controls(prefix, value, expected):
+    """Remove leading controls before applying either default domain or 
scheme."""
+    assert provide_url_scheme(prefix + value) == expected
+    assert url_normalize(prefix + value) == expected
+    with_domain = "https://default.example/path"; if value == "/path" else 
expected
+    assert (
+        url_normalize(prefix + value, default_domain="default.example") == 
with_domain
+    )
+
+
+def test_scheme_cleanup_preserves_encoded_controls_and_trailing_spaces():
+    """Remove only raw parser controls, keeping escaped bytes and component 
data."""
+    value = "ht\ntps://example.com/a%09b ?q=%0A%0D #part%00 "
+    assert url_normalize(value) == (
+        "https://example.com/a%09b%20?q=%0A%0D%20#part%00%20";
+    )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_url_humanize.py 
new/url-normalize-3.0.1/tests/test_url_humanize.py
--- old/url-normalize-3.0.0/tests/test_url_humanize.py  2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_url_humanize.py  2026-09-22 
23:31:41.000000000 +0200
@@ -101,12 +101,12 @@
     assert package.url_humanize(value) == normalized
 
 
-def test_url_humanize_documents_encoded_slash_normalization() -> None:
-    """Assert encoded slashes follow url_normalize path semantics."""
+def test_url_humanize_preserves_encoded_slash() -> None:
+    """Keep encoded slashes distinct from path segment boundaries."""
     value = "https://example.com/a%2Fb";
     humanized = package.url_humanize(value)
 
-    assert humanized == "https://example.com/a/b";
+    assert humanized == value
     assert package.url_normalize(humanized) == package.url_normalize(value)
 
 
@@ -120,3 +120,46 @@
     """Assert parts that break round-trip normalizations are kept as is."""
     value = "https://example.com/a%3Fb";
     assert package.url_humanize(value) == value
+
+
[email protected]("userinfo", ["user%5B", "user%5D", "user:%5B", 
"user:%5D"])
+def test_url_humanize_rejects_candidates_that_raise(userinfo):
+    """Retain valid escaped credentials when decoding makes parsing fail."""
+    value = f"https://{userinfo}@example.com/";
+    assert package.url_humanize(value) == value
+    assert package.url_normalize(package.url_humanize(value)) == value
+
+
[email protected](
+    "component",
+    ["/{value}", "/?{value}=x", "/?q={value}", "/#{value}", 
"//{value}@example.com/"],
+)
[email protected](
+    "escaped", ["%00", "%09", "%0A", "%0D", "%1B", "%7F", "%C2%85"]
+)
+def test_url_humanize_keeps_control_characters_encoded(component, escaped):
+    """Keep C0, DEL, and C1 controls encoded in every humanized component."""
+    suffix = component.format(value=f"a{escaped}b")
+    value = (
+        f"https:{suffix}" if suffix.startswith("//") else 
f"https://example.com{suffix}";
+    )
+    assert package.url_humanize(value) == value
+
+
[email protected]("scheme", ["http", "https", "ftp"])
[email protected](
+    "suffix",
+    [
+        "//evil.example%[email protected]/",
+        "//name:evil.example%[email protected]/",
+        "//trusted.example/a%5Cb",
+        "//trusted.example/?a%5Cb=x",
+        "//trusted.example/?q=a%5Cb",
+        "//trusted.example/#a%5Cb",
+    ],
+)
+def test_url_humanize_keeps_backslashes_encoded(scheme, suffix):
+    """Keep backslashes encoded because browsers can treat them as 
separators."""
+    value = f"{scheme}:{suffix}"
+    assert package.url_humanize(value) == value
+    assert package.url_normalize(package.url_humanize(value)) == value
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/tests/test_url_normalize.py 
new/url-normalize-3.0.1/tests/test_url_normalize.py
--- old/url-normalize-3.0.0/tests/test_url_normalize.py 2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/tests/test_url_normalize.py 2026-09-22 
23:31:41.000000000 +0200
@@ -80,7 +80,7 @@
         ("http://example.com";, "http://example.com/";),
         ("http://example.com/?b&a";, "http://example.com/?b&a";),
         ("http://example.com/?q=%5c";, "http://example.com/?q=%5C";),
-        ("http://example.com/?q=%C7";, "http://example.com/?q=%EF%BF%BD";),
+        ("http://example.com/?q=%C7";, "http://example.com/?q=%C7";),
         ("http://example.com/?q=C%CC%A7";, "http://example.com/?q=%C3%87";),
         ("http://EXAMPLE.COM/";, "http://example.com/";),
         ("http://example.com/%7Ejane";, "http://example.com/~jane";),
@@ -161,3 +161,47 @@
     actual = url_normalize(url, default_scheme="http", 
default_domain="example.com")
 
     assert actual == expected
+
+
[email protected]("component", ["/{value}", "/?q={value}", "/#{value}"])
[email protected](
+    ("value", "expected"),
+    [
+        ("%ff", "%FF"),
+        ("%C7", "%C7"),
+        ("%E0%A4%A", "%E0%A4%25A"),
+        ("%FFe%CC%81", "%FF%C3%A9"),
+        ("e%CC%81", "%C3%A9"),
+        ("%EF%BF%BD", "%EF%BF%BD"),
+    ],
+)
+def test_url_normalize_preserves_invalid_utf8_octets(component, value, 
expected):
+    """Preserve invalid octets while normalizing valid Unicode to NFC."""
+    prefix = "https://example.com";
+    normalized = prefix + component.format(value=expected)
+    assert url_normalize(prefix + component.format(value=value)) == normalized
+    assert url_normalize(normalized) == normalized
+
+
[email protected]("prefix", [" ", "\t", "\r\n", "\u00a0"])
[email protected]("default_domain", [None, "default.example"])
[email protected](
+    ("value", "expected"),
+    [
+        ("http://example.com/path ", "http://example.com/path%20";),
+        ("//x:443/path ", "https://x/path%20";),
+        ("example.com/?q=x ", "https://example.com/?q=x%20";),
+        ("/path ", "/path%20"),
+        ("", ""),
+        ("-", "-"),
+    ],
+)
+def test_url_normalize_strips_leading_whitespace_before_defaults(
+    prefix, default_domain, value, expected
+):
+    """Ignore leading whitespace before default-domain and scheme selection."""
+    if default_domain and value.startswith("/") and not value.startswith("//"):
+        expected = f"https://{default_domain}{expected}";
+    actual = url_normalize(prefix + value, default_domain=default_domain)
+    assert actual == expected
+    assert url_normalize(actual, default_domain=default_domain) == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/__init__.py 
new/url-normalize-3.0.1/url_normalize/__init__.py
--- old/url-normalize-3.0.0/url_normalize/__init__.py   2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/__init__.py   2026-09-22 
23:31:41.000000000 +0200
@@ -9,6 +9,6 @@
 from .url_normalize import url_normalize
 
 __license__ = "MIT"
-__version__ = "3.0.0"
+__version__ = "3.0.1"
 
 __all__ = ["url_humanize", "url_normalize"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/cli.py 
new/url-normalize-3.0.1/url_normalize/cli.py
--- old/url-normalize-3.0.0/url_normalize/cli.py        2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/cli.py        2026-09-22 
23:31:41.000000000 +0200
@@ -27,7 +27,9 @@
         "-c",
         "--charset",
         default="utf-8",
-        help="The charset of the URL. Default: utf-8",
+        help=(
+            "Retained for compatibility. Unicode characters use UTF-8 percent 
encoding."
+        ),
     )
     parser.add_argument(
         "-s",
@@ -39,7 +41,10 @@
         "-f",
         "--filter-params",
         action="store_true",
-        help="Filter common tracking parameters.",
+        help=(
+            "Keep only allowlisted query parameters. "
+            "Unknown domains have an empty default allowlist."
+        ),
     )
     parser.add_argument(
         "-d",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/url-normalize-3.0.0/url_normalize/generic_url_cleanup.py 
new/url-normalize-3.0.1/url_normalize/generic_url_cleanup.py
--- old/url-normalize-3.0.0/url_normalize/generic_url_cleanup.py        
2026-04-25 02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/generic_url_cleanup.py        
2026-09-22 23:31:41.000000000 +0200
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+from .tools import quote, unquote
+
 
 def generic_url_cleanup(url: str) -> str:
     """Cleanup the URL from unnecessary data and convert to final form.
@@ -15,5 +17,11 @@
         string : update url
 
     """
-    url = url.replace("#!", "?_escaped_fragment_=")
-    return url.rstrip("&? ")
+    base, fragment_separator, fragment = url.partition("#")
+    path, _, query = base.partition("?")
+    query = "&".join(param for param in query.split("&") if param)
+    if fragment.startswith("!"):
+        payload = quote(unquote(fragment[1:]), safe="/")
+        query += ("&" if query else "") + f"_escaped_fragment_={payload}"
+        fragment_separator = fragment = ""
+    return path + (f"?{query}" if query else "") + fragment_separator + 
fragment
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/normalize_host.py 
new/url-normalize-3.0.1/url_normalize/normalize_host.py
--- old/url-normalize-3.0.0/url_normalize/normalize_host.py     2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/normalize_host.py     2026-09-22 
23:31:41.000000000 +0200
@@ -24,6 +24,9 @@
 
     """
     host = force_unicode(host, charset)
+    if host.startswith("[") and host.endswith("]"):
+        address, separator, zone = host.partition("%")
+        return address.lower() + separator + zone
     host = host.lower()
     host = host.strip(".")
 
@@ -31,8 +34,8 @@
     parts = host.split(".")
     try:
         # Process each label separately to handle mixed unicode/ascii domains
-        parts = [idna.encode(p, uts46=True).decode(charset) for p in parts if 
p]
+        parts = [idna.encode(p, uts46=True).decode("ascii") for p in parts if 
p]
         return ".".join(parts)
     except idna.IDNAError:
         # Fallback to direct encoding if IDNA2008 processing fails
-        return host.encode("idna").decode(charset)
+        return host.encode("idna").decode("ascii")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/normalize_path.py 
new/url-normalize-3.0.1/url_normalize/normalize_path.py
--- old/url-normalize-3.0.0/url_normalize/normalize_path.py     2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/normalize_path.py     2026-09-22 
23:31:41.000000000 +0200
@@ -2,7 +2,7 @@
 
 from __future__ import annotations
 
-from .tools import quote, unquote
+from .tools import RESERVED_CHARACTERS, normalize_component
 
 
 def normalize_path(path: str, scheme: str) -> str:
@@ -21,7 +21,7 @@
     # Only perform percent-encoding where it is essential.
     # Always use uppercase A-through-F characters when percent-encoding.
     # All portions of the URI must be utf-8 encoded NFC from Unicode strings
-    path = quote(unquote(path), "~:/#[]@!$&'()*+,;=")
+    path = normalize_component(path, "~:/[]@!$&'()*+,;=", RESERVED_CHARACTERS)
     # Prevent dot-segments appearing in non-relative URI paths.
     if scheme in {"", "http", "https", "ftp", "file"}:
         output: list[str] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/normalize_query.py 
new/url-normalize-3.0.1/url_normalize/normalize_query.py
--- old/url-normalize-3.0.0/url_normalize/normalize_query.py    2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/normalize_query.py    2026-09-22 
23:31:41.000000000 +0200
@@ -3,7 +3,7 @@
 from __future__ import annotations
 
 from .param_allowlist import get_allowed_params
-from .tools import quote, unquote
+from .tools import normalize_component
 
 QUERY_PARAM_SAFE_CHARS = "~:/?[]@!$'()*+,;"
 
@@ -23,7 +23,7 @@
     """
     if not param:
         return ""
-    return quote(unquote(param), QUERY_PARAM_SAFE_CHARS)
+    return normalize_component(param, QUERY_PARAM_SAFE_CHARS, preserve="+")
 
 
 def normalize_query(
@@ -48,17 +48,18 @@
     if not query:
         return ""
 
+    allowed_params = (
+        get_allowed_params(host, param_allowlist) if filter_params else None
+    )
     processed = []
     for param in query.split("&"):
         if not param:
             continue
-        key, _, value = param.partition("=")
+        key, separator, value = param.partition("=")
         key = process_query_param(key)
-        if filter_params:
-            allowed_params = get_allowed_params(host, param_allowlist)
-            if key not in allowed_params:
-                continue
+        if allowed_params is not None and key not in allowed_params:
+            continue
         value = process_query_param(value)
-        processed.append(f"{key}={value}" if value else key)
+        processed.append(f"{key}{separator}{value}")
 
     return "&".join(processed)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/url-normalize-3.0.0/url_normalize/normalize_userinfo.py 
new/url-normalize-3.0.1/url_normalize/normalize_userinfo.py
--- old/url-normalize-3.0.0/url_normalize/normalize_userinfo.py 2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/normalize_userinfo.py 2026-09-22 
23:31:41.000000000 +0200
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+from .tools import RESERVED_CHARACTERS, normalize_component
+
 
 def normalize_userinfo(userinfo: str) -> str:
     """Normalize userinfo part of the url.
@@ -15,4 +17,10 @@
     """
     if userinfo in ["@", ":@"]:
         return ""
-    return userinfo
+    separator = "@" if userinfo.endswith("@") else ""
+    return (
+        normalize_component(
+            userinfo.removesuffix("@"), "!$&'()*+,;=:", RESERVED_CHARACTERS
+        )
+        + separator
+    )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/param_allowlist.py 
new/url-normalize-3.0.1/url_normalize/param_allowlist.py
--- old/url-normalize-3.0.0/url_normalize/param_allowlist.py    2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/param_allowlist.py    2026-09-22 
23:31:41.000000000 +0200
@@ -3,6 +3,8 @@
 
 from __future__ import annotations
 
+from .normalize_host import normalize_host
+
 DEFAULT_ALLOWLIST = {
     "google.com": ["q", "ie"],
     "baidu.com": ["wd", "ie"],
@@ -11,6 +13,15 @@
 }
 
 
+def _normalize_domain(host: str) -> str:
+    """Canonicalize an allowlist domain without its port or www prefix."""
+    if host.startswith("[") and "]" in host:
+        host = host.partition("]")[0] + "]"
+    else:
+        host = host.partition(":")[0]
+    return normalize_host(host).removeprefix("www.")
+
+
 def get_allowed_params(
     host: str | None = None,
     allowlist: dict | list | None = None,
@@ -35,14 +46,20 @@
     if not host:
         return set()
 
-    # Normalize host by removing www and port
-    domain = host.lower()
-    domain = domain.removeprefix("www.")
-    domain = domain.split(":")[0]
+    domain = _normalize_domain(host)
 
     # Use default allowlist if none provided
     if allowlist is None:
         allowlist = DEFAULT_ALLOWLIST
 
-    # Return allowed parameters for the domain, or an empty set if not found
-    return set(allowlist.get(domain, []))
+    # Preserve exact canonical-key precedence, including an empty allowlist.
+    if domain in allowlist:
+        return set(allowlist[domain])
+    for key, params in allowlist.items():
+        try:
+            key_domain = _normalize_domain(key)
+        except UnicodeError:
+            continue
+        if key_domain == domain:
+            return set(params)
+    return set()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/url-normalize-3.0.0/url_normalize/provide_url_scheme.py 
new/url-normalize-3.0.1/url_normalize/provide_url_scheme.py
--- old/url-normalize-3.0.0/url_normalize/provide_url_scheme.py 2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/provide_url_scheme.py 2026-09-22 
23:31:41.000000000 +0200
@@ -2,7 +2,10 @@
 
 from __future__ import annotations
 
+import re
+
 from .normalize_scheme import DEFAULT_SCHEME
+from .tools import cleanup_url_input
 
 # Schemes that require authority component reconstruction with //
 AUTHORITY_SCHEMES = frozenset(["http", "https", "ftp", "ftps"])
@@ -19,14 +22,21 @@
         string : updated url with validated/attached scheme
 
     """
-    has_scheme = ":" in url[:7]
+    url = cleanup_url_input(url)
     is_universal_scheme = url.startswith("//")
     is_file_path = url == "-" or (url.startswith("/") and not 
is_universal_scheme)
     if not url or is_file_path:
         return url
-    if not has_scheme:
+    if is_universal_scheme:
+        return f"{default_scheme}:{url}"
+    if not re.match(r"[A-Za-z][A-Za-z0-9+.-]*:", url):
         return f"{default_scheme}://{url.lstrip('/')}"
     scheme_part, rest = url.split(":", 1)
+    # Dotted hosts and localhost followed by a numeric port are host inputs.
+    if ("." in scheme_part or scheme_part.lower() == "localhost") and re.match(
+        r"[0-9]+\s*(?:[/?#]|$)", rest
+    ):
+        return f"{default_scheme}://{url}"
     if scheme_part.lower() not in AUTHORITY_SCHEMES:
         # handle cases like tel:, mailto:, etc.
         return url
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/tools.py 
new/url-normalize-3.0.1/url_normalize/tools.py
--- old/url-normalize-3.0.0/url_normalize/tools.py      2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/tools.py      2026-09-22 
23:31:41.000000000 +0200
@@ -9,6 +9,15 @@
 from urllib.parse import unquote as unquote_orig
 from urllib.parse import urlsplit, urlunsplit
 
+RESERVED_CHARACTERS = ":/?#[]@!$&'()*+,;="
+
+
+def cleanup_url_input(url: str) -> str:
+    """Remove parser-ignored characters before URL classification."""
+    # Match urlsplit's C0/tab/newline cleanup, retaining Unicode whitespace 
support.
+    url = re.sub(r"^[\x00-\x20\s]+", "", url)
+    return url.replace("\t", "").replace("\r", "").replace("\n", "")
+
 
 class URL(NamedTuple):
     """URL components tuple.
@@ -36,9 +45,15 @@
         URL
 
     """
-    scheme, auth, path, query, fragment = urlsplit(url.strip())
-    match = re.search(r"([^@]*@)?([^:]*):?(.*)", auth)
-    (userinfo, host, port) = match.groups()  # type: ignore  # noqa: PGH003
+    scheme, auth, path, query, fragment = urlsplit(url)
+    userinfo, separator, host_port = auth.rstrip().rpartition("@")
+    userinfo += separator
+    if host_port.startswith("["):
+        host, _, port = host_port.partition("]")
+        host += "]"
+        port = port.removeprefix(":")
+    else:
+        host, _, port = host_port.partition(":")
     return URL(
         fragment=fragment,
         host=host,
@@ -93,10 +108,9 @@
         string : an unquoted and normalized string
 
     """
-    string = unquote_orig(string)
+    string = unquote_orig(string, errors="surrogateescape")
     string = force_unicode(string, charset)
-    encoded_str = unicodedata.normalize("NFC", string).encode(charset)
-    return encoded_str.decode(charset)
+    return unicodedata.normalize("NFC", string)
 
 
 def quote(string: str, safe: str = "/") -> str:
@@ -110,4 +124,14 @@
         string : quoted string
 
     """
-    return quote_orig(string, safe)
+    return quote_orig(string, safe, errors="surrogateescape")
+
+
+def normalize_component(string: str, safe: str, preserve: str) -> str:
+    """Normalize escapes and Unicode without decoding protected delimiters."""
+    protected = "|".join(f"%{ord(character):02X}" for character in preserve)
+    parts = re.split(f"({protected})", string, flags=re.IGNORECASE)
+    return "".join(
+        part.upper() if index % 2 else quote(unquote(part), safe)
+        for index, part in enumerate(parts)
+    )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/url_humanize.py 
new/url-normalize-3.0.1/url_normalize/url_humanize.py
--- old/url-normalize-3.0.0/url_normalize/url_humanize.py       2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/url_humanize.py       2026-09-22 
23:31:41.000000000 +0200
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import unicodedata
 from urllib.parse import unquote
 
 import idna
@@ -27,14 +28,22 @@
 
 def _replace_if_round_trips(url: URL, normalized: str, **changes: str) -> URL:
     candidate = url._replace(**changes)
-    if url_normalize(reconstruct_url(candidate)) == normalized:
-        return candidate
+    try:
+        if url_normalize(reconstruct_url(candidate)) == normalized:
+            return candidate
+    except ValueError:
+        # Invalid candidate syntax or Unicode must not reject the original URL.
+        return url
     return url
 
 
 def _safe_unquote(value: str) -> str:
     decoded = unquote(value)
-    if UNICODE_REPLACEMENT_CHARACTER in decoded:
+    if (
+        UNICODE_REPLACEMENT_CHARACTER in decoded
+        or "\\" in decoded
+        or any(unicodedata.category(character) == "Cc" for character in 
decoded)
+    ):
         return value
     return decoded
 
@@ -52,8 +61,10 @@
 ) -> tuple[URL, list[str]]:
     candidate_parts = [*parts]
     candidate_parts[index] = part
-    candidate = url._replace(query="&".join(candidate_parts))
-    if url_normalize(reconstruct_url(candidate)) == normalized:
+    candidate = _replace_if_round_trips(
+        url, normalized, query="&".join(candidate_parts)
+    )
+    if candidate is not url:
         return candidate, candidate_parts
     return url, parts
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/url_normalize/url_normalize.py 
new/url-normalize-3.0.1/url_normalize/url_normalize.py
--- old/url-normalize-3.0.0/url_normalize/url_normalize.py      2026-04-25 
02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/url_normalize/url_normalize.py      2026-09-22 
23:31:41.000000000 +0200
@@ -18,7 +18,7 @@
 from .normalize_userinfo import normalize_userinfo
 from .provide_url_domain import provide_url_domain
 from .provide_url_scheme import provide_url_scheme
-from .tools import deconstruct_url, reconstruct_url
+from .tools import cleanup_url_input, deconstruct_url, reconstruct_url
 
 
 def url_normalize(  # noqa: PLR0913
@@ -38,12 +38,12 @@
     browsers handle data entered by the user:
 
     >>> url_normalize('http://de.wikipedia.org/wiki/Elf (Begriffsklärung)')
-    'http://de.wikipedia.org/wiki/Elf%20%28Begriffskl%C3%A4rung%29'
+    'http://de.wikipedia.org/wiki/Elf%20(Begriffskl%C3%A4rung)'
 
     Params:
         url : str | None : URL to normalize
         charset : str : optional
-            The target charset for the URL if the url was given as unicode 
string
+            Retained for compatibility. Unicode characters use UTF-8 percent 
encoding.
         default_scheme : str : default scheme to use if none present
         default_domain : str | None : optional
             Default domain to use for absolute paths (starting with '/')
@@ -56,6 +56,8 @@
         str | None : a normalized url
 
     """
+    if url is not None:
+        url = cleanup_url_input(url)
     if not url:
         return url
     url = provide_url_domain(url, default_domain)
@@ -63,9 +65,11 @@
     url = generic_url_cleanup(url)
     url_elements = deconstruct_url(url)
     url_elements = url_elements._replace(
+        host=normalize_host(url_elements.host, charset)
+    )
+    url_elements = url_elements._replace(
         scheme=normalize_scheme(url_elements.scheme),
         userinfo=normalize_userinfo(url_elements.userinfo),
-        host=normalize_host(url_elements.host, charset),
         query=normalize_query(
             url_elements.query,
             host=url_elements.host,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/url-normalize-3.0.0/uv.lock 
new/url-normalize-3.0.1/uv.lock
--- old/url-normalize-3.0.0/uv.lock     2026-04-25 02:23:36.000000000 +0200
+++ new/url-normalize-3.0.1/uv.lock     2026-09-22 23:31:41.000000000 +0200
@@ -632,7 +632,7 @@
 
 [[package]]
 name = "url-normalize"
-version = "3.0.0"
+version = "3.0.1"
 source = { editable = "." }
 dependencies = [
     { name = "idna" },

Reply via email to