Metadata-Version: 2.4
Name: rex-tls
Version: 2.3.0
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Dist: certifi>=2024
Requires-Dist: maturin>=1.9,<2 ; extra == 'dev'
Requires-Dist: pytest>=8,<10 ; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24,<2 ; extra == 'dev'
Requires-Dist: requests>=2.32,<3 ; extra == 'dev'
Requires-Dist: trustme>=1.2.1,<2 ; extra == 'dev'
Requires-Dist: websocket-client>=1.8,<2 ; extra == 'dev'
Requires-Dist: h2>=4.3,<4.4 ; python_full_version < '3.10' and extra == 'dev'
Requires-Dist: h2>=4.4,<5 ; python_full_version >= '3.10' and extra == 'dev'
Provides-Extra: dev
License-File: LICENSE
Summary: Python HTTP client with native Android Chrome and OkHttp TLS/HTTP2 profiles
Keywords: tls,http2,android,chrome,okhttp,boringssl
Home-Page: https://github.com/rex3129909440/rex-tls
Author: rex-tls contributors
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://github.com/rex3129909440/rex-tls#readme
Project-URL: Homepage, https://github.com/rex3129909440/rex-tls
Project-URL: Repository, https://github.com/rex3129909440/rex-tls

# rex-tls

`rex-tls` is a Python HTTP client backed by a native Rust TLS and HTTP core. It
provides a requests-style API with transport profiles for Android Chrome and
OkHttp, persistent sessions, cookies, proxies, streaming I/O, asynchronous
requests, and bounded connection pools.

## Installation

```bash
python -m pip install rex-tls
```

Python 3.9 or newer is required. Supported platforms are:

- Windows x86-64
- Linux x86-64 with manylinux 2.28 or newer

## Transport profiles

| Profile | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|:---:|:---:|:---:|
| `chrome_android_149` | Yes | Yes | Yes |
| `chrome_android_150` | Yes | Yes | Yes |
| `okhttp_4.12` | Yes | Yes | No |
| `okhttp_5.4` | Yes | Yes | No |

`chrome_android` and `chrome_android_latest` are aliases for
`chrome_android_150`. `okhttp` and `okhttp_latest` are aliases for `okhttp_5.4`.
Use the versioned names when reproducibility matters.

## Quick start

```python
import rex_tls

response = rex_tls.get(
    "https://example.com/",
    profile="chrome_android_150",
    params={"page": 1},
    timeout=20,
)

response.raise_for_status()
print(response.status_code)
print(response.http_version)
print(response.text)
```

JSON and form requests use familiar keyword arguments:

```python
response = rex_tls.post(
    "https://api.example.com/items",
    profile="okhttp_5.4",
    json={"name": "example", "enabled": True},
)

response = rex_tls.post(
    "https://api.example.com/form",
    profile="okhttp_4.12",
    data={"name": "example"},
)
```

## Persistent sessions

A `Session` keeps cookies, connections, and TLS session state across requests.

```python
from rex_tls import Session

with Session(
    profile="chrome_android_150",
    timeout=20,
) as session:
    session.headers.update({"accept": "application/json"})
    session.cookies.set("locale", "en-US")

    first = session.get("https://example.com/api/profile")
    second = session.get("https://example.com/api/settings")

    print(second.connection_reused)
    print(session.cookies.get_dict())
```

HTTP/1.1 concurrency can be bounded per origin and proxy route:

```python
with Session(
    profile="okhttp_5.4",
    max_connections_per_route=4,
) as session:
    ...
```

The default is `1`. HTTP/2 continues to multiplex streams on one connection;
additional connections are opened only after the route has negotiated HTTP/1.1
and every existing route connection is busy.

Per-request cookies are supported and do not modify the session jar:

```python
response = session.get(
    "https://example.com/api/items",
    params={"page": 2},
    cookies={"request-only": "value"},
    headers={"accept": "application/json"},
)
```

## Cookies

`Session.cookies` provides the commonly used requests-style CookieJar methods:

```python
session.cookies.update({"theme": "dark"})
session.cookies.set(
    "api-token",
    "value",
    domain="api.example.com",
    path="/v1",
    secure=True,
)

print(session.cookies.get("theme"))
print(session.cookies.get_dict())
print(session.cookies.items())

session.cookies.clear(domain="api.example.com", path="/v1", name="api-token")
```

Supported helpers include `set()`, `set_cookie()`, `get()`, `get_dict()`,
`update()`, `clear()`, `keys()`, `values()`, `items()`, `list_domains()`, and
`list_paths()`. Response cookies are validated against domain, path, Secure,
expiry, IP-address, and public-suffix rules before they enter the jar.

## Proxies

Use `proxy=` as a single proxy for all supported routes:

```python
proxy = "http://username:password@proxy.example:8080"

with Session("okhttp_5.4", proxy=proxy) as session:
    response = session.get("https://example.com/")
```

Or use a requests-style mapping:

```python
proxies = {
    "http": "http://proxy.example:8080",
    "https": "http://proxy.example:8080",
    "all": "http://fallback.example:8080",
    "no_proxy": ".internal.example,localhost,127.0.0.1",
}

with Session("chrome_android_149", proxies=proxies) as session:
    response = session.get("https://example.com/")
```

`Session.proxies` is mutable. A per-request `proxies=` mapping overrides the
session mapping. Set `trust_env=True` to read `HTTP_PROXY`, `HTTPS_PROXY`,
`ALL_PROXY`, and `NO_PROXY`.

Proxy URLs must use `http://`. HTTPS destinations use HTTP CONNECT. Basic proxy
credentials are supported; percent-encode reserved characters in usernames and
passwords. HTTPS proxies, SOCKS, PAC, NTLM/Digest, and MASQUE are not supported.

## HTTP version selection

The negotiated protocol is available as `response.http_version`.

Require an actual HTTP/2 connection with `http2=True`:

```python
with Session("chrome_android_150", http2=True) as session:
    response = session.get("https://example.com/")
    assert response.http_version == "HTTP/2"
```

`http2=True` preserves the profile ALPN list and rejects the response if the
server does not negotiate HTTP/2. It supports HTTPS only and is mutually
exclusive with HTTP/3 modes.

Chrome profiles support the following `http3` values:

| Value | Behavior |
|---|---|
| `off` | Disable HTTP/3. This is the default. |
| `auto` | Enable the Chrome HTTP/3 path with HTTP/2 or HTTP/1.1 fallback. |
| `only` | Require HTTP/3 and fail if it cannot be used. |

```python
with Session("chrome_android_150", http3="auto") as session:
    response = session.get("https://example.com/")

with Session("chrome_android_149", http3="only") as session:
    response = session.get("https://example.com/")
    assert response.http_version == "HTTP/3"
```

HTTP/3 requires HTTPS and a versioned Chrome profile. OkHttp profiles do not
enable HTTP/3. Conventional HTTP proxies cannot carry the QUIC path, so
`http3="only"` is rejected when such a proxy is selected.

## Request headers

Headers may be supplied as a mapping or as ordered `(name, value)` pairs. The
native core applies the selected profile's protocol-specific ordering and casing
rules before transmission.

If both session and request headers are empty, the profile's default request
headers are used. A non-empty caller header set is sent without adding unrelated
profile defaults. Protocol-required fields and fields derived from cookies or the
request body may still be generated.

HTTP/1.1 preserves the profile's observable field-name casing. HTTP/2 and HTTP/3
field names are lowercase as required by those protocols. Hop-by-hop HTTP/1.1
fields such as `Connection` are rejected for HTTP/2 and HTTP/3 instead of being
silently removed.

## Streaming downloads

Use a context manager so the connection is released when the body reaches EOF or
the response is closed:

```python
from rex_tls import Session

with Session("chrome_android_150") as session:
    with session.get("https://example.com/large.bin", stream=True) as response:
        response.raise_for_status()
        with open("large.bin", "wb") as output:
            for chunk in response.iter_content(64 * 1024):
                output.write(chunk)
```

`iter_lines()`, `raw.read()`, and `raw.readinto()` are also available. Set
`decode_content=False` to receive the compressed response body without automatic
content decoding.

## Streaming uploads and multipart files

File objects and byte iterables are uploaded incrementally:

```python
with Session("okhttp_5.4") as session:
    with open("large.bin", "rb") as source:
        response = session.post(
            "https://example.com/upload",
            content=source,
        )

    with open("image.png", "rb") as source:
        response = session.post(
            "https://example.com/form",
            data={"title": "example"},
            files={"file": ("image.png", source, "image/png")},
        )
```

Seekable upload sources can be replayed across 307/308 redirects. A one-shot
source raises `UnrewindableBodyError` if a redirect requires replay.

## Async API

`AsyncSession` provides an asyncio interface without blocking the event loop:

```python
import asyncio
from rex_tls import AsyncSession

async def main() -> None:
    async with AsyncSession(
        "okhttp_5.4",
        max_concurrency=8,
        http2=True,
    ) as session:
        urls = [f"https://example.com/items/{item}" for item in range(10)]
        responses = await asyncio.gather(*(session.get(url) for url in urls))
        print([response.status_code for response in responses])

asyncio.run(main())
```

Async streaming uses `aiter_content()` and `aiter_lines()`:

```python
from rex_tls import AsyncSession

async def stream_events(session: AsyncSession) -> None:
    response = await session.get("https://example.com/events", stream=True)
    async with response:
        async for line in response.aiter_lines():
            print(line)
```

## Bounded session pools

`SessionPool` and `AsyncSessionPool` create multiple independent native sessions
with a fixed concurrency limit.

```python
from concurrent.futures import ThreadPoolExecutor
from rex_tls import SessionPool

urls = [f"https://example.com/items/{item}" for item in range(20)]

with SessionPool(
    "okhttp_4.12",
    max_connections=8,
    session_mode="shared",
) as pool:
    pool.headers["accept"] = "application/json"
    with ThreadPoolExecutor(max_workers=16) as executor:
        responses = list(executor.map(pool.get, urls))
```

Pool session modes:

- `shared`: members share headers, proxy configuration, and one thread-safe
  CookieJar. Connections remain independent.
- `isolated`: each member owns independent headers, proxy configuration, cookies,
  connections, and TLS state.

Lease a specific member when multiple requests must keep the same isolated state:

```python
from rex_tls import SessionPool

with SessionPool(
    "okhttp_5.4",
    max_connections=4,
    session_mode="isolated",
) as pool:
    with pool.acquire(timeout=1) as account:
        account.headers["authorization"] = "Bearer example-token"
        account.cookies.set("account", "one")
        account.get("https://example.com/step-1")
        account.get("https://example.com/step-2")
```

Use `pool_timeout=` to limit how long a request waits for a pool member. The
ordinary `timeout=` argument continues to control the network request.

## Response API

Common response attributes and methods include:

- `status_code`, `reason`, `url`, and `http_version`
- `headers`, including `get_all()` and the ordered `raw` view
- `content`, `text`, and `json()`
- `ok` and `raise_for_status()`
- `history`
- `elapsed_seconds`
- `local_address` and `remote_address`
- `connection_reused` and `tls_session_reused`
- `iter_content()`, `iter_lines()`, `close()`, and `aclose()`

## Requests compatibility

The API directly supports common requests-style arguments and attributes:

- HTTP method helpers and `Session`
- `params`, `headers`, `data`, `json`, `files`, and `cookies`
- `timeout`, `verify`, `proxy`, and `proxies`
- `Session.headers`, `Session.proxies`, `Session.cookies`, and `trust_env`
- `stream=True`, `iter_content()`, `iter_lines()`, and raw reads
- per-request `allow_redirects`
- `.content`, `.text`, `.json()`, and `.raise_for_status()`

It is not a drop-in replacement for every requests extension point. `auth`,
`hooks`, `adapters`, `mount`, and custom RequestsCookieJar policy objects are not
implemented.

## TLS verification

Certificate verification is enabled by default and uses the installed `certifi`
CA bundle.

```python
# Default CA bundle
Session("chrome_android_150", verify=True)

# Custom CA file
Session("chrome_android_150", verify="/path/to/private-ca.pem")

# Disable verification explicitly
Session("chrome_android_150", verify=False)
```

Disabling verification removes server identity protection and should only be used
in controlled test environments.

## Error handling

```python
import rex_tls

try:
    response = rex_tls.get(
        "https://example.com/",
        profile="chrome_android_150",
        timeout=10,
    )
    response.raise_for_status()
except rex_tls.HTTPError as exc:
    print(f"HTTP error: {exc}")
except rex_tls.RequestError as exc:
    print(f"Request failed: {exc}")
except rex_tls.MobileTLSError as exc:
    print(f"Native client error: {exc}")
except (TypeError, ValueError) as exc:
    print(f"Invalid configuration: {exc}")
```

Additional exceptions include `CookieConflictError`, `ContentDecodingError`,
`InvalidHeader`, `InvalidURL`, `SessionClosedError`, `StreamClosedError`,
`StreamConsumedError`, and `UnrewindableBodyError`.

## Local performance comparison

The following results were measured on Windows 11 with CPython 3.14 using
rex-tls 2.3.0, curl_cffi 0.15.0, never_primp 3.0.3, and httpcloak 1.6.5. Each
cell is `median RPS / median P95 ms` from three isolated process runs. Every run
used 128 measured requests, 32 warmups, a warm client, an empty request and
response body, and a localhost server with a fixed 10 ms response delay. P95
includes time waiting behind the selected concurrency bound.

HTTP/1.1 used a plain localhost server and each library's synchronous API, so
this table does not include TLS handshake or resumption time. rex-tls used
`okhttp_4.12` with `max_connections_per_route` equal to concurrency;
never_primp used its Android OkHttp preset; curl_cffi used `chrome`; and
httpcloak used a bounded `chrome-146` Session pool.

| Client | c=1 | c=8 | c=32 | c=128 |
|---|---:|---:|---:|---:|
| rex-tls | 93.5 / 11.0 | 731.9 / 171.6 | 2,610.6 / 47.3 | 3,753.6 / 19.6 |
| curl_cffi | 92.4 / 11.2 | 716.1 / 174.8 | 2,013.8 / 60.7 | 2,372.1 / 40.0 |
| never_primp | 94.1 / 10.9 | 740.0 / 170.3 | 2,720.1 / 45.6 | 4,553.3 / 17.3 |
| httpcloak | 93.5 / 11.0 | 702.4 / 180.7 | 2,261.2 / 55.4 | 3,695.8 / 32.5 |

HTTP/2 used a localhost TLS server with ALPN fixed to `h2` and the native async
APIs of rex-tls, curl_cffi, and never_primp.
httpcloak's async Session was not stable under concurrent reuse in this runtime,
so its published row uses a bounded pool of synchronous HTTP/2 Sessions through
worker threads. rex-tls and never_primp reused one H2 connection in the measured
phase; curl_cffi and the httpcloak pool used multiple connections as concurrency
increased.

| Client | c=1 | c=8 | c=32 | c=128 |
|---|---:|---:|---:|---:|
| rex-tls | 64.5 / 1,893.4 | 298.8 / 412.7 | 1,095.2 / 114.5 | 1,851.6 / 66.6 |
| curl_cffi | 64.4 / 1,894.1 | 499.8 / 255.2 | 1,482.7 / 84.3 | 2,944.8 / 38.6 |
| never_primp | 63.4 / 1,890.6 | 495.4 / 257.8 | 1,785.7 / 70.9 | 2,236.8 / 56.0 |
| httpcloak | 64.8 / 1,882.0 | 473.9 / 254.6 | 1,071.7 / 116.2 | 1,084.8 / 111.8 |

The TLS-resumption check retained one synchronous Session while the TLS/H1
server closed every response connection. Each row is the median of three
isolated runs with 128 measured requests, eight warmups, concurrency 1, and the
same 10 ms server delay. `Resumed` is counted by the server from
`SSLSocket.session_reused`, not inferred from client timing.

| Client | RPS | P95 ms | Handshakes | Resumed |
|---|---:|---:|---:|---:|
| rex-tls | 85.6 | 12.9 | 128 | 128 |
| curl_cffi | 70.3 | 32.8 | 128 | 128 |
| never_primp | 72.3 | 30.2 | 128 | 0 |
| httpcloak | 82.9 | 13.0 | 128 | 0 |

The controlled H3 benchmark applies only to rex-tls because its server validates
the exact selected mobile profile instead of accepting a different client's
preset. It used one QUIC connection, 128 measured requests, at least 32 warmups,
1 ms response pacing, and five isolated runs. Cells are `median RPS / median
P95 ms`; P95 remains queue-inclusive.

| Profile | c=1 | c=8 | c=32 | c=128 |
|---|---:|---:|---:|---:|
| chrome_android_149 | 91.6 / 1,331.6 | 666.4 / 182.1 | 660.1 / 182.6 | 678.3 / 180.7 |
| chrome_android_150 | 91.4 / 1,335.0 | 672.2 / 179.0 | 661.0 / 181.8 | 668.7 / 183.3 |

These loopback results compare client scheduling and transport overhead; they do
not establish universal network performance or fingerprint equivalence between
different presets. The summary JSON SHA-256 values are
`21568fe7eac07157563167b77d5fa485ce7d165ded11d7754e9b1db721169b47`
for H1 and
`b3afb03b5845df52670a7dc2543e19b420f1f7e7fd2a3fd72a4a8a301e2653c8`
for H2. Reproduce the matrix with `tools/run_release_benchmarks.py`; raw result
files and resource metrics remain outside the distribution. The H1 TLS
resumption summary SHA-256 is
`a17faab8a594fc6765c17f19ddc1fe75015b39efada5d69d87cf6413118d469a`.
The five-round H3 summary hashes are
`ac9c08355cafa70be409b22c6f1e4422346c5ff372c5f4c19deed70b13f1795b`
for Chrome Android 149 and
`37d7b23156f2fb2912fa6421c29109e2b1ebe8519d9e0c59f155874c85220d3d`
for Chrome Android 150.

Extended stress gates cap the H2 Pool at 32 physical connections. On Windows,
long proxy-cold churn can return OS error 997 after hundreds of short-lived
CONNECT/TLS sockets, so only its bounded functional smoke is a release gate;
TLS ciphertext and request bodies are never automatically replayed. A regular
HTTP CONNECT proxy also cannot carry QUIC, so H3 proxying would require a
separate MASQUE/UDP-tunnel implementation and is not supported by this release.

## Runtime information

```python
import rex_tls

print(rex_tls.__version__)
print(rex_tls.profiles())
print(dict(rex_tls.native_versions()))
print(rex_tls.profile_info("chrome_android_150"))
```

## License

MIT

