xtr-http-client 5.2.0__tar.gz → 6.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/PKG-INFO +9 -6
  2. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/README.md +3 -0
  3. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/pyproject.toml +11 -10
  4. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/pyproject.toml.orig +12 -10
  5. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/httpx_http_client.py +17 -42
  6. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/httpx_clients.py +11 -2
  7. xtr_http_client-6.0.0/src/xtr_http_client/internal/httpx_redirect.py +109 -0
  8. xtr_http_client-5.2.0/src/xtr_http_client/internal/httpx_redirect.py +0 -63
  9. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/LICENSE +0 -0
  10. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +0 -0
  11. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/__init__.py +0 -0
  12. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/__init__.py +0 -0
  13. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/_clients.py +0 -0
  14. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/client_options_config.py +0 -0
  15. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/http_client_bundle.py +0 -0
  16. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/http_client_config.py +0 -0
  17. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/retry_failed_config.py +0 -0
  18. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/bundle/scoped_client_config.py +0 -0
  19. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/exception/__init__.py +0 -0
  20. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/exception/_http_error_message.py +0 -0
  21. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/exception/logic_error.py +0 -0
  22. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/http_client.py +0 -0
  23. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/http_client_mixin.py +0 -0
  24. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/__init__.py +0 -0
  25. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/debug_type.py +0 -0
  26. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/httpx_body.py +0 -0
  27. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/httpx_errors.py +0 -0
  28. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/httpx_request.py +0 -0
  29. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/mock_head.py +0 -0
  30. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/mock_info.py +0 -0
  31. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/option_merge.py +0 -0
  32. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/response_body.py +0 -0
  33. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/retry_count_response.py +0 -0
  34. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/retry_delay.py +0 -0
  35. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/internal/retry_options.py +0 -0
  36. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/mock_http_client.py +0 -0
  37. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/no_private_network_http_client.py +0 -0
  38. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/py.typed +0 -0
  39. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/recipe/__init__.py +0 -0
  40. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/recipe/files/config/http_client.py.tmpl +0 -0
  41. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/recipe/manifest.toml +0 -0
  42. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/response/__init__.py +0 -0
  43. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/response/http_response.py +0 -0
  44. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/response/httpx_response.py +0 -0
  45. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/response/json_mock_response.py +0 -0
  46. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/response/mock_response.py +0 -0
  47. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/retry/__init__.py +0 -0
  48. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/retry/generic_retry_strategy.py +0 -0
  49. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/retry/retry_context.py +0 -0
  50. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/retry/retry_strategy_interface.py +0 -0
  51. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/retryable_http_client.py +0 -0
  52. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/scoping_http_client.py +0 -0
  53. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/throttling_http_client.py +0 -0
  54. {xtr_http_client-5.2.0 → xtr_http_client-6.0.0}/src/xtr_http_client/traceable_http_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-http-client
3
- Version: 5.2.0
3
+ Version: 6.0.0
4
4
  Summary: An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests.
5
5
  Keywords: http,http-client,request,response,mock,asyncio
6
6
  Author: Xterr
@@ -19,11 +19,11 @@ Classifier: Typing :: Typed
19
19
  Requires-Dist: typing-extensions>=4.4
20
20
  Requires-Dist: anyio>=4.0
21
21
  Requires-Dist: httpx>=0.27
22
- Requires-Dist: xtr-http-client-contracts>=5.0,<6
23
- Requires-Dist: xtr-http-message>=5.0,<6
24
- Requires-Dist: xtr-http-message-contracts>=5.0,<6
25
- Requires-Dist: xtr-logging-contracts>=5.0,<6
26
- Requires-Dist: xtr-service-contracts>=5.0,<6
22
+ Requires-Dist: xtr-http-client-contracts>=6.0,<7
23
+ Requires-Dist: xtr-http-message>=6.0,<7
24
+ Requires-Dist: xtr-http-message-contracts>=6.0,<7
25
+ Requires-Dist: xtr-logging-contracts>=6.0,<7
26
+ Requires-Dist: xtr-service-contracts>=6.0,<7
27
27
  Requires-Dist: httpx[http2]>=0.27 ; extra == 'http2'
28
28
  Requires-Python: >=3.11
29
29
  Provides-Extra: http2
@@ -142,6 +142,9 @@ the rest. An option this client does not know is refused with `InvalidArgumentEr
142
142
  the closest one it does, because an option spelled wrong that was quietly dropped is a setting
143
143
  that silently never applied.
144
144
 
145
+ A host `resolve` pins to an address stays pinned for the requests after it; `reset()` forgets
146
+ every pin and keeps the connections open.
147
+
145
148
  A `body` mapping holding a file opened `"rb"` is uploaded as `multipart/form-data` — plain
146
149
  values as `data`, each file as a part named after its file and typed from that name — and a
147
150
  mapping without one stays url-encoded:
@@ -111,6 +111,9 @@ the rest. An option this client does not know is refused with `InvalidArgumentEr
111
111
  the closest one it does, because an option spelled wrong that was quietly dropped is a setting
112
112
  that silently never applied.
113
113
 
114
+ A host `resolve` pins to an address stays pinned for the requests after it; `reset()` forgets
115
+ every pin and keeps the connections open.
116
+
114
117
  A `body` mapping holding a file opened `"rb"` is uploaded as `multipart/form-data` — plain
115
118
  values as `data`, each file as a part named after its file and typed from that name — and a
116
119
  mapping without one stays url-encoded:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-http-client"
3
- version = "5.2.0"
3
+ version = "6.0.0"
4
4
  description = "An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -29,11 +29,11 @@ dependencies = [
29
29
  "typing-extensions>=4.4",
30
30
  "anyio>=4.0",
31
31
  "httpx>=0.27",
32
- "xtr-http-client-contracts>=5.0,<6",
33
- "xtr-http-message>=5.0,<6",
34
- "xtr-http-message-contracts>=5.0,<6",
35
- "xtr-logging-contracts>=5.0,<6",
36
- "xtr-service-contracts>=5.0,<6",
32
+ "xtr-http-client-contracts>=6.0,<7",
33
+ "xtr-http-message>=6.0,<7",
34
+ "xtr-http-message-contracts>=6.0,<7",
35
+ "xtr-logging-contracts>=6.0,<7",
36
+ "xtr-service-contracts>=6.0,<7",
37
37
  ]
38
38
 
39
39
  [[project.authors]]
@@ -58,10 +58,11 @@ dev = [
58
58
  "anyio>=4.0",
59
59
  "h2>=4.0",
60
60
  "ty>=0.0.83",
61
- "xtr-clock>=5.0,<6",
62
- "xtr-dependency-injection>=5.0,<6",
63
- "xtr-http-foundation>=5.0,<6",
64
- "xtr-rate-limiter>=5.0,<6",
61
+ "xtr-clock>=6.0,<7",
62
+ "xtr-dependency-injection>=6.0,<7",
63
+ "xtr-http-foundation>=6.0,<7",
64
+ "xtr-messenger>=6.0,<7",
65
+ "xtr-rate-limiter>=6.0,<7",
65
66
  ]
66
67
 
67
68
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-http-client"
3
- version = "5.2.0"
3
+ version = "6.0.0"
4
4
  description = "An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -27,11 +27,11 @@ dependencies = [
27
27
  "typing-extensions>=4.4",
28
28
  "anyio>=4.0",
29
29
  "httpx>=0.27",
30
- "xtr-http-client-contracts>=5.0,<6",
31
- "xtr-http-message>=5.0,<6",
32
- "xtr-http-message-contracts>=5.0,<6",
33
- "xtr-logging-contracts>=5.0,<6",
34
- "xtr-service-contracts>=5.0,<6",
30
+ "xtr-http-client-contracts>=6.0,<7",
31
+ "xtr-http-message>=6.0,<7",
32
+ "xtr-http-message-contracts>=6.0,<7",
33
+ "xtr-logging-contracts>=6.0,<7",
34
+ "xtr-service-contracts>=6.0,<7",
35
35
  ]
36
36
 
37
37
  [project.optional-dependencies]
@@ -58,11 +58,13 @@ dev = [
58
58
  "anyio>=4.0",
59
59
  "h2>=4.0",
60
60
  "ty>=0.0.83",
61
- "xtr-clock>=5.0,<6",
62
- "xtr-dependency-injection>=5.0,<6",
61
+ "xtr-clock>=6.0,<7",
62
+ "xtr-dependency-injection>=6.0,<7",
63
63
  # The networks NoPrivateNetworkHttpClient refuses; optional for an application.
64
- "xtr-http-foundation>=5.0,<6",
65
- "xtr-rate-limiter>=5.0,<6",
64
+ "xtr-http-foundation>=6.0,<7",
65
+ # A worker resets the clients between the messages it handles in the kernel tests.
66
+ "xtr-messenger>=6.0,<7",
67
+ "xtr-rate-limiter>=6.0,<7",
66
68
  ]
67
69
 
68
70
  [build-system]
@@ -10,11 +10,12 @@ import httpx
10
10
  from typing_extensions import Unpack, override
11
11
  from xtr_http_client_contracts import HttpClientInterface
12
12
  from xtr_http_message import Request
13
+ from xtr_service_contracts import ResetInterface
13
14
 
14
15
  from .http_client_mixin import HttpClientMixin
15
16
  from .internal.httpx_clients import HttpxClients
16
17
  from .internal.httpx_errors import timed_out, transport_error
17
- from .internal.httpx_redirect import next_hop, same_origin
18
+ from .internal.httpx_redirect import exchange
18
19
  from .internal.httpx_request import (
19
20
  assemble_headers,
20
21
  assemble_payload,
@@ -38,7 +39,7 @@ __all__ = ["HttpxHttpClient"]
38
39
 
39
40
 
40
41
  @final
41
- class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
42
+ class HttpxHttpClient(HttpClientMixin, HttpClientInterface, ResetInterface):
42
43
  """Sends requests over real connections, and hands back answers as they arrive.
43
44
 
44
45
  A request returns once the status line and the headers are in, whatever
@@ -51,8 +52,10 @@ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
51
52
  has no ``Authorization`` of its own — which is what lets one set of
52
53
  client-wide defaults sit under requests that each know their own mind.
53
54
  What each part of an outgoing request is assembled from is in
54
- :mod:`xtr_http_client.internal.httpx_request`; what is here is the
55
- exchange, and the redirect chain it may turn into.
55
+ :mod:`xtr_http_client.internal.httpx_request`, and the redirect chain an
56
+ exchange may turn into is followed in
57
+ :mod:`xtr_http_client.internal.httpx_redirect`; what is here is the
58
+ exchange itself, under its deadline.
56
59
 
57
60
  Connections are pooled by how they connect rather than by host: a proxy,
58
61
  a trust store, a protocol version and the deadlines all decide what a
@@ -60,6 +63,10 @@ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
60
63
  ``max_host_connections`` is each pool's ceiling across every host it
61
64
  reaches.
62
65
 
66
+ A host a request pins to an address through ``resolve`` stays pinned for
67
+ the requests after it, in the pool it went through; :meth:`reset` forgets
68
+ every pin, and keeps the connections open until the client is closed.
69
+
63
70
  ```python
64
71
  async with HttpxHttpClient(max_host_connections=4) as client:
65
72
  response = await client.request("GET", "https://example.com/orders")
@@ -164,6 +171,11 @@ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
164
171
 
165
172
  return await self._send(client, outgoing, merged, headers)
166
173
 
174
+ @override
175
+ async def reset(self) -> None:
176
+ """Forget the address every host was pinned to, keeping the connections open."""
177
+ self._clients.reset()
178
+
167
179
  async def aclose(self) -> None:
168
180
  """Close every pool, and with it every connection this client holds."""
169
181
  await self._clients.aclose()
@@ -207,7 +219,7 @@ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
207
219
 
208
220
  try:
209
221
  with anyio.fail_after(limit):
210
- response, history = await _exchange(
222
+ response, history = await exchange(
211
223
  client,
212
224
  outgoing,
213
225
  _basic_credentials(options, headers),
@@ -229,43 +241,6 @@ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
229
241
  )
230
242
 
231
243
 
232
- async def _exchange(
233
- client: httpx.AsyncClient,
234
- outgoing: httpx.Request,
235
- auth: tuple[str, str] | None,
236
- max_redirects: int,
237
- ) -> tuple[httpx.Response, list[httpx.Response]]:
238
- """Send, then follow where each answer points until one points nowhere.
239
-
240
- Following is done here, a hop at a time, for the one thing the backend
241
- cannot be told: credentials belong to the host they were meant for, so an
242
- ``Authorization`` the backend dropped on the way out of that host is put
243
- back the moment the chain returns to it, and nowhere else.
244
- """
245
- response = await client.send(outgoing, auth=auth, stream=True, follow_redirects=False)
246
- history: list[httpx.Response] = []
247
- origin = response.request.url
248
- authorization = cast("str | None", response.request.headers.get("authorization"))
249
-
250
- while len(history) < max_redirects:
251
- following = next_hop(response)
252
- if following is None:
253
- break
254
-
255
- if (
256
- authorization is not None
257
- and "authorization" not in following.headers
258
- and same_origin(following.url, origin)
259
- ):
260
- following.headers["Authorization"] = authorization
261
-
262
- history.append(response)
263
- await response.aclose()
264
- response = await client.send(following, stream=True, follow_redirects=False)
265
-
266
- return response, history
267
-
268
-
269
244
  def _reachable(url: str) -> str:
270
245
  """``url`` with every part of it written in the form that reaches it.
271
246
 
@@ -43,8 +43,8 @@ class HttpxClients:
43
43
  change in them asks for a different pool rather than being ignored.
44
44
 
45
45
  Each pool also remembers the addresses that requests pinned their hosts
46
- to, for as long as the pool lives: a host reached at an address once is
47
- reached at it again, without asking a resolver a second time.
46
+ to, until :meth:`reset` or :meth:`aclose`: a host reached at an address
47
+ once is reached at it again, without asking a resolver a second time.
48
48
 
49
49
  ``max_connections`` is the backend's pool-wide ceiling, not a per-host
50
50
  one: it counts every connection the pool holds, whichever host each of
@@ -90,6 +90,15 @@ class HttpxClients:
90
90
 
91
91
  return client, self._resolved[key]
92
92
 
93
+ def reset(self) -> None:
94
+ """Forget the address every host was pinned to, keeping every pool open.
95
+
96
+ A pin is what the requests of one unit of work asked for; the
97
+ connections are how the next one gets anywhere, so they stay.
98
+ """
99
+ for resolved in self._resolved.values():
100
+ resolved.clear()
101
+
93
102
  async def aclose(self) -> None:
94
103
  """Close every pool, and with it every connection any of them holds."""
95
104
  clients = list(self._clients.values())
@@ -0,0 +1,109 @@
1
+ """Following a redirect chain: where each answer points, and whether it stays where it began."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ if TYPE_CHECKING:
8
+ import httpx
9
+
10
+ __all__ = ["exchange", "next_hop", "same_origin"]
11
+
12
+
13
+ async def exchange(
14
+ client: httpx.AsyncClient,
15
+ outgoing: httpx.Request,
16
+ auth: tuple[str, str] | None,
17
+ max_redirects: int,
18
+ ) -> tuple[httpx.Response, list[httpx.Response]]:
19
+ """Send, then follow where each answer points until one points nowhere.
20
+
21
+ Following is done here, a hop at a time, for the one thing the backend
22
+ cannot be told: credentials belong to the host they were meant for, so an
23
+ ``Authorization`` the backend dropped on the way out of that host is put
24
+ back the moment the chain returns to it, and nowhere else.
25
+
26
+ Args:
27
+ client: The pool the request and every hop after it are sent through.
28
+ outgoing: The request to send.
29
+ auth: The user and password the first request authenticates with.
30
+ max_redirects: How many hops may be followed.
31
+
32
+ Returns:
33
+ The last answer, and every answer before it, oldest first.
34
+ """
35
+ response = await client.send(outgoing, auth=auth, stream=True, follow_redirects=False)
36
+ history: list[httpx.Response] = []
37
+ origin = response.request.url
38
+ authorization = cast("str | None", response.request.headers.get("authorization"))
39
+
40
+ while len(history) < max_redirects:
41
+ following = next_hop(response)
42
+ if following is None:
43
+ break
44
+
45
+ if (
46
+ authorization is not None
47
+ and "authorization" not in following.headers
48
+ and same_origin(following.url, origin)
49
+ ):
50
+ following.headers["Authorization"] = authorization
51
+
52
+ history.append(response)
53
+ await response.aclose()
54
+ response = await client.send(following, stream=True, follow_redirects=False)
55
+
56
+ return response, history
57
+
58
+
59
+ def next_hop(response: httpx.Response) -> httpx.Request | None:
60
+ """Return the request that following ``response`` would make.
61
+
62
+ Args:
63
+ response: The answer to read a redirect off.
64
+
65
+ Returns:
66
+ The next request, or ``None`` when there is nowhere to go — either
67
+ because the answer is not a redirect, or because where it points is
68
+ not somewhere a request can be made to.
69
+ """
70
+ following = response.next_request
71
+ if following is None:
72
+ return None
73
+ location = cast("str", response.headers.get("location", ""))
74
+ if _names_no_host(location) or not following.url.host:
75
+ return None
76
+
77
+ return following
78
+
79
+
80
+ def same_origin(one: httpx.URL, other: httpx.URL) -> bool:
81
+ """Tell whether two URLs name the same scheme, host and port.
82
+
83
+ Args:
84
+ one: A URL.
85
+ other: The URL to compare it with.
86
+
87
+ Returns:
88
+ ``True`` when a connection to either would be a connection to the
89
+ same place.
90
+ """
91
+ return (one.scheme, one.host, one.port) == (other.scheme, other.host, other.port)
92
+
93
+
94
+ def _names_no_host(location: str) -> bool:
95
+ """Whether a Location names an authority and then leaves it empty.
96
+
97
+ ``//?foo=bar`` says "another host, the same scheme" and names no host, so
98
+ there is nowhere for it to point; a relative reference such as ``..``
99
+ names no authority at all, which is not the same thing — it is resolved
100
+ against the URL it arrived on.
101
+ """
102
+ if not location.startswith("//"):
103
+ return False
104
+
105
+ authority = location[2:]
106
+ for delimiter in ("/", "?", "#"):
107
+ authority = authority.partition(delimiter)[0]
108
+
109
+ return authority == ""
@@ -1,63 +0,0 @@
1
- """Where an answer points, and whether following it stays where it started."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import TYPE_CHECKING, cast
6
-
7
- if TYPE_CHECKING:
8
- import httpx
9
-
10
- __all__ = ["next_hop", "same_origin"]
11
-
12
-
13
- def next_hop(response: httpx.Response) -> httpx.Request | None:
14
- """Return the request that following ``response`` would make.
15
-
16
- Args:
17
- response: The answer to read a redirect off.
18
-
19
- Returns:
20
- The next request, or ``None`` when there is nowhere to go — either
21
- because the answer is not a redirect, or because where it points is
22
- not somewhere a request can be made to.
23
- """
24
- following = response.next_request
25
- if following is None:
26
- return None
27
- location = cast("str", response.headers.get("location", ""))
28
- if _names_no_host(location) or not following.url.host:
29
- return None
30
-
31
- return following
32
-
33
-
34
- def same_origin(one: httpx.URL, other: httpx.URL) -> bool:
35
- """Tell whether two URLs name the same scheme, host and port.
36
-
37
- Args:
38
- one: A URL.
39
- other: The URL to compare it with.
40
-
41
- Returns:
42
- ``True`` when a connection to either would be a connection to the
43
- same place.
44
- """
45
- return (one.scheme, one.host, one.port) == (other.scheme, other.host, other.port)
46
-
47
-
48
- def _names_no_host(location: str) -> bool:
49
- """Whether a Location names an authority and then leaves it empty.
50
-
51
- ``//?foo=bar`` says "another host, the same scheme" and names no host, so
52
- there is nowhere for it to point; a relative reference such as ``..``
53
- names no authority at all, which is not the same thing — it is resolved
54
- against the URL it arrived on.
55
- """
56
- if not location.startswith("//"):
57
- return False
58
-
59
- authority = location[2:]
60
- for delimiter in ("/", "?", "#"):
61
- authority = authority.partition(delimiter)[0]
62
-
63
- return authority == ""
File without changes