xtr-http-message 4.0.2__py3-none-any.whl

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.
@@ -0,0 +1,660 @@
1
+ """A URI as a value: every component readable, every change a new instance.
2
+
3
+ A string is read as RFC 3986 defines a URI reference, and `uritools` does the
4
+ splitting — the five-part grammar of its appendix B, the scheme of §3.1, the
5
+ authority of §3.2. The standard library's splitter cannot be used for this: it
6
+ deletes a tab, a carriage return and a newline wherever it finds one, and trims
7
+ the blanks and control characters off both ends, all before anything of ours
8
+ sees the string, so it answers about a URI that was never asked about.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import re
15
+ from ipaddress import IPv6Address
16
+ from types import MappingProxyType
17
+ from typing import TYPE_CHECKING, Final, Self, final
18
+ from urllib.parse import quote
19
+
20
+ from typing_extensions import override
21
+ from uritools import uridefrag, urisplit
22
+ from xtr_http_message_contracts import UriInterface
23
+
24
+ from ._ascii import lower
25
+ from .exception import InvalidArgumentError
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping
29
+
30
+ from uritools import SplitResult
31
+
32
+ __all__ = ["Uri"]
33
+
34
+ # The schemes whose standard port is worth dropping from an authority.
35
+ _SCHEMES: Final[Mapping[str, int]] = MappingProxyType({"http": 80, "https": 443})
36
+
37
+ # The schemes that name a server to reach rather than a thing to look up, so
38
+ # that an authority with no host in it names nothing at all: RFC 9110 §4.2.1
39
+ # and §4.2.2 have a recipient reject such a reference instead of reading it.
40
+ _NEEDS_A_HOST: Final[frozenset[str]] = frozenset({"http", "https"})
41
+
42
+ _MAX_PORT = 0xFFFF
43
+
44
+ _CHAR_UNRESERVED = r"a-zA-Z0-9_\-\.~"
45
+ _CHAR_SUB_DELIMS = r"!\$&\'\(\)\*\+,;="
46
+ _CHAR_GEN_DELIMS = r":\/\?#\[\]@"
47
+
48
+ # RFC 3986 §3.2.2: ``reg-name = *( unreserved / pct-encoded / sub-delims )``.
49
+ # A registered name carries nothing else, so a blank, a tab, a control
50
+ # character or a stray per cent sign in one leaves a host nobody can look up.
51
+ _REG_NAME = re.compile(f"(?:[{_CHAR_UNRESERVED}{_CHAR_SUB_DELIMS}]|%[A-Fa-f0-9]{{2}})*")
52
+
53
+ # A run of delimiters in user information, which has to be encoded to stay
54
+ # inside the authority, or a per cent sign that does not open an escape —
55
+ # encoding one of those again would double it.
56
+ _IN_USER_INFO = re.compile(f"(?:[{_CHAR_GEN_DELIMS}{_CHAR_SUB_DELIMS}]+|%(?![A-Fa-f0-9]{{2}}))")
57
+ # A run of characters a path may not carry as it is, or a per cent sign that
58
+ # does not open an escape — encoding one of those again would double it.
59
+ _IN_PATH = re.compile(f"(?:[^{_CHAR_UNRESERVED}{_CHAR_SUB_DELIMS}%:@/]+|%(?![A-Fa-f0-9]{{2}}))")
60
+ # The same, for a query string or a fragment, where a question mark is data.
61
+ _IN_QUERY_OR_FRAGMENT = re.compile(
62
+ f"(?:[^{_CHAR_UNRESERVED}{_CHAR_SUB_DELIMS}%:@/?]+|%(?![A-Fa-f0-9]{{2}}))"
63
+ )
64
+
65
+
66
+ def _encode(match: re.Match[str]) -> str:
67
+ """Return the matched run of characters, per cent-encoded.
68
+
69
+ Args:
70
+ match: What one of the filter patterns matched.
71
+
72
+ Returns:
73
+ The run with every byte of its UTF-8 form escaped, except for the
74
+ characters no URI ever needs escaped.
75
+ """
76
+ return quote(match[0], safe="")
77
+
78
+
79
+ def _unparseable(uri: str) -> InvalidArgumentError:
80
+ """Return the error for a string that is not a URI.
81
+
82
+ Args:
83
+ uri: The string that could not be read as one.
84
+
85
+ Returns:
86
+ The error to raise, carrying the string it was raised over.
87
+ """
88
+ return InvalidArgumentError(f'Unable to parse URI: "{uri}"')
89
+
90
+
91
+ def _filter_path(path: str) -> str:
92
+ """Return a path with everything that has to be encoded encoded, and nothing twice."""
93
+ return _IN_PATH.sub(_encode, path)
94
+
95
+
96
+ def _filter_user_info(user_info: str) -> str:
97
+ """Return user information encoded as RFC 3986 §3.2.1 writes it, and nothing twice.
98
+
99
+ The first colon is the one the grammar keeps, between the user name and
100
+ what follows it; a later one is data and goes the way of every other
101
+ delimiter. Without this a user information read off a string would be
102
+ written back out as it arrived, and an ``@`` or a bracket in it would move
103
+ where the authority ends.
104
+
105
+ Args:
106
+ user_info: The user information, as the authority holds it.
107
+
108
+ Returns:
109
+ The user information, every delimiter and every broken escape in it
110
+ per cent-encoded.
111
+ """
112
+ user, colon, rest = user_info.partition(":")
113
+ encoded = _IN_USER_INFO.sub(_encode, user)
114
+ if colon == "":
115
+ return encoded
116
+
117
+ return f"{encoded}:{_IN_USER_INFO.sub(_encode, rest)}"
118
+
119
+
120
+ def _filter_query_or_fragment(text: str) -> str:
121
+ """Return a query string or fragment encoded the same way, and nothing twice."""
122
+ return _IN_QUERY_OR_FRAGMENT.sub(_encode, text)
123
+
124
+
125
+ def _is_an_ip_literal(host: str) -> bool:
126
+ """Tell whether what two brackets wrap is an address, as RFC 3986 §3.2.2 writes one.
127
+
128
+ ``IP-literal = "[" ( IPv6address / IPvFuture ) "]"``, and the section says
129
+ of that grammar that it "does not support IPv6 scoped addressing zone
130
+ identifiers" — so a per cent sign between the brackets is a zone
131
+ identifier or an escape with no business there, and neither names a host.
132
+ An ``IPvFuture`` is a format announced by a version flag but left
133
+ undefined, which §3.2.2 has an application report an error over rather
134
+ than guess at: it is not an IPv6 address either, so it is refused here.
135
+
136
+ Args:
137
+ host: The host, brackets included.
138
+
139
+ Returns:
140
+ Whether the brackets wrap an address.
141
+ """
142
+ address = host[1:-1]
143
+ if "%" in address:
144
+ return False
145
+
146
+ try:
147
+ _ = IPv6Address(address)
148
+ except ValueError:
149
+ return False
150
+
151
+ return True
152
+
153
+
154
+ def _is_a_host(host: str) -> bool:
155
+ """Tell whether a string is a host, as RFC 3986 §3.2.2 writes one.
156
+
157
+ Brackets are only ever there to wrap an IP-literal, so what they wrap has
158
+ to be one; anything else is a registered name, which carries only
159
+ unreserved characters, per cent escapes and sub-delimiters. Empty is a
160
+ host too: §3.2.2 lets an authority name none.
161
+
162
+ Args:
163
+ host: The host, as an authority holds it.
164
+
165
+ Returns:
166
+ Whether it is one.
167
+ """
168
+ if host.startswith("[") and host.endswith("]"):
169
+ return _is_an_ip_literal(host)
170
+
171
+ return _REG_NAME.fullmatch(host) is not None
172
+
173
+
174
+ def _is_non_standard_port(scheme: str, port: int) -> bool:
175
+ """Tell whether a port says anything the scheme does not already imply."""
176
+ return _SCHEMES.get(scheme) != port
177
+
178
+
179
+ def _read_authority(parts: SplitResult[str], scheme: str, uri: str) -> tuple[str, str, int | None]:
180
+ """Return the user information, host and port an authority names, each checked.
181
+
182
+ RFC 3986 §3.2 reads an authority as ``[ userinfo "@" ] host [ ":" port ]``.
183
+ The host is checked against §3.2.2 by :func:`_is_a_host`. The port is
184
+ checked against §3.2.3, which allows digits and nothing else, an empty port
185
+ meaning none; the range is the sixteen bits a TCP or UDP port is assigned
186
+ from, as the contract refuses anything outside them. A port with no host in
187
+ front of it is refused: §3.2 hangs the port off the host, so there is no
188
+ authority to write it into.
189
+
190
+ Args:
191
+ parts: The URI reference as RFC 3986 appendix B splits it.
192
+ scheme: The scheme already read off it, lowercased.
193
+ uri: The whole string, which a refusal names.
194
+
195
+ Returns:
196
+ The user information, the host and the port — the first two empty and
197
+ the last ``None`` where the URI names none of them.
198
+
199
+ Raises:
200
+ InvalidArgumentError: When the authority names something that is not a
201
+ host or not a port, names a port with no host to belong to, or
202
+ names no host under a scheme that needs one.
203
+ """
204
+ host = parts.host or ""
205
+
206
+ if not _is_a_host(host):
207
+ raise _unparseable(uri)
208
+
209
+ # RFC 3986 §3.2.2 lets a host be empty; RFC 9110 §4.2.1 and §4.2.2 do not,
210
+ # for the two schemes that name a server to reach.
211
+ if host == "" and parts.authority is not None and scheme in _NEEDS_A_HOST:
212
+ raise _unparseable(uri)
213
+
214
+ port = parts.getport()
215
+ # RFC 3986 §3.2.3 leaves the range to the scheme, and the split lets
216
+ # nothing but digits through, so only the ceiling the contract sets — the
217
+ # sixteen bits a TCP or UDP port is assigned from — can be broken.
218
+ if port is not None and port > _MAX_PORT:
219
+ raise _unparseable(uri)
220
+
221
+ # A port belongs to the host it is written after, and with no host there is
222
+ # no authority to write down at all (:meth:`Uri.get_authority`). Reading a
223
+ # port off such a reference would leave an instance reporting one that no
224
+ # text of its own could carry, so the reference is refused instead.
225
+ if host == "" and port is not None:
226
+ raise _unparseable(uri)
227
+
228
+ return parts.userinfo or "", host, port
229
+
230
+
231
+ @final
232
+ class Uri(UriInterface):
233
+ """A URI, taken apart into its components and put back together as text.
234
+
235
+ Build one from a string and read any component off it; change one and get
236
+ a new instance, since a URI is a value rather than a thing with a state:
237
+
238
+ ```python
239
+ from xtr_http_message import Uri
240
+
241
+ uri = Uri("https://example.com/orders?page=2")
242
+ assert uri.get_host() == "example.com"
243
+ assert str(uri.with_path("/orders/7")) == "https://example.com/orders/7?page=2"
244
+ ```
245
+
246
+ Components arrive encoded or not, as the caller has them, and come back
247
+ encoded exactly once. The standard port of the scheme is dropped, since it
248
+ says nothing the scheme does not: ``Uri("https://example.com:443")`` has
249
+ no port to report.
250
+
251
+ A string is read as RFC 3986 defines a URI reference: a scheme opens with a
252
+ letter (§3.1), a host is an IPv6 address in brackets — a grammar with no
253
+ room for a zone identifier — or a registered name of unreserved
254
+ characters, per cent escapes and sub-delimiters (§3.2.2), and a port is
255
+ digits inside sixteen bits, written after a host (§3.2.3 and §3.2). A
256
+ string that breaks one of those — ``http://exa mple.com``,
257
+ ``http://[::1``, ``http://[fe80::1%eth0]``, ``http://h:65536``, ``//:80``
258
+ — is refused, as is an ``http`` or ``https`` URI naming an authority with
259
+ no host in it, such as ``http://`` (RFC 9110 §4.2.1 and §4.2.2). Nothing
260
+ is trimmed and no character is dropped: whatever a path, a query string or
261
+ a fragment may not carry as it is comes back per cent-encoded.
262
+
263
+ Being read is not the same as being a URI reference. A string whose front
264
+ is no scheme — ``ht tp://h/``, ``0:0`` — is read as a path and comes back
265
+ per cent-encoded, and the result may still be no URI reference: §4.2
266
+ leaves a colon out of the first segment of a relative-path one, so
267
+ ``0:0`` would read as a scheme to somebody resolving it. Put ``./`` in
268
+ front of such a path, or give the reference a scheme.
269
+ """
270
+
271
+ __slots__ = ("_fragment", "_host", "_path", "_port", "_query", "_scheme", "_user_info")
272
+
273
+ def __init__(self, uri: str = "") -> None:
274
+ """Read a URI out of a string, as RFC 3986 defines one.
275
+
276
+ Args:
277
+ uri: The URI. Empty — the default — builds one with no component
278
+ at all, to be assembled through the ``with_*`` methods.
279
+
280
+ Raises:
281
+ InvalidArgumentError: When the string cannot be read as a URI
282
+ reference — a host or a port that is not one, a port with no
283
+ host to belong to, or no host under a scheme that needs one.
284
+ """
285
+ self._scheme: str = ""
286
+ self._user_info: str = ""
287
+ self._host: str = ""
288
+ self._port: int | None = None
289
+ self._path: str = ""
290
+ self._query: str = ""
291
+ self._fragment: str = ""
292
+
293
+ if uri == "":
294
+ return
295
+
296
+ # RFC 3986 §3.5 puts the fragment behind the first ``#``, and it is cut
297
+ # off first because the appendix B pattern reads the rest of the
298
+ # reference but stops a fragment at a newline.
299
+ head, fragment = uridefrag(uri)
300
+ parts = urisplit(head)
301
+
302
+ self._scheme = lower(parts.scheme or "")
303
+ user_info, host, port = _read_authority(parts, self._scheme, uri)
304
+ self._user_info = _filter_user_info(user_info)
305
+ self._host = lower(host)
306
+ # The scheme is recorded first: whether a port is worth keeping is
307
+ # weighed against it.
308
+ self._port = self._filter_port(port)
309
+ self._path = _filter_path(parts.path)
310
+ self._query = _filter_query_or_fragment(parts.query or "")
311
+ self._fragment = _filter_query_or_fragment(fragment or "")
312
+
313
+ @override
314
+ def __str__(self) -> str:
315
+ """Return the URI as text, either absolute or a relative reference.
316
+
317
+ Never raises: a path that would read as something else once the other
318
+ components are put in front of it is corrected rather than refused.
319
+
320
+ Returns:
321
+ The assembled URI.
322
+ """
323
+ return _assemble(
324
+ self._scheme,
325
+ self.get_authority(),
326
+ self._path,
327
+ self._query,
328
+ self._fragment,
329
+ )
330
+
331
+ @override
332
+ def get_scheme(self) -> str:
333
+ """Return the scheme, lowercased, without its trailing colon.
334
+
335
+ Returns:
336
+ The scheme, or an empty string when the URI has none.
337
+ """
338
+ return self._scheme
339
+
340
+ @override
341
+ def get_authority(self) -> str:
342
+ """Return the authority as ``[user-info@]host[:port]``.
343
+
344
+ Returns:
345
+ The authority, or an empty string when the URI has no host — user
346
+ information without a host cannot be written down.
347
+ """
348
+ if self._host == "":
349
+ return ""
350
+
351
+ authority = self._host
352
+ if self._user_info != "":
353
+ authority = f"{self._user_info}@{authority}"
354
+ if self._port is not None:
355
+ authority = f"{authority}:{self._port}"
356
+
357
+ return authority
358
+
359
+ @override
360
+ def get_user_info(self) -> str:
361
+ """Return the user information as ``username[:password]``.
362
+
363
+ Returns:
364
+ The user information, or an empty string when the URI has none.
365
+ """
366
+ return self._user_info
367
+
368
+ @override
369
+ def get_host(self) -> str:
370
+ """Return the host, lowercased.
371
+
372
+ Returns:
373
+ The host, or an empty string when the URI has none.
374
+ """
375
+ return self._host
376
+
377
+ @override
378
+ def get_port(self) -> int | None:
379
+ """Return the port, when one is worth reporting.
380
+
381
+ Returns:
382
+ The port when it is not the standard one of the scheme, otherwise
383
+ ``None``.
384
+ """
385
+ return self._port
386
+
387
+ @override
388
+ def get_path(self) -> str:
389
+ """Return the path, percent-encoded.
390
+
391
+ Two corrections are applied, so that the path read off a URI is one
392
+ that can be used as it is: a rootless path gains a leading ``/`` when
393
+ there is an authority to keep it apart from, and a path opening with
394
+ more than one ``/`` has those reduced to one.
395
+
396
+ Returns:
397
+ The path.
398
+ """
399
+ path = self._path
400
+
401
+ if path.startswith("//"):
402
+ return "/" + path.lstrip("/")
403
+ if path != "" and not path.startswith("/") and self._host != "":
404
+ return f"/{path}"
405
+
406
+ return path
407
+
408
+ @override
409
+ def get_query(self) -> str:
410
+ """Return the query string, percent-encoded, without its leading ``?``.
411
+
412
+ Returns:
413
+ The query string, or an empty string when the URI has none.
414
+ """
415
+ return self._query
416
+
417
+ @override
418
+ def get_fragment(self) -> str:
419
+ """Return the fragment, percent-encoded, without its leading ``#``.
420
+
421
+ Returns:
422
+ The fragment, or an empty string when the URI has none.
423
+ """
424
+ return self._fragment
425
+
426
+ @override
427
+ def with_scheme(self, scheme: str) -> Self:
428
+ """Return an instance carrying ``scheme``.
429
+
430
+ A port that was non-standard for the old scheme may be standard for
431
+ the new one, so the port is weighed again.
432
+
433
+ Args:
434
+ scheme: The scheme the new instance carries, in any case. Empty
435
+ removes the scheme.
436
+
437
+ Returns:
438
+ A new instance, or this one when the scheme is already that.
439
+ """
440
+ scheme = lower(scheme)
441
+ if self._scheme == scheme:
442
+ return self
443
+
444
+ new = self._copy()
445
+ new._scheme = scheme # noqa: SLF001 — a copy of this very class.
446
+ new._port = new._filter_port(new._port) # noqa: SLF001 — a copy of this very class.
447
+
448
+ return new
449
+
450
+ @override
451
+ def with_user_info(self, user: str, password: str | None = None) -> Self:
452
+ """Return an instance carrying ``user`` and, optionally, ``password``.
453
+
454
+ Args:
455
+ user: The user name. Empty with no password removes the user
456
+ information; empty with a password keeps the password under a
457
+ user name that is empty, which is what ``:pass`` says.
458
+ password: The password that goes with ``user``, when there is one.
459
+
460
+ Returns:
461
+ A new instance, or this one when the user information is already
462
+ that.
463
+ """
464
+ info = _IN_USER_INFO.sub(_encode, user)
465
+ if password is not None and password != "":
466
+ info = f"{info}:{_IN_USER_INFO.sub(_encode, password)}"
467
+
468
+ if self._user_info == info:
469
+ return self
470
+
471
+ new = self._copy()
472
+ new._user_info = info # noqa: SLF001 — a copy of this very class.
473
+
474
+ return new
475
+
476
+ @override
477
+ def with_host(self, host: str) -> Self:
478
+ """Return an instance carrying ``host``.
479
+
480
+ Args:
481
+ host: The host, in any case. Empty removes the host.
482
+
483
+ Returns:
484
+ A new instance, or this one when the host is already that.
485
+
486
+ Raises:
487
+ InvalidArgumentError: When the host is not one as RFC 3986 §3.2.2
488
+ writes it. That grammar is ASCII throughout, so a name in
489
+ another script is given in its ``xn--`` form — and a ``Host``
490
+ header is written straight off this component, which is the
491
+ other reason nothing else may reach it.
492
+ """
493
+ host = lower(host)
494
+ if not _is_a_host(host):
495
+ message = f'Invalid host: "{host}". Must be an address in brackets or a registered name'
496
+ raise InvalidArgumentError(message)
497
+
498
+ if self._host == host:
499
+ return self
500
+
501
+ new = self._copy()
502
+ new._host = host # noqa: SLF001 — a copy of this very class.
503
+
504
+ return new
505
+
506
+ @override
507
+ def with_port(self, port: int | None) -> Self:
508
+ """Return an instance carrying ``port``.
509
+
510
+ Args:
511
+ port: The port, or ``None`` to remove it. The standard port of the
512
+ current scheme is taken as ``None``.
513
+
514
+ Returns:
515
+ A new instance, or this one when the port is already that.
516
+
517
+ Raises:
518
+ InvalidArgumentError: When the port falls outside the range a TCP
519
+ or UDP port is assigned from.
520
+ """
521
+ filtered = self._filter_port(port)
522
+ if self._port == filtered:
523
+ return self
524
+
525
+ new = self._copy()
526
+ new._port = filtered # noqa: SLF001 — a copy of this very class.
527
+
528
+ return new
529
+
530
+ @override
531
+ def with_path(self, path: str) -> Self:
532
+ """Return an instance carrying ``path``.
533
+
534
+ Args:
535
+ path: The path, encoded or not. It may be empty, absolute, or
536
+ rootless.
537
+
538
+ Returns:
539
+ A new instance, or this one when the path is already that.
540
+ """
541
+ path = _filter_path(path)
542
+ if self._path == path:
543
+ return self
544
+
545
+ new = self._copy()
546
+ new._path = path # noqa: SLF001 — a copy of this very class.
547
+
548
+ return new
549
+
550
+ @override
551
+ def with_query(self, query: str) -> Self:
552
+ """Return an instance carrying ``query``.
553
+
554
+ Args:
555
+ query: The query string, encoded or not, without a leading ``?``
556
+ unless that question mark is data. Empty removes it.
557
+
558
+ Returns:
559
+ A new instance, or this one when the query string is already that.
560
+ """
561
+ query = _filter_query_or_fragment(query)
562
+ if self._query == query:
563
+ return self
564
+
565
+ new = self._copy()
566
+ new._query = query # noqa: SLF001 — a copy of this very class.
567
+
568
+ return new
569
+
570
+ @override
571
+ def with_fragment(self, fragment: str) -> Self:
572
+ """Return an instance carrying ``fragment``.
573
+
574
+ Args:
575
+ fragment: The fragment, encoded or not, without a leading ``#``
576
+ unless that hash is data. Empty removes it.
577
+
578
+ Returns:
579
+ A new instance, or this one when the fragment is already that.
580
+ """
581
+ fragment = _filter_query_or_fragment(fragment)
582
+ if self._fragment == fragment:
583
+ return self
584
+
585
+ new = self._copy()
586
+ new._fragment = fragment # noqa: SLF001 — a copy of this very class.
587
+
588
+ return new
589
+
590
+ def _copy(self) -> Self:
591
+ """Return a copy to change, leaving this instance as it is.
592
+
593
+ Every component is a string or a number, so a shallow copy shares
594
+ nothing that could be changed through it.
595
+ """
596
+ return copy.copy(self)
597
+
598
+ def _filter_port(self, port: int | None) -> int | None:
599
+ """Return a port worth keeping, weighed against the current scheme.
600
+
601
+ Args:
602
+ port: The port to weigh, or ``None``.
603
+
604
+ Returns:
605
+ The port when the scheme does not already imply it, otherwise
606
+ ``None``.
607
+
608
+ Raises:
609
+ InvalidArgumentError: When the port falls outside the range a TCP
610
+ or UDP port is assigned from.
611
+ """
612
+ if port is None:
613
+ return None
614
+
615
+ if port < 0 or port > _MAX_PORT:
616
+ message = f"Invalid port: {port}. Must be between 0 and 65535"
617
+ raise InvalidArgumentError(message)
618
+
619
+ return port if _is_non_standard_port(self._scheme, port) else None
620
+
621
+
622
+ def _assemble(scheme: str, authority: str, path: str, query: str, fragment: str) -> str:
623
+ """Put a URI back together from its components.
624
+
625
+ The path is corrected where joining it to the components in front of it
626
+ would read as something else: a rootless path after an authority gains a
627
+ leading ``/``, and a path opening with more than one ``/`` with no
628
+ authority in front of it has those reduced to one — otherwise it would
629
+ read as an authority itself.
630
+
631
+ Args:
632
+ scheme: The scheme, or empty.
633
+ authority: The authority, or empty.
634
+ path: The path, as it is held rather than as :meth:`Uri.get_path`
635
+ reports it.
636
+ query: The query string, or empty.
637
+ fragment: The fragment, or empty.
638
+
639
+ Returns:
640
+ The assembled URI.
641
+ """
642
+ uri = f"{scheme}:" if scheme != "" else ""
643
+
644
+ if authority != "":
645
+ uri = f"{uri}//{authority}"
646
+
647
+ if path != "":
648
+ if path.startswith("//") and authority == "":
649
+ path = "/" + path.lstrip("/")
650
+ elif not path.startswith("/") and authority != "":
651
+ path = f"/{path}"
652
+ uri = f"{uri}{path}"
653
+
654
+ if query != "":
655
+ uri = f"{uri}?{query}"
656
+
657
+ if fragment != "":
658
+ uri = f"{uri}#{fragment}"
659
+
660
+ return uri