Compare commits

..

2 Commits

Author SHA1 Message Date
Kar Petrosyan
f88fa846d9 changelog 2025-06-10 23:13:35 +04:00
Kar Petrosyan
2f737cadc1 feat: add socket_options to Client and AsyncClient classes 2025-06-10 23:03:04 +04:00
25 changed files with 77 additions and 91 deletions

View File

@ -15,9 +15,9 @@ jobs:
steps: steps:
- uses: "actions/checkout@v4" - uses: "actions/checkout@v4"
- uses: "actions/setup-python@v6" - uses: "actions/setup-python@v5"
with: with:
python-version: 3.9 python-version: 3.8
- name: "Install dependencies" - name: "Install dependencies"
run: "scripts/install" run: "scripts/install"
- name: "Build package & docs" - name: "Build package & docs"

View File

@ -14,11 +14,11 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: "actions/checkout@v4" - uses: "actions/checkout@v4"
- uses: "actions/setup-python@v6" - uses: "actions/setup-python@v5"
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
allow-prereleases: true allow-prereleases: true

View File

@ -4,15 +4,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [UNRELEASED] ## Development
### Removed * Add `socket_options` to `Client` and `AsyncClient` classes. (#3587)
* Drop support for Python 3.8
### Added
* Expose `FunctionAuth` from the public API. (#3699)
## 0.28.1 (6th December, 2024) ## 0.28.1 (6th December, 2024)

View File

@ -101,7 +101,7 @@ Or, to include the optional HTTP/2 support, use:
$ pip install httpx[http2] $ pip install httpx[http2]
``` ```
HTTPX requires Python 3.9+. HTTPX requires Python 3.8+.
## Documentation ## Documentation

View File

@ -29,7 +29,7 @@ import certifi
import httpx import httpx
import ssl import ssl
# This SSL context is equivalent to the default `verify=True`. # This SSL context is equivelent to the default `verify=True`.
ctx = ssl.create_default_context(cafile=certifi.where()) ctx = ssl.create_default_context(cafile=certifi.where())
client = httpx.Client(verify=ctx) client = httpx.Client(verify=ctx)
``` ```
@ -71,7 +71,19 @@ client = httpx.Client(verify=ctx)
### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR` ### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR`
`httpx` does respect the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables by default. For details, refer to [the section on the environment variables page](../environment_variables.md#ssl_cert_file). Unlike `requests`, the `httpx` package does not automatically pull in [the environment variables `SSL_CERT_FILE` or `SSL_CERT_DIR`](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_default_verify_paths.html). If you want to use these they need to be enabled explicitly.
For example...
```python
# Use `SSL_CERT_FILE` or `SSL_CERT_DIR` if configured.
# Otherwise default to certifi.
ctx = ssl.create_default_context(
cafile=os.environ.get("SSL_CERT_FILE", certifi.where()),
capath=os.environ.get("SSL_CERT_DIR"),
)
client = httpx.Client(verify=ctx)
```
### Making HTTPS requests to a local server ### Making HTTPS requests to a local server

View File

@ -23,7 +23,7 @@ To make asynchronous requests, you'll need an `AsyncClient`.
``` ```
!!! tip !!! tip
Use [IPython](https://ipython.readthedocs.io/en/stable/) or Python 3.9+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console. Use [IPython](https://ipython.readthedocs.io/en/stable/) or Python 3.8+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console.
## API Differences ## API Differences

View File

@ -226,7 +226,3 @@ For both query params (`params=`) and form data (`data=`), `requests` supports s
In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response. In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response.
If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports). If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports).
## Exceptions and Errors
`requests` exception hierarchy is slightly different to the `httpx` exception hierarchy. `requests` exposes a top level `RequestException`, where as `httpx` exposes a top level `HTTPError`. see the exceptions exposes in requests [here](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/). See the `httpx` error hierarchy [here](https://www.python-httpx.org/exceptions/).

View File

@ -51,29 +51,3 @@ python -c "import httpx; httpx.get('http://example.com')"
python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')" python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')"
python -c "import httpx; httpx.get('https://www.python-httpx.org')" python -c "import httpx; httpx.get('https://www.python-httpx.org')"
``` ```
## `SSL_CERT_FILE`
Valid values: a filename
If this environment variable is set then HTTPX will load
CA certificate from the specified file instead of the default
location.
Example:
```console
SSL_CERT_FILE=/path/to/ca-certs/ca-bundle.crt python -c "import httpx; httpx.get('https://example.com')"
```
## `SSL_CERT_DIR`
Valid values: a directory following an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html).
If this environment variable is set and the directory follows an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html) (ie. you ran `c_rehash`) then HTTPX will load CA certificates from this directory instead of the default location.
Example:
```console
SSL_CERT_DIR=/path/to/ca-certs/ python -c "import httpx; httpx.get('https://example.com')"
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -145,6 +145,6 @@ To include the optional brotli and zstandard decoders support, use:
$ pip install httpx[brotli,zstd] $ pip install httpx[brotli,zstd]
``` ```
HTTPX requires Python 3.9+ HTTPX requires Python 3.8+
[sync-support]: https://github.com/encode/httpx/issues/572 [sync-support]: https://github.com/encode/httpx/issues/572

View File

@ -20,6 +20,8 @@ httpx.get("https://www.example.com")
Will send debug level output to the console, or wherever `stdout` is directed too... Will send debug level output to the console, or wherever `stdout` is directed too...
``` ```
DEBUG [2024-09-28 17:27:40] httpx - load_ssl_context verify=True cert=None
DEBUG [2024-09-28 17:27:40] httpx - load_verify_locations cafile='/Users/karenpetrosyan/oss/karhttpx/.venv/lib/python3.9/site-packages/certifi/cacert.pem'
DEBUG [2024-09-28 17:27:40] httpcore.connection - connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 socket_options=None DEBUG [2024-09-28 17:27:40] httpcore.connection - connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 socket_options=None
DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0> DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0>
DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.started ssl_context=SSLContext(verify=True) server_hostname='www.example.com' timeout=5.0 DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.started ssl_context=SSLContext(verify=True) server_hostname='www.example.com' timeout=5.0

View File

@ -191,7 +191,7 @@ You can also explicitly set the filename and content type, by using a tuple
of items for the file value: of items for the file value:
```pycon ```pycon
>>> with open('report.xls', 'rb') as report_file: >>> with open('report.xls', 'rb') report_file:
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')} ... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
... r = httpx.post("https://httpbin.org/post", files=files) ... r = httpx.post("https://httpbin.org/post", files=files)
>>> print(r.text) >>> print(r.text)

View File

@ -24,12 +24,6 @@ Provides authentication classes to be used with HTTPX's [authentication paramete
This package adds caching functionality to HTTPX This package adds caching functionality to HTTPX
### httpx-secure
[GitHub](https://github.com/Zaczero/httpx-secure)
Drop-in SSRF protection for httpx with DNS caching and custom validation support.
### httpx-socks ### httpx-socks
[GitHub](https://github.com/romis2012/httpx-socks) [GitHub](https://github.com/romis2012/httpx-socks)

View File

@ -50,7 +50,6 @@ __all__ = [
"DecodingError", "DecodingError",
"delete", "delete",
"DigestAuth", "DigestAuth",
"FunctionAuth",
"get", "get",
"head", "head",
"Headers", "Headers",

View File

@ -16,7 +16,7 @@ if typing.TYPE_CHECKING: # pragma: no cover
from hashlib import _Hash from hashlib import _Hash
__all__ = ["Auth", "BasicAuth", "DigestAuth", "FunctionAuth", "NetRCAuth"] __all__ = ["Auth", "BasicAuth", "DigestAuth", "NetRCAuth"]
class Auth: class Auth:

View File

@ -29,7 +29,7 @@ from ._exceptions import (
from ._models import Cookies, Headers, Request, Response from ._models import Cookies, Headers, Request, Response
from ._status_codes import codes from ._status_codes import codes
from ._transports.base import AsyncBaseTransport, BaseTransport from ._transports.base import AsyncBaseTransport, BaseTransport
from ._transports.default import AsyncHTTPTransport, HTTPTransport from ._transports.default import SOCKET_OPTION, AsyncHTTPTransport, HTTPTransport
from ._types import ( from ._types import (
AsyncByteStream, AsyncByteStream,
AuthTypes, AuthTypes,
@ -653,6 +653,7 @@ class Client(BaseClient):
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False, follow_redirects: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
max_redirects: int = DEFAULT_MAX_REDIRECTS, max_redirects: int = DEFAULT_MAX_REDIRECTS,
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
base_url: URL | str = "", base_url: URL | str = "",
@ -693,6 +694,7 @@ class Client(BaseClient):
http2=http2, http2=http2,
limits=limits, limits=limits,
transport=transport, transport=transport,
socket_options=socket_options,
) )
self._mounts: dict[URLPattern, BaseTransport | None] = { self._mounts: dict[URLPattern, BaseTransport | None] = {
URLPattern(key): None URLPattern(key): None
@ -705,6 +707,7 @@ class Client(BaseClient):
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
socket_options=socket_options,
) )
for key, proxy in proxy_map.items() for key, proxy in proxy_map.items()
} }
@ -723,6 +726,7 @@ class Client(BaseClient):
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
transport: BaseTransport | None = None, transport: BaseTransport | None = None,
) -> BaseTransport: ) -> BaseTransport:
if transport is not None: if transport is not None:
@ -735,6 +739,7 @@ class Client(BaseClient):
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
socket_options=socket_options,
) )
def _init_proxy_transport( def _init_proxy_transport(
@ -746,6 +751,7 @@ class Client(BaseClient):
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> BaseTransport: ) -> BaseTransport:
return HTTPTransport( return HTTPTransport(
verify=verify, verify=verify,
@ -755,6 +761,7 @@ class Client(BaseClient):
http2=http2, http2=http2,
limits=limits, limits=limits,
proxy=proxy, proxy=proxy,
socket_options=socket_options,
) )
def _transport_for_url(self, url: URL) -> BaseTransport: def _transport_for_url(self, url: URL) -> BaseTransport:
@ -1366,6 +1373,7 @@ class AsyncClient(BaseClient):
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False, follow_redirects: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
max_redirects: int = DEFAULT_MAX_REDIRECTS, max_redirects: int = DEFAULT_MAX_REDIRECTS,
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
base_url: URL | str = "", base_url: URL | str = "",
@ -1407,6 +1415,7 @@ class AsyncClient(BaseClient):
http2=http2, http2=http2,
limits=limits, limits=limits,
transport=transport, transport=transport,
socket_options=socket_options,
) )
self._mounts: dict[URLPattern, AsyncBaseTransport | None] = { self._mounts: dict[URLPattern, AsyncBaseTransport | None] = {
@ -1420,6 +1429,7 @@ class AsyncClient(BaseClient):
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
socket_options=socket_options,
) )
for key, proxy in proxy_map.items() for key, proxy in proxy_map.items()
} }
@ -1437,6 +1447,7 @@ class AsyncClient(BaseClient):
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
transport: AsyncBaseTransport | None = None, transport: AsyncBaseTransport | None = None,
) -> AsyncBaseTransport: ) -> AsyncBaseTransport:
if transport is not None: if transport is not None:
@ -1449,6 +1460,7 @@ class AsyncClient(BaseClient):
http1=http1, http1=http1,
http2=http2, http2=http2,
limits=limits, limits=limits,
socket_options=socket_options,
) )
def _init_proxy_transport( def _init_proxy_transport(
@ -1460,6 +1472,7 @@ class AsyncClient(BaseClient):
http1: bool = True, http1: bool = True,
http2: bool = False, http2: bool = False,
limits: Limits = DEFAULT_LIMITS, limits: Limits = DEFAULT_LIMITS,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> AsyncBaseTransport: ) -> AsyncBaseTransport:
return AsyncHTTPTransport( return AsyncHTTPTransport(
verify=verify, verify=verify,
@ -1469,6 +1482,7 @@ class AsyncClient(BaseClient):
http2=http2, http2=http2,
limits=limits, limits=limits,
proxy=proxy, proxy=proxy,
socket_options=socket_options,
) )
def _transport_for_url(self, url: URL) -> AsyncBaseTransport: def _transport_for_url(self, url: URL) -> AsyncBaseTransport:

View File

@ -331,7 +331,9 @@ class StreamClosed(StreamError):
""" """
def __init__(self) -> None: def __init__(self) -> None:
message = "Attempted to read or stream content, but the stream has been closed." message = (
"Attempted to read or stream content, but the stream has " "been closed."
)
super().__init__(message) super().__init__(message)

View File

@ -379,7 +379,7 @@ class URL:
if ":" in userinfo: if ":" in userinfo:
# Mask any password component. # Mask any password component.
userinfo = f"{userinfo.split(':')[0]}:[secure]" userinfo = f'{userinfo.split(":")[0]}:[secure]'
authority = "".join( authority = "".join(
[ [

View File

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "httpx" name = "httpx"
description = "The next generation HTTP client." description = "The next generation HTTP client."
license = "BSD-3-Clause" license = "BSD-3-Clause"
requires-python = ">=3.9" requires-python = ">=3.8"
authors = [ authors = [
{ name = "Tom Christie", email = "tom@tomchristie.com" }, { name = "Tom Christie", email = "tom@tomchristie.com" },
] ]
@ -20,6 +20,7 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
@ -43,7 +44,7 @@ brotli = [
cli = [ cli = [
"click==8.*", "click==8.*",
"pygments==2.*", "pygments==2.*",
"rich>=10,<15", "rich>=10,<14",
] ]
http2 = [ http2 = [
"h2>=3,<5", "h2>=3,<5",

View File

@ -11,19 +11,20 @@ chardet==5.2.0
# Documentation # Documentation
mkdocs==1.6.1 mkdocs==1.6.1
mkautodoc==0.2.0 mkautodoc==0.2.0
mkdocs-material==9.6.18 mkdocs-material==9.5.47
# Packaging # Packaging
build==1.3.0 build==1.2.2.post1
twine==6.1.0 twine==6.0.1
# Tests & Linting # Tests & Linting
coverage[toml]==7.10.6 coverage[toml]==7.6.1
cryptography==45.0.7 cryptography==44.0.1
mypy==1.17.1 mypy==1.13.0
pytest==8.4.1 pytest==8.3.4
ruff==0.12.11 ruff==0.8.1
trio==0.31.0 trio==0.27.0
trio-typing==0.10.0 trio-typing==0.10.0
trustme==1.2.1 trustme==1.1.0; python_version < '3.9'
uvicorn==0.35.0 trustme==1.2.0; python_version >= '3.9'
uvicorn==0.32.1

View File

@ -326,7 +326,7 @@ async def test_auth_property() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
assert client.auth is None assert client.auth is None
client.auth = ("user", "password123") client.auth = ("user", "password123") # type: ignore
assert isinstance(client.auth, httpx.BasicAuth) assert isinstance(client.auth, httpx.BasicAuth)
url = "https://example.org/" url = "https://example.org/"

View File

@ -3,35 +3,35 @@ import httpx
def test_client_base_url(): def test_client_base_url():
client = httpx.Client() client = httpx.Client()
client.base_url = "https://www.example.org/" client.base_url = "https://www.example.org/" # type: ignore
assert isinstance(client.base_url, httpx.URL) assert isinstance(client.base_url, httpx.URL)
assert client.base_url == "https://www.example.org/" assert client.base_url == "https://www.example.org/"
def test_client_base_url_without_trailing_slash(): def test_client_base_url_without_trailing_slash():
client = httpx.Client() client = httpx.Client()
client.base_url = "https://www.example.org/path" client.base_url = "https://www.example.org/path" # type: ignore
assert isinstance(client.base_url, httpx.URL) assert isinstance(client.base_url, httpx.URL)
assert client.base_url == "https://www.example.org/path/" assert client.base_url == "https://www.example.org/path/"
def test_client_base_url_with_trailing_slash(): def test_client_base_url_with_trailing_slash():
client = httpx.Client() client = httpx.Client()
client.base_url = "https://www.example.org/path/" client.base_url = "https://www.example.org/path/" # type: ignore
assert isinstance(client.base_url, httpx.URL) assert isinstance(client.base_url, httpx.URL)
assert client.base_url == "https://www.example.org/path/" assert client.base_url == "https://www.example.org/path/"
def test_client_headers(): def test_client_headers():
client = httpx.Client() client = httpx.Client()
client.headers = {"a": "b"} client.headers = {"a": "b"} # type: ignore
assert isinstance(client.headers, httpx.Headers) assert isinstance(client.headers, httpx.Headers)
assert client.headers["A"] == "b" assert client.headers["A"] == "b"
def test_client_cookies(): def test_client_cookies():
client = httpx.Client() client = httpx.Client()
client.cookies = {"a": "b"} client.cookies = {"a": "b"} # type: ignore
assert isinstance(client.cookies, httpx.Cookies) assert isinstance(client.cookies, httpx.Cookies)
mycookies = list(client.cookies.jar) mycookies = list(client.cookies.jar)
assert len(mycookies) == 1 assert len(mycookies) == 1
@ -42,7 +42,7 @@ def test_client_timeout():
expected_timeout = 12.0 expected_timeout = 12.0
client = httpx.Client() client = httpx.Client()
client.timeout = expected_timeout client.timeout = expected_timeout # type: ignore
assert isinstance(client.timeout, httpx.Timeout) assert isinstance(client.timeout, httpx.Timeout)
assert client.timeout.connect == expected_timeout assert client.timeout.connect == expected_timeout

View File

@ -17,7 +17,7 @@ def test_client_queryparams_string():
assert client.params["a"] == "b" assert client.params["a"] == "b"
client = httpx.Client() client = httpx.Client()
client.params = "a=b" client.params = "a=b" # type: ignore
assert isinstance(client.params, httpx.QueryParams) assert isinstance(client.params, httpx.QueryParams)
assert client.params["a"] == "b" assert client.params["a"] == "b"

View File

@ -1011,10 +1011,7 @@ def test_response_decode_text_using_autodetect():
assert response.status_code == 200 assert response.status_code == 200
assert response.reason_phrase == "OK" assert response.reason_phrase == "OK"
# The encoded byte string is consistent with either ISO-8859-1 or assert response.encoding == "ISO-8859-1"
# WINDOWS-1252. Versions <6.0 of chardet claim the former, while chardet
# 6.0 detects the latter.
assert response.encoding in ("ISO-8859-1", "WINDOWS-1252")
assert response.text == text assert response.text == text

View File

@ -489,18 +489,18 @@ def test_response_invalid_argument():
def test_ensure_ascii_false_with_french_characters(): def test_ensure_ascii_false_with_french_characters():
data = {"greeting": "Bonjour, ça va ?"} data = {"greeting": "Bonjour, ça va ?"}
response = httpx.Response(200, json=data) response = httpx.Response(200, json=data)
assert "ça va" in response.text, ( assert (
"ensure_ascii=False should preserve French accented characters" "ça va" in response.text
) ), "ensure_ascii=False should preserve French accented characters"
assert response.headers["Content-Type"] == "application/json" assert response.headers["Content-Type"] == "application/json"
def test_separators_for_compact_json(): def test_separators_for_compact_json():
data = {"clé": "valeur", "liste": [1, 2, 3]} data = {"clé": "valeur", "liste": [1, 2, 3]}
response = httpx.Response(200, json=data) response = httpx.Response(200, json=data)
assert response.text == '{"clé":"valeur","liste":[1,2,3]}', ( assert (
"separators=(',', ':') should produce a compact representation" response.text == '{"clé":"valeur","liste":[1,2,3]}'
) ), "separators=(',', ':') should produce a compact representation"
assert response.headers["Content-Type"] == "application/json" assert response.headers["Content-Type"] == "application/json"