fluidattacks_core_http 12.0.0__tar.gz → 12.1.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 (19) hide show
  1. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/.gitignore +4 -0
  2. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/PKG-INFO +1 -1
  3. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/build/default.nix +0 -1
  4. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/build/filter.nix +1 -0
  5. fluidattacks_core_http-12.1.0/fluidattacks_core/http/client.py +158 -0
  6. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/import-linter.cfg +1 -10
  7. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/pyproject.toml +3 -1
  8. fluidattacks_core_http-12.1.0/tests/__init__.py +1 -0
  9. fluidattacks_core_http-12.1.0/tests/test_client.py +239 -0
  10. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/uv.lock +52 -1
  11. fluidattacks_core_http-12.0.0/fluidattacks_core/http/client.py +0 -90
  12. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/.envrc +0 -0
  13. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/flake.lock +0 -0
  14. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/flake.nix +0 -0
  15. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/fluidattacks_core/http/__init__.py +0 -0
  16. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/fluidattacks_core/http/py.typed +0 -0
  17. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/fluidattacks_core/http/validations.py +0 -0
  18. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/mypy.ini +0 -0
  19. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.1.0}/ruff.toml +0 -0
@@ -1,5 +1,9 @@
1
+ dist/
2
+ dist-*/
3
+ /.cargo-cache
1
4
  target/
2
5
  .direnv/
3
6
  .venv/
4
7
  .vscode/settings.json
5
8
  .zed/settings.json
9
+ *.so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks_core_http
3
- Version: 12.0.0
3
+ Version: 12.1.0
4
4
  Summary: Fluid Attacks Core HTTP Library
5
5
  Author-email: Development <development@fluidattacks.com>
6
6
  License: MPL-2.0
@@ -9,7 +9,6 @@
9
9
  override = {
10
10
  checks = {
11
11
  basedpyright = false;
12
- pytest = false;
13
12
  };
14
13
  configs = {
15
14
  import_linter = ../import-linter.cfg;
@@ -3,6 +3,7 @@ path_filter {
3
3
  root = src;
4
4
  include = [
5
5
  "fluidattacks_core"
6
+ "tests"
6
7
  "import-linter.cfg"
7
8
  "mypy.ini"
8
9
  "pyproject.toml"
@@ -0,0 +1,158 @@
1
+ import ipaddress
2
+ import ssl
3
+ from collections.abc import Sequence
4
+ from typing import (
5
+ Any,
6
+ Literal,
7
+ )
8
+ from urllib.parse import urljoin
9
+
10
+ import aiohttp
11
+ import certifi
12
+
13
+ from .validations import (
14
+ validate_local_request,
15
+ validate_url,
16
+ )
17
+
18
+ _MAX_REDIRECTS = 10
19
+ _METHODS_SWITCH_TO_GET = frozenset({301, 302, 303})
20
+
21
+
22
+ def get_secure_connector(
23
+ *,
24
+ allow_local_network: bool,
25
+ allow_localhost: bool,
26
+ ) -> type[aiohttp.TCPConnector]:
27
+ class SecureTCPConnector(aiohttp.TCPConnector):
28
+ async def _resolve_host(
29
+ self,
30
+ host: str,
31
+ port: int,
32
+ traces: Sequence[aiohttp.tracing.Trace] | None = None,
33
+ ) -> list[aiohttp.abc.ResolveResult]:
34
+ hosts = await super()._resolve_host(host, port, traces)
35
+ resolved_ips = [ipaddress.ip_address(host["host"]) for host in hosts]
36
+ validate_local_request(
37
+ resolved_ips,
38
+ allow_local_network=allow_local_network,
39
+ allow_localhost=allow_localhost,
40
+ )
41
+ return hosts
42
+
43
+ return SecureTCPConnector
44
+
45
+
46
+ async def request( # noqa: PLR0913
47
+ url: str,
48
+ *,
49
+ method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
50
+ allow_local_network: bool = False,
51
+ allow_localhost: bool = False,
52
+ allow_redirects: bool | None = None,
53
+ ascii_only: bool = False,
54
+ dns_rebind_protection: bool = True,
55
+ enforce_sanitization: bool = False,
56
+ headers: dict[str, str] | None = None,
57
+ json: Any | None = None, # noqa: ANN401
58
+ ports: list[int] | None = None,
59
+ schemes: list[str] | None = None,
60
+ timeout: int = 10, # noqa: ASYNC109
61
+ ) -> aiohttp.ClientResponse:
62
+ validate_url(
63
+ url,
64
+ ascii_only=ascii_only,
65
+ enforce_sanitization=enforce_sanitization,
66
+ ports=ports or [],
67
+ schemes=schemes or [],
68
+ )
69
+ connector = (
70
+ get_secure_connector(
71
+ allow_local_network=allow_local_network,
72
+ allow_localhost=allow_localhost,
73
+ )
74
+ if dns_rebind_protection
75
+ else aiohttp.TCPConnector
76
+ )
77
+ connection = connector(
78
+ ssl=ssl.create_default_context(cafile=certifi.where()),
79
+ )
80
+ if allow_redirects is True:
81
+ return await _request_with_validated_redirects(
82
+ connection=connection,
83
+ headers=headers,
84
+ initial_url=url,
85
+ initial_method=method,
86
+ json=json,
87
+ timeout=timeout,
88
+ validation_options={
89
+ "ascii_only": ascii_only,
90
+ "enforce_sanitization": enforce_sanitization,
91
+ "ports": ports or [],
92
+ "schemes": schemes or [],
93
+ },
94
+ )
95
+ aiohttp_follow = allow_redirects if allow_redirects is not None else not dns_rebind_protection
96
+
97
+ async with (
98
+ aiohttp.ClientSession(
99
+ connector=connection,
100
+ headers=headers,
101
+ ) as session,
102
+ session.request(
103
+ method,
104
+ url,
105
+ allow_redirects=aiohttp_follow,
106
+ json=json,
107
+ timeout=aiohttp.ClientTimeout(total=timeout),
108
+ ) as response,
109
+ ):
110
+ await response.read()
111
+ return response
112
+
113
+
114
+ async def _request_with_validated_redirects( # noqa: PLR0913
115
+ *,
116
+ connection: aiohttp.BaseConnector,
117
+ headers: dict[str, str] | None,
118
+ initial_url: str,
119
+ initial_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
120
+ json: Any, # noqa: ANN401
121
+ timeout: int, # noqa: ASYNC109
122
+ validation_options: dict[str, Any],
123
+ ) -> aiohttp.ClientResponse:
124
+ """Follow redirects manually so validate_url runs on every hop.
125
+
126
+ aiohttp's ``allow_redirects=True`` short-circuits our
127
+ scheme/port/ascii/sanitization checks after the first hop, letting a
128
+ compromised server redirect to a target the caller had restricted.
129
+ """
130
+ current_url = initial_url
131
+ current_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = initial_method
132
+ current_json: Any = json
133
+ async with aiohttp.ClientSession(connector=connection, headers=headers) as session:
134
+ for _ in range(_MAX_REDIRECTS + 1):
135
+ async with session.request(
136
+ current_method,
137
+ current_url,
138
+ allow_redirects=False,
139
+ json=current_json,
140
+ timeout=aiohttp.ClientTimeout(total=timeout),
141
+ ) as response:
142
+ await response.read()
143
+ status = response.status
144
+ location = response.headers.get("Location")
145
+ if not (300 <= status < 400) or location is None:
146
+ return response
147
+ current_url = urljoin(current_url, location)
148
+ validate_url(current_url, **validation_options)
149
+ if status in _METHODS_SWITCH_TO_GET:
150
+ current_method = "GET"
151
+ current_json = None
152
+ raise aiohttp.TooManyRedirects(
153
+ response.request_info,
154
+ (),
155
+ message=(
156
+ f"exceeded {_MAX_REDIRECTS} redirects, aborted before requesting {current_url}"
157
+ ),
158
+ )
@@ -1,16 +1,7 @@
1
1
  [importlinter]
2
- root_package = fluidattacks_core
2
+ root_package = fluidattacks_core.http
3
3
  include_external_packages = True
4
4
 
5
- [importlinter:contract:portion]
6
- name = Single Portion
7
- type = layers
8
- containers =
9
- fluidattacks_core
10
- exhaustive = true
11
- layers =
12
- http
13
-
14
5
  [importlinter:contract:dag]
15
6
  name = Direct Acyclic Graph
16
7
  type = layers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks_core_http"
3
- version = "12.0.0"
3
+ version = "12.1.0"
4
4
  description = "Fluid Attacks Core HTTP Library"
5
5
  authors = [{ name = "Development", email = "development@fluidattacks.com" }]
6
6
  license = { text = "MPL-2.0" }
@@ -24,6 +24,8 @@ dev = [
24
24
  "deptry>=0.23.1",
25
25
  "import-linter==2.11",
26
26
  "mypy==1.19.1",
27
+ "pytest==9.0.3",
28
+ "pytest-asyncio>=1.1.0",
27
29
  "ruff==0.15.0",
28
30
  ]
29
31
 
@@ -0,0 +1 @@
1
+ """Tests for fluidattacks_core.http."""
@@ -0,0 +1,239 @@
1
+ """Tests for fluidattacks_core.http.client.request."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ import aiohttp
6
+ import pytest
7
+ from aiohttp import web
8
+ from aiohttp.test_utils import TestServer
9
+
10
+ from fluidattacks_core.http import request
11
+ from fluidattacks_core.http.validations import HTTPValidationError
12
+
13
+ RouteHandler = Callable[[web.Request], Awaitable[web.StreamResponse]]
14
+
15
+
16
+ async def _build_server(routes: dict[str, RouteHandler]) -> TestServer:
17
+ app = web.Application()
18
+ for path, handler in routes.items():
19
+ app.router.add_get(path, handler)
20
+ app.router.add_post(path, handler)
21
+ server = TestServer(app)
22
+ await server.start_server()
23
+ return server
24
+
25
+
26
+ async def _permissive_request(
27
+ url: str,
28
+ *,
29
+ method: str = "GET",
30
+ allow_redirects: bool | None = None,
31
+ schemes: list[str] | None = None,
32
+ ports: list[int] | None = None,
33
+ json: object | None = None,
34
+ ) -> aiohttp.ClientResponse:
35
+ """Wrapper that turns off protections irrelevant to redirect-logic tests."""
36
+ return await request(
37
+ url,
38
+ method=method, # type: ignore[arg-type]
39
+ allow_redirects=allow_redirects,
40
+ allow_local_network=True,
41
+ allow_localhost=True,
42
+ dns_rebind_protection=False,
43
+ schemes=schemes,
44
+ ports=ports,
45
+ json=json,
46
+ )
47
+
48
+
49
+ def _make_server_url(server: TestServer, path: str) -> str:
50
+ return f"http://{server.host}:{server.port}{path}"
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_allow_redirects_default_does_not_follow_when_rebind_protected() -> None:
55
+ hits: list[str] = []
56
+
57
+ async def handle_start(_req: web.Request) -> web.Response:
58
+ hits.append("start")
59
+ return web.Response(status=301, headers={"Location": "/final"})
60
+
61
+ async def handle_final(_req: web.Request) -> web.Response:
62
+ hits.append("final")
63
+ return web.Response(status=200)
64
+
65
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
66
+ try:
67
+ response = await request(
68
+ _make_server_url(server, "/start"),
69
+ method="GET",
70
+ allow_local_network=True,
71
+ allow_localhost=True,
72
+ )
73
+ assert response.status == 301
74
+ assert hits == ["start"]
75
+ finally:
76
+ await server.close()
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_allow_redirects_default_follows_when_rebind_disabled() -> None:
81
+ async def handle_start(_req: web.Request) -> web.Response:
82
+ return web.Response(status=301, headers={"Location": "/final"})
83
+
84
+ async def handle_final(_req: web.Request) -> web.Response:
85
+ return web.Response(status=200)
86
+
87
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
88
+ try:
89
+ response = await _permissive_request(_make_server_url(server, "/start"))
90
+ assert response.status == 200
91
+ finally:
92
+ await server.close()
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_allow_redirects_false_never_follows() -> None:
97
+ async def handle_start(_req: web.Request) -> web.Response:
98
+ return web.Response(status=301, headers={"Location": "/final"})
99
+
100
+ async def handle_final(_req: web.Request) -> web.Response:
101
+ return web.Response(status=200)
102
+
103
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
104
+ try:
105
+ response = await _permissive_request(
106
+ _make_server_url(server, "/start"), allow_redirects=False
107
+ )
108
+ assert response.status == 301
109
+ finally:
110
+ await server.close()
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_allow_redirects_true_follows_canonical_chain_to_2xx() -> None:
115
+ async def handle_start(_req: web.Request) -> web.Response:
116
+ return web.Response(status=301, headers={"Location": "/final"})
117
+
118
+ async def handle_final(_req: web.Request) -> web.Response:
119
+ return web.Response(status=200, text="ok")
120
+
121
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
122
+ try:
123
+ response = await _permissive_request(
124
+ _make_server_url(server, "/start"), allow_redirects=True
125
+ )
126
+ assert response.status == 200
127
+ assert await response.text() == "ok"
128
+ finally:
129
+ await server.close()
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_allow_redirects_true_validates_scheme_on_redirect_target() -> None:
134
+ async def handle_start(_req: web.Request) -> web.Response:
135
+ return web.Response(
136
+ status=302, headers={"Location": "ftp://outside.example.com/attack"}
137
+ )
138
+
139
+ server = await _build_server({"/start": handle_start})
140
+ try:
141
+ with pytest.raises(HTTPValidationError):
142
+ await _permissive_request(
143
+ _make_server_url(server, "/start"),
144
+ allow_redirects=True,
145
+ schemes=["http"],
146
+ )
147
+ finally:
148
+ await server.close()
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_allow_redirects_true_validates_port_on_redirect_target() -> None:
153
+ async def handle_start(_req: web.Request) -> web.Response:
154
+ return web.Response(
155
+ status=302,
156
+ headers={"Location": "http://outside.example.com:22/attack"},
157
+ )
158
+
159
+ server = await _build_server({"/start": handle_start})
160
+ try:
161
+ with pytest.raises(HTTPValidationError):
162
+ await _permissive_request(
163
+ _make_server_url(server, "/start"),
164
+ allow_redirects=True,
165
+ ports=[80, 443],
166
+ )
167
+ finally:
168
+ await server.close()
169
+
170
+
171
+ @pytest.mark.asyncio
172
+ @pytest.mark.parametrize("status", [301, 302, 303])
173
+ async def test_status_rewrites_follow_up_method_to_get(status: int) -> None:
174
+ methods: list[str] = []
175
+
176
+ async def handle_start(req: web.Request) -> web.Response:
177
+ methods.append(req.method)
178
+ return web.Response(status=status, headers={"Location": "/final"})
179
+
180
+ async def handle_final(req: web.Request) -> web.Response:
181
+ methods.append(req.method)
182
+ return web.Response(status=200)
183
+
184
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
185
+ try:
186
+ response = await _permissive_request(
187
+ _make_server_url(server, "/start"),
188
+ method="POST",
189
+ allow_redirects=True,
190
+ json={"payload": "value"},
191
+ )
192
+ assert response.status == 200
193
+ assert methods == ["POST", "GET"]
194
+ finally:
195
+ await server.close()
196
+
197
+
198
+ @pytest.mark.asyncio
199
+ @pytest.mark.parametrize("status", [307, 308])
200
+ async def test_status_preserves_follow_up_method(status: int) -> None:
201
+ methods: list[str] = []
202
+
203
+ async def handle_start(req: web.Request) -> web.Response:
204
+ methods.append(req.method)
205
+ return web.Response(status=status, headers={"Location": "/final"})
206
+
207
+ async def handle_final(req: web.Request) -> web.Response:
208
+ methods.append(req.method)
209
+ return web.Response(status=200)
210
+
211
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
212
+ try:
213
+ response = await _permissive_request(
214
+ _make_server_url(server, "/start"),
215
+ method="POST",
216
+ allow_redirects=True,
217
+ json={"payload": "value"},
218
+ )
219
+ assert response.status == 200
220
+ assert methods == ["POST", "POST"]
221
+ finally:
222
+ await server.close()
223
+
224
+
225
+ @pytest.mark.asyncio
226
+ async def test_exceeding_max_redirects_raises_too_many_redirects() -> None:
227
+ async def handle_hop(req: web.Request) -> web.Response:
228
+ return web.Response(
229
+ status=301, headers={"Location": f"/hop?n={req.query.get('n', '0')}"}
230
+ )
231
+
232
+ server = await _build_server({"/hop": handle_hop})
233
+ try:
234
+ with pytest.raises(aiohttp.TooManyRedirects):
235
+ await _permissive_request(
236
+ _make_server_url(server, "/hop?n=0"), allow_redirects=True
237
+ )
238
+ finally:
239
+ await server.close()
@@ -217,7 +217,7 @@ wheels = [
217
217
 
218
218
  [[package]]
219
219
  name = "fluidattacks-core-http"
220
- version = "12.0.0"
220
+ version = "12.1.0"
221
221
  source = { editable = "." }
222
222
  dependencies = [
223
223
  { name = "aiohttp" },
@@ -230,6 +230,8 @@ dev = [
230
230
  { name = "deptry" },
231
231
  { name = "import-linter" },
232
232
  { name = "mypy" },
233
+ { name = "pytest" },
234
+ { name = "pytest-asyncio" },
233
235
  { name = "ruff" },
234
236
  ]
235
237
 
@@ -245,6 +247,8 @@ dev = [
245
247
  { name = "deptry", specifier = ">=0.23.1" },
246
248
  { name = "import-linter", specifier = "==2.11" },
247
249
  { name = "mypy", specifier = "==1.19.1" },
250
+ { name = "pytest", specifier = "==9.0.3" },
251
+ { name = "pytest-asyncio", specifier = ">=1.1.0" },
248
252
  { name = "ruff", specifier = "==0.15.0" },
249
253
  ]
250
254
 
@@ -465,6 +469,15 @@ wheels = [
465
469
  { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" },
466
470
  ]
467
471
 
472
+ [[package]]
473
+ name = "iniconfig"
474
+ version = "2.3.0"
475
+ source = { registry = "https://pypi.org/simple" }
476
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
477
+ wheels = [
478
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
479
+ ]
480
+
468
481
  [[package]]
469
482
  name = "librt"
470
483
  version = "0.13.0"
@@ -744,6 +757,15 @@ wheels = [
744
757
  { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
745
758
  ]
746
759
 
760
+ [[package]]
761
+ name = "pluggy"
762
+ version = "1.6.0"
763
+ source = { registry = "https://pypi.org/simple" }
764
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
765
+ wheels = [
766
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
767
+ ]
768
+
747
769
  [[package]]
748
770
  name = "propcache"
749
771
  version = "0.5.2"
@@ -864,6 +886,35 @@ wheels = [
864
886
  { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
865
887
  ]
866
888
 
889
+ [[package]]
890
+ name = "pytest"
891
+ version = "9.0.3"
892
+ source = { registry = "https://pypi.org/simple" }
893
+ dependencies = [
894
+ { name = "colorama", marker = "sys_platform == 'win32'" },
895
+ { name = "iniconfig" },
896
+ { name = "packaging" },
897
+ { name = "pluggy" },
898
+ { name = "pygments" },
899
+ ]
900
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
901
+ wheels = [
902
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
903
+ ]
904
+
905
+ [[package]]
906
+ name = "pytest-asyncio"
907
+ version = "1.4.0"
908
+ source = { registry = "https://pypi.org/simple" }
909
+ dependencies = [
910
+ { name = "pytest" },
911
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
912
+ ]
913
+ sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
914
+ wheels = [
915
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
916
+ ]
917
+
867
918
  [[package]]
868
919
  name = "requirements-parser"
869
920
  version = "0.13.1"
@@ -1,90 +0,0 @@
1
- import ipaddress
2
- import ssl
3
- from collections.abc import Sequence
4
- from typing import (
5
- Any,
6
- Literal,
7
- )
8
-
9
- import aiohttp
10
- import certifi
11
-
12
- from .validations import (
13
- validate_local_request,
14
- validate_url,
15
- )
16
-
17
-
18
- def get_secure_connector(
19
- *,
20
- allow_local_network: bool,
21
- allow_localhost: bool,
22
- ) -> type[aiohttp.TCPConnector]:
23
- class SecureTCPConnector(aiohttp.TCPConnector):
24
- async def _resolve_host(
25
- self,
26
- host: str,
27
- port: int,
28
- traces: Sequence[aiohttp.tracing.Trace] | None = None,
29
- ) -> list[aiohttp.abc.ResolveResult]:
30
- hosts = await super()._resolve_host(host, port, traces)
31
- resolved_ips = [ipaddress.ip_address(host["host"]) for host in hosts]
32
- validate_local_request(
33
- resolved_ips,
34
- allow_local_network=allow_local_network,
35
- allow_localhost=allow_localhost,
36
- )
37
- return hosts
38
-
39
- return SecureTCPConnector
40
-
41
-
42
- async def request( # noqa: PLR0913
43
- url: str,
44
- *,
45
- method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
46
- allow_local_network: bool = False,
47
- allow_localhost: bool = False,
48
- ascii_only: bool = False,
49
- dns_rebind_protection: bool = True,
50
- enforce_sanitization: bool = False,
51
- headers: dict[str, str] | None = None,
52
- json: Any | None = None, # noqa: ANN401
53
- ports: list[int] | None = None,
54
- schemes: list[str] | None = None,
55
- timeout: int = 10, # noqa: ASYNC109
56
- ) -> aiohttp.ClientResponse:
57
- validate_url(
58
- url,
59
- ascii_only=ascii_only,
60
- enforce_sanitization=enforce_sanitization,
61
- ports=ports or [],
62
- schemes=schemes or [],
63
- )
64
- connector = (
65
- get_secure_connector(
66
- allow_local_network=allow_local_network,
67
- allow_localhost=allow_localhost,
68
- )
69
- if dns_rebind_protection
70
- else aiohttp.TCPConnector
71
- )
72
- connection = connector(
73
- ssl=ssl.create_default_context(cafile=certifi.where()),
74
- )
75
-
76
- async with (
77
- aiohttp.ClientSession(
78
- connector=connection,
79
- headers=headers,
80
- ) as session,
81
- session.request(
82
- method,
83
- url,
84
- allow_redirects=not dns_rebind_protection,
85
- json=json,
86
- timeout=aiohttp.ClientTimeout(total=timeout),
87
- ) as response,
88
- ):
89
- await response.read()
90
- return response