async-requests-tls 0.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.
@@ -0,0 +1,220 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleksandr Shcherbinin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: async-requests-tls
3
+ Version: 0.1.0
4
+ Summary: Async HTTP/1.1 client whose TLS handshake is identical to the `requests` library.
5
+ Project-URL: Homepage, https://github.com/OleksandrShcherbinin/async-requests
6
+ Project-URL: Source, https://github.com/OleksandrShcherbinin/async-requests
7
+ Project-URL: Issues, https://github.com/OleksandrShcherbinin/async-requests/issues
8
+ Author: Oleksandr Shcherbinin
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Oleksandr Shcherbinin
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: async,fingerprint,http,ja3,requests,tls
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Framework :: AsyncIO
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Internet :: WWW/HTTP
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Classifier: Typing :: Typed
43
+ Requires-Python: >=3.13
44
+ Requires-Dist: urllib3>=2.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
47
+ Requires-Dist: pytest>=8; extra == 'dev'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # async-requests-tls
51
+
52
+ Async Python HTTP/1.1 client whose TLS ClientHello is **byte-for-byte
53
+ identical** to what the [`requests`](https://pypi.org/project/requests/)
54
+ library sends — same JA3, same JA4. Useful when a site fingerprints the TLS
55
+ handshake and rejects modern async clients (`httpx`, `niquests`, …) while
56
+ happily accepting plain `requests`.
57
+
58
+ The trick: `requests` builds its `ssl.SSLContext` through
59
+ `urllib3.util.ssl_.create_urllib3_context()`. So do we. The context is then
60
+ fed into `asyncio.open_connection(..., ssl=ctx)`, so both stacks drive the
61
+ same OpenSSL build with the same cipher list, options, ALPN, curves, and
62
+ sigalgs.
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ pip install async-requests-tls
68
+ # or with uv:
69
+ uv add async-requests-tls
70
+ ```
71
+
72
+ > The PyPI distribution name is `async-requests-tls` but the Python import
73
+ > name is `async_requests_tls`.
74
+
75
+ For development from a clone of this repo:
76
+
77
+ ```bash
78
+ pip install -e .[dev]
79
+ # or:
80
+ uv sync --extra dev
81
+ ```
82
+
83
+ Requires Python 3.13+ and `urllib3>=2.0`.
84
+
85
+ ## Quickstart
86
+
87
+ ```python
88
+ import asyncio
89
+ from async_requests_tls import AsyncSession
90
+
91
+ async def main():
92
+ async with AsyncSession() as s:
93
+ # GET
94
+ r = await s.get("https://httpbin.org/get", params={"hello": "world"})
95
+ print(r.status_code, r.json())
96
+
97
+ # POST with JSON
98
+ r = await s.post("https://httpbin.org/post", json={"x": 1})
99
+ print(r.json())
100
+
101
+ # Session-wide base_url, headers, and cookie jar
102
+ async with AsyncSession(
103
+ base_url="https://api.example.com",
104
+ headers={"X-Token": "secret"},
105
+ ) as api:
106
+ await api.get("/users") # GET https://api.example.com/users
107
+ await api.post("/orders", data={"sku": "abc"})
108
+
109
+ asyncio.run(main())
110
+ ```
111
+
112
+ `AsyncSession` supports `get` / `post` / `put` / `patch` / `delete` /
113
+ `head` / `options`, with a `requests`-style API: `params`, `headers`,
114
+ `data`, `json`, `cookies`, `allow_redirects`. Responses expose
115
+ `status_code`, `headers`, `content`, `text`, `json()`, `ok`, and
116
+ `cookies`.
117
+
118
+ ## Caveats
119
+
120
+ - **HTTP/1.1 only.** ALPN defaults to `["http/1.1"]` so the server never
121
+ upgrades us to h2. If you pass `alpn=alpn_protocols_like_urllib3()` (to
122
+ match the *exact* ALPN bytes `requests` sends when urllib3-future is
123
+ installed) and the server picks `h2`, the session raises
124
+ `NotImplementedError` — the TLS handshake still matched, the wire
125
+ protocol did not.
126
+ - **No connection pooling, no proxies, no streaming, no multipart, no
127
+ HTTP/2.** This is a small, focused library.
128
+ - **JA3 parity ≠ detection-evasion parity.** Modern detectors also look at
129
+ HTTP/2 SETTINGS frames, header order (JA4_H), and Akamai-style timing.
130
+ This library only matches the TLS layer.
131
+
132
+ ## Testing
133
+
134
+ ```bash
135
+ uv run pytest # or: pytest
136
+ ```
137
+
138
+ All tests are unit tests — they don't open real sockets. The wire-level
139
+ tests drive `asyncio.StreamReader` with canned bytes; the session tests
140
+ patch `_send_once` with a fake.
141
+
142
+ ## Public API
143
+
144
+ ```python
145
+ from async_requests_tls import (
146
+ AsyncSession,
147
+ Response,
148
+ build_ssl_context, # if you want the SSLContext directly
149
+ alpn_protocols_like_urllib3, # exact ALPN list urllib3 would send
150
+ )
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT — see `LICENSE`.
@@ -0,0 +1,106 @@
1
+ # async-requests-tls
2
+
3
+ Async Python HTTP/1.1 client whose TLS ClientHello is **byte-for-byte
4
+ identical** to what the [`requests`](https://pypi.org/project/requests/)
5
+ library sends — same JA3, same JA4. Useful when a site fingerprints the TLS
6
+ handshake and rejects modern async clients (`httpx`, `niquests`, …) while
7
+ happily accepting plain `requests`.
8
+
9
+ The trick: `requests` builds its `ssl.SSLContext` through
10
+ `urllib3.util.ssl_.create_urllib3_context()`. So do we. The context is then
11
+ fed into `asyncio.open_connection(..., ssl=ctx)`, so both stacks drive the
12
+ same OpenSSL build with the same cipher list, options, ALPN, curves, and
13
+ sigalgs.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install async-requests-tls
19
+ # or with uv:
20
+ uv add async-requests-tls
21
+ ```
22
+
23
+ > The PyPI distribution name is `async-requests-tls` but the Python import
24
+ > name is `async_requests_tls`.
25
+
26
+ For development from a clone of this repo:
27
+
28
+ ```bash
29
+ pip install -e .[dev]
30
+ # or:
31
+ uv sync --extra dev
32
+ ```
33
+
34
+ Requires Python 3.13+ and `urllib3>=2.0`.
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ import asyncio
40
+ from async_requests_tls import AsyncSession
41
+
42
+ async def main():
43
+ async with AsyncSession() as s:
44
+ # GET
45
+ r = await s.get("https://httpbin.org/get", params={"hello": "world"})
46
+ print(r.status_code, r.json())
47
+
48
+ # POST with JSON
49
+ r = await s.post("https://httpbin.org/post", json={"x": 1})
50
+ print(r.json())
51
+
52
+ # Session-wide base_url, headers, and cookie jar
53
+ async with AsyncSession(
54
+ base_url="https://api.example.com",
55
+ headers={"X-Token": "secret"},
56
+ ) as api:
57
+ await api.get("/users") # GET https://api.example.com/users
58
+ await api.post("/orders", data={"sku": "abc"})
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ `AsyncSession` supports `get` / `post` / `put` / `patch` / `delete` /
64
+ `head` / `options`, with a `requests`-style API: `params`, `headers`,
65
+ `data`, `json`, `cookies`, `allow_redirects`. Responses expose
66
+ `status_code`, `headers`, `content`, `text`, `json()`, `ok`, and
67
+ `cookies`.
68
+
69
+ ## Caveats
70
+
71
+ - **HTTP/1.1 only.** ALPN defaults to `["http/1.1"]` so the server never
72
+ upgrades us to h2. If you pass `alpn=alpn_protocols_like_urllib3()` (to
73
+ match the *exact* ALPN bytes `requests` sends when urllib3-future is
74
+ installed) and the server picks `h2`, the session raises
75
+ `NotImplementedError` — the TLS handshake still matched, the wire
76
+ protocol did not.
77
+ - **No connection pooling, no proxies, no streaming, no multipart, no
78
+ HTTP/2.** This is a small, focused library.
79
+ - **JA3 parity ≠ detection-evasion parity.** Modern detectors also look at
80
+ HTTP/2 SETTINGS frames, header order (JA4_H), and Akamai-style timing.
81
+ This library only matches the TLS layer.
82
+
83
+ ## Testing
84
+
85
+ ```bash
86
+ uv run pytest # or: pytest
87
+ ```
88
+
89
+ All tests are unit tests — they don't open real sockets. The wire-level
90
+ tests drive `asyncio.StreamReader` with canned bytes; the session tests
91
+ patch `_send_once` with a fake.
92
+
93
+ ## Public API
94
+
95
+ ```python
96
+ from async_requests_tls import (
97
+ AsyncSession,
98
+ Response,
99
+ build_ssl_context, # if you want the SSLContext directly
100
+ alpn_protocols_like_urllib3, # exact ALPN list urllib3 would send
101
+ )
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT — see `LICENSE`.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "async-requests-tls"
3
+ version = "0.1.0"
4
+ description = "Async HTTP/1.1 client whose TLS handshake is identical to the `requests` library."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = { file = "LICENSE" }
8
+ authors = [{ name = "Oleksandr Shcherbinin" }]
9
+ keywords = ["async", "http", "requests", "tls", "ja3", "fingerprint"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: AsyncIO",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = ["urllib3>=2.0"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/OleksandrShcherbinin/async-requests"
27
+ Source = "https://github.com/OleksandrShcherbinin/async-requests"
28
+ Issues = "https://github.com/OleksandrShcherbinin/async-requests/issues"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/async_requests_tls"]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+ testpaths = ["tests"]
@@ -0,0 +1,30 @@
1
+ """async-requests-tls: async HTTP/1.1 client with a `requests`-identical TLS handshake.
2
+
3
+ The TLS ClientHello is byte-for-byte identical to what the `requests` library
4
+ sends — both build their `ssl.SSLContext` via urllib3's
5
+ `create_urllib3_context`, so the resulting JA3 / JA4 fingerprints match.
6
+
7
+ Quickstart::
8
+
9
+ import asyncio
10
+ from async_requests_tls import AsyncSession
11
+
12
+ async def main():
13
+ async with AsyncSession() as s:
14
+ r = await s.get("https://example.com")
15
+ print(r.status_code, len(r.text))
16
+
17
+ asyncio.run(main())
18
+ """
19
+
20
+ from ._ssl import alpn_protocols_like_urllib3, build_ssl_context
21
+ from .response import Response
22
+ from .session import AsyncSession
23
+
24
+ __all__ = [
25
+ "AsyncSession",
26
+ "Response",
27
+ "alpn_protocols_like_urllib3",
28
+ "build_ssl_context",
29
+ ]
30
+ __version__ = "0.1.0"
@@ -0,0 +1,100 @@
1
+ """Pure (no-I/O) encoding/decoding helpers: URLs, bodies, cookies, charsets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import json as _json
7
+ import zlib
8
+ from typing import Any
9
+ from urllib.parse import urlencode, urlsplit
10
+
11
+ __all__ = [
12
+ "parse_url",
13
+ "encode_params",
14
+ "encode_body",
15
+ "decompress",
16
+ "detect_encoding",
17
+ "extract_cookies",
18
+ ]
19
+
20
+
21
+ def parse_url(url: str) -> tuple[str, str, int, str]:
22
+ """Return (scheme, host, port, path-with-query)."""
23
+ parts = urlsplit(url)
24
+ scheme = parts.scheme.lower()
25
+ if scheme not in ("http", "https"):
26
+ raise ValueError(f"unsupported scheme: {scheme!r}")
27
+ host = parts.hostname or ""
28
+ port = parts.port or (443 if scheme == "https" else 80)
29
+ path = parts.path or "/"
30
+ if parts.query:
31
+ path = f"{path}?{parts.query}"
32
+ return scheme, host, port, path
33
+
34
+
35
+ def encode_params(url: str, params: dict[str, Any] | None) -> str:
36
+ if not params:
37
+ return url
38
+ parts = urlsplit(url)
39
+ encoded = urlencode(params, doseq=True)
40
+ new_query = f"{parts.query}&{encoded}" if parts.query else encoded
41
+ return parts._replace(query=new_query).geturl()
42
+
43
+
44
+ def encode_body(
45
+ data: dict[str, Any] | bytes | str | None,
46
+ json_payload: Any,
47
+ headers: dict[str, str],
48
+ ) -> bytes | None:
49
+ if json_payload is not None:
50
+ body = _json.dumps(json_payload).encode("utf-8")
51
+ headers.setdefault("Content-Type", "application/json")
52
+ headers["Content-Length"] = str(len(body))
53
+ return body
54
+ if data is None:
55
+ return None
56
+ if isinstance(data, bytes):
57
+ body = data
58
+ elif isinstance(data, str):
59
+ body = data.encode("utf-8")
60
+ elif isinstance(data, dict):
61
+ body = urlencode(data, doseq=True).encode("utf-8")
62
+ headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
63
+ else:
64
+ raise TypeError(f"unsupported body type: {type(data).__name__}")
65
+ headers["Content-Length"] = str(len(body))
66
+ return body
67
+
68
+
69
+ def decompress(content: bytes, headers: dict[str, str]) -> bytes:
70
+ encoding = headers.get("content-encoding", "").lower()
71
+ if not encoding or not content:
72
+ return content
73
+ if encoding == "gzip":
74
+ return gzip.decompress(content)
75
+ if encoding == "deflate":
76
+ try:
77
+ return zlib.decompress(content)
78
+ except zlib.error:
79
+ return zlib.decompress(content, -zlib.MAX_WBITS)
80
+ return content
81
+
82
+
83
+ def detect_encoding(headers: dict[str, str]) -> str | None:
84
+ ct = headers.get("content-type", "")
85
+ if "charset=" in ct:
86
+ return ct.split("charset=", 1)[1].split(";", 1)[0].strip()
87
+ return None
88
+
89
+
90
+ def extract_cookies(headers: dict[str, str]) -> dict[str, str]:
91
+ raw = headers.get("set-cookie")
92
+ if not raw:
93
+ return {}
94
+ out: dict[str, str] = {}
95
+ for line in raw.split("\n"):
96
+ first = line.split(";", 1)[0].strip()
97
+ if "=" in first:
98
+ name, _, value = first.partition("=")
99
+ out[name.strip()] = value.strip()
100
+ return out