uritools 6.1.2__tar.gz → 6.1.4__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 (26) hide show
  1. {uritools-6.1.2 → uritools-6.1.4}/CHANGELOG.rst +24 -0
  2. {uritools-6.1.2/src/uritools.egg-info → uritools-6.1.4}/PKG-INFO +2 -1
  3. uritools-6.1.4/docs/conf.py +18 -0
  4. {uritools-6.1.2 → uritools-6.1.4}/docs/index.rst +25 -1
  5. {uritools-6.1.2 → uritools-6.1.4}/pyproject.toml +5 -2
  6. {uritools-6.1.2 → uritools-6.1.4}/src/uritools/__init__.py +44 -25
  7. {uritools-6.1.2 → uritools-6.1.4}/src/uritools/__init__.pyi +3 -4
  8. {uritools-6.1.2 → uritools-6.1.4/src/uritools.egg-info}/PKG-INFO +2 -1
  9. {uritools-6.1.2 → uritools-6.1.4}/tests/test_compose.py +76 -9
  10. {uritools-6.1.2 → uritools-6.1.4}/tests/test_defrag.py +13 -1
  11. {uritools-6.1.2 → uritools-6.1.4}/tests/test_encoding.py +1 -1
  12. {uritools-6.1.2 → uritools-6.1.4}/tests/test_join.py +8 -0
  13. {uritools-6.1.2 → uritools-6.1.4}/tests/test_split.py +33 -1
  14. {uritools-6.1.2 → uritools-6.1.4}/tests/test_unsplit.py +14 -0
  15. {uritools-6.1.2 → uritools-6.1.4}/tox.ini +8 -11
  16. uritools-6.1.2/docs/conf.py +0 -36
  17. {uritools-6.1.2 → uritools-6.1.4}/LICENSE +0 -0
  18. {uritools-6.1.2 → uritools-6.1.4}/MANIFEST.in +0 -0
  19. {uritools-6.1.2 → uritools-6.1.4}/README.rst +0 -0
  20. {uritools-6.1.2 → uritools-6.1.4}/setup.cfg +0 -0
  21. {uritools-6.1.2 → uritools-6.1.4}/src/uritools/py.typed +0 -0
  22. {uritools-6.1.2 → uritools-6.1.4}/src/uritools.egg-info/SOURCES.txt +0 -0
  23. {uritools-6.1.2 → uritools-6.1.4}/src/uritools.egg-info/dependency_links.txt +0 -0
  24. {uritools-6.1.2 → uritools-6.1.4}/src/uritools.egg-info/top_level.txt +0 -0
  25. {uritools-6.1.2 → uritools-6.1.4}/tests/__init__.py +0 -0
  26. {uritools-6.1.2 → uritools-6.1.4}/tests/test_classify.py +0 -0
@@ -1,3 +1,27 @@
1
+ v6.1.4 (2026-11-04)
2
+ ===================
3
+
4
+ - Fix newline handling in ``uricompose()`` and ``urisplit()``.
5
+
6
+ - Compare schemes case-insensitively in non-strict ``urijoin()``.
7
+
8
+ - Recognize an uppercase ``IPvFuture`` version flag.
9
+
10
+ - Various documentation improvements.
11
+
12
+ - Minor unit test improvements.
13
+
14
+ - Update build environment.
15
+
16
+
17
+ v6.1.3 (2026-07-24)
18
+ ===================
19
+
20
+ - Minor style improvements to keep ``ruff`` happy.
21
+
22
+ - Update build environment.
23
+
24
+
1
25
  v6.1.2 (2026-05-22)
2
26
  ===================
3
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uritools
3
- Version: 6.1.2
3
+ Version: 6.1.4
4
4
  Summary: URI parsing, classification and composition
5
5
  Author-email: Thomas Kemmer <tkemmer@computer.org>
6
6
  Maintainer-email: Thomas Kemmer <tkemmer@computer.org>
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
24
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
26
  Requires-Python: >=3.10
26
27
  Description-Content-Type: text/x-rst
@@ -0,0 +1,18 @@
1
+ import importlib.metadata
2
+
3
+ project = "uritools"
4
+ copyright = "2014-2026 Thomas Kemmer"
5
+ release = importlib.metadata.version(project)
6
+ version = ".".join(release.split(".")[:2])
7
+
8
+ extensions = [
9
+ "sphinx.ext.autodoc",
10
+ "sphinx.ext.coverage",
11
+ "sphinx.ext.doctest",
12
+ "sphinx.ext.intersphinx",
13
+ "sphinx.ext.todo",
14
+ ]
15
+ exclude_patterns = ["_build"]
16
+ root_doc = "index"
17
+ html_theme = "classic"
18
+ intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
@@ -102,6 +102,9 @@ URI Composition
102
102
 
103
103
  All components may be specified as either Unicode strings, which
104
104
  will be encoded according to `encoding`, or :class:`bytes` objects.
105
+ A `host` given as a Unicode string is always encoded as UTF-8,
106
+ regardless of `encoding`, as :rfc:`3986#section-3.2.2` requires
107
+ for non-ASCII registered names.
105
108
 
106
109
  `authority` may also be passed a three-item sequence specifying
107
110
  userinfo, host and port subcomponents. If both `authority` and any
@@ -118,10 +121,15 @@ URI Composition
118
121
  .. autofunction:: urijoin
119
122
 
120
123
  If `strict` is :const:`False`, a scheme in the reference is
121
- ignored if it is identical to the base URI's scheme.
124
+ ignored if it matches the base URI's scheme, ignoring case.
122
125
 
123
126
  .. autofunction:: uriunsplit
124
127
 
128
+ The type of the returned URI reference is that of the `path`
129
+ element. All other elements must be of the same type, or
130
+ :const:`None`; mixing :class:`str` and :class:`bytes` elements
131
+ raises :exc:`TypeError`.
132
+
125
133
 
126
134
  URI Decomposition
127
135
  =================
@@ -187,6 +195,11 @@ URI Encoding
187
195
  percent-encodings and decode `uristring` using the codec registered
188
196
  for `encoding`, returning a Unicode string.
189
197
 
198
+ `errors` specifies how decoding errors are handled, as for
199
+ :meth:`bytes.decode`. A Unicode `uristring` is first encoded as
200
+ ASCII if `encoding` is :const:`None`, and using `encoding`
201
+ otherwise, with the same `errors` setting.
202
+
190
203
  .. autofunction:: uriencode
191
204
 
192
205
  If `uristring` is a :class:`bytes` object, replace any characters
@@ -195,6 +208,12 @@ URI Encoding
195
208
  Otherwise, encode `uristring` using the codec registered for
196
209
  `encoding` before replacing any percent encodings.
197
210
 
211
+ `safe` specifies additional characters that are left unencoded, as
212
+ a string of ASCII characters or a :class:`bytes` object, for
213
+ example ``"/"`` to keep path separators. `encoding` and `errors`
214
+ are passed to :meth:`str.encode` when `uristring` is a Unicode
215
+ string; they are ignored for :class:`bytes` input.
216
+
198
217
 
199
218
  Structured Parse Results
200
219
  ========================
@@ -211,6 +230,11 @@ convenience methods.
211
230
  .. autoclass:: SplitResult
212
231
  :members:
213
232
 
233
+ :meth:`transform` expects `ref` to be of the same type as the
234
+ components of the result, i.e. a string or a :class:`bytes`
235
+ object; :func:`urijoin` converts mixed `base` and `ref` types
236
+ itself.
237
+
214
238
 
215
239
  Character Constants
216
240
  ===================
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.12",
28
28
  "Programming Language :: Python :: 3.13",
29
29
  "Programming Language :: Python :: 3.14",
30
+ "Programming Language :: Python :: 3.15",
30
31
  "Topic :: Software Development :: Libraries :: Python Modules",
31
32
  ]
32
33
 
@@ -49,5 +50,7 @@ version = {attr = "uritools.__version__"}
49
50
 
50
51
  [tool.pyright]
51
52
  typeCheckingMode = "standard"
52
- reportAttributeAccessIssue = "information"
53
- reportOptionalMemberAccess = "warning"
53
+ reportUnnecessaryTypeIgnoreComment = "error"
54
+
55
+ [tool.ruff.lint]
56
+ ignore = ["DTZ005", "SIM102", "UP031"]
@@ -33,7 +33,7 @@ __all__ = (
33
33
  "uriunsplit",
34
34
  )
35
35
 
36
- __version__ = "6.1.2"
36
+ __version__ = "6.1.4"
37
37
 
38
38
 
39
39
  # RFC 3986 2.2. Reserved Characters
@@ -74,7 +74,7 @@ def uriencode(uristring, safe="", encoding="utf-8", errors="strict"):
74
74
  """Encode a URI string or string component."""
75
75
  if not isinstance(uristring, bytes):
76
76
  uristring = uristring.encode(encoding, errors)
77
- if not isinstance(safe, bytes):
77
+ if isinstance(safe, str):
78
78
  safe = safe.encode("ascii")
79
79
  # FIXME: though unlikely, _encoded may grow without bounds if arbitrary
80
80
  # safe values are passed by a caller - consider using @functools.lru_cache
@@ -84,7 +84,7 @@ def uriencode(uristring, safe="", encoding="utf-8", errors="strict"):
84
84
  except KeyError:
85
85
  encoded = _encoded[b""][:]
86
86
  for i in safe:
87
- encoded[i] = bytes([i]) # type: ignore
87
+ encoded[i] = bytes([i])
88
88
  _encoded[safe] = encoded
89
89
  return b"".join(map(encoded.__getitem__, uristring))
90
90
 
@@ -141,6 +141,21 @@ class SplitResult(
141
141
 
142
142
  __slots__ = () # prevent creation of instance dictionary
143
143
 
144
+ # regex and delimiter constants, defined by SplitResultBytes and SplitResultString
145
+ _RE: re.Pattern
146
+ _COLON: str | bytes
147
+ _SLASH: str | bytes
148
+ _QUEST: str | bytes
149
+ _HASH: str | bytes
150
+ _LBRACKET: str | bytes
151
+ _RBRACKET: str | bytes
152
+ _AT: str | bytes
153
+ _DOT: str | bytes
154
+ _DOTDOT: str | bytes
155
+ _EMPTY: str | bytes
156
+ _EQ: str | bytes
157
+ _DIGITS: str | bytes
158
+
144
159
  @property
145
160
  def userinfo(self):
146
161
  authority = self.authority
@@ -260,6 +275,10 @@ class SplitResult(
260
275
  else:
261
276
  return ipaddress.IPv4Address(host)
262
277
  except ValueError:
278
+ # RFC 3986 3.2.2. Host: Non-ASCII characters must first be
279
+ # encoded according to UTF-8 [STD63], and then each octet
280
+ # of the corresponding UTF-8 sequence must be
281
+ # percent-encoded to be represented as URI characters.
263
282
  return uridecode(host, "utf-8", errors).lower()
264
283
 
265
284
  def getport(self, default=None):
@@ -381,8 +400,11 @@ class SplitResult(
381
400
  """
382
401
  scheme, authority, path, query, fragment = self._match(ref).groups()
383
402
 
384
- # RFC 3986 5.2.2. Transform References
385
- if scheme is not None and (strict or scheme != self.scheme):
403
+ # RFC 3986 5.2.2. Transform References; scheme names are
404
+ # case-insensitive (RFC 3986 3.1)
405
+ if scheme is not None and (
406
+ strict or self.scheme is None or scheme.lower() != self.scheme.lower()
407
+ ):
386
408
  path = self.__remove_dot_segments(path)
387
409
  elif authority is not None:
388
410
  scheme = self.scheme
@@ -402,6 +424,10 @@ class SplitResult(
402
424
  path = self.__remove_dot_segments(self.__merge(path))
403
425
  return type(self)(scheme, authority, path, query, fragment)
404
426
 
427
+ @classmethod
428
+ def _match(cls, ref):
429
+ return cls._RE.match(ref)
430
+
405
431
  def __merge(self, path):
406
432
  # RFC 3986 5.2.3. Merge Paths
407
433
  if self.authority is not None and not self.path:
@@ -451,8 +477,8 @@ class SplitResult(
451
477
  # not supported".
452
478
  if isinstance(address, bytes):
453
479
  address = address.decode("ascii")
454
- if address.startswith("v"):
455
- raise ValueError("address mechanism not supported")
480
+ if address.startswith(("v", "V")):
481
+ raise ValueError("Address mechanism not supported")
456
482
  return ipaddress.IPv6Address(address)
457
483
 
458
484
 
@@ -469,13 +495,9 @@ class SplitResultBytes(SplitResult):
469
495
  (?:\?([^#]*))? # query
470
496
  (?:\#(.*))? # fragment
471
497
  """,
472
- flags=re.VERBOSE,
498
+ flags=re.VERBOSE | re.DOTALL,
473
499
  )
474
500
 
475
- @classmethod
476
- def _match(cls, ref):
477
- return cls._RE.match(ref)
478
-
479
501
  # RFC 3986 2.2 gen-delims
480
502
  _COLON, _SLASH, _QUEST, _HASH, _LBRACKET, _RBRACKET, _AT = (
481
503
  b":",
@@ -508,13 +530,9 @@ class SplitResultString(SplitResult):
508
530
  (?:\?([^#]*))? # query
509
531
  (?:\#(.*))? # fragment
510
532
  """,
511
- flags=re.VERBOSE,
533
+ flags=re.VERBOSE | re.DOTALL,
512
534
  )
513
535
 
514
- @classmethod
515
- def _match(cls, ref):
516
- return cls._RE.match(ref)
517
-
518
536
  # RFC 3986 2.2 gen-delims
519
537
  _COLON, _SLASH, _QUEST, _HASH, _LBRACKET, _RBRACKET, _AT = (
520
538
  ":",
@@ -614,11 +632,11 @@ def issamedoc(uristring):
614
632
 
615
633
 
616
634
  # RFC 3986 3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
617
- _SCHEME_RE = re.compile(b"^[A-Za-z][A-Za-z0-9+.-]*$")
635
+ _SCHEME_RE = re.compile(b"[A-Za-z][A-Za-z0-9+.-]*")
618
636
 
619
637
  # RFC 3986 3.2: authority = [ userinfo "@" ] host [ ":" port ]
620
- _AUTHORITY_RE_BYTES = re.compile(b"^(?:(.*)@)?(.*?)(?::([0-9]*))?$")
621
- _AUTHORITY_RE_STR = re.compile("^(?:(.*)@)?(.*?)(?::([0-9]*))?$")
638
+ _AUTHORITY_RE_BYTES = re.compile(b"(?:(.*)@)?(.*?)(?::([0-9]*))?", re.DOTALL)
639
+ _AUTHORITY_RE_STR = re.compile("(?:(.*)@)?(.*?)(?::([0-9]*))?", re.DOTALL)
622
640
 
623
641
  # safe component characters
624
642
  _SAFE_USERINFO = SUB_DELIMS + ":"
@@ -629,7 +647,7 @@ _SAFE_FRAGMENT = SUB_DELIMS + ":@/?"
629
647
 
630
648
 
631
649
  def _scheme(scheme):
632
- if _SCHEME_RE.match(scheme):
650
+ if _SCHEME_RE.fullmatch(scheme):
633
651
  return scheme.lower()
634
652
  else:
635
653
  raise ValueError("Invalid scheme component")
@@ -664,7 +682,7 @@ def _authority(userinfo, host, port, encoding):
664
682
 
665
683
 
666
684
  def _ip_literal(address):
667
- if address.startswith("v"):
685
+ if address.startswith(("v", "V")):
668
686
  raise ValueError("Address mechanism not supported")
669
687
  else:
670
688
  return b"[" + ipaddress.IPv6Address(address).compressed.encode() + b"]"
@@ -755,9 +773,9 @@ def uricompose(
755
773
  if authority is None:
756
774
  authority = (None, None, None)
757
775
  elif isinstance(authority, bytes):
758
- authority = _AUTHORITY_RE_BYTES.match(authority).groups()
776
+ authority = _AUTHORITY_RE_BYTES.fullmatch(authority).groups() # type: ignore
759
777
  elif isinstance(authority, str):
760
- authority = _AUTHORITY_RE_STR.match(authority).groups()
778
+ authority = _AUTHORITY_RE_STR.fullmatch(authority).groups() # type: ignore
761
779
  elif not isinstance(authority, collections.abc.Sequence):
762
780
  raise TypeError("Invalid authority type")
763
781
  elif len(authority) != 3:
@@ -817,4 +835,5 @@ def uricompose(
817
835
  fragment = uriencode(fragment, _SAFE_FRAGMENT, encoding)
818
836
 
819
837
  # return URI reference as `str`
820
- return uriunsplit((scheme, authority, path, query, fragment)).decode()
838
+ parts = (scheme, authority, path, query, fragment)
839
+ return uriunsplit(parts).decode() # type: ignore
@@ -1,9 +1,8 @@
1
1
  import ipaddress
2
2
  from collections.abc import Iterable, Mapping, Sequence
3
- from typing import Any, AnyStr, Generic, NamedTuple, overload
4
- from typing_extensions import TypeAlias
3
+ from typing import Any, AnyStr, Generic, NamedTuple, TypeAlias, overload
5
4
 
6
- __all__ = [
5
+ __all__ = (
7
6
  "GEN_DELIMS",
8
7
  "RESERVED",
9
8
  "SUB_DELIMS",
@@ -21,7 +20,7 @@ __all__ = [
21
20
  "urijoin",
22
21
  "urisplit",
23
22
  "uriunsplit",
24
- ]
23
+ )
25
24
  __version__: str
26
25
 
27
26
  GEN_DELIMS: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uritools
3
- Version: 6.1.2
3
+ Version: 6.1.4
4
4
  Summary: URI parsing, classification and composition
5
5
  Author-email: Thomas Kemmer <tkemmer@computer.org>
6
6
  Maintainer-email: Thomas Kemmer <tkemmer@computer.org>
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
24
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
26
  Requires-Python: >=3.10
26
27
  Description-Content-Type: text/x-rst
@@ -37,7 +37,7 @@ class ComposeTest(unittest.TestCase):
37
37
  for uri, scheme in cases:
38
38
  self.check(uri, scheme=scheme)
39
39
  # invalid scheme
40
- for scheme in ("", "foo:", "\xf6lk\xfcrbis"):
40
+ for scheme in ("", "foo:", "foo\n", b"foo\n", "\xf6lk\xfcrbis"):
41
41
  with self.assertRaises(ValueError, msg="scheme=%r" % scheme):
42
42
  uricompose(scheme=scheme)
43
43
 
@@ -62,6 +62,12 @@ class ComposeTest(unittest.TestCase):
62
62
  ("//user@[::1]:42", b"user@[::1]:42"),
63
63
  ("//user:c2VjcmV0@example.com", "user:c2VjcmV0@example.com"),
64
64
  ("//user:c2VjcmV0@example.com", b"user:c2VjcmV0@example.com"),
65
+ ("//foo%0Abar", "foo\nbar"),
66
+ ("//foo%0Abar", b"foo\nbar"),
67
+ ("//a%0Ab@example.com", "a\nb@example.com"),
68
+ ("//a%0Ab@example.com", b"a\nb@example.com"),
69
+ ("//example.com%3A42%0A", "example.com:42\n"),
70
+ ("//example.com%3A42%0A", b"example.com:42\n"),
65
71
  ]
66
72
  for uri, authority in cases:
67
73
  self.check(uri, authority=authority)
@@ -120,11 +126,15 @@ class ComposeTest(unittest.TestCase):
120
126
  with self.assertRaises(TypeError, msg="host=%r" % host):
121
127
  uricompose(host=host) # type: ignore
122
128
  # invalid host ip-literal
123
- for host in ("[foo]", "[v1.x]"):
129
+ for host in ("[foo]", "[v1.x]", "[V1.x]"):
124
130
  with self.assertRaises(ValueError, msg="host=%r" % host):
125
131
  uricompose(authority=[None, host, None])
126
132
  with self.assertRaises(ValueError, msg="host=%r" % host):
127
133
  uricompose(host=host)
134
+ # RFC 3986 3.2.2: the IPvFuture version flag is case-insensitive
135
+ for host in ("[v1.x]", "[V1.x]", b"[v1.x]", b"[V1.x]"):
136
+ with self.assertRaisesRegex(ValueError, "mechanism not supported"):
137
+ uricompose(host=host)
128
138
  # invalid port value
129
139
  for port in (-1, "foo", 3.14):
130
140
  with self.assertRaises(ValueError, msg="port=%r" % port):
@@ -138,6 +148,25 @@ class ComposeTest(unittest.TestCase):
138
148
  ("//user@example.com:42", "", "user", "example.com", 42),
139
149
  ("//user@example.com:42", "example.com", "user", None, 42),
140
150
  ("//user@example.com:42", "user@:42", None, "example.com", None),
151
+ ("//user@example.com:42", b"", b"user", b"example.com", b"42"),
152
+ ("//user@example.com:42", b"example.com", b"user", None, 42),
153
+ ("//user@example.com:42", b"user@:42", None, b"example.com", None),
154
+ ("//user@example.com:42", b"user@:42", None, "example.com", None),
155
+ ("//user@example.com:42", "user@:42", None, b"example.com", None),
156
+ (
157
+ "//user@example.com:42",
158
+ b"nobody@localhost:80",
159
+ "user",
160
+ b"example.com",
161
+ "42",
162
+ ),
163
+ (
164
+ "//user@example.com:42",
165
+ "nobody@localhost:80",
166
+ b"user",
167
+ "example.com",
168
+ b"42",
169
+ ),
141
170
  ]
142
171
  for uri, authority, userinfo, host, port in cases:
143
172
  self.check(
@@ -212,13 +241,13 @@ class ComposeTest(unittest.TestCase):
212
241
  ("?name=foo&type=bar", od([("name", "foo"), ("type", "bar")])),
213
242
  ("?name=foo&name=bar", [("name", "foo"), ("name", "bar")]),
214
243
  ("?name=foo&name=bar", {"name": ["foo", "bar"]}),
215
- ("?name=a/b/c", dict(name="a/b/c")),
216
- ("?name=a:b:c", dict(name="a:b:c")),
217
- ("?name=a?b?c", dict(name="a?b?c")),
218
- ("?name=a@b@c", dict(name="a@b@c")),
219
- ("?name=a;b;c", dict(name="a;b;c")),
220
- ("?name=a%23b%23c", dict(name="a#b#c")),
221
- ("?name=a%26b%26c", dict(name="a&b&c")),
244
+ ("?name=a/b/c", {"name": "a/b/c"}),
245
+ ("?name=a:b:c", {"name": "a:b:c"}),
246
+ ("?name=a?b?c", {"name": "a?b?c"}),
247
+ ("?name=a@b@c", {"name": "a@b@c"}),
248
+ ("?name=a;b;c", {"name": "a;b;c"}),
249
+ ("?name=a%23b%23c", {"name": "a#b#c"}),
250
+ ("?name=a%26b%26c", {"name": "a&b&c"}),
222
251
  ]
223
252
  for uri, query in cases:
224
253
  self.check(uri, query=query)
@@ -236,3 +265,41 @@ class ComposeTest(unittest.TestCase):
236
265
  ]
237
266
  for sep, uri, query in cases:
238
267
  self.check(uri, query=query, querysep=sep)
268
+
269
+ def test_encoding(self):
270
+ cases = [
271
+ ("//%E4@example.com", {"userinfo": "\xe4", "host": "example.com"}),
272
+ ("/%E4", {"path": "/\xe4"}),
273
+ ("?name=%E4", {"query": "name=\xe4"}),
274
+ ("?name=%E4", {"query": {"name": "\xe4"}}),
275
+ ("?name=%E4", {"query": [("name", "\xe4")]}),
276
+ ("#%E4", {"fragment": "\xe4"}),
277
+ ]
278
+ for uri, kwargs in cases:
279
+ self.check(uri, encoding="latin-1", **kwargs)
280
+ # host is always encoded as UTF-8
281
+ self.check("//%C3%A4", host="\xe4", encoding="latin-1")
282
+ # bytes components are not affected by encoding
283
+ self.check("/%C3%A4", path=b"/\xc3\xa4", encoding="latin-1")
284
+ # characters not representable in encoding
285
+ with self.assertRaises(UnicodeEncodeError):
286
+ uricompose(path="/\u20ac", encoding="latin-1")
287
+
288
+ def test_fragment(self):
289
+ cases = [
290
+ ("", None),
291
+ ("#", ""),
292
+ ("#", b""),
293
+ ("#nose", "nose"),
294
+ ("#nose", b"nose"),
295
+ ("#foo%20bar", "foo bar"),
296
+ ("#foo%20bar", b"foo bar"),
297
+ ("#a/b?c:d@e", "a/b?c:d@e"),
298
+ ("#a/b?c:d@e", b"a/b?c:d@e"),
299
+ ("#a%23b", "a#b"),
300
+ ("#a%23b", b"a#b"),
301
+ ("#%C3%A4", "\xe4"),
302
+ ("#%C3%A4", b"\xc3\xa4"),
303
+ ]
304
+ for uri, fragment in cases:
305
+ self.check(uri, fragment=fragment)
@@ -23,7 +23,7 @@ class DefragTest(unittest.TestCase):
23
23
  def encode(s):
24
24
  return s.encode() if s is not None else None
25
25
 
26
- cases += list(map(encode, case) for case in cases)
26
+ cases += [map(encode, case) for case in cases]
27
27
 
28
28
  for uri, base, fragment in cases:
29
29
  defrag = uridefrag(uri)
@@ -42,6 +42,18 @@ class DefragTest(unittest.TestCase):
42
42
  self.assertEqual(uridefrag("#foo%20bar").getfragment(), "foo bar")
43
43
  self.assertEqual(uridefrag(b"#foo%20bar").getfragment(), "foo bar")
44
44
 
45
+ def test_getfragment_default(self):
46
+ self.assertEqual(uridefrag("").getfragment("baz"), "baz")
47
+ self.assertEqual(uridefrag(b"").getfragment("baz"), "baz")
48
+ self.assertEqual(uridefrag("").getfragment(default="baz"), "baz")
49
+ self.assertEqual(uridefrag(b"").getfragment(default="baz"), "baz")
50
+ self.assertEqual(uridefrag("#").getfragment("baz"), "")
51
+ self.assertEqual(uridefrag(b"#").getfragment("baz"), "")
52
+ self.assertEqual(uridefrag("#foo").getfragment("baz"), "foo")
53
+ self.assertEqual(uridefrag(b"#foo").getfragment("baz"), "foo")
54
+ self.assertEqual(uridefrag("#foo%20bar").getfragment("baz"), "foo bar")
55
+ self.assertEqual(uridefrag(b"#foo%20bar").getfragment("baz"), "foo bar")
56
+
45
57
  def test_getfragment_encoding_none(self):
46
58
  self.assertEqual(uridefrag("").getfragment(encoding=None), None)
47
59
  self.assertEqual(uridefrag(b"").getfragment(encoding=None), None)
@@ -8,7 +8,7 @@ class EncodingTest(unittest.TestCase):
8
8
  self.assertEqual(uriencode(decoded, safe, encoding), encoded)
9
9
  self.assertEqual(uridecode(encoded, encoding), decoded)
10
10
  # swap bytes/string types
11
- self.assertEqual(uriencode(decoded.encode(encoding), safe, encoding), encoded) # noqa
11
+ self.assertEqual(uriencode(decoded.encode(encoding), safe, encoding), encoded)
12
12
  self.assertEqual(uridecode(encoded.decode("ascii"), encoding), decoded)
13
13
 
14
14
  def test_encoding(self):
@@ -66,6 +66,14 @@ class JoinTest(unittest.TestCase):
66
66
  self.check(self.RFC3986_BASE, "http:g", "http:g", True)
67
67
  self.check(self.RFC3986_BASE, "http:g", "http://a/b/c/g", False)
68
68
 
69
+ def test_scheme_case(self):
70
+ # RFC 3986 3.1: scheme names are case-insensitive
71
+ self.check(self.RFC3986_BASE, "HTTP:g", "http://a/b/c/g")
72
+ self.check(self.RFC3986_BASE, "HTTP:g", "HTTP:g", strict=True)
73
+ self.check("HTTP://a/b/c/d;p?q", "http:g", "HTTP://a/b/c/g")
74
+ self.check("HTTP://a/b/c/d;p?q", "http:g", "http:g", strict=True)
75
+ self.check("g:h", "G:h", "g:h")
76
+
69
77
  def test_rfc3986_merge(self):
70
78
  """urijoin test cases for RFC 3986 5.2.3. Merge Paths"""
71
79
  self.check("http://a", "b", "http://a/b")
@@ -48,6 +48,8 @@ class SplitTest(unittest.TestCase):
48
48
  ("//?", (None, "", "", "", None)),
49
49
  ("//#", (None, "", "", None, "")),
50
50
  ("//?#", (None, "", "", "", "")),
51
+ ("#foo\nbar", (None, None, "", None, "foo\nbar")),
52
+ ("?foo\nbar#baz\n", (None, None, "", "foo\nbar", "baz\n")),
51
53
  ("?", (None, None, "", "", None)),
52
54
  ("??", (None, None, "", "?", None)),
53
55
  ("?#", (None, None, "", "", "")),
@@ -77,6 +79,8 @@ class SplitTest(unittest.TestCase):
77
79
  (b"?:", (None, None, b"", b":", None)),
78
80
  (b"#:", (None, None, b"", None, b":")),
79
81
  (b"_:", (None, None, b"_:", None, None)),
82
+ (b"#foo\nbar", (None, None, b"", None, b"foo\nbar")),
83
+ (b"?foo\nbar#baz\n", (None, None, b"", b"foo\nbar", b"baz\n")),
80
84
  ]
81
85
  for uri, parts in cases:
82
86
  self.check(uri, parts)
@@ -281,9 +285,31 @@ class SplitTest(unittest.TestCase):
281
285
  ("nobody", "localhost", 42),
282
286
  ("userinfo", "test.python.org", 5432),
283
287
  ),
288
+ (
289
+ "urn:example:animal:ferret:nose",
290
+ (b"nobody", b"localhost", 42),
291
+ (b"nobody", b"localhost", 42),
292
+ ),
293
+ ("file:///", (b"nobody", b"localhost", 42), (b"nobody", b"localhost", 42)),
294
+ (
295
+ "http://Test.python.org/foo/",
296
+ (b"nobody", b"localhost", 42),
297
+ (b"nobody", "test.python.org", 42),
298
+ ),
299
+ (
300
+ "http://@Test.python.org:5432/foo/",
301
+ (b"nobody", b"localhost", 42),
302
+ ("", "test.python.org", 5432),
303
+ ),
304
+ (
305
+ "http://userinfo@Test.python.org:5432/foo/",
306
+ (b"nobody", b"localhost", 42),
307
+ ("userinfo", "test.python.org", 5432),
308
+ ),
284
309
  ]
285
310
  for uri, default, authority in cases:
286
311
  self.assertEqual(urisplit(uri).getauthority(default), authority)
312
+ self.assertEqual(urisplit(uri.encode()).getauthority(default), authority)
287
313
  for uri in ["http://[::1/", "http://::1]/"]:
288
314
  with self.assertRaises(ValueError, msg="%r" % uri):
289
315
  urisplit(uri).getauthority()
@@ -314,7 +340,7 @@ class SplitTest(unittest.TestCase):
314
340
  def test_getport(self):
315
341
  for uri in ["foo://bar", "foo://bar:", "foo://bar/", "foo://bar:/"]:
316
342
  result = urisplit(uri)
317
- if result.authority.endswith(":"):
343
+ if result.authority.endswith(":"): # type: ignore
318
344
  self.assertEqual(result.port, "")
319
345
  else:
320
346
  self.assertEqual(result.port, None)
@@ -565,6 +591,12 @@ class SplitTest(unittest.TestCase):
565
591
  urisplit(uri).gethost()
566
592
  with self.assertRaises(ValueError, msg="%r" % uri.encode("ascii")):
567
593
  urisplit(uri.encode("ascii")).gethost()
594
+ # RFC 3986 3.2.2: the IPvFuture version flag is case-insensitive
595
+ for uri in ["http://[v7.future]/", "http://[V7.future]/"]:
596
+ with self.assertRaisesRegex(ValueError, "mechanism not supported"):
597
+ urisplit(uri).gethost()
598
+ with self.assertRaisesRegex(ValueError, "mechanism not supported"):
599
+ urisplit(uri.encode("ascii")).gethost()
568
600
 
569
601
  def test_encoding_none(self):
570
602
  uri = "foo://user@example.com:8042/over/there?name=ferret#nose"
@@ -30,3 +30,17 @@ class UnsplitTest(unittest.TestCase):
30
30
  ]
31
31
  for parts, uri in cases:
32
32
  self.check(parts, uri)
33
+
34
+ def test_mixed_types(self):
35
+ # the type of `path` selects the result type; all other parts must match
36
+ cases = [
37
+ ("foo", None, b"/over/there", None, None),
38
+ (b"foo", None, "/over/there", None, None),
39
+ (None, "example.com", b"/over/there", None, None),
40
+ (None, b"example.com", "/over/there", None, None),
41
+ (None, None, "/over/there", None, b"nose"),
42
+ (None, None, "/over/there", b"name=ferret", None),
43
+ ]
44
+ for parts in cases:
45
+ with self.assertRaises(TypeError):
46
+ uriunsplit(parts)
@@ -1,12 +1,12 @@
1
1
  [tox]
2
- envlist = py,docs,doctest,ruff,ruff-format,pyright
2
+ envlist = py,docs,doctest,pyright,ruff,ruff-format
3
3
 
4
4
  [testenv]
5
5
  deps =
6
6
  pytest
7
7
  pytest-cov
8
8
  commands =
9
- py.test --basetemp={envtmpdir} --cov=uritools --cov-report term-missing {posargs}
9
+ py.test --basetemp={envtmpdir} --cov=uritools --cov-branch --cov-report term-missing {posargs}
10
10
 
11
11
  [testenv:docs]
12
12
  deps =
@@ -20,23 +20,20 @@ deps =
20
20
  commands =
21
21
  sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs {envtmpdir}/doctest
22
22
 
23
+ [testenv:pyright]
24
+ deps =
25
+ pyright
26
+ commands =
27
+ pyright {posargs}
28
+
23
29
  [testenv:ruff]
24
30
  deps =
25
31
  ruff
26
32
  commands =
27
33
  ruff check {posargs}
28
- skip_install = true
29
34
 
30
35
  [testenv:ruff-format]
31
36
  deps =
32
37
  ruff
33
38
  commands =
34
39
  ruff format --diff -q {posargs}
35
- skip_install = true
36
-
37
- [testenv:pyright]
38
- deps =
39
- pyright
40
- commands =
41
- pyright {posargs}
42
- skip_install = true
@@ -1,36 +0,0 @@
1
- import pathlib
2
- import sys
3
-
4
- src_directory = (pathlib.Path(__file__).parent.parent / "src").resolve()
5
- sys.path.insert(0, str(src_directory))
6
-
7
-
8
- # Extract the current version from the source.
9
- def get_version():
10
- """Get the version and release from the source code."""
11
-
12
- text = (src_directory / "uritools/__init__.py").read_text()
13
- for line in text.splitlines():
14
- if not line.strip().startswith("__version__"):
15
- continue
16
- full_version = line.partition("=")[2].strip().strip("\"'")
17
- partial_version = ".".join(full_version.split(".")[:2])
18
- return full_version, partial_version
19
- return (None, None)
20
-
21
-
22
- project = "uritools"
23
- copyright = "2014-2026 Thomas Kemmer"
24
- release, version = get_version()
25
-
26
- extensions = [
27
- "sphinx.ext.autodoc",
28
- "sphinx.ext.coverage",
29
- "sphinx.ext.doctest",
30
- "sphinx.ext.intersphinx",
31
- "sphinx.ext.todo",
32
- ]
33
- exclude_patterns = ["_build"]
34
- master_doc = "index"
35
- html_theme = "classic"
36
- intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes