├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-issue.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test-suite.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs ├── CNAME ├── advanced │ ├── authentication.md │ ├── clients.md │ ├── event-hooks.md │ ├── extensions.md │ ├── proxies.md │ ├── resource-limits.md │ ├── ssl.md │ ├── text-encodings.md │ ├── timeouts.md │ └── transports.md ├── api.md ├── async.md ├── code_of_conduct.md ├── compatibility.md ├── contributing.md ├── css │ └── custom.css ├── environment_variables.md ├── exceptions.md ├── http2.md ├── img │ ├── butterfly.png │ ├── gh-actions-fail-check.png │ ├── gh-actions-fail-test.png │ ├── gh-actions-fail.png │ ├── httpx-help.png │ ├── httpx-request.png │ ├── logo.jpg │ ├── rich-progress.gif │ ├── speakeasy.png │ └── tqdm-progress.gif ├── index.md ├── logging.md ├── overrides │ └── partials │ │ └── nav.html ├── quickstart.md ├── third_party_packages.md └── troubleshooting.md ├── httpx ├── __init__.py ├── __version__.py ├── _api.py ├── _auth.py ├── _client.py ├── _config.py ├── _content.py ├── _decoders.py ├── _exceptions.py ├── _main.py ├── _models.py ├── _multipart.py ├── _status_codes.py ├── _transports │ ├── __init__.py │ ├── asgi.py │ ├── base.py │ ├── default.py │ ├── mock.py │ └── wsgi.py ├── _types.py ├── _urlparse.py ├── _urls.py ├── _utils.py └── py.typed ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── scripts ├── build ├── check ├── clean ├── coverage ├── docs ├── install ├── lint ├── publish ├── sync-version └── test └── tests ├── __init__.py ├── client ├── __init__.py ├── test_async_client.py ├── test_auth.py ├── test_client.py ├── test_cookies.py ├── test_event_hooks.py ├── test_headers.py ├── test_properties.py ├── test_proxies.py ├── test_queryparams.py └── test_redirects.py ├── common.py ├── concurrency.py ├── conftest.py ├── fixtures ├── .netrc └── .netrc-nopassword ├── models ├── __init__.py ├── test_cookies.py ├── test_headers.py ├── test_queryparams.py ├── test_requests.py ├── test_responses.py ├── test_url.py ├── test_whatwg.py └── whatwg.json ├── test_api.py ├── test_asgi.py ├── test_auth.py ├── test_config.py ├── test_content.py ├── test_decoders.py ├── test_exceptions.py ├── test_exported_members.py ├── test_main.py ├── test_multipart.py ├── test_status_codes.py ├── test_timeouts.py ├── test_utils.py └── test_wsgi.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: encode 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Please only raise an issue if you've been advised to do so after discussion. Thanks! 🙏 4 | --- 5 | 6 | The starting point for issues should usually be a discussion... 7 | 8 | https://github.com/encode/httpx/discussions 9 | 10 | Possible bugs may be raised as a "Potential Issue" discussion, feature requests may be raised as an "Ideas" discussion. We can then determine if the discussion needs to be escalated into an "Issue" or not. 11 | 12 | This will help us ensure that the "Issues" list properly reflects ongoing or needed work on the project. 13 | 14 | --- 15 | 16 | - [ ] Initially raised as discussion #... 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: Discussions 5 | url: https://github.com/encode/httpx/discussions 6 | about: > 7 | The "Discussions" forum is where you want to start. 💖 8 | - name: Chat 9 | url: https://gitter.im/encode/community 10 | about: > 11 | Our community chat forum. 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 3 | 4 | # Summary 5 | 6 | 7 | 8 | # Checklist 9 | 10 | - [ ] I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!) 11 | - [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change. 12 | - [ ] I've updated the documentation accordingly. 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | python-packages: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: monthly 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | name: "Publish release" 11 | runs-on: "ubuntu-latest" 12 | 13 | environment: 14 | name: deploy 15 | 16 | steps: 17 | - uses: "actions/checkout@v4" 18 | - uses: "actions/setup-python@v5" 19 | with: 20 | python-version: 3.8 21 | - name: "Install dependencies" 22 | run: "scripts/install" 23 | - name: "Build package & docs" 24 | run: "scripts/build" 25 | - name: "Publish to PyPI & deploy docs" 26 | run: "scripts/publish" 27 | env: 28 | TWINE_USERNAME: __token__ 29 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/test-suite.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test Suite 3 | 4 | on: 5 | push: 6 | branches: ["master"] 7 | pull_request: 8 | branches: ["master", "version-*"] 9 | 10 | jobs: 11 | tests: 12 | name: "Python ${{ matrix.python-version }}" 13 | runs-on: "ubuntu-latest" 14 | 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 18 | 19 | steps: 20 | - uses: "actions/checkout@v4" 21 | - uses: "actions/setup-python@v5" 22 | with: 23 | python-version: "${{ matrix.python-version }}" 24 | allow-prereleases: true 25 | - name: "Install dependencies" 26 | run: "scripts/install" 27 | - name: "Run linting checks" 28 | run: "scripts/check" 29 | - name: "Build package & docs" 30 | run: "scripts/build" 31 | - name: "Run tests" 32 | run: "scripts/test" 33 | - name: "Enforce coverage" 34 | run: "scripts/coverage" 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | .pytest_cache/ 4 | .mypy_cache/ 5 | __pycache__/ 6 | htmlcov/ 7 | site/ 8 | *.egg-info/ 9 | venv*/ 10 | .python-version 11 | build/ 12 | dist/ 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/). 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | HTTPX 3 |

4 | 5 |

HTTPX - A next-generation HTTP client for Python.

6 | 7 |

8 | 9 | Test Suite 10 | 11 | 12 | Package version 13 | 14 |

15 | 16 | HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**. 17 | 18 | --- 19 | 20 | Install HTTPX using pip: 21 | 22 | ```shell 23 | $ pip install httpx 24 | ``` 25 | 26 | Now, let's get started: 27 | 28 | ```pycon 29 | >>> import httpx 30 | >>> r = httpx.get('https://www.example.org/') 31 | >>> r 32 | 33 | >>> r.status_code 34 | 200 35 | >>> r.headers['content-type'] 36 | 'text/html; charset=UTF-8' 37 | >>> r.text 38 | '\n\n\nExample Domain...' 39 | ``` 40 | 41 | Or, using the command-line client. 42 | 43 | ```shell 44 | $ pip install 'httpx[cli]' # The command line client is an optional dependency. 45 | ``` 46 | 47 | Which now allows us to use HTTPX directly from the command-line... 48 | 49 |

50 | httpx --help 51 |

52 | 53 | Sending a request... 54 | 55 |

56 | httpx http://httpbin.org/json 57 |

58 | 59 | ## Features 60 | 61 | HTTPX builds on the well-established usability of `requests`, and gives you: 62 | 63 | * A broadly [requests-compatible API](https://www.python-httpx.org/compatibility/). 64 | * An integrated command-line client. 65 | * HTTP/1.1 [and HTTP/2 support](https://www.python-httpx.org/http2/). 66 | * Standard synchronous interface, but with [async support if you need it](https://www.python-httpx.org/async/). 67 | * Ability to make requests directly to [WSGI applications](https://www.python-httpx.org/advanced/transports/#wsgi-transport) or [ASGI applications](https://www.python-httpx.org/advanced/transports/#asgi-transport). 68 | * Strict timeouts everywhere. 69 | * Fully type annotated. 70 | * 100% test coverage. 71 | 72 | Plus all the standard features of `requests`... 73 | 74 | * International Domains and URLs 75 | * Keep-Alive & Connection Pooling 76 | * Sessions with Cookie Persistence 77 | * Browser-style SSL Verification 78 | * Basic/Digest Authentication 79 | * Elegant Key/Value Cookies 80 | * Automatic Decompression 81 | * Automatic Content Decoding 82 | * Unicode Response Bodies 83 | * Multipart File Uploads 84 | * HTTP(S) Proxy Support 85 | * Connection Timeouts 86 | * Streaming Downloads 87 | * .netrc Support 88 | * Chunked Requests 89 | 90 | ## Installation 91 | 92 | Install with pip: 93 | 94 | ```shell 95 | $ pip install httpx 96 | ``` 97 | 98 | Or, to include the optional HTTP/2 support, use: 99 | 100 | ```shell 101 | $ pip install httpx[http2] 102 | ``` 103 | 104 | HTTPX requires Python 3.8+. 105 | 106 | ## Documentation 107 | 108 | Project documentation is available at [https://www.python-httpx.org/](https://www.python-httpx.org/). 109 | 110 | For a run-through of all the basics, head over to the [QuickStart](https://www.python-httpx.org/quickstart/). 111 | 112 | For more advanced topics, see the [Advanced Usage](https://www.python-httpx.org/advanced/) section, the [async support](https://www.python-httpx.org/async/) section, or the [HTTP/2](https://www.python-httpx.org/http2/) section. 113 | 114 | The [Developer Interface](https://www.python-httpx.org/api/) provides a comprehensive API reference. 115 | 116 | To find out about tools that integrate with HTTPX, see [Third Party Packages](https://www.python-httpx.org/third_party_packages/). 117 | 118 | ## Contribute 119 | 120 | If you want to contribute with HTTPX check out the [Contributing Guide](https://www.python-httpx.org/contributing/) to learn how to start. 121 | 122 | ## Dependencies 123 | 124 | The HTTPX project relies on these excellent libraries: 125 | 126 | * `httpcore` - The underlying transport implementation for `httpx`. 127 | * `h11` - HTTP/1.1 support. 128 | * `certifi` - SSL certificates. 129 | * `idna` - Internationalized domain name support. 130 | * `sniffio` - Async library autodetection. 131 | 132 | As well as these optional installs: 133 | 134 | * `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)* 135 | * `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)* 136 | * `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)* 137 | * `click` - Command line client support. *(Optional, with `httpx[cli]`)* 138 | * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)* 139 | * `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)* 140 | 141 | A huge amount of credit is due to `requests` for the API layout that 142 | much of this work follows, as well as to `urllib3` for plenty of design 143 | inspiration around the lower-level networking details. 144 | 145 | --- 146 | 147 |

HTTPX is BSD licensed code.
Designed & crafted with care.

— 🦋 —

148 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.python-httpx.org 2 | -------------------------------------------------------------------------------- /docs/advanced/event-hooks.md: -------------------------------------------------------------------------------- 1 | HTTPX allows you to register "event hooks" with the client, that are called 2 | every time a particular type of event takes place. 3 | 4 | There are currently two event hooks: 5 | 6 | * `request` - Called after a request is fully prepared, but before it is sent to the network. Passed the `request` instance. 7 | * `response` - Called after the response has been fetched from the network, but before it is returned to the caller. Passed the `response` instance. 8 | 9 | These allow you to install client-wide functionality such as logging, monitoring or tracing. 10 | 11 | ```python 12 | def log_request(request): 13 | print(f"Request event hook: {request.method} {request.url} - Waiting for response") 14 | 15 | def log_response(response): 16 | request = response.request 17 | print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}") 18 | 19 | client = httpx.Client(event_hooks={'request': [log_request], 'response': [log_response]}) 20 | ``` 21 | 22 | You can also use these hooks to install response processing code, such as this 23 | example, which creates a client instance that always raises `httpx.HTTPStatusError` 24 | on 4xx and 5xx responses. 25 | 26 | ```python 27 | def raise_on_4xx_5xx(response): 28 | response.raise_for_status() 29 | 30 | client = httpx.Client(event_hooks={'response': [raise_on_4xx_5xx]}) 31 | ``` 32 | 33 | !!! note 34 | Response event hooks are called before determining if the response body 35 | should be read or not. 36 | 37 | If you need access to the response body inside an event hook, you'll 38 | need to call `response.read()`, or for AsyncClients, `response.aread()`. 39 | 40 | The hooks are also allowed to modify `request` and `response` objects. 41 | 42 | ```python 43 | def add_timestamp(request): 44 | request.headers['x-request-timestamp'] = datetime.now(tz=datetime.utc).isoformat() 45 | 46 | client = httpx.Client(event_hooks={'request': [add_timestamp]}) 47 | ``` 48 | 49 | Event hooks must always be set as a **list of callables**, and you may register 50 | multiple event hooks for each type of event. 51 | 52 | As well as being able to set event hooks on instantiating the client, there 53 | is also an `.event_hooks` property, that allows you to inspect and modify 54 | the installed hooks. 55 | 56 | ```python 57 | client = httpx.Client() 58 | client.event_hooks['request'] = [log_request] 59 | client.event_hooks['response'] = [log_response, raise_on_4xx_5xx] 60 | ``` 61 | 62 | !!! note 63 | If you are using HTTPX's async support, then you need to be aware that 64 | hooks registered with `httpx.AsyncClient` MUST be async functions, 65 | rather than plain functions. 66 | -------------------------------------------------------------------------------- /docs/advanced/proxies.md: -------------------------------------------------------------------------------- 1 | HTTPX supports setting up [HTTP proxies](https://en.wikipedia.org/wiki/Proxy_server#Web_proxy_servers) via the `proxy` parameter to be passed on client initialization or top-level API functions like `httpx.get(..., proxy=...)`. 2 | 3 |
4 | 5 |
Diagram of how a proxy works (source: Wikipedia). The left hand side "Internet" blob may be your HTTPX client requesting example.com through a proxy.
6 |
7 | 8 | ## HTTP Proxies 9 | 10 | To route all traffic (HTTP and HTTPS) to a proxy located at `http://localhost:8030`, pass the proxy URL to the client... 11 | 12 | ```python 13 | with httpx.Client(proxy="http://localhost:8030") as client: 14 | ... 15 | ``` 16 | 17 | For more advanced use cases, pass a mounts `dict`. For example, to route HTTP and HTTPS requests to 2 different proxies, respectively located at `http://localhost:8030`, and `http://localhost:8031`, pass a `dict` of proxy URLs: 18 | 19 | ```python 20 | proxy_mounts = { 21 | "http://": httpx.HTTPTransport(proxy="http://localhost:8030"), 22 | "https://": httpx.HTTPTransport(proxy="http://localhost:8031"), 23 | } 24 | 25 | with httpx.Client(mounts=proxy_mounts) as client: 26 | ... 27 | ``` 28 | 29 | For detailed information about proxy routing, see the [Routing](#routing) section. 30 | 31 | !!! tip "Gotcha" 32 | In most cases, the proxy URL for the `https://` key _should_ use the `http://` scheme (that's not a typo!). 33 | 34 | This is because HTTP proxying requires initiating a connection with the proxy server. While it's possible that your proxy supports doing it via HTTPS, most proxies only support doing it via HTTP. 35 | 36 | For more information, see [FORWARD vs TUNNEL](#forward-vs-tunnel). 37 | 38 | ## Authentication 39 | 40 | Proxy credentials can be passed as the `userinfo` section of the proxy URL. For example: 41 | 42 | ```python 43 | with httpx.Client(proxy="http://username:password@localhost:8030") as client: 44 | ... 45 | ``` 46 | 47 | ## Proxy mechanisms 48 | 49 | !!! note 50 | This section describes **advanced** proxy concepts and functionality. 51 | 52 | ### FORWARD vs TUNNEL 53 | 54 | In general, the flow for making an HTTP request through a proxy is as follows: 55 | 56 | 1. The client connects to the proxy (initial connection request). 57 | 2. The proxy transfers data to the server on your behalf. 58 | 59 | How exactly step 2/ is performed depends on which of two proxying mechanisms is used: 60 | 61 | * **Forwarding**: the proxy makes the request for you, and sends back the response it obtained from the server. 62 | * **Tunnelling**: the proxy establishes a TCP connection to the server on your behalf, and the client reuses this connection to send the request and receive the response. This is known as an [HTTP Tunnel](https://en.wikipedia.org/wiki/HTTP_tunnel). This mechanism is how you can access websites that use HTTPS from an HTTP proxy (the client "upgrades" the connection to HTTPS by performing the TLS handshake with the server over the TCP connection provided by the proxy). 63 | 64 | ### Troubleshooting proxies 65 | 66 | If you encounter issues when setting up proxies, please refer to our [Troubleshooting guide](../troubleshooting.md#proxies). 67 | 68 | ## SOCKS 69 | 70 | In addition to HTTP proxies, `httpcore` also supports proxies using the SOCKS protocol. 71 | This is an optional feature that requires an additional third-party library be installed before use. 72 | 73 | You can install SOCKS support using `pip`: 74 | 75 | ```shell 76 | $ pip install httpx[socks] 77 | ``` 78 | 79 | You can now configure a client to make requests via a proxy using the SOCKS protocol: 80 | 81 | ```python 82 | httpx.Client(proxy='socks5://user:pass@host:port') 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/advanced/resource-limits.md: -------------------------------------------------------------------------------- 1 | You can control the connection pool size using the `limits` keyword 2 | argument on the client. It takes instances of `httpx.Limits` which define: 3 | 4 | - `max_keepalive_connections`, number of allowable keep-alive connections, or `None` to always 5 | allow. (Defaults 20) 6 | - `max_connections`, maximum number of allowable connections, or `None` for no limits. 7 | (Default 100) 8 | - `keepalive_expiry`, time limit on idle keep-alive connections in seconds, or `None` for no limits. (Default 5) 9 | 10 | ```python 11 | limits = httpx.Limits(max_keepalive_connections=5, max_connections=10) 12 | client = httpx.Client(limits=limits) 13 | ``` -------------------------------------------------------------------------------- /docs/advanced/ssl.md: -------------------------------------------------------------------------------- 1 | When making a request over HTTPS, HTTPX needs to verify the identity of the requested host. To do this, it uses a bundle of SSL certificates (a.k.a. CA bundle) delivered by a trusted certificate authority (CA). 2 | 3 | ### Enabling and disabling verification 4 | 5 | By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases... 6 | 7 | ```pycon 8 | >>> httpx.get("https://expired.badssl.com/") 9 | httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997) 10 | ``` 11 | 12 | You can disable SSL verification completely and allow insecure requests... 13 | 14 | ```pycon 15 | >>> httpx.get("https://expired.badssl.com/", verify=False) 16 | 17 | ``` 18 | 19 | ### Configuring client instances 20 | 21 | If you're using a `Client()` instance you should pass any `verify=<...>` configuration when instantiating the client. 22 | 23 | By default the [certifi CA bundle](https://certifiio.readthedocs.io/en/latest/) is used for SSL verification. 24 | 25 | For more complex configurations you can pass an [SSL Context](https://docs.python.org/3/library/ssl.html) instance... 26 | 27 | ```python 28 | import certifi 29 | import httpx 30 | import ssl 31 | 32 | # This SSL context is equivelent to the default `verify=True`. 33 | ctx = ssl.create_default_context(cafile=certifi.where()) 34 | client = httpx.Client(verify=ctx) 35 | ``` 36 | 37 | Using [the `truststore` package](https://truststore.readthedocs.io/) to support system certificate stores... 38 | 39 | ```python 40 | import ssl 41 | import truststore 42 | import httpx 43 | 44 | # Use system certificate stores. 45 | ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 46 | client = httpx.Client(verify=ctx) 47 | ``` 48 | 49 | Loding an alternative certificate verification store using [the standard SSL context API](https://docs.python.org/3/library/ssl.html)... 50 | 51 | ```python 52 | import httpx 53 | import ssl 54 | 55 | # Use an explicitly configured certificate store. 56 | ctx = ssl.create_default_context(cafile="path/to/certs.pem") # Either cafile or capath. 57 | client = httpx.Client(verify=ctx) 58 | ``` 59 | 60 | ### Client side certificates 61 | 62 | Client side certificates allow a remote server to verify the client. They tend to be used within private organizations to authenticate requests to remote servers. 63 | 64 | You can specify client-side certificates, using the [`.load_cert_chain()`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.load_cert_chain) API... 65 | 66 | ```python 67 | ctx = ssl.create_default_context() 68 | ctx.load_cert_chain(certfile="path/to/client.pem") # Optionally also keyfile or password. 69 | client = httpx.Client(verify=ctx) 70 | ``` 71 | 72 | ### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR` 73 | 74 | 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. 75 | 76 | For example... 77 | 78 | ```python 79 | # Use `SSL_CERT_FILE` or `SSL_CERT_DIR` if configured. 80 | # Otherwise default to certifi. 81 | ctx = ssl.create_default_context( 82 | cafile=os.environ.get("SSL_CERT_FILE", certifi.where()), 83 | capath=os.environ.get("SSL_CERT_DIR"), 84 | ) 85 | client = httpx.Client(verify=ctx) 86 | ``` 87 | 88 | ### Making HTTPS requests to a local server 89 | 90 | When making requests to local servers, such as a development server running on `localhost`, you will typically be using unencrypted HTTP connections. 91 | 92 | If you do need to make HTTPS connections to a local server, for example to test an HTTPS-only service, you will need to create and use your own certificates. Here's one way to do it... 93 | 94 | 1. Use [trustme](https://github.com/python-trio/trustme) to generate a pair of server key/cert files, and a client cert file. 95 | 2. Pass the server key/cert files when starting your local server. (This depends on the particular web server you're using. For example, [Uvicorn](https://www.uvicorn.org) provides the `--ssl-keyfile` and `--ssl-certfile` options.) 96 | 3. Configure `httpx` to use the certificates stored in `client.pem`. 97 | 98 | ```python 99 | ctx = ssl.create_default_context(cafile="client.pem") 100 | client = httpx.Client(verify=ctx) 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/advanced/text-encodings.md: -------------------------------------------------------------------------------- 1 | When accessing `response.text`, we need to decode the response bytes into a unicode text representation. 2 | 3 | By default `httpx` will use `"charset"` information included in the response `Content-Type` header to determine how the response bytes should be decoded into text. 4 | 5 | In cases where no charset information is included on the response, the default behaviour is to assume "utf-8" encoding, which is by far the most widely used text encoding on the internet. 6 | 7 | ## Using the default encoding 8 | 9 | To understand this better let's start by looking at the default behaviour for text decoding... 10 | 11 | ```python 12 | import httpx 13 | # Instantiate a client with the default configuration. 14 | client = httpx.Client() 15 | # Using the client... 16 | response = client.get(...) 17 | print(response.encoding) # This will either print the charset given in 18 | # the Content-Type charset, or else "utf-8". 19 | print(response.text) # The text will either be decoded with the Content-Type 20 | # charset, or using "utf-8". 21 | ``` 22 | 23 | This is normally absolutely fine. Most servers will respond with a properly formatted Content-Type header, including a charset encoding. And in most cases where no charset encoding is included, UTF-8 is very likely to be used, since it is so widely adopted. 24 | 25 | ## Using an explicit encoding 26 | 27 | In some cases we might be making requests to a site where no character set information is being set explicitly by the server, but we know what the encoding is. In this case it's best to set the default encoding explicitly on the client. 28 | 29 | ```python 30 | import httpx 31 | # Instantiate a client with a Japanese character set as the default encoding. 32 | client = httpx.Client(default_encoding="shift-jis") 33 | # Using the client... 34 | response = client.get(...) 35 | print(response.encoding) # This will either print the charset given in 36 | # the Content-Type charset, or else "shift-jis". 37 | print(response.text) # The text will either be decoded with the Content-Type 38 | # charset, or using "shift-jis". 39 | ``` 40 | 41 | ## Using auto-detection 42 | 43 | In cases where the server is not reliably including character set information, and where we don't know what encoding is being used, we can enable auto-detection to make a best-guess attempt when decoding from bytes to text. 44 | 45 | To use auto-detection you need to set the `default_encoding` argument to a callable instead of a string. This callable should be a function which takes the input bytes as an argument and returns the character set to use for decoding those bytes to text. 46 | 47 | There are two widely used Python packages which both handle this functionality: 48 | 49 | * [`chardet`](https://chardet.readthedocs.io/) - This is a well established package, and is a port of [the auto-detection code in Mozilla](https://www-archive.mozilla.org/projects/intl/chardet.html). 50 | * [`charset-normalizer`](https://charset-normalizer.readthedocs.io/) - A newer package, motivated by `chardet`, with a different approach. 51 | 52 | Let's take a look at installing autodetection using one of these packages... 53 | 54 | ```shell 55 | $ pip install httpx 56 | $ pip install chardet 57 | ``` 58 | 59 | Once `chardet` is installed, we can configure a client to use character-set autodetection. 60 | 61 | ```python 62 | import httpx 63 | import chardet 64 | 65 | def autodetect(content): 66 | return chardet.detect(content).get("encoding") 67 | 68 | # Using a client with character-set autodetection enabled. 69 | client = httpx.Client(default_encoding=autodetect) 70 | response = client.get(...) 71 | print(response.encoding) # This will either print the charset given in 72 | # the Content-Type charset, or else the auto-detected 73 | # character set. 74 | print(response.text) 75 | ``` 76 | -------------------------------------------------------------------------------- /docs/advanced/timeouts.md: -------------------------------------------------------------------------------- 1 | HTTPX is careful to enforce timeouts everywhere by default. 2 | 3 | The default behavior is to raise a `TimeoutException` after 5 seconds of 4 | network inactivity. 5 | 6 | ## Setting and disabling timeouts 7 | 8 | You can set timeouts for an individual request: 9 | 10 | ```python 11 | # Using the top-level API: 12 | httpx.get('http://example.com/api/v1/example', timeout=10.0) 13 | 14 | # Using a client instance: 15 | with httpx.Client() as client: 16 | client.get("http://example.com/api/v1/example", timeout=10.0) 17 | ``` 18 | 19 | Or disable timeouts for an individual request: 20 | 21 | ```python 22 | # Using the top-level API: 23 | httpx.get('http://example.com/api/v1/example', timeout=None) 24 | 25 | # Using a client instance: 26 | with httpx.Client() as client: 27 | client.get("http://example.com/api/v1/example", timeout=None) 28 | ``` 29 | 30 | ## Setting a default timeout on a client 31 | 32 | You can set a timeout on a client instance, which results in the given 33 | `timeout` being used as the default for requests made with this client: 34 | 35 | ```python 36 | client = httpx.Client() # Use a default 5s timeout everywhere. 37 | client = httpx.Client(timeout=10.0) # Use a default 10s timeout everywhere. 38 | client = httpx.Client(timeout=None) # Disable all timeouts by default. 39 | ``` 40 | 41 | ## Fine tuning the configuration 42 | 43 | HTTPX also allows you to specify the timeout behavior in more fine grained detail. 44 | 45 | There are four different types of timeouts that may occur. These are **connect**, 46 | **read**, **write**, and **pool** timeouts. 47 | 48 | * The **connect** timeout specifies the maximum amount of time to wait until 49 | a socket connection to the requested host is established. If HTTPX is unable to connect 50 | within this time frame, a `ConnectTimeout` exception is raised. 51 | * The **read** timeout specifies the maximum duration to wait for a chunk of 52 | data to be received (for example, a chunk of the response body). If HTTPX is 53 | unable to receive data within this time frame, a `ReadTimeout` exception is raised. 54 | * The **write** timeout specifies the maximum duration to wait for a chunk of 55 | data to be sent (for example, a chunk of the request body). If HTTPX is unable 56 | to send data within this time frame, a `WriteTimeout` exception is raised. 57 | * The **pool** timeout specifies the maximum duration to wait for acquiring 58 | a connection from the connection pool. If HTTPX is unable to acquire a connection 59 | within this time frame, a `PoolTimeout` exception is raised. A related 60 | configuration here is the maximum number of allowable connections in the 61 | connection pool, which is configured by the `limits` argument. 62 | 63 | You can configure the timeout behavior for any of these values... 64 | 65 | ```python 66 | # A client with a 60s timeout for connecting, and a 10s timeout elsewhere. 67 | timeout = httpx.Timeout(10.0, connect=60.0) 68 | client = httpx.Client(timeout=timeout) 69 | 70 | response = client.get('http://example.com/') 71 | ``` -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Developer Interface 2 | 3 | ## Helper Functions 4 | 5 | !!! note 6 | Only use these functions if you're testing HTTPX in a console 7 | or making a small number of requests. Using a `Client` will 8 | enable HTTP/2 and connection pooling for more efficient and 9 | long-lived connections. 10 | 11 | ::: httpx.request 12 | :docstring: 13 | 14 | ::: httpx.get 15 | :docstring: 16 | 17 | ::: httpx.options 18 | :docstring: 19 | 20 | ::: httpx.head 21 | :docstring: 22 | 23 | ::: httpx.post 24 | :docstring: 25 | 26 | ::: httpx.put 27 | :docstring: 28 | 29 | ::: httpx.patch 30 | :docstring: 31 | 32 | ::: httpx.delete 33 | :docstring: 34 | 35 | ::: httpx.stream 36 | :docstring: 37 | 38 | ## `Client` 39 | 40 | ::: httpx.Client 41 | :docstring: 42 | :members: headers cookies params auth request get head options post put patch delete stream build_request send close 43 | 44 | ## `AsyncClient` 45 | 46 | ::: httpx.AsyncClient 47 | :docstring: 48 | :members: headers cookies params auth request get head options post put patch delete stream build_request send aclose 49 | 50 | 51 | ## `Response` 52 | 53 | *An HTTP response.* 54 | 55 | * `def __init__(...)` 56 | * `.status_code` - **int** 57 | * `.reason_phrase` - **str** 58 | * `.http_version` - `"HTTP/2"` or `"HTTP/1.1"` 59 | * `.url` - **URL** 60 | * `.headers` - **Headers** 61 | * `.content` - **bytes** 62 | * `.text` - **str** 63 | * `.encoding` - **str** 64 | * `.is_redirect` - **bool** 65 | * `.request` - **Request** 66 | * `.next_request` - **Optional[Request]** 67 | * `.cookies` - **Cookies** 68 | * `.history` - **List[Response]** 69 | * `.elapsed` - **[timedelta](https://docs.python.org/3/library/datetime.html)** 70 | * The amount of time elapsed between sending the request and calling `close()` on the corresponding response received for that request. 71 | [total_seconds()](https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds) to correctly get 72 | the total elapsed seconds. 73 | * `def .raise_for_status()` - **Response** 74 | * `def .json()` - **Any** 75 | * `def .read()` - **bytes** 76 | * `def .iter_raw([chunk_size])` - **bytes iterator** 77 | * `def .iter_bytes([chunk_size])` - **bytes iterator** 78 | * `def .iter_text([chunk_size])` - **text iterator** 79 | * `def .iter_lines()` - **text iterator** 80 | * `def .close()` - **None** 81 | * `def .next()` - **Response** 82 | * `def .aread()` - **bytes** 83 | * `def .aiter_raw([chunk_size])` - **async bytes iterator** 84 | * `def .aiter_bytes([chunk_size])` - **async bytes iterator** 85 | * `def .aiter_text([chunk_size])` - **async text iterator** 86 | * `def .aiter_lines()` - **async text iterator** 87 | * `def .aclose()` - **None** 88 | * `def .anext()` - **Response** 89 | 90 | ## `Request` 91 | 92 | *An HTTP request. Can be constructed explicitly for more control over exactly 93 | what gets sent over the wire.* 94 | 95 | ```pycon 96 | >>> request = httpx.Request("GET", "https://example.org", headers={'host': 'example.org'}) 97 | >>> response = client.send(request) 98 | ``` 99 | 100 | * `def __init__(method, url, [params], [headers], [cookies], [content], [data], [files], [json], [stream])` 101 | * `.method` - **str** 102 | * `.url` - **URL** 103 | * `.content` - **byte**, **byte iterator**, or **byte async iterator** 104 | * `.headers` - **Headers** 105 | * `.cookies` - **Cookies** 106 | 107 | ## `URL` 108 | 109 | *A normalized, IDNA supporting URL.* 110 | 111 | ```pycon 112 | >>> url = URL("https://example.org/") 113 | >>> url.host 114 | 'example.org' 115 | ``` 116 | 117 | * `def __init__(url, **kwargs)` 118 | * `.scheme` - **str** 119 | * `.authority` - **str** 120 | * `.host` - **str** 121 | * `.port` - **int** 122 | * `.path` - **str** 123 | * `.query` - **str** 124 | * `.raw_path` - **str** 125 | * `.fragment` - **str** 126 | * `.is_ssl` - **bool** 127 | * `.is_absolute_url` - **bool** 128 | * `.is_relative_url` - **bool** 129 | * `def .copy_with([scheme], [authority], [path], [query], [fragment])` - **URL** 130 | 131 | ## `Headers` 132 | 133 | *A case-insensitive multi-dict.* 134 | 135 | ```pycon 136 | >>> headers = Headers({'Content-Type': 'application/json'}) 137 | >>> headers['content-type'] 138 | 'application/json' 139 | ``` 140 | 141 | * `def __init__(self, headers, encoding=None)` 142 | * `def copy()` - **Headers** 143 | 144 | ## `Cookies` 145 | 146 | *A dict-like cookie store.* 147 | 148 | ```pycon 149 | >>> cookies = Cookies() 150 | >>> cookies.set("name", "value", domain="example.org") 151 | ``` 152 | 153 | * `def __init__(cookies: [dict, Cookies, CookieJar])` 154 | * `.jar` - **CookieJar** 155 | * `def extract_cookies(response)` 156 | * `def set_cookie_header(request)` 157 | * `def set(name, value, [domain], [path])` 158 | * `def get(name, [domain], [path])` 159 | * `def delete(name, [domain], [path])` 160 | * `def clear([domain], [path])` 161 | * *Standard mutable mapping interface* 162 | 163 | ## `Proxy` 164 | 165 | *A configuration of the proxy server.* 166 | 167 | ```pycon 168 | >>> proxy = Proxy("http://proxy.example.com:8030") 169 | >>> client = Client(proxy=proxy) 170 | ``` 171 | 172 | * `def __init__(url, [ssl_context], [auth], [headers])` 173 | * `.url` - **URL** 174 | * `.auth` - **tuple[str, str]** 175 | * `.headers` - **Headers** 176 | * `.ssl_context` - **SSLContext** 177 | -------------------------------------------------------------------------------- /docs/async.md: -------------------------------------------------------------------------------- 1 | # Async Support 2 | 3 | HTTPX offers a standard synchronous API by default, but also gives you 4 | the option of an async client if you need it. 5 | 6 | Async is a concurrency model that is far more efficient than multi-threading, 7 | and can provide significant performance benefits and enable the use of 8 | long-lived network connections such as WebSockets. 9 | 10 | If you're working with an async web framework then you'll also want to use an 11 | async client for sending outgoing HTTP requests. 12 | 13 | ## Making Async requests 14 | 15 | To make asynchronous requests, you'll need an `AsyncClient`. 16 | 17 | ```pycon 18 | >>> async with httpx.AsyncClient() as client: 19 | ... r = await client.get('https://www.example.com/') 20 | ... 21 | >>> r 22 | 23 | ``` 24 | 25 | !!! tip 26 | 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. 27 | 28 | ## API Differences 29 | 30 | If you're using an async client then there are a few bits of API that 31 | use async methods. 32 | 33 | ### Making requests 34 | 35 | The request methods are all async, so you should use `response = await client.get(...)` style for all of the following: 36 | 37 | * `AsyncClient.get(url, ...)` 38 | * `AsyncClient.options(url, ...)` 39 | * `AsyncClient.head(url, ...)` 40 | * `AsyncClient.post(url, ...)` 41 | * `AsyncClient.put(url, ...)` 42 | * `AsyncClient.patch(url, ...)` 43 | * `AsyncClient.delete(url, ...)` 44 | * `AsyncClient.request(method, url, ...)` 45 | * `AsyncClient.send(request, ...)` 46 | 47 | ### Opening and closing clients 48 | 49 | Use `async with httpx.AsyncClient()` if you want a context-managed client... 50 | 51 | ```python 52 | async with httpx.AsyncClient() as client: 53 | ... 54 | ``` 55 | 56 | !!! warning 57 | In order to get the most benefit from connection pooling, make sure you're not instantiating multiple client instances - for example by using `async with` inside a "hot loop". This can be achieved either by having a single scoped client that's passed throughout wherever it's needed, or by having a single global client instance. 58 | 59 | Alternatively, use `await client.aclose()` if you want to close a client explicitly: 60 | 61 | ```python 62 | client = httpx.AsyncClient() 63 | ... 64 | await client.aclose() 65 | ``` 66 | 67 | ### Streaming responses 68 | 69 | The `AsyncClient.stream(method, url, ...)` method is an async context block. 70 | 71 | ```pycon 72 | >>> client = httpx.AsyncClient() 73 | >>> async with client.stream('GET', 'https://www.example.com/') as response: 74 | ... async for chunk in response.aiter_bytes(): 75 | ... ... 76 | ``` 77 | 78 | The async response streaming methods are: 79 | 80 | * `Response.aread()` - For conditionally reading a response inside a stream block. 81 | * `Response.aiter_bytes()` - For streaming the response content as bytes. 82 | * `Response.aiter_text()` - For streaming the response content as text. 83 | * `Response.aiter_lines()` - For streaming the response content as lines of text. 84 | * `Response.aiter_raw()` - For streaming the raw response bytes, without applying content decoding. 85 | * `Response.aclose()` - For closing the response. You don't usually need this, since `.stream` block closes the response automatically on exit. 86 | 87 | For situations when context block usage is not practical, it is possible to enter "manual mode" by sending a [`Request` instance](advanced/clients.md#request-instances) using `client.send(..., stream=True)`. 88 | 89 | Example in the context of forwarding the response to a streaming web endpoint with [Starlette](https://www.starlette.io): 90 | 91 | ```python 92 | import httpx 93 | from starlette.background import BackgroundTask 94 | from starlette.responses import StreamingResponse 95 | 96 | client = httpx.AsyncClient() 97 | 98 | async def home(request): 99 | req = client.build_request("GET", "https://www.example.com/") 100 | r = await client.send(req, stream=True) 101 | return StreamingResponse(r.aiter_text(), background=BackgroundTask(r.aclose)) 102 | ``` 103 | 104 | !!! warning 105 | When using this "manual streaming mode", it is your duty as a developer to make sure that `Response.aclose()` is called eventually. Failing to do so would leave connections open, most likely resulting in resource leaks down the line. 106 | 107 | ### Streaming requests 108 | 109 | When sending a streaming request body with an `AsyncClient` instance, you should use an async bytes generator instead of a bytes generator: 110 | 111 | ```python 112 | async def upload_bytes(): 113 | ... # yield byte content 114 | 115 | await client.post(url, content=upload_bytes()) 116 | ``` 117 | 118 | ### Explicit transport instances 119 | 120 | When instantiating a transport instance directly, you need to use `httpx.AsyncHTTPTransport`. 121 | 122 | For instance: 123 | 124 | ```pycon 125 | >>> import httpx 126 | >>> transport = httpx.AsyncHTTPTransport(retries=1) 127 | >>> async with httpx.AsyncClient(transport=transport) as client: 128 | >>> ... 129 | ``` 130 | 131 | ## Supported async environments 132 | 133 | HTTPX supports either `asyncio` or `trio` as an async environment. 134 | 135 | It will auto-detect which of those two to use as the backend 136 | for socket operations and concurrency primitives. 137 | 138 | ### [AsyncIO](https://docs.python.org/3/library/asyncio.html) 139 | 140 | AsyncIO is Python's [built-in library](https://docs.python.org/3/library/asyncio.html) 141 | for writing concurrent code with the async/await syntax. 142 | 143 | ```python 144 | import asyncio 145 | import httpx 146 | 147 | async def main(): 148 | async with httpx.AsyncClient() as client: 149 | response = await client.get('https://www.example.com/') 150 | print(response) 151 | 152 | asyncio.run(main()) 153 | ``` 154 | 155 | ### [Trio](https://github.com/python-trio/trio) 156 | 157 | Trio is [an alternative async library](https://trio.readthedocs.io/en/stable/), 158 | designed around the [the principles of structured concurrency](https://en.wikipedia.org/wiki/Structured_concurrency). 159 | 160 | ```python 161 | import httpx 162 | import trio 163 | 164 | async def main(): 165 | async with httpx.AsyncClient() as client: 166 | response = await client.get('https://www.example.com/') 167 | print(response) 168 | 169 | trio.run(main) 170 | ``` 171 | 172 | !!! important 173 | The `trio` package must be installed to use the Trio backend. 174 | 175 | 176 | ### [AnyIO](https://github.com/agronholm/anyio) 177 | 178 | AnyIO is an [asynchronous networking and concurrency library](https://anyio.readthedocs.io/) that works on top of either `asyncio` or `trio`. It blends in with native libraries of your chosen backend (defaults to `asyncio`). 179 | 180 | ```python 181 | import httpx 182 | import anyio 183 | 184 | async def main(): 185 | async with httpx.AsyncClient() as client: 186 | response = await client.get('https://www.example.com/') 187 | print(response) 188 | 189 | anyio.run(main, backend='trio') 190 | ``` 191 | 192 | ## Calling into Python Web Apps 193 | 194 | For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport). -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We expect contributors to our projects and online spaces to follow [the Python Software Foundation’s Code of Conduct](https://www.python.org/psf/conduct/). 4 | 5 | The Python community is made up of members from around the globe with a diverse set of skills, personalities, and experiences. It is through these differences that our community experiences great successes and continued growth. When you're working with members of the community, this Code of Conduct will help steer your interactions and keep Python a positive, successful, and growing community. 6 | 7 | ## Our Community 8 | 9 | Members of the Python community are **open, considerate, and respectful**. Behaviours that reinforce these values contribute to a positive environment, and include: 10 | 11 | * **Being open.** Members of the community are open to collaboration, whether it's on PEPs, patches, problems, or otherwise. 12 | * **Focusing on what is best for the community.** We're respectful of the processes set forth in the community, and we work within them. 13 | * **Acknowledging time and effort.** We're respectful of the volunteer efforts that permeate the Python community. We're thoughtful when addressing the efforts of others, keeping in mind that often times the labor was completed simply for the good of the community. 14 | * **Being respectful of differing viewpoints and experiences.** We're receptive to constructive comments and criticism, as the experiences and skill sets of other members contribute to the whole of our efforts. 15 | * **Showing empathy towards other community members.** We're attentive in our communications, whether in person or online, and we're tactful when approaching differing views. 16 | * **Being considerate.** Members of the community are considerate of their peers -- other Python users. 17 | * **Being respectful.** We're respectful of others, their positions, their skills, their commitments, and their efforts. 18 | * **Gracefully accepting constructive criticism.** When we disagree, we are courteous in raising our issues. 19 | * **Using welcoming and inclusive language.** We're accepting of all who wish to take part in our activities, fostering an environment where anyone can participate and everyone can make a difference. 20 | 21 | ## Our Standards 22 | 23 | Every member of our community has the right to have their identity respected. The Python community is dedicated to providing a positive experience for everyone, regardless of age, gender identity and expression, sexual orientation, disability, physical appearance, body size, ethnicity, nationality, race, or religion (or lack thereof), education, or socio-economic status. 24 | 25 | ## Inappropriate Behavior 26 | 27 | Examples of unacceptable behavior by participants include: 28 | 29 | * Harassment of any participants in any form 30 | * Deliberate intimidation, stalking, or following 31 | * Logging or taking screenshots of online activity for harassment purposes 32 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 33 | * Violent threats or language directed against another person 34 | * Incitement of violence or harassment towards any individual, including encouraging a person to commit suicide or to engage in self-harm 35 | * Creating additional online accounts in order to harass another person or circumvent a ban 36 | * Sexual language and imagery in online communities or in any conference venue, including talks 37 | * Insults, put downs, or jokes that are based upon stereotypes, that are exclusionary, or that hold others up for ridicule 38 | * Excessive swearing 39 | * Unwelcome sexual attention or advances 40 | * Unwelcome physical contact, including simulated physical contact (eg, textual descriptions like "hug" or "backrub") without consent or after a request to stop 41 | * Pattern of inappropriate social contact, such as requesting/assuming inappropriate levels of intimacy with others 42 | * Sustained disruption of online community discussions, in-person presentations, or other in-person events 43 | * Continued one-on-one communication after requests to cease 44 | * Other conduct that is inappropriate for a professional audience including people of many different backgrounds 45 | 46 | Community members asked to stop any inappropriate behavior are expected to comply immediately. 47 | 48 | ## Enforcement 49 | 50 | We take Code of Conduct violations seriously, and will act to ensure our spaces are welcoming, inclusive, and professional environments to communicate in. 51 | 52 | If you need to raise a Code of Conduct report, you may do so privately by email to tom@tomchristie.com. 53 | 54 | Reports will be treated confidentially. 55 | 56 | Alternately you may [make a report to the Python Software Foundation](https://www.python.org/psf/conduct/reporting/). 57 | -------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | div.autodoc-docstring { 2 | padding-left: 20px; 3 | margin-bottom: 30px; 4 | border-left: 5px solid rgba(230, 230, 230); 5 | } 6 | 7 | div.autodoc-members { 8 | padding-left: 20px; 9 | margin-bottom: 15px; 10 | } 11 | -------------------------------------------------------------------------------- /docs/environment_variables.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | The HTTPX library can be configured via environment variables. 4 | Environment variables are used by default. To ignore environment variables, `trust_env` has to be set `False`. There are two ways to set `trust_env` to disable environment variables: 5 | 6 | * On the client via `httpx.Client(trust_env=False)`. 7 | * Using the top-level API, such as `httpx.get("", trust_env=False)`. 8 | 9 | Here is a list of environment variables that HTTPX recognizes and what function they serve: 10 | 11 | ## Proxies 12 | 13 | The environment variables documented below are used as a convention by various HTTP tooling, including: 14 | 15 | * [cURL](https://github.com/curl/curl/blob/master/docs/MANUAL.md#environment-variables) 16 | * [requests](https://github.com/psf/requests/blob/master/docs/user/advanced.rst#proxies) 17 | 18 | For more information on using proxies in HTTPX, see [HTTP Proxying](advanced/proxies.md#http-proxying). 19 | 20 | ### `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` 21 | 22 | Valid values: A URL to a proxy 23 | 24 | `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` set the proxy to be used for `http`, `https`, or all requests respectively. 25 | 26 | ```bash 27 | export HTTP_PROXY=http://my-external-proxy.com:1234 28 | 29 | # This request will be sent through the proxy 30 | python -c "import httpx; httpx.get('http://example.com')" 31 | 32 | # This request will be sent directly, as we set `trust_env=False` 33 | python -c "import httpx; httpx.get('http://example.com', trust_env=False)" 34 | 35 | ``` 36 | 37 | ### `NO_PROXY` 38 | 39 | Valid values: a comma-separated list of hostnames/urls 40 | 41 | `NO_PROXY` disables the proxy for specific urls 42 | 43 | ```bash 44 | export HTTP_PROXY=http://my-external-proxy.com:1234 45 | export NO_PROXY=http://127.0.0.1,python-httpx.org 46 | 47 | # As in the previous example, this request will be sent through the proxy 48 | python -c "import httpx; httpx.get('http://example.com')" 49 | 50 | # These requests will be sent directly, bypassing the proxy 51 | python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')" 52 | python -c "import httpx; httpx.get('https://www.python-httpx.org')" 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions 2 | 3 | This page lists exceptions that may be raised when using HTTPX. 4 | 5 | For an overview of how to work with HTTPX exceptions, see [Exceptions (Quickstart)](quickstart.md#exceptions). 6 | 7 | ## The exception hierarchy 8 | 9 | * HTTPError 10 | * RequestError 11 | * TransportError 12 | * TimeoutException 13 | * ConnectTimeout 14 | * ReadTimeout 15 | * WriteTimeout 16 | * PoolTimeout 17 | * NetworkError 18 | * ConnectError 19 | * ReadError 20 | * WriteError 21 | * CloseError 22 | * ProtocolError 23 | * LocalProtocolError 24 | * RemoteProtocolError 25 | * ProxyError 26 | * UnsupportedProtocol 27 | * DecodingError 28 | * TooManyRedirects 29 | * HTTPStatusError 30 | * InvalidURL 31 | * CookieConflict 32 | * StreamError 33 | * StreamConsumed 34 | * ResponseNotRead 35 | * RequestNotRead 36 | * StreamClosed 37 | 38 | --- 39 | 40 | ## Exception classes 41 | 42 | ::: httpx.HTTPError 43 | :docstring: 44 | 45 | ::: httpx.RequestError 46 | :docstring: 47 | 48 | ::: httpx.TransportError 49 | :docstring: 50 | 51 | ::: httpx.TimeoutException 52 | :docstring: 53 | 54 | ::: httpx.ConnectTimeout 55 | :docstring: 56 | 57 | ::: httpx.ReadTimeout 58 | :docstring: 59 | 60 | ::: httpx.WriteTimeout 61 | :docstring: 62 | 63 | ::: httpx.PoolTimeout 64 | :docstring: 65 | 66 | ::: httpx.NetworkError 67 | :docstring: 68 | 69 | ::: httpx.ConnectError 70 | :docstring: 71 | 72 | ::: httpx.ReadError 73 | :docstring: 74 | 75 | ::: httpx.WriteError 76 | :docstring: 77 | 78 | ::: httpx.CloseError 79 | :docstring: 80 | 81 | ::: httpx.ProtocolError 82 | :docstring: 83 | 84 | ::: httpx.LocalProtocolError 85 | :docstring: 86 | 87 | ::: httpx.RemoteProtocolError 88 | :docstring: 89 | 90 | ::: httpx.ProxyError 91 | :docstring: 92 | 93 | ::: httpx.UnsupportedProtocol 94 | :docstring: 95 | 96 | ::: httpx.DecodingError 97 | :docstring: 98 | 99 | ::: httpx.TooManyRedirects 100 | :docstring: 101 | 102 | ::: httpx.HTTPStatusError 103 | :docstring: 104 | 105 | ::: httpx.InvalidURL 106 | :docstring: 107 | 108 | ::: httpx.CookieConflict 109 | :docstring: 110 | 111 | ::: httpx.StreamError 112 | :docstring: 113 | 114 | ::: httpx.StreamConsumed 115 | :docstring: 116 | 117 | ::: httpx.StreamClosed 118 | :docstring: 119 | 120 | ::: httpx.ResponseNotRead 121 | :docstring: 122 | 123 | ::: httpx.RequestNotRead 124 | :docstring: 125 | -------------------------------------------------------------------------------- /docs/http2.md: -------------------------------------------------------------------------------- 1 | # HTTP/2 2 | 3 | HTTP/2 is a major new iteration of the HTTP protocol, that provides a far more 4 | efficient transport, with potential performance benefits. HTTP/2 does not change 5 | the core semantics of the request or response, but alters the way that data is 6 | sent to and from the server. 7 | 8 | Rather than the text format that HTTP/1.1 uses, HTTP/2 is a binary format. 9 | The binary format provides full request and response multiplexing, and efficient 10 | compression of HTTP headers. The stream multiplexing means that where HTTP/1.1 11 | requires one TCP stream for each concurrent request, HTTP/2 allows a single TCP 12 | stream to handle multiple concurrent requests. 13 | 14 | HTTP/2 also provides support for functionality such as response prioritization, 15 | and server push. 16 | 17 | For a comprehensive guide to HTTP/2 you may want to check out "[http2 explained](https://http2-explained.haxx.se/)". 18 | 19 | ## Enabling HTTP/2 20 | 21 | When using the `httpx` client, HTTP/2 support is not enabled by default, because 22 | HTTP/1.1 is a mature, battle-hardened transport layer, and our HTTP/1.1 23 | implementation may be considered the more robust option at this point in time. 24 | It is possible that a future version of `httpx` may enable HTTP/2 support by default. 25 | 26 | If you're issuing highly concurrent requests you might want to consider 27 | trying out our HTTP/2 support. You can do so by first making sure to install 28 | the optional HTTP/2 dependencies... 29 | 30 | ```shell 31 | $ pip install httpx[http2] 32 | ``` 33 | 34 | And then instantiating a client with HTTP/2 support enabled: 35 | 36 | ```python 37 | client = httpx.AsyncClient(http2=True) 38 | ... 39 | ``` 40 | 41 | You can also instantiate a client as a context manager, to ensure that all 42 | HTTP connections are nicely scoped, and will be closed once the context block 43 | is exited. 44 | 45 | ```python 46 | async with httpx.AsyncClient(http2=True) as client: 47 | ... 48 | ``` 49 | 50 | HTTP/2 support is available on both `Client` and `AsyncClient`, although it's 51 | typically more useful in async contexts if you're issuing lots of concurrent 52 | requests. 53 | 54 | ## Inspecting the HTTP version 55 | 56 | Enabling HTTP/2 support on the client does not *necessarily* mean that your 57 | requests and responses will be transported over HTTP/2, since both the client 58 | *and* the server need to support HTTP/2. If you connect to a server that only 59 | supports HTTP/1.1 the client will use a standard HTTP/1.1 connection instead. 60 | 61 | You can determine which version of the HTTP protocol was used by examining 62 | the `.http_version` property on the response. 63 | 64 | ```python 65 | client = httpx.AsyncClient(http2=True) 66 | response = await client.get(...) 67 | print(response.http_version) # "HTTP/1.0", "HTTP/1.1", or "HTTP/2". 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/img/butterfly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/butterfly.png -------------------------------------------------------------------------------- /docs/img/gh-actions-fail-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/gh-actions-fail-check.png -------------------------------------------------------------------------------- /docs/img/gh-actions-fail-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/gh-actions-fail-test.png -------------------------------------------------------------------------------- /docs/img/gh-actions-fail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/gh-actions-fail.png -------------------------------------------------------------------------------- /docs/img/httpx-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/httpx-help.png -------------------------------------------------------------------------------- /docs/img/httpx-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/httpx-request.png -------------------------------------------------------------------------------- /docs/img/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/logo.jpg -------------------------------------------------------------------------------- /docs/img/rich-progress.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/rich-progress.gif -------------------------------------------------------------------------------- /docs/img/speakeasy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/speakeasy.png -------------------------------------------------------------------------------- /docs/img/tqdm-progress.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/docs/img/tqdm-progress.gif -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | HTTPX 3 |

4 | 5 |

6 | HTTPX 7 |

8 | 9 | --- 10 | 11 |
12 |

13 | 14 | Test Suite 15 | 16 | 17 | Package version 18 | 19 |

20 | 21 | A next-generation HTTP client for Python. 22 |
23 | 24 | HTTPX is a fully featured HTTP client for Python 3, which provides sync and async APIs, and support for both HTTP/1.1 and HTTP/2. 25 | 26 | --- 27 | 28 | Install HTTPX using pip: 29 | 30 | ```shell 31 | $ pip install httpx 32 | ``` 33 | 34 | Now, let's get started: 35 | 36 | ```pycon 37 | >>> import httpx 38 | >>> r = httpx.get('https://www.example.org/') 39 | >>> r 40 | 41 | >>> r.status_code 42 | 200 43 | >>> r.headers['content-type'] 44 | 'text/html; charset=UTF-8' 45 | >>> r.text 46 | '\n\n\nExample Domain...' 47 | ``` 48 | 49 | Or, using the command-line client. 50 | 51 | ```shell 52 | # The command line client is an optional dependency. 53 | $ pip install 'httpx[cli]' 54 | ``` 55 | 56 | Which now allows us to use HTTPX directly from the command-line... 57 | 58 | ![httpx --help](img/httpx-help.png) 59 | 60 | Sending a request... 61 | 62 | ![httpx http://httpbin.org/json](img/httpx-request.png) 63 | 64 | ## Features 65 | 66 | HTTPX builds on the well-established usability of `requests`, and gives you: 67 | 68 | * A broadly [requests-compatible API](compatibility.md). 69 | * Standard synchronous interface, but with [async support if you need it](async.md). 70 | * HTTP/1.1 [and HTTP/2 support](http2.md). 71 | * Ability to make requests directly to [WSGI applications](advanced/transports.md#wsgi-transport) or [ASGI applications](advanced/transports.md#asgi-transport). 72 | * Strict timeouts everywhere. 73 | * Fully type annotated. 74 | * 100% test coverage. 75 | 76 | Plus all the standard features of `requests`... 77 | 78 | * International Domains and URLs 79 | * Keep-Alive & Connection Pooling 80 | * Sessions with Cookie Persistence 81 | * Browser-style SSL Verification 82 | * Basic/Digest Authentication 83 | * Elegant Key/Value Cookies 84 | * Automatic Decompression 85 | * Automatic Content Decoding 86 | * Unicode Response Bodies 87 | * Multipart File Uploads 88 | * HTTP(S) Proxy Support 89 | * Connection Timeouts 90 | * Streaming Downloads 91 | * .netrc Support 92 | * Chunked Requests 93 | 94 | ## Documentation 95 | 96 | For a run-through of all the basics, head over to the [QuickStart](quickstart.md). 97 | 98 | For more advanced topics, see the **Advanced** section, 99 | the [async support](async.md) section, or the [HTTP/2](http2.md) section. 100 | 101 | The [Developer Interface](api.md) provides a comprehensive API reference. 102 | 103 | To find out about tools that integrate with HTTPX, see [Third Party Packages](third_party_packages.md). 104 | 105 | ## Dependencies 106 | 107 | The HTTPX project relies on these excellent libraries: 108 | 109 | * `httpcore` - The underlying transport implementation for `httpx`. 110 | * `h11` - HTTP/1.1 support. 111 | * `certifi` - SSL certificates. 112 | * `idna` - Internationalized domain name support. 113 | * `sniffio` - Async library autodetection. 114 | 115 | As well as these optional installs: 116 | 117 | * `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)* 118 | * `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)* 119 | * `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)* 120 | * `click` - Command line client support. *(Optional, with `httpx[cli]`)* 121 | * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)* 122 | * `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)* 123 | 124 | A huge amount of credit is due to `requests` for the API layout that 125 | much of this work follows, as well as to `urllib3` for plenty of design 126 | inspiration around the lower-level networking details. 127 | 128 | ## Installation 129 | 130 | Install with pip: 131 | 132 | ```shell 133 | $ pip install httpx 134 | ``` 135 | 136 | Or, to include the optional HTTP/2 support, use: 137 | 138 | ```shell 139 | $ pip install httpx[http2] 140 | ``` 141 | 142 | To include the optional brotli and zstandard decoders support, use: 143 | 144 | ```shell 145 | $ pip install httpx[brotli,zstd] 146 | ``` 147 | 148 | HTTPX requires Python 3.8+ 149 | 150 | [sync-support]: https://github.com/encode/httpx/issues/572 151 | -------------------------------------------------------------------------------- /docs/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | If you need to inspect the internal behaviour of `httpx`, you can use Python's standard logging to output information about the underlying network behaviour. 4 | 5 | For example, the following configuration... 6 | 7 | ```python 8 | import logging 9 | import httpx 10 | 11 | logging.basicConfig( 12 | format="%(levelname)s [%(asctime)s] %(name)s - %(message)s", 13 | datefmt="%Y-%m-%d %H:%M:%S", 14 | level=logging.DEBUG 15 | ) 16 | 17 | httpx.get("https://www.example.com") 18 | ``` 19 | 20 | Will send debug level output to the console, or wherever `stdout` is directed too... 21 | 22 | ``` 23 | DEBUG [2024-09-28 17:27:40] httpx - load_ssl_context verify=True cert=None 24 | 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' 25 | 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 26 | DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value= 27 | 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 28 | DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.complete return_value= 29 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.started request= 30 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.complete 31 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.started request= 32 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.complete 33 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.started request= 34 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Content-Encoding', b'gzip'), (b'Accept-Ranges', b'bytes'), (b'Age', b'407727'), (b'Cache-Control', b'max-age=604800'), (b'Content-Type', b'text/html; charset=UTF-8'), (b'Date', b'Sat, 28 Sep 2024 13:27:42 GMT'), (b'Etag', b'"3147526947+gzip"'), (b'Expires', b'Sat, 05 Oct 2024 13:27:42 GMT'), (b'Last-Modified', b'Thu, 17 Oct 2019 07:18:26 GMT'), (b'Server', b'ECAcc (dcd/7D43)'), (b'Vary', b'Accept-Encoding'), (b'X-Cache', b'HIT'), (b'Content-Length', b'648')]) 35 | INFO [2024-09-28 17:27:41] httpx - HTTP Request: GET https://www.example.com "HTTP/1.1 200 OK" 36 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.started request= 37 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.complete 38 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.started 39 | DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.complete 40 | DEBUG [2024-09-28 17:27:41] httpcore.connection - close.started 41 | DEBUG [2024-09-28 17:27:41] httpcore.connection - close.complete 42 | ``` 43 | 44 | Logging output includes information from both the high-level `httpx` logger, and the network-level `httpcore` logger, which can be configured separately. 45 | 46 | For handling more complex logging configurations you might want to use the dictionary configuration style... 47 | 48 | ```python 49 | import logging.config 50 | import httpx 51 | 52 | LOGGING_CONFIG = { 53 | "version": 1, 54 | "handlers": { 55 | "default": { 56 | "class": "logging.StreamHandler", 57 | "formatter": "http", 58 | "stream": "ext://sys.stderr" 59 | } 60 | }, 61 | "formatters": { 62 | "http": { 63 | "format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s", 64 | "datefmt": "%Y-%m-%d %H:%M:%S", 65 | } 66 | }, 67 | 'loggers': { 68 | 'httpx': { 69 | 'handlers': ['default'], 70 | 'level': 'DEBUG', 71 | }, 72 | 'httpcore': { 73 | 'handlers': ['default'], 74 | 'level': 'DEBUG', 75 | }, 76 | } 77 | } 78 | 79 | logging.config.dictConfig(LOGGING_CONFIG) 80 | httpx.get('https://www.example.com') 81 | ``` 82 | 83 | The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions. -------------------------------------------------------------------------------- /docs/overrides/partials/nav.html: -------------------------------------------------------------------------------- 1 | {% import "partials/nav-item.html" as item with context %} 2 | 3 | 4 | {% set class = "md-nav md-nav--primary" %} 5 | {% if "navigation.tabs" in features %} 6 | {% set class = class ~ " md-nav--lifted" %} 7 | {% endif %} 8 | {% if "toc.integrate" in features %} 9 | {% set class = class ~ " md-nav--integrated" %} 10 | {% endif %} 11 | 12 | 13 | 54 | -------------------------------------------------------------------------------- /docs/third_party_packages.md: -------------------------------------------------------------------------------- 1 | # Third Party Packages 2 | 3 | As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them. 4 | 5 | 6 | 7 | ## Plugins 8 | 9 | ### Hishel 10 | 11 | [GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/) 12 | 13 | An elegant HTTP Cache implementation for HTTPX and HTTP Core. 14 | 15 | ### HTTPX-Auth 16 | 17 | [GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/httpx_auth/) 18 | 19 | Provides authentication classes to be used with HTTPX's [authentication parameter](advanced/authentication.md#customizing-authentication). 20 | 21 | ### httpx-caching 22 | 23 | [Github](https://github.com/johtso/httpx-caching) 24 | 25 | This package adds caching functionality to HTTPX 26 | 27 | ### httpx-socks 28 | 29 | [GitHub](https://github.com/romis2012/httpx-socks) 30 | 31 | Proxy (HTTP, SOCKS) transports for httpx. 32 | 33 | ### httpx-sse 34 | 35 | [GitHub](https://github.com/florimondmanca/httpx-sse) 36 | 37 | Allows consuming Server-Sent Events (SSE) with HTTPX. 38 | 39 | ### httpx-retries 40 | 41 | [GitHub](https://github.com/will-ockmore/httpx-retries) - [Documentation](https://will-ockmore.github.io/httpx-retries/) 42 | 43 | A retry layer for HTTPX. 44 | 45 | ### httpx-ws 46 | 47 | [GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/) 48 | 49 | WebSocket support for HTTPX. 50 | 51 | ### pytest-HTTPX 52 | 53 | [GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/) 54 | 55 | Provides a [pytest](https://docs.pytest.org/en/latest/) fixture to mock HTTPX within test cases. 56 | 57 | ### RESPX 58 | 59 | [GitHub](https://github.com/lundberg/respx) - [Documentation](https://lundberg.github.io/respx/) 60 | 61 | A utility for mocking out HTTPX. 62 | 63 | ### rpc.py 64 | 65 | [Github](https://github.com/abersheeran/rpc.py) - [Documentation](https://github.com/abersheeran/rpc.py#rpcpy) 66 | 67 | A fast and powerful RPC framework based on ASGI/WSGI. Use HTTPX as the client of the RPC service. 68 | 69 | ## Libraries with HTTPX support 70 | 71 | ### Authlib 72 | 73 | [GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/) 74 | 75 | A python library for building OAuth and OpenID Connect clients and servers. Includes an [OAuth HTTPX client](https://docs.authlib.org/en/latest/client/httpx.html). 76 | 77 | ### Gidgethub 78 | 79 | [GitHub](https://github.com/brettcannon/gidgethub) - [Documentation](https://gidgethub.readthedocs.io/en/latest/index.html) 80 | 81 | An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html). 82 | 83 | ### httpdbg 84 | 85 | [GitHub](https://github.com/cle-b/httpdbg) - [Documentation](https://httpdbg.readthedocs.io/) 86 | 87 | A tool for python developers to easily debug the HTTP(S) client requests in a python program. 88 | 89 | ### VCR.py 90 | 91 | [GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/) 92 | 93 | Record and repeat requests. 94 | 95 | ## Gists 96 | 97 | ### urllib3-transport 98 | 99 | [GitHub](https://gist.github.com/florimondmanca/d56764d78d748eb9f73165da388e546e) 100 | 101 | This public gist provides an example implementation for a [custom transport](advanced/transports.md#custom-transports) implementation on top of the battle-tested [`urllib3`](https://urllib3.readthedocs.io) library. 102 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | This page lists some common problems or issues you could encounter while developing with HTTPX, as well as possible solutions. 4 | 5 | ## Proxies 6 | 7 | --- 8 | 9 | ### "`The handshake operation timed out`" on HTTPS requests when using a proxy 10 | 11 | **Description**: When using a proxy and making an HTTPS request, you see an exception looking like this: 12 | 13 | ```console 14 | httpx.ProxyError: _ssl.c:1091: The handshake operation timed out 15 | ``` 16 | 17 | **Similar issues**: [encode/httpx#1412](https://github.com/encode/httpx/issues/1412), [encode/httpx#1433](https://github.com/encode/httpx/issues/1433) 18 | 19 | **Resolution**: it is likely that you've set up your proxies like this... 20 | 21 | ```python 22 | mounts = { 23 | "http://": httpx.HTTPTransport(proxy="http://myproxy.org"), 24 | "https://": httpx.HTTPTransport(proxy="https://myproxy.org"), 25 | } 26 | ``` 27 | 28 | Using this setup, you're telling HTTPX to connect to the proxy using HTTP for HTTP requests, and using HTTPS for HTTPS requests. 29 | 30 | But if you get the error above, it is likely that your proxy doesn't support connecting via HTTPS. Don't worry: that's a [common gotcha](advanced/proxies.md#http-proxies). 31 | 32 | Change the scheme of your HTTPS proxy to `http://...` instead of `https://...`: 33 | 34 | ```python 35 | mounts = { 36 | "http://": httpx.HTTPTransport(proxy="http://myproxy.org"), 37 | "https://": httpx.HTTPTransport(proxy="http://myproxy.org"), 38 | } 39 | ``` 40 | 41 | This can be simplified to: 42 | 43 | ```python 44 | proxy = "http://myproxy.org" 45 | with httpx.Client(proxy=proxy) as client: 46 | ... 47 | ``` 48 | 49 | For more information, see [Proxies: FORWARD vs TUNNEL](advanced/proxies.md#forward-vs-tunnel). 50 | 51 | --- 52 | 53 | ### Error when making requests to an HTTPS proxy 54 | 55 | **Description**: your proxy _does_ support connecting via HTTPS, but you are seeing errors along the lines of... 56 | 57 | ```console 58 | httpx.ProxyError: [SSL: PRE_MAC_LENGTH_TOO_LONG] invalid alert (_ssl.c:1091) 59 | ``` 60 | 61 | **Similar issues**: [encode/httpx#1424](https://github.com/encode/httpx/issues/1424). 62 | 63 | **Resolution**: HTTPX does not properly support HTTPS proxies at this time. If that's something you're interested in having, please see [encode/httpx#1434](https://github.com/encode/httpx/issues/1434) and consider lending a hand there. 64 | -------------------------------------------------------------------------------- /httpx/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __description__, __title__, __version__ 2 | from ._api import * 3 | from ._auth import * 4 | from ._client import * 5 | from ._config import * 6 | from ._content import * 7 | from ._exceptions import * 8 | from ._models import * 9 | from ._status_codes import * 10 | from ._transports import * 11 | from ._types import * 12 | from ._urls import * 13 | 14 | try: 15 | from ._main import main 16 | except ImportError: # pragma: no cover 17 | 18 | def main() -> None: # type: ignore 19 | import sys 20 | 21 | print( 22 | "The httpx command line client could not run because the required " 23 | "dependencies were not installed.\nMake sure you've installed " 24 | "everything with: pip install 'httpx[cli]'" 25 | ) 26 | sys.exit(1) 27 | 28 | 29 | __all__ = [ 30 | "__description__", 31 | "__title__", 32 | "__version__", 33 | "ASGITransport", 34 | "AsyncBaseTransport", 35 | "AsyncByteStream", 36 | "AsyncClient", 37 | "AsyncHTTPTransport", 38 | "Auth", 39 | "BaseTransport", 40 | "BasicAuth", 41 | "ByteStream", 42 | "Client", 43 | "CloseError", 44 | "codes", 45 | "ConnectError", 46 | "ConnectTimeout", 47 | "CookieConflict", 48 | "Cookies", 49 | "create_ssl_context", 50 | "DecodingError", 51 | "delete", 52 | "DigestAuth", 53 | "get", 54 | "head", 55 | "Headers", 56 | "HTTPError", 57 | "HTTPStatusError", 58 | "HTTPTransport", 59 | "InvalidURL", 60 | "Limits", 61 | "LocalProtocolError", 62 | "main", 63 | "MockTransport", 64 | "NetRCAuth", 65 | "NetworkError", 66 | "options", 67 | "patch", 68 | "PoolTimeout", 69 | "post", 70 | "ProtocolError", 71 | "Proxy", 72 | "ProxyError", 73 | "put", 74 | "QueryParams", 75 | "ReadError", 76 | "ReadTimeout", 77 | "RemoteProtocolError", 78 | "request", 79 | "Request", 80 | "RequestError", 81 | "RequestNotRead", 82 | "Response", 83 | "ResponseNotRead", 84 | "stream", 85 | "StreamClosed", 86 | "StreamConsumed", 87 | "StreamError", 88 | "SyncByteStream", 89 | "Timeout", 90 | "TimeoutException", 91 | "TooManyRedirects", 92 | "TransportError", 93 | "UnsupportedProtocol", 94 | "URL", 95 | "USE_CLIENT_DEFAULT", 96 | "WriteError", 97 | "WriteTimeout", 98 | "WSGITransport", 99 | ] 100 | 101 | 102 | __locals = locals() 103 | for __name in __all__: 104 | if not __name.startswith("__"): 105 | setattr(__locals[__name], "__module__", "httpx") # noqa 106 | -------------------------------------------------------------------------------- /httpx/__version__.py: -------------------------------------------------------------------------------- 1 | __title__ = "httpx" 2 | __description__ = "A next generation HTTP client, for Python 3." 3 | __version__ = "0.28.1" 4 | -------------------------------------------------------------------------------- /httpx/_content.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import warnings 5 | from json import dumps as json_dumps 6 | from typing import ( 7 | Any, 8 | AsyncIterable, 9 | AsyncIterator, 10 | Iterable, 11 | Iterator, 12 | Mapping, 13 | ) 14 | from urllib.parse import urlencode 15 | 16 | from ._exceptions import StreamClosed, StreamConsumed 17 | from ._multipart import MultipartStream 18 | from ._types import ( 19 | AsyncByteStream, 20 | RequestContent, 21 | RequestData, 22 | RequestFiles, 23 | ResponseContent, 24 | SyncByteStream, 25 | ) 26 | from ._utils import peek_filelike_length, primitive_value_to_str 27 | 28 | __all__ = ["ByteStream"] 29 | 30 | 31 | class ByteStream(AsyncByteStream, SyncByteStream): 32 | def __init__(self, stream: bytes) -> None: 33 | self._stream = stream 34 | 35 | def __iter__(self) -> Iterator[bytes]: 36 | yield self._stream 37 | 38 | async def __aiter__(self) -> AsyncIterator[bytes]: 39 | yield self._stream 40 | 41 | 42 | class IteratorByteStream(SyncByteStream): 43 | CHUNK_SIZE = 65_536 44 | 45 | def __init__(self, stream: Iterable[bytes]) -> None: 46 | self._stream = stream 47 | self._is_stream_consumed = False 48 | self._is_generator = inspect.isgenerator(stream) 49 | 50 | def __iter__(self) -> Iterator[bytes]: 51 | if self._is_stream_consumed and self._is_generator: 52 | raise StreamConsumed() 53 | 54 | self._is_stream_consumed = True 55 | if hasattr(self._stream, "read"): 56 | # File-like interfaces should use 'read' directly. 57 | chunk = self._stream.read(self.CHUNK_SIZE) 58 | while chunk: 59 | yield chunk 60 | chunk = self._stream.read(self.CHUNK_SIZE) 61 | else: 62 | # Otherwise iterate. 63 | for part in self._stream: 64 | yield part 65 | 66 | 67 | class AsyncIteratorByteStream(AsyncByteStream): 68 | CHUNK_SIZE = 65_536 69 | 70 | def __init__(self, stream: AsyncIterable[bytes]) -> None: 71 | self._stream = stream 72 | self._is_stream_consumed = False 73 | self._is_generator = inspect.isasyncgen(stream) 74 | 75 | async def __aiter__(self) -> AsyncIterator[bytes]: 76 | if self._is_stream_consumed and self._is_generator: 77 | raise StreamConsumed() 78 | 79 | self._is_stream_consumed = True 80 | if hasattr(self._stream, "aread"): 81 | # File-like interfaces should use 'aread' directly. 82 | chunk = await self._stream.aread(self.CHUNK_SIZE) 83 | while chunk: 84 | yield chunk 85 | chunk = await self._stream.aread(self.CHUNK_SIZE) 86 | else: 87 | # Otherwise iterate. 88 | async for part in self._stream: 89 | yield part 90 | 91 | 92 | class UnattachedStream(AsyncByteStream, SyncByteStream): 93 | """ 94 | If a request or response is serialized using pickle, then it is no longer 95 | attached to a stream for I/O purposes. Any stream operations should result 96 | in `httpx.StreamClosed`. 97 | """ 98 | 99 | def __iter__(self) -> Iterator[bytes]: 100 | raise StreamClosed() 101 | 102 | async def __aiter__(self) -> AsyncIterator[bytes]: 103 | raise StreamClosed() 104 | yield b"" # pragma: no cover 105 | 106 | 107 | def encode_content( 108 | content: str | bytes | Iterable[bytes] | AsyncIterable[bytes], 109 | ) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]: 110 | if isinstance(content, (bytes, str)): 111 | body = content.encode("utf-8") if isinstance(content, str) else content 112 | content_length = len(body) 113 | headers = {"Content-Length": str(content_length)} if body else {} 114 | return headers, ByteStream(body) 115 | 116 | elif isinstance(content, Iterable) and not isinstance(content, dict): 117 | # `not isinstance(content, dict)` is a bit oddly specific, but it 118 | # catches a case that's easy for users to make in error, and would 119 | # otherwise pass through here, like any other bytes-iterable, 120 | # because `dict` happens to be iterable. See issue #2491. 121 | content_length_or_none = peek_filelike_length(content) 122 | 123 | if content_length_or_none is None: 124 | headers = {"Transfer-Encoding": "chunked"} 125 | else: 126 | headers = {"Content-Length": str(content_length_or_none)} 127 | return headers, IteratorByteStream(content) # type: ignore 128 | 129 | elif isinstance(content, AsyncIterable): 130 | headers = {"Transfer-Encoding": "chunked"} 131 | return headers, AsyncIteratorByteStream(content) 132 | 133 | raise TypeError(f"Unexpected type for 'content', {type(content)!r}") 134 | 135 | 136 | def encode_urlencoded_data( 137 | data: RequestData, 138 | ) -> tuple[dict[str, str], ByteStream]: 139 | plain_data = [] 140 | for key, value in data.items(): 141 | if isinstance(value, (list, tuple)): 142 | plain_data.extend([(key, primitive_value_to_str(item)) for item in value]) 143 | else: 144 | plain_data.append((key, primitive_value_to_str(value))) 145 | body = urlencode(plain_data, doseq=True).encode("utf-8") 146 | content_length = str(len(body)) 147 | content_type = "application/x-www-form-urlencoded" 148 | headers = {"Content-Length": content_length, "Content-Type": content_type} 149 | return headers, ByteStream(body) 150 | 151 | 152 | def encode_multipart_data( 153 | data: RequestData, files: RequestFiles, boundary: bytes | None 154 | ) -> tuple[dict[str, str], MultipartStream]: 155 | multipart = MultipartStream(data=data, files=files, boundary=boundary) 156 | headers = multipart.get_headers() 157 | return headers, multipart 158 | 159 | 160 | def encode_text(text: str) -> tuple[dict[str, str], ByteStream]: 161 | body = text.encode("utf-8") 162 | content_length = str(len(body)) 163 | content_type = "text/plain; charset=utf-8" 164 | headers = {"Content-Length": content_length, "Content-Type": content_type} 165 | return headers, ByteStream(body) 166 | 167 | 168 | def encode_html(html: str) -> tuple[dict[str, str], ByteStream]: 169 | body = html.encode("utf-8") 170 | content_length = str(len(body)) 171 | content_type = "text/html; charset=utf-8" 172 | headers = {"Content-Length": content_length, "Content-Type": content_type} 173 | return headers, ByteStream(body) 174 | 175 | 176 | def encode_json(json: Any) -> tuple[dict[str, str], ByteStream]: 177 | body = json_dumps( 178 | json, ensure_ascii=False, separators=(",", ":"), allow_nan=False 179 | ).encode("utf-8") 180 | content_length = str(len(body)) 181 | content_type = "application/json" 182 | headers = {"Content-Length": content_length, "Content-Type": content_type} 183 | return headers, ByteStream(body) 184 | 185 | 186 | def encode_request( 187 | content: RequestContent | None = None, 188 | data: RequestData | None = None, 189 | files: RequestFiles | None = None, 190 | json: Any | None = None, 191 | boundary: bytes | None = None, 192 | ) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]: 193 | """ 194 | Handles encoding the given `content`, `data`, `files`, and `json`, 195 | returning a two-tuple of (, ). 196 | """ 197 | if data is not None and not isinstance(data, Mapping): 198 | # We prefer to separate `content=` 199 | # for raw request content, and `data=
` for url encoded or 200 | # multipart form content. 201 | # 202 | # However for compat with requests, we *do* still support 203 | # `data=` usages. We deal with that case here, treating it 204 | # as if `content=<...>` had been supplied instead. 205 | message = "Use 'content=<...>' to upload raw bytes/text content." 206 | warnings.warn(message, DeprecationWarning, stacklevel=2) 207 | return encode_content(data) 208 | 209 | if content is not None: 210 | return encode_content(content) 211 | elif files: 212 | return encode_multipart_data(data or {}, files, boundary) 213 | elif data: 214 | return encode_urlencoded_data(data) 215 | elif json is not None: 216 | return encode_json(json) 217 | 218 | return {}, ByteStream(b"") 219 | 220 | 221 | def encode_response( 222 | content: ResponseContent | None = None, 223 | text: str | None = None, 224 | html: str | None = None, 225 | json: Any | None = None, 226 | ) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]: 227 | """ 228 | Handles encoding the given `content`, returning a two-tuple of 229 | (, ). 230 | """ 231 | if content is not None: 232 | return encode_content(content) 233 | elif text is not None: 234 | return encode_text(text) 235 | elif html is not None: 236 | return encode_html(html) 237 | elif json is not None: 238 | return encode_json(json) 239 | 240 | return {}, ByteStream(b"") 241 | -------------------------------------------------------------------------------- /httpx/_status_codes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import IntEnum 4 | 5 | __all__ = ["codes"] 6 | 7 | 8 | class codes(IntEnum): 9 | """HTTP status codes and reason phrases 10 | 11 | Status codes from the following RFCs are all observed: 12 | 13 | * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616 14 | * RFC 6585: Additional HTTP Status Codes 15 | * RFC 3229: Delta encoding in HTTP 16 | * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518 17 | * RFC 5842: Binding Extensions to WebDAV 18 | * RFC 7238: Permanent Redirect 19 | * RFC 2295: Transparent Content Negotiation in HTTP 20 | * RFC 2774: An HTTP Extension Framework 21 | * RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) 22 | * RFC 2324: Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0) 23 | * RFC 7725: An HTTP Status Code to Report Legal Obstacles 24 | * RFC 8297: An HTTP Status Code for Indicating Hints 25 | * RFC 8470: Using Early Data in HTTP 26 | """ 27 | 28 | def __new__(cls, value: int, phrase: str = "") -> codes: 29 | obj = int.__new__(cls, value) 30 | obj._value_ = value 31 | 32 | obj.phrase = phrase # type: ignore[attr-defined] 33 | return obj 34 | 35 | def __str__(self) -> str: 36 | return str(self.value) 37 | 38 | @classmethod 39 | def get_reason_phrase(cls, value: int) -> str: 40 | try: 41 | return codes(value).phrase # type: ignore 42 | except ValueError: 43 | return "" 44 | 45 | @classmethod 46 | def is_informational(cls, value: int) -> bool: 47 | """ 48 | Returns `True` for 1xx status codes, `False` otherwise. 49 | """ 50 | return 100 <= value <= 199 51 | 52 | @classmethod 53 | def is_success(cls, value: int) -> bool: 54 | """ 55 | Returns `True` for 2xx status codes, `False` otherwise. 56 | """ 57 | return 200 <= value <= 299 58 | 59 | @classmethod 60 | def is_redirect(cls, value: int) -> bool: 61 | """ 62 | Returns `True` for 3xx status codes, `False` otherwise. 63 | """ 64 | return 300 <= value <= 399 65 | 66 | @classmethod 67 | def is_client_error(cls, value: int) -> bool: 68 | """ 69 | Returns `True` for 4xx status codes, `False` otherwise. 70 | """ 71 | return 400 <= value <= 499 72 | 73 | @classmethod 74 | def is_server_error(cls, value: int) -> bool: 75 | """ 76 | Returns `True` for 5xx status codes, `False` otherwise. 77 | """ 78 | return 500 <= value <= 599 79 | 80 | @classmethod 81 | def is_error(cls, value: int) -> bool: 82 | """ 83 | Returns `True` for 4xx or 5xx status codes, `False` otherwise. 84 | """ 85 | return 400 <= value <= 599 86 | 87 | # informational 88 | CONTINUE = 100, "Continue" 89 | SWITCHING_PROTOCOLS = 101, "Switching Protocols" 90 | PROCESSING = 102, "Processing" 91 | EARLY_HINTS = 103, "Early Hints" 92 | 93 | # success 94 | OK = 200, "OK" 95 | CREATED = 201, "Created" 96 | ACCEPTED = 202, "Accepted" 97 | NON_AUTHORITATIVE_INFORMATION = 203, "Non-Authoritative Information" 98 | NO_CONTENT = 204, "No Content" 99 | RESET_CONTENT = 205, "Reset Content" 100 | PARTIAL_CONTENT = 206, "Partial Content" 101 | MULTI_STATUS = 207, "Multi-Status" 102 | ALREADY_REPORTED = 208, "Already Reported" 103 | IM_USED = 226, "IM Used" 104 | 105 | # redirection 106 | MULTIPLE_CHOICES = 300, "Multiple Choices" 107 | MOVED_PERMANENTLY = 301, "Moved Permanently" 108 | FOUND = 302, "Found" 109 | SEE_OTHER = 303, "See Other" 110 | NOT_MODIFIED = 304, "Not Modified" 111 | USE_PROXY = 305, "Use Proxy" 112 | TEMPORARY_REDIRECT = 307, "Temporary Redirect" 113 | PERMANENT_REDIRECT = 308, "Permanent Redirect" 114 | 115 | # client error 116 | BAD_REQUEST = 400, "Bad Request" 117 | UNAUTHORIZED = 401, "Unauthorized" 118 | PAYMENT_REQUIRED = 402, "Payment Required" 119 | FORBIDDEN = 403, "Forbidden" 120 | NOT_FOUND = 404, "Not Found" 121 | METHOD_NOT_ALLOWED = 405, "Method Not Allowed" 122 | NOT_ACCEPTABLE = 406, "Not Acceptable" 123 | PROXY_AUTHENTICATION_REQUIRED = 407, "Proxy Authentication Required" 124 | REQUEST_TIMEOUT = 408, "Request Timeout" 125 | CONFLICT = 409, "Conflict" 126 | GONE = 410, "Gone" 127 | LENGTH_REQUIRED = 411, "Length Required" 128 | PRECONDITION_FAILED = 412, "Precondition Failed" 129 | REQUEST_ENTITY_TOO_LARGE = 413, "Request Entity Too Large" 130 | REQUEST_URI_TOO_LONG = 414, "Request-URI Too Long" 131 | UNSUPPORTED_MEDIA_TYPE = 415, "Unsupported Media Type" 132 | REQUESTED_RANGE_NOT_SATISFIABLE = 416, "Requested Range Not Satisfiable" 133 | EXPECTATION_FAILED = 417, "Expectation Failed" 134 | IM_A_TEAPOT = 418, "I'm a teapot" 135 | MISDIRECTED_REQUEST = 421, "Misdirected Request" 136 | UNPROCESSABLE_ENTITY = 422, "Unprocessable Entity" 137 | LOCKED = 423, "Locked" 138 | FAILED_DEPENDENCY = 424, "Failed Dependency" 139 | TOO_EARLY = 425, "Too Early" 140 | UPGRADE_REQUIRED = 426, "Upgrade Required" 141 | PRECONDITION_REQUIRED = 428, "Precondition Required" 142 | TOO_MANY_REQUESTS = 429, "Too Many Requests" 143 | REQUEST_HEADER_FIELDS_TOO_LARGE = 431, "Request Header Fields Too Large" 144 | UNAVAILABLE_FOR_LEGAL_REASONS = 451, "Unavailable For Legal Reasons" 145 | 146 | # server errors 147 | INTERNAL_SERVER_ERROR = 500, "Internal Server Error" 148 | NOT_IMPLEMENTED = 501, "Not Implemented" 149 | BAD_GATEWAY = 502, "Bad Gateway" 150 | SERVICE_UNAVAILABLE = 503, "Service Unavailable" 151 | GATEWAY_TIMEOUT = 504, "Gateway Timeout" 152 | HTTP_VERSION_NOT_SUPPORTED = 505, "HTTP Version Not Supported" 153 | VARIANT_ALSO_NEGOTIATES = 506, "Variant Also Negotiates" 154 | INSUFFICIENT_STORAGE = 507, "Insufficient Storage" 155 | LOOP_DETECTED = 508, "Loop Detected" 156 | NOT_EXTENDED = 510, "Not Extended" 157 | NETWORK_AUTHENTICATION_REQUIRED = 511, "Network Authentication Required" 158 | 159 | 160 | # Include lower-case styles for `requests` compatibility. 161 | for code in codes: 162 | setattr(codes, code._name_.lower(), int(code)) 163 | -------------------------------------------------------------------------------- /httpx/_transports/__init__.py: -------------------------------------------------------------------------------- 1 | from .asgi import * 2 | from .base import * 3 | from .default import * 4 | from .mock import * 5 | from .wsgi import * 6 | 7 | __all__ = [ 8 | "ASGITransport", 9 | "AsyncBaseTransport", 10 | "BaseTransport", 11 | "AsyncHTTPTransport", 12 | "HTTPTransport", 13 | "MockTransport", 14 | "WSGITransport", 15 | ] 16 | -------------------------------------------------------------------------------- /httpx/_transports/asgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from .._models import Request, Response 6 | from .._types import AsyncByteStream 7 | from .base import AsyncBaseTransport 8 | 9 | if typing.TYPE_CHECKING: # pragma: no cover 10 | import asyncio 11 | 12 | import trio 13 | 14 | Event = typing.Union[asyncio.Event, trio.Event] 15 | 16 | 17 | _Message = typing.MutableMapping[str, typing.Any] 18 | _Receive = typing.Callable[[], typing.Awaitable[_Message]] 19 | _Send = typing.Callable[ 20 | [typing.MutableMapping[str, typing.Any]], typing.Awaitable[None] 21 | ] 22 | _ASGIApp = typing.Callable[ 23 | [typing.MutableMapping[str, typing.Any], _Receive, _Send], typing.Awaitable[None] 24 | ] 25 | 26 | __all__ = ["ASGITransport"] 27 | 28 | 29 | def is_running_trio() -> bool: 30 | try: 31 | # sniffio is a dependency of trio. 32 | 33 | # See https://github.com/python-trio/trio/issues/2802 34 | import sniffio 35 | 36 | if sniffio.current_async_library() == "trio": 37 | return True 38 | except ImportError: # pragma: nocover 39 | pass 40 | 41 | return False 42 | 43 | 44 | def create_event() -> Event: 45 | if is_running_trio(): 46 | import trio 47 | 48 | return trio.Event() 49 | 50 | import asyncio 51 | 52 | return asyncio.Event() 53 | 54 | 55 | class ASGIResponseStream(AsyncByteStream): 56 | def __init__(self, body: list[bytes]) -> None: 57 | self._body = body 58 | 59 | async def __aiter__(self) -> typing.AsyncIterator[bytes]: 60 | yield b"".join(self._body) 61 | 62 | 63 | class ASGITransport(AsyncBaseTransport): 64 | """ 65 | A custom AsyncTransport that handles sending requests directly to an ASGI app. 66 | 67 | ```python 68 | transport = httpx.ASGITransport( 69 | app=app, 70 | root_path="/submount", 71 | client=("1.2.3.4", 123) 72 | ) 73 | client = httpx.AsyncClient(transport=transport) 74 | ``` 75 | 76 | Arguments: 77 | 78 | * `app` - The ASGI application. 79 | * `raise_app_exceptions` - Boolean indicating if exceptions in the application 80 | should be raised. Default to `True`. Can be set to `False` for use cases 81 | such as testing the content of a client 500 response. 82 | * `root_path` - The root path on which the ASGI application should be mounted. 83 | * `client` - A two-tuple indicating the client IP and port of incoming requests. 84 | ``` 85 | """ 86 | 87 | def __init__( 88 | self, 89 | app: _ASGIApp, 90 | raise_app_exceptions: bool = True, 91 | root_path: str = "", 92 | client: tuple[str, int] = ("127.0.0.1", 123), 93 | ) -> None: 94 | self.app = app 95 | self.raise_app_exceptions = raise_app_exceptions 96 | self.root_path = root_path 97 | self.client = client 98 | 99 | async def handle_async_request( 100 | self, 101 | request: Request, 102 | ) -> Response: 103 | assert isinstance(request.stream, AsyncByteStream) 104 | 105 | # ASGI scope. 106 | scope = { 107 | "type": "http", 108 | "asgi": {"version": "3.0"}, 109 | "http_version": "1.1", 110 | "method": request.method, 111 | "headers": [(k.lower(), v) for (k, v) in request.headers.raw], 112 | "scheme": request.url.scheme, 113 | "path": request.url.path, 114 | "raw_path": request.url.raw_path.split(b"?")[0], 115 | "query_string": request.url.query, 116 | "server": (request.url.host, request.url.port), 117 | "client": self.client, 118 | "root_path": self.root_path, 119 | } 120 | 121 | # Request. 122 | request_body_chunks = request.stream.__aiter__() 123 | request_complete = False 124 | 125 | # Response. 126 | status_code = None 127 | response_headers = None 128 | body_parts = [] 129 | response_started = False 130 | response_complete = create_event() 131 | 132 | # ASGI callables. 133 | 134 | async def receive() -> dict[str, typing.Any]: 135 | nonlocal request_complete 136 | 137 | if request_complete: 138 | await response_complete.wait() 139 | return {"type": "http.disconnect"} 140 | 141 | try: 142 | body = await request_body_chunks.__anext__() 143 | except StopAsyncIteration: 144 | request_complete = True 145 | return {"type": "http.request", "body": b"", "more_body": False} 146 | return {"type": "http.request", "body": body, "more_body": True} 147 | 148 | async def send(message: typing.MutableMapping[str, typing.Any]) -> None: 149 | nonlocal status_code, response_headers, response_started 150 | 151 | if message["type"] == "http.response.start": 152 | assert not response_started 153 | 154 | status_code = message["status"] 155 | response_headers = message.get("headers", []) 156 | response_started = True 157 | 158 | elif message["type"] == "http.response.body": 159 | assert not response_complete.is_set() 160 | body = message.get("body", b"") 161 | more_body = message.get("more_body", False) 162 | 163 | if body and request.method != "HEAD": 164 | body_parts.append(body) 165 | 166 | if not more_body: 167 | response_complete.set() 168 | 169 | try: 170 | await self.app(scope, receive, send) 171 | except Exception: # noqa: PIE-786 172 | if self.raise_app_exceptions: 173 | raise 174 | 175 | response_complete.set() 176 | if status_code is None: 177 | status_code = 500 178 | if response_headers is None: 179 | response_headers = {} 180 | 181 | assert response_complete.is_set() 182 | assert status_code is not None 183 | assert response_headers is not None 184 | 185 | stream = ASGIResponseStream(body_parts) 186 | 187 | return Response(status_code, headers=response_headers, stream=stream) 188 | -------------------------------------------------------------------------------- /httpx/_transports/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from types import TracebackType 5 | 6 | from .._models import Request, Response 7 | 8 | T = typing.TypeVar("T", bound="BaseTransport") 9 | A = typing.TypeVar("A", bound="AsyncBaseTransport") 10 | 11 | __all__ = ["AsyncBaseTransport", "BaseTransport"] 12 | 13 | 14 | class BaseTransport: 15 | def __enter__(self: T) -> T: 16 | return self 17 | 18 | def __exit__( 19 | self, 20 | exc_type: type[BaseException] | None = None, 21 | exc_value: BaseException | None = None, 22 | traceback: TracebackType | None = None, 23 | ) -> None: 24 | self.close() 25 | 26 | def handle_request(self, request: Request) -> Response: 27 | """ 28 | Send a single HTTP request and return a response. 29 | 30 | Developers shouldn't typically ever need to call into this API directly, 31 | since the Client class provides all the higher level user-facing API 32 | niceties. 33 | 34 | In order to properly release any network resources, the response 35 | stream should *either* be consumed immediately, with a call to 36 | `response.stream.read()`, or else the `handle_request` call should 37 | be followed with a try/finally block to ensuring the stream is 38 | always closed. 39 | 40 | Example usage: 41 | 42 | with httpx.HTTPTransport() as transport: 43 | req = httpx.Request( 44 | method=b"GET", 45 | url=(b"https", b"www.example.com", 443, b"/"), 46 | headers=[(b"Host", b"www.example.com")], 47 | ) 48 | resp = transport.handle_request(req) 49 | body = resp.stream.read() 50 | print(resp.status_code, resp.headers, body) 51 | 52 | 53 | Takes a `Request` instance as the only argument. 54 | 55 | Returns a `Response` instance. 56 | """ 57 | raise NotImplementedError( 58 | "The 'handle_request' method must be implemented." 59 | ) # pragma: no cover 60 | 61 | def close(self) -> None: 62 | pass 63 | 64 | 65 | class AsyncBaseTransport: 66 | async def __aenter__(self: A) -> A: 67 | return self 68 | 69 | async def __aexit__( 70 | self, 71 | exc_type: type[BaseException] | None = None, 72 | exc_value: BaseException | None = None, 73 | traceback: TracebackType | None = None, 74 | ) -> None: 75 | await self.aclose() 76 | 77 | async def handle_async_request( 78 | self, 79 | request: Request, 80 | ) -> Response: 81 | raise NotImplementedError( 82 | "The 'handle_async_request' method must be implemented." 83 | ) # pragma: no cover 84 | 85 | async def aclose(self) -> None: 86 | pass 87 | -------------------------------------------------------------------------------- /httpx/_transports/mock.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from .._models import Request, Response 6 | from .base import AsyncBaseTransport, BaseTransport 7 | 8 | SyncHandler = typing.Callable[[Request], Response] 9 | AsyncHandler = typing.Callable[[Request], typing.Coroutine[None, None, Response]] 10 | 11 | 12 | __all__ = ["MockTransport"] 13 | 14 | 15 | class MockTransport(AsyncBaseTransport, BaseTransport): 16 | def __init__(self, handler: SyncHandler | AsyncHandler) -> None: 17 | self.handler = handler 18 | 19 | def handle_request( 20 | self, 21 | request: Request, 22 | ) -> Response: 23 | request.read() 24 | response = self.handler(request) 25 | if not isinstance(response, Response): # pragma: no cover 26 | raise TypeError("Cannot use an async handler in a sync Client") 27 | return response 28 | 29 | async def handle_async_request( 30 | self, 31 | request: Request, 32 | ) -> Response: 33 | await request.aread() 34 | response = self.handler(request) 35 | 36 | # Allow handler to *optionally* be an `async` function. 37 | # If it is, then the `response` variable need to be awaited to actually 38 | # return the result. 39 | 40 | if not isinstance(response, Response): 41 | response = await response 42 | 43 | return response 44 | -------------------------------------------------------------------------------- /httpx/_transports/wsgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import itertools 5 | import sys 6 | import typing 7 | 8 | from .._models import Request, Response 9 | from .._types import SyncByteStream 10 | from .base import BaseTransport 11 | 12 | if typing.TYPE_CHECKING: 13 | from _typeshed import OptExcInfo # pragma: no cover 14 | from _typeshed.wsgi import WSGIApplication # pragma: no cover 15 | 16 | _T = typing.TypeVar("_T") 17 | 18 | 19 | __all__ = ["WSGITransport"] 20 | 21 | 22 | def _skip_leading_empty_chunks(body: typing.Iterable[_T]) -> typing.Iterable[_T]: 23 | body = iter(body) 24 | for chunk in body: 25 | if chunk: 26 | return itertools.chain([chunk], body) 27 | return [] 28 | 29 | 30 | class WSGIByteStream(SyncByteStream): 31 | def __init__(self, result: typing.Iterable[bytes]) -> None: 32 | self._close = getattr(result, "close", None) 33 | self._result = _skip_leading_empty_chunks(result) 34 | 35 | def __iter__(self) -> typing.Iterator[bytes]: 36 | for part in self._result: 37 | yield part 38 | 39 | def close(self) -> None: 40 | if self._close is not None: 41 | self._close() 42 | 43 | 44 | class WSGITransport(BaseTransport): 45 | """ 46 | A custom transport that handles sending requests directly to an WSGI app. 47 | The simplest way to use this functionality is to use the `app` argument. 48 | 49 | ``` 50 | client = httpx.Client(app=app) 51 | ``` 52 | 53 | Alternatively, you can setup the transport instance explicitly. 54 | This allows you to include any additional configuration arguments specific 55 | to the WSGITransport class: 56 | 57 | ``` 58 | transport = httpx.WSGITransport( 59 | app=app, 60 | script_name="/submount", 61 | remote_addr="1.2.3.4" 62 | ) 63 | client = httpx.Client(transport=transport) 64 | ``` 65 | 66 | Arguments: 67 | 68 | * `app` - The WSGI application. 69 | * `raise_app_exceptions` - Boolean indicating if exceptions in the application 70 | should be raised. Default to `True`. Can be set to `False` for use cases 71 | such as testing the content of a client 500 response. 72 | * `script_name` - The root path on which the WSGI application should be mounted. 73 | * `remote_addr` - A string indicating the client IP of incoming requests. 74 | ``` 75 | """ 76 | 77 | def __init__( 78 | self, 79 | app: WSGIApplication, 80 | raise_app_exceptions: bool = True, 81 | script_name: str = "", 82 | remote_addr: str = "127.0.0.1", 83 | wsgi_errors: typing.TextIO | None = None, 84 | ) -> None: 85 | self.app = app 86 | self.raise_app_exceptions = raise_app_exceptions 87 | self.script_name = script_name 88 | self.remote_addr = remote_addr 89 | self.wsgi_errors = wsgi_errors 90 | 91 | def handle_request(self, request: Request) -> Response: 92 | request.read() 93 | wsgi_input = io.BytesIO(request.content) 94 | 95 | port = request.url.port or {"http": 80, "https": 443}[request.url.scheme] 96 | environ = { 97 | "wsgi.version": (1, 0), 98 | "wsgi.url_scheme": request.url.scheme, 99 | "wsgi.input": wsgi_input, 100 | "wsgi.errors": self.wsgi_errors or sys.stderr, 101 | "wsgi.multithread": True, 102 | "wsgi.multiprocess": False, 103 | "wsgi.run_once": False, 104 | "REQUEST_METHOD": request.method, 105 | "SCRIPT_NAME": self.script_name, 106 | "PATH_INFO": request.url.path, 107 | "QUERY_STRING": request.url.query.decode("ascii"), 108 | "SERVER_NAME": request.url.host, 109 | "SERVER_PORT": str(port), 110 | "SERVER_PROTOCOL": "HTTP/1.1", 111 | "REMOTE_ADDR": self.remote_addr, 112 | } 113 | for header_key, header_value in request.headers.raw: 114 | key = header_key.decode("ascii").upper().replace("-", "_") 115 | if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"): 116 | key = "HTTP_" + key 117 | environ[key] = header_value.decode("ascii") 118 | 119 | seen_status = None 120 | seen_response_headers = None 121 | seen_exc_info = None 122 | 123 | def start_response( 124 | status: str, 125 | response_headers: list[tuple[str, str]], 126 | exc_info: OptExcInfo | None = None, 127 | ) -> typing.Callable[[bytes], typing.Any]: 128 | nonlocal seen_status, seen_response_headers, seen_exc_info 129 | seen_status = status 130 | seen_response_headers = response_headers 131 | seen_exc_info = exc_info 132 | return lambda _: None 133 | 134 | result = self.app(environ, start_response) 135 | 136 | stream = WSGIByteStream(result) 137 | 138 | assert seen_status is not None 139 | assert seen_response_headers is not None 140 | if seen_exc_info and seen_exc_info[0] and self.raise_app_exceptions: 141 | raise seen_exc_info[1] 142 | 143 | status_code = int(seen_status.split()[0]) 144 | headers = [ 145 | (key.encode("ascii"), value.encode("ascii")) 146 | for key, value in seen_response_headers 147 | ] 148 | 149 | return Response(status_code, headers=headers, stream=stream) 150 | -------------------------------------------------------------------------------- /httpx/_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Type definitions for type checking purposes. 3 | """ 4 | 5 | from http.cookiejar import CookieJar 6 | from typing import ( 7 | IO, 8 | TYPE_CHECKING, 9 | Any, 10 | AsyncIterable, 11 | AsyncIterator, 12 | Callable, 13 | Dict, 14 | Iterable, 15 | Iterator, 16 | List, 17 | Mapping, 18 | Optional, 19 | Sequence, 20 | Tuple, 21 | Union, 22 | ) 23 | 24 | if TYPE_CHECKING: # pragma: no cover 25 | from ._auth import Auth # noqa: F401 26 | from ._config import Proxy, Timeout # noqa: F401 27 | from ._models import Cookies, Headers, Request # noqa: F401 28 | from ._urls import URL, QueryParams # noqa: F401 29 | 30 | 31 | PrimitiveData = Optional[Union[str, int, float, bool]] 32 | 33 | URLTypes = Union["URL", str] 34 | 35 | QueryParamTypes = Union[ 36 | "QueryParams", 37 | Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]], 38 | List[Tuple[str, PrimitiveData]], 39 | Tuple[Tuple[str, PrimitiveData], ...], 40 | str, 41 | bytes, 42 | ] 43 | 44 | HeaderTypes = Union[ 45 | "Headers", 46 | Mapping[str, str], 47 | Mapping[bytes, bytes], 48 | Sequence[Tuple[str, str]], 49 | Sequence[Tuple[bytes, bytes]], 50 | ] 51 | 52 | CookieTypes = Union["Cookies", CookieJar, Dict[str, str], List[Tuple[str, str]]] 53 | 54 | TimeoutTypes = Union[ 55 | Optional[float], 56 | Tuple[Optional[float], Optional[float], Optional[float], Optional[float]], 57 | "Timeout", 58 | ] 59 | ProxyTypes = Union["URL", str, "Proxy"] 60 | CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] 61 | 62 | AuthTypes = Union[ 63 | Tuple[Union[str, bytes], Union[str, bytes]], 64 | Callable[["Request"], "Request"], 65 | "Auth", 66 | ] 67 | 68 | RequestContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] 69 | ResponseContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] 70 | ResponseExtensions = Mapping[str, Any] 71 | 72 | RequestData = Mapping[str, Any] 73 | 74 | FileContent = Union[IO[bytes], bytes, str] 75 | FileTypes = Union[ 76 | # file (or bytes) 77 | FileContent, 78 | # (filename, file (or bytes)) 79 | Tuple[Optional[str], FileContent], 80 | # (filename, file (or bytes), content_type) 81 | Tuple[Optional[str], FileContent, Optional[str]], 82 | # (filename, file (or bytes), content_type, headers) 83 | Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], 84 | ] 85 | RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] 86 | 87 | RequestExtensions = Mapping[str, Any] 88 | 89 | __all__ = ["AsyncByteStream", "SyncByteStream"] 90 | 91 | 92 | class SyncByteStream: 93 | def __iter__(self) -> Iterator[bytes]: 94 | raise NotImplementedError( 95 | "The '__iter__' method must be implemented." 96 | ) # pragma: no cover 97 | yield b"" # pragma: no cover 98 | 99 | def close(self) -> None: 100 | """ 101 | Subclasses can override this method to release any network resources 102 | after a request/response cycle is complete. 103 | """ 104 | 105 | 106 | class AsyncByteStream: 107 | async def __aiter__(self) -> AsyncIterator[bytes]: 108 | raise NotImplementedError( 109 | "The '__aiter__' method must be implemented." 110 | ) # pragma: no cover 111 | yield b"" # pragma: no cover 112 | 113 | async def aclose(self) -> None: 114 | pass 115 | -------------------------------------------------------------------------------- /httpx/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ipaddress 4 | import os 5 | import re 6 | import typing 7 | from urllib.request import getproxies 8 | 9 | from ._types import PrimitiveData 10 | 11 | if typing.TYPE_CHECKING: # pragma: no cover 12 | from ._urls import URL 13 | 14 | 15 | def primitive_value_to_str(value: PrimitiveData) -> str: 16 | """ 17 | Coerce a primitive data type into a string value. 18 | 19 | Note that we prefer JSON-style 'true'/'false' for boolean values here. 20 | """ 21 | if value is True: 22 | return "true" 23 | elif value is False: 24 | return "false" 25 | elif value is None: 26 | return "" 27 | return str(value) 28 | 29 | 30 | def get_environment_proxies() -> dict[str, str | None]: 31 | """Gets proxy information from the environment""" 32 | 33 | # urllib.request.getproxies() falls back on System 34 | # Registry and Config for proxies on Windows and macOS. 35 | # We don't want to propagate non-HTTP proxies into 36 | # our configuration such as 'TRAVIS_APT_PROXY'. 37 | proxy_info = getproxies() 38 | mounts: dict[str, str | None] = {} 39 | 40 | for scheme in ("http", "https", "all"): 41 | if proxy_info.get(scheme): 42 | hostname = proxy_info[scheme] 43 | mounts[f"{scheme}://"] = ( 44 | hostname if "://" in hostname else f"http://{hostname}" 45 | ) 46 | 47 | no_proxy_hosts = [host.strip() for host in proxy_info.get("no", "").split(",")] 48 | for hostname in no_proxy_hosts: 49 | # See https://curl.haxx.se/libcurl/c/CURLOPT_NOPROXY.html for details 50 | # on how names in `NO_PROXY` are handled. 51 | if hostname == "*": 52 | # If NO_PROXY=* is used or if "*" occurs as any one of the comma 53 | # separated hostnames, then we should just bypass any information 54 | # from HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and always ignore 55 | # proxies. 56 | return {} 57 | elif hostname: 58 | # NO_PROXY=.google.com is marked as "all://*.google.com, 59 | # which disables "www.google.com" but not "google.com" 60 | # NO_PROXY=google.com is marked as "all://*google.com, 61 | # which disables "www.google.com" and "google.com". 62 | # (But not "wwwgoogle.com") 63 | # NO_PROXY can include domains, IPv6, IPv4 addresses and "localhost" 64 | # NO_PROXY=example.com,::1,localhost,192.168.0.0/16 65 | if "://" in hostname: 66 | mounts[hostname] = None 67 | elif is_ipv4_hostname(hostname): 68 | mounts[f"all://{hostname}"] = None 69 | elif is_ipv6_hostname(hostname): 70 | mounts[f"all://[{hostname}]"] = None 71 | elif hostname.lower() == "localhost": 72 | mounts[f"all://{hostname}"] = None 73 | else: 74 | mounts[f"all://*{hostname}"] = None 75 | 76 | return mounts 77 | 78 | 79 | def to_bytes(value: str | bytes, encoding: str = "utf-8") -> bytes: 80 | return value.encode(encoding) if isinstance(value, str) else value 81 | 82 | 83 | def to_str(value: str | bytes, encoding: str = "utf-8") -> str: 84 | return value if isinstance(value, str) else value.decode(encoding) 85 | 86 | 87 | def to_bytes_or_str(value: str, match_type_of: typing.AnyStr) -> typing.AnyStr: 88 | return value if isinstance(match_type_of, str) else value.encode() 89 | 90 | 91 | def unquote(value: str) -> str: 92 | return value[1:-1] if value[0] == value[-1] == '"' else value 93 | 94 | 95 | def peek_filelike_length(stream: typing.Any) -> int | None: 96 | """ 97 | Given a file-like stream object, return its length in number of bytes 98 | without reading it into memory. 99 | """ 100 | try: 101 | # Is it an actual file? 102 | fd = stream.fileno() 103 | # Yup, seems to be an actual file. 104 | length = os.fstat(fd).st_size 105 | except (AttributeError, OSError): 106 | # No... Maybe it's something that supports random access, like `io.BytesIO`? 107 | try: 108 | # Assuming so, go to end of stream to figure out its length, 109 | # then put it back in place. 110 | offset = stream.tell() 111 | length = stream.seek(0, os.SEEK_END) 112 | stream.seek(offset) 113 | except (AttributeError, OSError): 114 | # Not even that? Sorry, we're doomed... 115 | return None 116 | 117 | return length 118 | 119 | 120 | class URLPattern: 121 | """ 122 | A utility class currently used for making lookups against proxy keys... 123 | 124 | # Wildcard matching... 125 | >>> pattern = URLPattern("all://") 126 | >>> pattern.matches(httpx.URL("http://example.com")) 127 | True 128 | 129 | # Witch scheme matching... 130 | >>> pattern = URLPattern("https://") 131 | >>> pattern.matches(httpx.URL("https://example.com")) 132 | True 133 | >>> pattern.matches(httpx.URL("http://example.com")) 134 | False 135 | 136 | # With domain matching... 137 | >>> pattern = URLPattern("https://example.com") 138 | >>> pattern.matches(httpx.URL("https://example.com")) 139 | True 140 | >>> pattern.matches(httpx.URL("http://example.com")) 141 | False 142 | >>> pattern.matches(httpx.URL("https://other.com")) 143 | False 144 | 145 | # Wildcard scheme, with domain matching... 146 | >>> pattern = URLPattern("all://example.com") 147 | >>> pattern.matches(httpx.URL("https://example.com")) 148 | True 149 | >>> pattern.matches(httpx.URL("http://example.com")) 150 | True 151 | >>> pattern.matches(httpx.URL("https://other.com")) 152 | False 153 | 154 | # With port matching... 155 | >>> pattern = URLPattern("https://example.com:1234") 156 | >>> pattern.matches(httpx.URL("https://example.com:1234")) 157 | True 158 | >>> pattern.matches(httpx.URL("https://example.com")) 159 | False 160 | """ 161 | 162 | def __init__(self, pattern: str) -> None: 163 | from ._urls import URL 164 | 165 | if pattern and ":" not in pattern: 166 | raise ValueError( 167 | f"Proxy keys should use proper URL forms rather " 168 | f"than plain scheme strings. " 169 | f'Instead of "{pattern}", use "{pattern}://"' 170 | ) 171 | 172 | url = URL(pattern) 173 | self.pattern = pattern 174 | self.scheme = "" if url.scheme == "all" else url.scheme 175 | self.host = "" if url.host == "*" else url.host 176 | self.port = url.port 177 | if not url.host or url.host == "*": 178 | self.host_regex: typing.Pattern[str] | None = None 179 | elif url.host.startswith("*."): 180 | # *.example.com should match "www.example.com", but not "example.com" 181 | domain = re.escape(url.host[2:]) 182 | self.host_regex = re.compile(f"^.+\\.{domain}$") 183 | elif url.host.startswith("*"): 184 | # *example.com should match "www.example.com" and "example.com" 185 | domain = re.escape(url.host[1:]) 186 | self.host_regex = re.compile(f"^(.+\\.)?{domain}$") 187 | else: 188 | # example.com should match "example.com" but not "www.example.com" 189 | domain = re.escape(url.host) 190 | self.host_regex = re.compile(f"^{domain}$") 191 | 192 | def matches(self, other: URL) -> bool: 193 | if self.scheme and self.scheme != other.scheme: 194 | return False 195 | if ( 196 | self.host 197 | and self.host_regex is not None 198 | and not self.host_regex.match(other.host) 199 | ): 200 | return False 201 | if self.port is not None and self.port != other.port: 202 | return False 203 | return True 204 | 205 | @property 206 | def priority(self) -> tuple[int, int, int]: 207 | """ 208 | The priority allows URLPattern instances to be sortable, so that 209 | we can match from most specific to least specific. 210 | """ 211 | # URLs with a port should take priority over URLs without a port. 212 | port_priority = 0 if self.port is not None else 1 213 | # Longer hostnames should match first. 214 | host_priority = -len(self.host) 215 | # Longer schemes should match first. 216 | scheme_priority = -len(self.scheme) 217 | return (port_priority, host_priority, scheme_priority) 218 | 219 | def __hash__(self) -> int: 220 | return hash(self.pattern) 221 | 222 | def __lt__(self, other: URLPattern) -> bool: 223 | return self.priority < other.priority 224 | 225 | def __eq__(self, other: typing.Any) -> bool: 226 | return isinstance(other, URLPattern) and self.pattern == other.pattern 227 | 228 | 229 | def is_ipv4_hostname(hostname: str) -> bool: 230 | try: 231 | ipaddress.IPv4Address(hostname.split("/")[0]) 232 | except Exception: 233 | return False 234 | return True 235 | 236 | 237 | def is_ipv6_hostname(hostname: str) -> bool: 238 | try: 239 | ipaddress.IPv6Address(hostname.split("/")[0]) 240 | except Exception: 241 | return False 242 | return True 243 | -------------------------------------------------------------------------------- /httpx/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/httpx/py.typed -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: HTTPX 2 | site_description: A next-generation HTTP client for Python. 3 | site_url: https://www.python-httpx.org/ 4 | 5 | theme: 6 | name: 'material' 7 | custom_dir: 'docs/overrides' 8 | palette: 9 | - scheme: 'default' 10 | media: '(prefers-color-scheme: light)' 11 | toggle: 12 | icon: 'material/lightbulb' 13 | name: "Switch to dark mode" 14 | - scheme: 'slate' 15 | media: '(prefers-color-scheme: dark)' 16 | primary: 'blue' 17 | toggle: 18 | icon: 'material/lightbulb-outline' 19 | name: 'Switch to light mode' 20 | 21 | repo_name: encode/httpx 22 | repo_url: https://github.com/encode/httpx/ 23 | edit_uri: "" 24 | 25 | nav: 26 | - Introduction: 'index.md' 27 | - QuickStart: 'quickstart.md' 28 | - Advanced: 29 | - Clients: 'advanced/clients.md' 30 | - Authentication: 'advanced/authentication.md' 31 | - SSL: 'advanced/ssl.md' 32 | - Proxies: 'advanced/proxies.md' 33 | - Timeouts: 'advanced/timeouts.md' 34 | - Resource Limits: 'advanced/resource-limits.md' 35 | - Event Hooks: 'advanced/event-hooks.md' 36 | - Transports: 'advanced/transports.md' 37 | - Text Encodings: 'advanced/text-encodings.md' 38 | - Extensions: 'advanced/extensions.md' 39 | - Guides: 40 | - Async Support: 'async.md' 41 | - HTTP/2 Support: 'http2.md' 42 | - Logging: 'logging.md' 43 | - Requests Compatibility: 'compatibility.md' 44 | - Troubleshooting: 'troubleshooting.md' 45 | - API Reference: 46 | - Developer Interface: 'api.md' 47 | - Exceptions: 'exceptions.md' 48 | - Environment Variables: 'environment_variables.md' 49 | - Community: 50 | - Third Party Packages: 'third_party_packages.md' 51 | - Contributing: 'contributing.md' 52 | - Code of Conduct: 'code_of_conduct.md' 53 | 54 | markdown_extensions: 55 | - admonition 56 | - codehilite: 57 | css_class: highlight 58 | - mkautodoc 59 | 60 | extra_css: 61 | - css/custom.css 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "httpx" 7 | description = "The next generation HTTP client." 8 | license = "BSD-3-Clause" 9 | requires-python = ">=3.8" 10 | authors = [ 11 | { name = "Tom Christie", email = "tom@tomchristie.com" }, 12 | ] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Environment :: Web Environment", 16 | "Framework :: AsyncIO", 17 | "Framework :: Trio", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: BSD License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Topic :: Internet :: WWW/HTTP", 30 | ] 31 | dependencies = [ 32 | "certifi", 33 | "httpcore==1.*", 34 | "anyio", 35 | "idna", 36 | ] 37 | dynamic = ["readme", "version"] 38 | 39 | [project.optional-dependencies] 40 | brotli = [ 41 | "brotli; platform_python_implementation == 'CPython'", 42 | "brotlicffi; platform_python_implementation != 'CPython'", 43 | ] 44 | cli = [ 45 | "click==8.*", 46 | "pygments==2.*", 47 | "rich>=10,<14", 48 | ] 49 | http2 = [ 50 | "h2>=3,<5", 51 | ] 52 | socks = [ 53 | "socksio==1.*", 54 | ] 55 | zstd = [ 56 | "zstandard>=0.18.0", 57 | ] 58 | 59 | [project.scripts] 60 | httpx = "httpx:main" 61 | 62 | [project.urls] 63 | Changelog = "https://github.com/encode/httpx/blob/master/CHANGELOG.md" 64 | Documentation = "https://www.python-httpx.org" 65 | Homepage = "https://github.com/encode/httpx" 66 | Source = "https://github.com/encode/httpx" 67 | 68 | [tool.hatch.version] 69 | path = "httpx/__version__.py" 70 | 71 | [tool.hatch.build.targets.sdist] 72 | include = [ 73 | "/httpx", 74 | "/CHANGELOG.md", 75 | "/README.md", 76 | "/tests", 77 | ] 78 | 79 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 80 | content-type = "text/markdown" 81 | 82 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 83 | path = "README.md" 84 | 85 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 86 | text = "\n## Release Information\n\n" 87 | 88 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 89 | path = "CHANGELOG.md" 90 | pattern = "\n(###.+?\n)## " 91 | 92 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 93 | text = "\n---\n\n[Full changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)\n" 94 | 95 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 96 | pattern = 'src="(docs/img/.*?)"' 97 | replacement = 'src="https://raw.githubusercontent.com/encode/httpx/master/\1"' 98 | 99 | [tool.ruff.lint] 100 | select = ["E", "F", "I", "B", "PIE"] 101 | ignore = ["B904", "B028"] 102 | 103 | [tool.ruff.lint.isort] 104 | combine-as-imports = true 105 | 106 | [tool.ruff.lint.per-file-ignores] 107 | "__init__.py" = ["F403", "F405"] 108 | 109 | [tool.mypy] 110 | ignore_missing_imports = true 111 | strict = true 112 | 113 | [[tool.mypy.overrides]] 114 | module = "tests.*" 115 | disallow_untyped_defs = false 116 | check_untyped_defs = true 117 | 118 | [tool.pytest.ini_options] 119 | addopts = "-rxXs" 120 | filterwarnings = [ 121 | "error", 122 | "ignore: You seem to already have a custom sys.excepthook handler installed. I'll skip installing Trio's custom handler, but this means MultiErrors will not show full tracebacks.:RuntimeWarning", 123 | # See: https://github.com/agronholm/anyio/issues/508 124 | "ignore: trio.MultiError is deprecated since Trio 0.22.0:trio.TrioDeprecationWarning" 125 | ] 126 | markers = [ 127 | "copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup", 128 | "network: marks tests which require network connection. Used in 3rd-party build environments that have network disabled." 129 | ] 130 | 131 | [tool.coverage.run] 132 | omit = ["venv/*"] 133 | include = ["httpx/*", "tests/*"] 134 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # We're pinning our tooling, because it's an environment we can strictly control. 2 | # On the other hand, we're not pinning package dependencies, because our tests 3 | # needs to pass with the latest version of the packages. 4 | # Reference: https://github.com/encode/httpx/pull/1721#discussion_r661241588 5 | -e .[brotli,cli,http2,socks,zstd] 6 | 7 | # Optional charset auto-detection 8 | # Used in our test cases 9 | chardet==5.2.0 10 | 11 | # Documentation 12 | mkdocs==1.6.1 13 | mkautodoc==0.2.0 14 | mkdocs-material==9.5.47 15 | 16 | # Packaging 17 | build==1.2.2.post1 18 | twine==6.0.1 19 | 20 | # Tests & Linting 21 | coverage[toml]==7.6.1 22 | cryptography==44.0.1 23 | mypy==1.13.0 24 | pytest==8.3.4 25 | ruff==0.8.1 26 | trio==0.27.0 27 | trio-typing==0.10.0 28 | trustme==1.1.0; python_version < '3.9' 29 | trustme==1.2.0; python_version >= '3.9' 30 | uvicorn==0.32.1 31 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if [ -d 'venv' ] ; then 4 | PREFIX="venv/bin/" 5 | else 6 | PREFIX="" 7 | fi 8 | 9 | set -x 10 | 11 | ${PREFIX}python -m build 12 | ${PREFIX}twine check dist/* 13 | ${PREFIX}mkdocs build 14 | -------------------------------------------------------------------------------- /scripts/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | export SOURCE_FILES="httpx tests" 8 | 9 | set -x 10 | 11 | ./scripts/sync-version 12 | ${PREFIX}ruff format $SOURCE_FILES --diff 13 | ${PREFIX}mypy $SOURCE_FILES 14 | ${PREFIX}ruff check $SOURCE_FILES 15 | -------------------------------------------------------------------------------- /scripts/clean: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if [ -d 'dist' ] ; then 4 | rm -r dist 5 | fi 6 | if [ -d 'site' ] ; then 7 | rm -r site 8 | fi 9 | if [ -d 'htmlcov' ] ; then 10 | rm -r htmlcov 11 | fi 12 | if [ -d 'httpx.egg-info' ] ; then 13 | rm -r httpx.egg-info 14 | fi 15 | -------------------------------------------------------------------------------- /scripts/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | export SOURCE_FILES="httpx tests" 8 | 9 | set -x 10 | 11 | ${PREFIX}coverage report --show-missing --skip-covered --fail-under=100 12 | -------------------------------------------------------------------------------- /scripts/docs: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -x 9 | 10 | ${PREFIX}mkdocs serve 11 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # Use the Python executable provided from the `-p` option, or a default. 4 | [ "$1" = "-p" ] && PYTHON=$2 || PYTHON="python3" 5 | 6 | REQUIREMENTS="requirements.txt" 7 | VENV="venv" 8 | 9 | set -x 10 | 11 | if [ -z "$GITHUB_ACTIONS" ]; then 12 | "$PYTHON" -m venv "$VENV" 13 | PIP="$VENV/bin/pip" 14 | else 15 | PIP="pip" 16 | fi 17 | 18 | "$PIP" install -U pip 19 | "$PIP" install -r "$REQUIREMENTS" 20 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ]; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | export SOURCE_FILES="httpx tests" 8 | 9 | set -x 10 | 11 | ${PREFIX}ruff check --fix $SOURCE_FILES 12 | ${PREFIX}ruff format $SOURCE_FILES 13 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | VERSION_FILE="httpx/__version__.py" 4 | 5 | if [ -d 'venv' ] ; then 6 | PREFIX="venv/bin/" 7 | else 8 | PREFIX="" 9 | fi 10 | 11 | if [ ! -z "$GITHUB_ACTIONS" ]; then 12 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" 13 | git config --local user.name "GitHub Action" 14 | 15 | VERSION=`grep __version__ ${VERSION_FILE} | grep -o '[0-9][^"]*'` 16 | 17 | if [ "refs/tags/${VERSION}" != "${GITHUB_REF}" ] ; then 18 | echo "GitHub Ref '${GITHUB_REF}' did not match package version '${VERSION}'" 19 | exit 1 20 | fi 21 | fi 22 | 23 | set -x 24 | 25 | ${PREFIX}twine upload dist/* 26 | ${PREFIX}mkdocs gh-deploy --force 27 | -------------------------------------------------------------------------------- /scripts/sync-version: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | SEMVER_REGEX="([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?" 4 | CHANGELOG_VERSION=$(grep -o -E $SEMVER_REGEX CHANGELOG.md | sed -n 2p) 5 | VERSION=$(grep -o -E $SEMVER_REGEX httpx/__version__.py | head -1) 6 | echo "CHANGELOG_VERSION: $CHANGELOG_VERSION" 7 | echo "VERSION: $VERSION" 8 | if [ "$CHANGELOG_VERSION" != "$VERSION" ]; then 9 | echo "Version in changelog does not match version in httpx/__version__.py!" 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -ex 9 | 10 | if [ -z $GITHUB_ACTIONS ]; then 11 | scripts/check 12 | fi 13 | 14 | ${PREFIX}coverage run -m pytest "$@" 15 | 16 | if [ -z $GITHUB_ACTIONS ]; then 17 | scripts/coverage 18 | fi 19 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/tests/__init__.py -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/tests/client/__init__.py -------------------------------------------------------------------------------- /tests/client/test_cookies.py: -------------------------------------------------------------------------------- 1 | from http.cookiejar import Cookie, CookieJar 2 | 3 | import pytest 4 | 5 | import httpx 6 | 7 | 8 | def get_and_set_cookies(request: httpx.Request) -> httpx.Response: 9 | if request.url.path == "/echo_cookies": 10 | data = {"cookies": request.headers.get("cookie")} 11 | return httpx.Response(200, json=data) 12 | elif request.url.path == "/set_cookie": 13 | return httpx.Response(200, headers={"set-cookie": "example-name=example-value"}) 14 | else: 15 | raise NotImplementedError() # pragma: no cover 16 | 17 | 18 | def test_set_cookie() -> None: 19 | """ 20 | Send a request including a cookie. 21 | """ 22 | url = "http://example.org/echo_cookies" 23 | cookies = {"example-name": "example-value"} 24 | 25 | client = httpx.Client( 26 | cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies) 27 | ) 28 | response = client.get(url) 29 | 30 | assert response.status_code == 200 31 | assert response.json() == {"cookies": "example-name=example-value"} 32 | 33 | 34 | def test_set_per_request_cookie_is_deprecated() -> None: 35 | """ 36 | Sending a request including a per-request cookie is deprecated. 37 | """ 38 | url = "http://example.org/echo_cookies" 39 | cookies = {"example-name": "example-value"} 40 | 41 | client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies)) 42 | with pytest.warns(DeprecationWarning): 43 | response = client.get(url, cookies=cookies) 44 | 45 | assert response.status_code == 200 46 | assert response.json() == {"cookies": "example-name=example-value"} 47 | 48 | 49 | def test_set_cookie_with_cookiejar() -> None: 50 | """ 51 | Send a request including a cookie, using a `CookieJar` instance. 52 | """ 53 | 54 | url = "http://example.org/echo_cookies" 55 | cookies = CookieJar() 56 | cookie = Cookie( 57 | version=0, 58 | name="example-name", 59 | value="example-value", 60 | port=None, 61 | port_specified=False, 62 | domain="", 63 | domain_specified=False, 64 | domain_initial_dot=False, 65 | path="/", 66 | path_specified=True, 67 | secure=False, 68 | expires=None, 69 | discard=True, 70 | comment=None, 71 | comment_url=None, 72 | rest={"HttpOnly": ""}, 73 | rfc2109=False, 74 | ) 75 | cookies.set_cookie(cookie) 76 | 77 | client = httpx.Client( 78 | cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies) 79 | ) 80 | response = client.get(url) 81 | 82 | assert response.status_code == 200 83 | assert response.json() == {"cookies": "example-name=example-value"} 84 | 85 | 86 | def test_setting_client_cookies_to_cookiejar() -> None: 87 | """ 88 | Send a request including a cookie, using a `CookieJar` instance. 89 | """ 90 | 91 | url = "http://example.org/echo_cookies" 92 | cookies = CookieJar() 93 | cookie = Cookie( 94 | version=0, 95 | name="example-name", 96 | value="example-value", 97 | port=None, 98 | port_specified=False, 99 | domain="", 100 | domain_specified=False, 101 | domain_initial_dot=False, 102 | path="/", 103 | path_specified=True, 104 | secure=False, 105 | expires=None, 106 | discard=True, 107 | comment=None, 108 | comment_url=None, 109 | rest={"HttpOnly": ""}, 110 | rfc2109=False, 111 | ) 112 | cookies.set_cookie(cookie) 113 | 114 | client = httpx.Client( 115 | cookies=cookies, transport=httpx.MockTransport(get_and_set_cookies) 116 | ) 117 | response = client.get(url) 118 | 119 | assert response.status_code == 200 120 | assert response.json() == {"cookies": "example-name=example-value"} 121 | 122 | 123 | def test_set_cookie_with_cookies_model() -> None: 124 | """ 125 | Send a request including a cookie, using a `Cookies` instance. 126 | """ 127 | 128 | url = "http://example.org/echo_cookies" 129 | cookies = httpx.Cookies() 130 | cookies["example-name"] = "example-value" 131 | 132 | client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies)) 133 | client.cookies = cookies 134 | response = client.get(url) 135 | 136 | assert response.status_code == 200 137 | assert response.json() == {"cookies": "example-name=example-value"} 138 | 139 | 140 | def test_get_cookie() -> None: 141 | url = "http://example.org/set_cookie" 142 | 143 | client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies)) 144 | response = client.get(url) 145 | 146 | assert response.status_code == 200 147 | assert response.cookies["example-name"] == "example-value" 148 | assert client.cookies["example-name"] == "example-value" 149 | 150 | 151 | def test_cookie_persistence() -> None: 152 | """ 153 | Ensure that Client instances persist cookies between requests. 154 | """ 155 | client = httpx.Client(transport=httpx.MockTransport(get_and_set_cookies)) 156 | 157 | response = client.get("http://example.org/echo_cookies") 158 | assert response.status_code == 200 159 | assert response.json() == {"cookies": None} 160 | 161 | response = client.get("http://example.org/set_cookie") 162 | assert response.status_code == 200 163 | assert response.cookies["example-name"] == "example-value" 164 | assert client.cookies["example-name"] == "example-value" 165 | 166 | response = client.get("http://example.org/echo_cookies") 167 | assert response.status_code == 200 168 | assert response.json() == {"cookies": "example-name=example-value"} 169 | -------------------------------------------------------------------------------- /tests/client/test_event_hooks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import httpx 4 | 5 | 6 | def app(request: httpx.Request) -> httpx.Response: 7 | if request.url.path == "/redirect": 8 | return httpx.Response(303, headers={"server": "testserver", "location": "/"}) 9 | elif request.url.path.startswith("/status/"): 10 | status_code = int(request.url.path[-3:]) 11 | return httpx.Response(status_code, headers={"server": "testserver"}) 12 | 13 | return httpx.Response(200, headers={"server": "testserver"}) 14 | 15 | 16 | def test_event_hooks(): 17 | events = [] 18 | 19 | def on_request(request): 20 | events.append({"event": "request", "headers": dict(request.headers)}) 21 | 22 | def on_response(response): 23 | events.append({"event": "response", "headers": dict(response.headers)}) 24 | 25 | event_hooks = {"request": [on_request], "response": [on_response]} 26 | 27 | with httpx.Client( 28 | event_hooks=event_hooks, transport=httpx.MockTransport(app) 29 | ) as http: 30 | http.get("http://127.0.0.1:8000/", auth=("username", "password")) 31 | 32 | assert events == [ 33 | { 34 | "event": "request", 35 | "headers": { 36 | "host": "127.0.0.1:8000", 37 | "user-agent": f"python-httpx/{httpx.__version__}", 38 | "accept": "*/*", 39 | "accept-encoding": "gzip, deflate, br, zstd", 40 | "connection": "keep-alive", 41 | "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 42 | }, 43 | }, 44 | { 45 | "event": "response", 46 | "headers": {"server": "testserver"}, 47 | }, 48 | ] 49 | 50 | 51 | def test_event_hooks_raising_exception(server): 52 | def raise_on_4xx_5xx(response): 53 | response.raise_for_status() 54 | 55 | event_hooks = {"response": [raise_on_4xx_5xx]} 56 | 57 | with httpx.Client( 58 | event_hooks=event_hooks, transport=httpx.MockTransport(app) 59 | ) as http: 60 | try: 61 | http.get("http://127.0.0.1:8000/status/400") 62 | except httpx.HTTPStatusError as exc: 63 | assert exc.response.is_closed 64 | 65 | 66 | @pytest.mark.anyio 67 | async def test_async_event_hooks(): 68 | events = [] 69 | 70 | async def on_request(request): 71 | events.append({"event": "request", "headers": dict(request.headers)}) 72 | 73 | async def on_response(response): 74 | events.append({"event": "response", "headers": dict(response.headers)}) 75 | 76 | event_hooks = {"request": [on_request], "response": [on_response]} 77 | 78 | async with httpx.AsyncClient( 79 | event_hooks=event_hooks, transport=httpx.MockTransport(app) 80 | ) as http: 81 | await http.get("http://127.0.0.1:8000/", auth=("username", "password")) 82 | 83 | assert events == [ 84 | { 85 | "event": "request", 86 | "headers": { 87 | "host": "127.0.0.1:8000", 88 | "user-agent": f"python-httpx/{httpx.__version__}", 89 | "accept": "*/*", 90 | "accept-encoding": "gzip, deflate, br, zstd", 91 | "connection": "keep-alive", 92 | "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 93 | }, 94 | }, 95 | { 96 | "event": "response", 97 | "headers": {"server": "testserver"}, 98 | }, 99 | ] 100 | 101 | 102 | @pytest.mark.anyio 103 | async def test_async_event_hooks_raising_exception(): 104 | async def raise_on_4xx_5xx(response): 105 | response.raise_for_status() 106 | 107 | event_hooks = {"response": [raise_on_4xx_5xx]} 108 | 109 | async with httpx.AsyncClient( 110 | event_hooks=event_hooks, transport=httpx.MockTransport(app) 111 | ) as http: 112 | try: 113 | await http.get("http://127.0.0.1:8000/status/400") 114 | except httpx.HTTPStatusError as exc: 115 | assert exc.response.is_closed 116 | 117 | 118 | def test_event_hooks_with_redirect(): 119 | """ 120 | A redirect request should trigger additional 'request' and 'response' event hooks. 121 | """ 122 | 123 | events = [] 124 | 125 | def on_request(request): 126 | events.append({"event": "request", "headers": dict(request.headers)}) 127 | 128 | def on_response(response): 129 | events.append({"event": "response", "headers": dict(response.headers)}) 130 | 131 | event_hooks = {"request": [on_request], "response": [on_response]} 132 | 133 | with httpx.Client( 134 | event_hooks=event_hooks, 135 | transport=httpx.MockTransport(app), 136 | follow_redirects=True, 137 | ) as http: 138 | http.get("http://127.0.0.1:8000/redirect", auth=("username", "password")) 139 | 140 | assert events == [ 141 | { 142 | "event": "request", 143 | "headers": { 144 | "host": "127.0.0.1:8000", 145 | "user-agent": f"python-httpx/{httpx.__version__}", 146 | "accept": "*/*", 147 | "accept-encoding": "gzip, deflate, br, zstd", 148 | "connection": "keep-alive", 149 | "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 150 | }, 151 | }, 152 | { 153 | "event": "response", 154 | "headers": {"location": "/", "server": "testserver"}, 155 | }, 156 | { 157 | "event": "request", 158 | "headers": { 159 | "host": "127.0.0.1:8000", 160 | "user-agent": f"python-httpx/{httpx.__version__}", 161 | "accept": "*/*", 162 | "accept-encoding": "gzip, deflate, br, zstd", 163 | "connection": "keep-alive", 164 | "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 165 | }, 166 | }, 167 | { 168 | "event": "response", 169 | "headers": {"server": "testserver"}, 170 | }, 171 | ] 172 | 173 | 174 | @pytest.mark.anyio 175 | async def test_async_event_hooks_with_redirect(): 176 | """ 177 | A redirect request should trigger additional 'request' and 'response' event hooks. 178 | """ 179 | 180 | events = [] 181 | 182 | async def on_request(request): 183 | events.append({"event": "request", "headers": dict(request.headers)}) 184 | 185 | async def on_response(response): 186 | events.append({"event": "response", "headers": dict(response.headers)}) 187 | 188 | event_hooks = {"request": [on_request], "response": [on_response]} 189 | 190 | async with httpx.AsyncClient( 191 | event_hooks=event_hooks, 192 | transport=httpx.MockTransport(app), 193 | follow_redirects=True, 194 | ) as http: 195 | await http.get("http://127.0.0.1:8000/redirect", auth=("username", "password")) 196 | 197 | assert events == [ 198 | { 199 | "event": "request", 200 | "headers": { 201 | "host": "127.0.0.1:8000", 202 | "user-agent": f"python-httpx/{httpx.__version__}", 203 | "accept": "*/*", 204 | "accept-encoding": "gzip, deflate, br, zstd", 205 | "connection": "keep-alive", 206 | "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 207 | }, 208 | }, 209 | { 210 | "event": "response", 211 | "headers": {"location": "/", "server": "testserver"}, 212 | }, 213 | { 214 | "event": "request", 215 | "headers": { 216 | "host": "127.0.0.1:8000", 217 | "user-agent": f"python-httpx/{httpx.__version__}", 218 | "accept": "*/*", 219 | "accept-encoding": "gzip, deflate, br, zstd", 220 | "connection": "keep-alive", 221 | "authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 222 | }, 223 | }, 224 | { 225 | "event": "response", 226 | "headers": {"server": "testserver"}, 227 | }, 228 | ] 229 | -------------------------------------------------------------------------------- /tests/client/test_properties.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | 4 | def test_client_base_url(): 5 | client = httpx.Client() 6 | client.base_url = "https://www.example.org/" # type: ignore 7 | assert isinstance(client.base_url, httpx.URL) 8 | assert client.base_url == "https://www.example.org/" 9 | 10 | 11 | def test_client_base_url_without_trailing_slash(): 12 | client = httpx.Client() 13 | client.base_url = "https://www.example.org/path" # type: ignore 14 | assert isinstance(client.base_url, httpx.URL) 15 | assert client.base_url == "https://www.example.org/path/" 16 | 17 | 18 | def test_client_base_url_with_trailing_slash(): 19 | client = httpx.Client() 20 | client.base_url = "https://www.example.org/path/" # type: ignore 21 | assert isinstance(client.base_url, httpx.URL) 22 | assert client.base_url == "https://www.example.org/path/" 23 | 24 | 25 | def test_client_headers(): 26 | client = httpx.Client() 27 | client.headers = {"a": "b"} # type: ignore 28 | assert isinstance(client.headers, httpx.Headers) 29 | assert client.headers["A"] == "b" 30 | 31 | 32 | def test_client_cookies(): 33 | client = httpx.Client() 34 | client.cookies = {"a": "b"} # type: ignore 35 | assert isinstance(client.cookies, httpx.Cookies) 36 | mycookies = list(client.cookies.jar) 37 | assert len(mycookies) == 1 38 | assert mycookies[0].name == "a" and mycookies[0].value == "b" 39 | 40 | 41 | def test_client_timeout(): 42 | expected_timeout = 12.0 43 | client = httpx.Client() 44 | 45 | client.timeout = expected_timeout # type: ignore 46 | 47 | assert isinstance(client.timeout, httpx.Timeout) 48 | assert client.timeout.connect == expected_timeout 49 | assert client.timeout.read == expected_timeout 50 | assert client.timeout.write == expected_timeout 51 | assert client.timeout.pool == expected_timeout 52 | 53 | 54 | def test_client_event_hooks(): 55 | def on_request(request): 56 | pass # pragma: no cover 57 | 58 | client = httpx.Client() 59 | client.event_hooks = {"request": [on_request]} 60 | assert client.event_hooks == {"request": [on_request], "response": []} 61 | 62 | 63 | def test_client_trust_env(): 64 | client = httpx.Client() 65 | assert client.trust_env 66 | 67 | client = httpx.Client(trust_env=False) 68 | assert not client.trust_env 69 | -------------------------------------------------------------------------------- /tests/client/test_queryparams.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | 4 | def hello_world(request: httpx.Request) -> httpx.Response: 5 | return httpx.Response(200, text="Hello, world") 6 | 7 | 8 | def test_client_queryparams(): 9 | client = httpx.Client(params={"a": "b"}) 10 | assert isinstance(client.params, httpx.QueryParams) 11 | assert client.params["a"] == "b" 12 | 13 | 14 | def test_client_queryparams_string(): 15 | client = httpx.Client(params="a=b") 16 | assert isinstance(client.params, httpx.QueryParams) 17 | assert client.params["a"] == "b" 18 | 19 | client = httpx.Client() 20 | client.params = "a=b" # type: ignore 21 | assert isinstance(client.params, httpx.QueryParams) 22 | assert client.params["a"] == "b" 23 | 24 | 25 | def test_client_queryparams_echo(): 26 | url = "http://example.org/echo_queryparams" 27 | client_queryparams = "first=str" 28 | request_queryparams = {"second": "dict"} 29 | client = httpx.Client( 30 | transport=httpx.MockTransport(hello_world), params=client_queryparams 31 | ) 32 | response = client.get(url, params=request_queryparams) 33 | 34 | assert response.status_code == 200 35 | assert response.url == "http://example.org/echo_queryparams?first=str&second=dict" 36 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | TESTS_DIR = pathlib.Path(__file__).parent 4 | FIXTURES_DIR = TESTS_DIR / "fixtures" 5 | -------------------------------------------------------------------------------- /tests/concurrency.py: -------------------------------------------------------------------------------- 1 | """ 2 | Async environment-agnostic concurrency utilities that are only used in tests. 3 | """ 4 | 5 | import asyncio 6 | 7 | import sniffio 8 | import trio 9 | 10 | 11 | async def sleep(seconds: float) -> None: 12 | if sniffio.current_async_library() == "trio": 13 | await trio.sleep(seconds) # pragma: no cover 14 | else: 15 | await asyncio.sleep(seconds) 16 | -------------------------------------------------------------------------------- /tests/fixtures/.netrc: -------------------------------------------------------------------------------- 1 | machine netrcexample.org 2 | login example-username 3 | password example-password -------------------------------------------------------------------------------- /tests/fixtures/.netrc-nopassword: -------------------------------------------------------------------------------- 1 | machine netrcexample.org 2 | login example-username 3 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encode/httpx/6c7af967734bafd011164f2a1653abc87905a62b/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_cookies.py: -------------------------------------------------------------------------------- 1 | import http 2 | 3 | import pytest 4 | 5 | import httpx 6 | 7 | 8 | def test_cookies(): 9 | cookies = httpx.Cookies({"name": "value"}) 10 | assert cookies["name"] == "value" 11 | assert "name" in cookies 12 | assert len(cookies) == 1 13 | assert dict(cookies) == {"name": "value"} 14 | assert bool(cookies) is True 15 | 16 | del cookies["name"] 17 | assert "name" not in cookies 18 | assert len(cookies) == 0 19 | assert dict(cookies) == {} 20 | assert bool(cookies) is False 21 | 22 | 23 | def test_cookies_update(): 24 | cookies = httpx.Cookies() 25 | more_cookies = httpx.Cookies() 26 | more_cookies.set("name", "value", domain="example.com") 27 | 28 | cookies.update(more_cookies) 29 | assert dict(cookies) == {"name": "value"} 30 | assert cookies.get("name", domain="example.com") == "value" 31 | 32 | 33 | def test_cookies_with_domain(): 34 | cookies = httpx.Cookies() 35 | cookies.set("name", "value", domain="example.com") 36 | cookies.set("name", "value", domain="example.org") 37 | 38 | with pytest.raises(httpx.CookieConflict): 39 | cookies["name"] 40 | 41 | cookies.clear(domain="example.com") 42 | assert len(cookies) == 1 43 | 44 | 45 | def test_cookies_with_domain_and_path(): 46 | cookies = httpx.Cookies() 47 | cookies.set("name", "value", domain="example.com", path="/subpath/1") 48 | cookies.set("name", "value", domain="example.com", path="/subpath/2") 49 | cookies.clear(domain="example.com", path="/subpath/1") 50 | assert len(cookies) == 1 51 | cookies.delete("name", domain="example.com", path="/subpath/2") 52 | assert len(cookies) == 0 53 | 54 | 55 | def test_multiple_set_cookie(): 56 | jar = http.cookiejar.CookieJar() 57 | headers = [ 58 | ( 59 | b"Set-Cookie", 60 | b"1P_JAR=2020-08-09-18; expires=Tue, 08-Sep-2099 18:33:35 GMT; " 61 | b"path=/; domain=.example.org; Secure", 62 | ), 63 | ( 64 | b"Set-Cookie", 65 | b"NID=204=KWdXOuypc86YvRfBSiWoW1dEXfSl_5qI7sxZY4umlk4J35yNTeNEkw15" 66 | b"MRaujK6uYCwkrtjihTTXZPp285z_xDOUzrdHt4dj0Z5C0VOpbvdLwRdHatHAzQs7" 67 | b"7TsaiWY78a3qU9r7KP_RbSLvLl2hlhnWFR2Hp5nWKPsAcOhQgSg; expires=Mon, " 68 | b"08-Feb-2099 18:33:35 GMT; path=/; domain=.example.org; HttpOnly", 69 | ), 70 | ] 71 | request = httpx.Request("GET", "https://www.example.org") 72 | response = httpx.Response(200, request=request, headers=headers) 73 | 74 | cookies = httpx.Cookies(jar) 75 | cookies.extract_cookies(response) 76 | 77 | assert len(cookies) == 2 78 | 79 | 80 | def test_cookies_can_be_a_list_of_tuples(): 81 | cookies_val = [("name1", "val1"), ("name2", "val2")] 82 | 83 | cookies = httpx.Cookies(cookies_val) 84 | 85 | assert len(cookies.items()) == 2 86 | for k, v in cookies_val: 87 | assert cookies[k] == v 88 | 89 | 90 | def test_cookies_repr(): 91 | cookies = httpx.Cookies() 92 | cookies.set(name="foo", value="bar", domain="http://blah.com") 93 | cookies.set(name="fizz", value="buzz", domain="http://hello.com") 94 | 95 | assert repr(cookies) == ( 96 | "," 97 | " ]>" 98 | ) 99 | -------------------------------------------------------------------------------- /tests/models/test_headers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import httpx 4 | 5 | 6 | def test_headers(): 7 | h = httpx.Headers([("a", "123"), ("a", "456"), ("b", "789")]) 8 | assert "a" in h 9 | assert "A" in h 10 | assert "b" in h 11 | assert "B" in h 12 | assert "c" not in h 13 | assert h["a"] == "123, 456" 14 | assert h.get("a") == "123, 456" 15 | assert h.get("nope", default=None) is None 16 | assert h.get_list("a") == ["123", "456"] 17 | 18 | assert list(h.keys()) == ["a", "b"] 19 | assert list(h.values()) == ["123, 456", "789"] 20 | assert list(h.items()) == [("a", "123, 456"), ("b", "789")] 21 | assert h.multi_items() == [("a", "123"), ("a", "456"), ("b", "789")] 22 | assert list(h) == ["a", "b"] 23 | assert dict(h) == {"a": "123, 456", "b": "789"} 24 | assert repr(h) == "Headers([('a', '123'), ('a', '456'), ('b', '789')])" 25 | assert h == [("a", "123"), ("b", "789"), ("a", "456")] 26 | assert h == [("a", "123"), ("A", "456"), ("b", "789")] 27 | assert h == {"a": "123", "A": "456", "b": "789"} 28 | assert h != "a: 123\nA: 456\nb: 789" 29 | 30 | h = httpx.Headers({"a": "123", "b": "789"}) 31 | assert h["A"] == "123" 32 | assert h["B"] == "789" 33 | assert h.raw == [(b"a", b"123"), (b"b", b"789")] 34 | assert repr(h) == "Headers({'a': '123', 'b': '789'})" 35 | 36 | 37 | def test_header_mutations(): 38 | h = httpx.Headers() 39 | assert dict(h) == {} 40 | h["a"] = "1" 41 | assert dict(h) == {"a": "1"} 42 | h["a"] = "2" 43 | assert dict(h) == {"a": "2"} 44 | h.setdefault("a", "3") 45 | assert dict(h) == {"a": "2"} 46 | h.setdefault("b", "4") 47 | assert dict(h) == {"a": "2", "b": "4"} 48 | del h["a"] 49 | assert dict(h) == {"b": "4"} 50 | assert h.raw == [(b"b", b"4")] 51 | 52 | 53 | def test_copy_headers_method(): 54 | headers = httpx.Headers({"custom": "example"}) 55 | headers_copy = headers.copy() 56 | assert headers == headers_copy 57 | assert headers is not headers_copy 58 | 59 | 60 | def test_copy_headers_init(): 61 | headers = httpx.Headers({"custom": "example"}) 62 | headers_copy = httpx.Headers(headers) 63 | assert headers == headers_copy 64 | 65 | 66 | def test_headers_insert_retains_ordering(): 67 | headers = httpx.Headers({"a": "a", "b": "b", "c": "c"}) 68 | headers["b"] = "123" 69 | assert list(headers.values()) == ["a", "123", "c"] 70 | 71 | 72 | def test_headers_insert_appends_if_new(): 73 | headers = httpx.Headers({"a": "a", "b": "b", "c": "c"}) 74 | headers["d"] = "123" 75 | assert list(headers.values()) == ["a", "b", "c", "123"] 76 | 77 | 78 | def test_headers_insert_removes_all_existing(): 79 | headers = httpx.Headers([("a", "123"), ("a", "456")]) 80 | headers["a"] = "789" 81 | assert dict(headers) == {"a": "789"} 82 | 83 | 84 | def test_headers_delete_removes_all_existing(): 85 | headers = httpx.Headers([("a", "123"), ("a", "456")]) 86 | del headers["a"] 87 | assert dict(headers) == {} 88 | 89 | 90 | def test_headers_dict_repr(): 91 | """ 92 | Headers should display with a dict repr by default. 93 | """ 94 | headers = httpx.Headers({"custom": "example"}) 95 | assert repr(headers) == "Headers({'custom': 'example'})" 96 | 97 | 98 | def test_headers_encoding_in_repr(): 99 | """ 100 | Headers should display an encoding in the repr if required. 101 | """ 102 | headers = httpx.Headers({b"custom": "example ☃".encode("utf-8")}) 103 | assert repr(headers) == "Headers({'custom': 'example ☃'}, encoding='utf-8')" 104 | 105 | 106 | def test_headers_list_repr(): 107 | """ 108 | Headers should display with a list repr if they include multiple identical keys. 109 | """ 110 | headers = httpx.Headers([("custom", "example 1"), ("custom", "example 2")]) 111 | assert ( 112 | repr(headers) == "Headers([('custom', 'example 1'), ('custom', 'example 2')])" 113 | ) 114 | 115 | 116 | def test_headers_decode_ascii(): 117 | """ 118 | Headers should decode as ascii by default. 119 | """ 120 | raw_headers = [(b"Custom", b"Example")] 121 | headers = httpx.Headers(raw_headers) 122 | assert dict(headers) == {"custom": "Example"} 123 | assert headers.encoding == "ascii" 124 | 125 | 126 | def test_headers_decode_utf_8(): 127 | """ 128 | Headers containing non-ascii codepoints should default to decoding as utf-8. 129 | """ 130 | raw_headers = [(b"Custom", "Code point: ☃".encode("utf-8"))] 131 | headers = httpx.Headers(raw_headers) 132 | assert dict(headers) == {"custom": "Code point: ☃"} 133 | assert headers.encoding == "utf-8" 134 | 135 | 136 | def test_headers_decode_iso_8859_1(): 137 | """ 138 | Headers containing non-UTF-8 codepoints should default to decoding as iso-8859-1. 139 | """ 140 | raw_headers = [(b"Custom", "Code point: ÿ".encode("iso-8859-1"))] 141 | headers = httpx.Headers(raw_headers) 142 | assert dict(headers) == {"custom": "Code point: ÿ"} 143 | assert headers.encoding == "iso-8859-1" 144 | 145 | 146 | def test_headers_decode_explicit_encoding(): 147 | """ 148 | An explicit encoding may be set on headers in order to force a 149 | particular decoding. 150 | """ 151 | raw_headers = [(b"Custom", "Code point: ☃".encode("utf-8"))] 152 | headers = httpx.Headers(raw_headers) 153 | headers.encoding = "iso-8859-1" 154 | assert dict(headers) == {"custom": "Code point: â\x98\x83"} 155 | assert headers.encoding == "iso-8859-1" 156 | 157 | 158 | def test_multiple_headers(): 159 | """ 160 | `Headers.get_list` should support both split_commas=False and split_commas=True. 161 | """ 162 | h = httpx.Headers([("set-cookie", "a, b"), ("set-cookie", "c")]) 163 | assert h.get_list("Set-Cookie") == ["a, b", "c"] 164 | 165 | h = httpx.Headers([("vary", "a, b"), ("vary", "c")]) 166 | assert h.get_list("Vary", split_commas=True) == ["a", "b", "c"] 167 | 168 | 169 | @pytest.mark.parametrize("header", ["authorization", "proxy-authorization"]) 170 | def test_sensitive_headers(header): 171 | """ 172 | Some headers should be obfuscated because they contain sensitive data. 173 | """ 174 | value = "s3kr3t" 175 | h = httpx.Headers({header: value}) 176 | assert repr(h) == "Headers({'%s': '[secure]'})" % header 177 | 178 | 179 | @pytest.mark.parametrize( 180 | "headers, output", 181 | [ 182 | ([("content-type", "text/html")], [("content-type", "text/html")]), 183 | ([("authorization", "s3kr3t")], [("authorization", "[secure]")]), 184 | ([("proxy-authorization", "s3kr3t")], [("proxy-authorization", "[secure]")]), 185 | ], 186 | ) 187 | def test_obfuscate_sensitive_headers(headers, output): 188 | as_dict = {k: v for k, v in output} 189 | headers_class = httpx.Headers({k: v for k, v in headers}) 190 | assert repr(headers_class) == f"Headers({as_dict!r})" 191 | 192 | 193 | @pytest.mark.parametrize( 194 | "value, expected", 195 | ( 196 | ( 197 | '; rel=front; type="image/jpeg"', 198 | [{"url": "http:/.../front.jpeg", "rel": "front", "type": "image/jpeg"}], 199 | ), 200 | ("", [{"url": "http:/.../front.jpeg"}]), 201 | (";", [{"url": "http:/.../front.jpeg"}]), 202 | ( 203 | '; type="image/jpeg",;', 204 | [ 205 | {"url": "http:/.../front.jpeg", "type": "image/jpeg"}, 206 | {"url": "http://.../back.jpeg"}, 207 | ], 208 | ), 209 | ("", []), 210 | ), 211 | ) 212 | def test_parse_header_links(value, expected): 213 | all_links = httpx.Response(200, headers={"link": value}).links.values() 214 | assert all(link in all_links for link in expected) 215 | 216 | 217 | def test_parse_header_links_no_link(): 218 | all_links = httpx.Response(200).links 219 | assert all_links == {} 220 | -------------------------------------------------------------------------------- /tests/models/test_queryparams.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import httpx 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "source", 8 | [ 9 | "a=123&a=456&b=789", 10 | {"a": ["123", "456"], "b": 789}, 11 | {"a": ("123", "456"), "b": 789}, 12 | [("a", "123"), ("a", "456"), ("b", "789")], 13 | (("a", "123"), ("a", "456"), ("b", "789")), 14 | ], 15 | ) 16 | def test_queryparams(source): 17 | q = httpx.QueryParams(source) 18 | assert "a" in q 19 | assert "A" not in q 20 | assert "c" not in q 21 | assert q["a"] == "123" 22 | assert q.get("a") == "123" 23 | assert q.get("nope", default=None) is None 24 | assert q.get_list("a") == ["123", "456"] 25 | 26 | assert list(q.keys()) == ["a", "b"] 27 | assert list(q.values()) == ["123", "789"] 28 | assert list(q.items()) == [("a", "123"), ("b", "789")] 29 | assert len(q) == 2 30 | assert list(q) == ["a", "b"] 31 | assert dict(q) == {"a": "123", "b": "789"} 32 | assert str(q) == "a=123&a=456&b=789" 33 | assert repr(q) == "QueryParams('a=123&a=456&b=789')" 34 | assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( 35 | [("a", "123"), ("b", "456")] 36 | ) 37 | assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( 38 | "a=123&b=456" 39 | ) 40 | assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( 41 | {"b": "456", "a": "123"} 42 | ) 43 | assert httpx.QueryParams() == httpx.QueryParams({}) 44 | assert httpx.QueryParams([("a", "123"), ("a", "456")]) == httpx.QueryParams( 45 | "a=123&a=456" 46 | ) 47 | assert httpx.QueryParams({"a": "123", "b": "456"}) != "invalid" 48 | 49 | q = httpx.QueryParams([("a", "123"), ("a", "456")]) 50 | assert httpx.QueryParams(q) == q 51 | 52 | 53 | def test_queryparam_types(): 54 | q = httpx.QueryParams(None) 55 | assert str(q) == "" 56 | 57 | q = httpx.QueryParams({"a": True}) 58 | assert str(q) == "a=true" 59 | 60 | q = httpx.QueryParams({"a": False}) 61 | assert str(q) == "a=false" 62 | 63 | q = httpx.QueryParams({"a": ""}) 64 | assert str(q) == "a=" 65 | 66 | q = httpx.QueryParams({"a": None}) 67 | assert str(q) == "a=" 68 | 69 | q = httpx.QueryParams({"a": 1.23}) 70 | assert str(q) == "a=1.23" 71 | 72 | q = httpx.QueryParams({"a": 123}) 73 | assert str(q) == "a=123" 74 | 75 | q = httpx.QueryParams({"a": [1, 2]}) 76 | assert str(q) == "a=1&a=2" 77 | 78 | 79 | def test_empty_query_params(): 80 | q = httpx.QueryParams({"a": ""}) 81 | assert str(q) == "a=" 82 | 83 | q = httpx.QueryParams("a=") 84 | assert str(q) == "a=" 85 | 86 | q = httpx.QueryParams("a") 87 | assert str(q) == "a=" 88 | 89 | 90 | def test_queryparam_update_is_hard_deprecated(): 91 | q = httpx.QueryParams("a=123") 92 | with pytest.raises(RuntimeError): 93 | q.update({"a": "456"}) 94 | 95 | 96 | def test_queryparam_setter_is_hard_deprecated(): 97 | q = httpx.QueryParams("a=123") 98 | with pytest.raises(RuntimeError): 99 | q["a"] = "456" 100 | 101 | 102 | def test_queryparam_set(): 103 | q = httpx.QueryParams("a=123") 104 | q = q.set("a", "456") 105 | assert q == httpx.QueryParams("a=456") 106 | 107 | 108 | def test_queryparam_add(): 109 | q = httpx.QueryParams("a=123") 110 | q = q.add("a", "456") 111 | assert q == httpx.QueryParams("a=123&a=456") 112 | 113 | 114 | def test_queryparam_remove(): 115 | q = httpx.QueryParams("a=123") 116 | q = q.remove("a") 117 | assert q == httpx.QueryParams("") 118 | 119 | 120 | def test_queryparam_merge(): 121 | q = httpx.QueryParams("a=123") 122 | q = q.merge({"b": "456"}) 123 | assert q == httpx.QueryParams("a=123&b=456") 124 | q = q.merge({"a": "000", "c": "789"}) 125 | assert q == httpx.QueryParams("a=000&b=456&c=789") 126 | 127 | 128 | def test_queryparams_are_hashable(): 129 | params = ( 130 | httpx.QueryParams("a=123"), 131 | httpx.QueryParams({"a": 123}), 132 | httpx.QueryParams("b=456"), 133 | httpx.QueryParams({"b": 456}), 134 | ) 135 | 136 | assert len(set(params)) == 2 137 | -------------------------------------------------------------------------------- /tests/models/test_requests.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import typing 3 | 4 | import pytest 5 | 6 | import httpx 7 | 8 | 9 | def test_request_repr(): 10 | request = httpx.Request("GET", "http://example.org") 11 | assert repr(request) == "" 12 | 13 | 14 | def test_no_content(): 15 | request = httpx.Request("GET", "http://example.org") 16 | assert "Content-Length" not in request.headers 17 | 18 | 19 | def test_content_length_header(): 20 | request = httpx.Request("POST", "http://example.org", content=b"test 123") 21 | assert request.headers["Content-Length"] == "8" 22 | 23 | 24 | def test_iterable_content(): 25 | class Content: 26 | def __iter__(self): 27 | yield b"test 123" # pragma: no cover 28 | 29 | request = httpx.Request("POST", "http://example.org", content=Content()) 30 | assert request.headers == {"Host": "example.org", "Transfer-Encoding": "chunked"} 31 | 32 | 33 | def test_generator_with_transfer_encoding_header(): 34 | def content() -> typing.Iterator[bytes]: 35 | yield b"test 123" # pragma: no cover 36 | 37 | request = httpx.Request("POST", "http://example.org", content=content()) 38 | assert request.headers == {"Host": "example.org", "Transfer-Encoding": "chunked"} 39 | 40 | 41 | def test_generator_with_content_length_header(): 42 | def content() -> typing.Iterator[bytes]: 43 | yield b"test 123" # pragma: no cover 44 | 45 | headers = {"Content-Length": "8"} 46 | request = httpx.Request( 47 | "POST", "http://example.org", content=content(), headers=headers 48 | ) 49 | assert request.headers == {"Host": "example.org", "Content-Length": "8"} 50 | 51 | 52 | def test_url_encoded_data(): 53 | request = httpx.Request("POST", "http://example.org", data={"test": "123"}) 54 | request.read() 55 | 56 | assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" 57 | assert request.content == b"test=123" 58 | 59 | 60 | def test_json_encoded_data(): 61 | request = httpx.Request("POST", "http://example.org", json={"test": 123}) 62 | request.read() 63 | 64 | assert request.headers["Content-Type"] == "application/json" 65 | assert request.content == b'{"test":123}' 66 | 67 | 68 | def test_headers(): 69 | request = httpx.Request("POST", "http://example.org", json={"test": 123}) 70 | 71 | assert request.headers == { 72 | "Host": "example.org", 73 | "Content-Type": "application/json", 74 | "Content-Length": "12", 75 | } 76 | 77 | 78 | def test_read_and_stream_data(): 79 | # Ensure a request may still be streamed if it has been read. 80 | # Needed for cases such as authentication classes that read the request body. 81 | request = httpx.Request("POST", "http://example.org", json={"test": 123}) 82 | request.read() 83 | assert request.stream is not None 84 | assert isinstance(request.stream, typing.Iterable) 85 | content = b"".join(list(request.stream)) 86 | assert content == request.content 87 | 88 | 89 | @pytest.mark.anyio 90 | async def test_aread_and_stream_data(): 91 | # Ensure a request may still be streamed if it has been read. 92 | # Needed for cases such as authentication classes that read the request body. 93 | request = httpx.Request("POST", "http://example.org", json={"test": 123}) 94 | await request.aread() 95 | assert request.stream is not None 96 | assert isinstance(request.stream, typing.AsyncIterable) 97 | content = b"".join([part async for part in request.stream]) 98 | assert content == request.content 99 | 100 | 101 | def test_cannot_access_streaming_content_without_read(): 102 | # Ensure that streaming requests 103 | def streaming_body() -> typing.Iterator[bytes]: # pragma: no cover 104 | yield b"" 105 | 106 | request = httpx.Request("POST", "http://example.org", content=streaming_body()) 107 | with pytest.raises(httpx.RequestNotRead): 108 | request.content # noqa: B018 109 | 110 | 111 | def test_transfer_encoding_header(): 112 | async def streaming_body(data: bytes) -> typing.AsyncIterator[bytes]: 113 | yield data # pragma: no cover 114 | 115 | data = streaming_body(b"test 123") 116 | 117 | request = httpx.Request("POST", "http://example.org", content=data) 118 | assert "Content-Length" not in request.headers 119 | assert request.headers["Transfer-Encoding"] == "chunked" 120 | 121 | 122 | def test_ignore_transfer_encoding_header_if_content_length_exists(): 123 | """ 124 | `Transfer-Encoding` should be ignored if `Content-Length` has been set explicitly. 125 | See https://github.com/encode/httpx/issues/1168 126 | """ 127 | 128 | def streaming_body(data: bytes) -> typing.Iterator[bytes]: 129 | yield data # pragma: no cover 130 | 131 | data = streaming_body(b"abcd") 132 | 133 | headers = {"Content-Length": "4"} 134 | request = httpx.Request("POST", "http://example.org", content=data, headers=headers) 135 | assert "Transfer-Encoding" not in request.headers 136 | assert request.headers["Content-Length"] == "4" 137 | 138 | 139 | def test_override_host_header(): 140 | headers = {"host": "1.2.3.4:80"} 141 | 142 | request = httpx.Request("GET", "http://example.org", headers=headers) 143 | assert request.headers["Host"] == "1.2.3.4:80" 144 | 145 | 146 | def test_override_accept_encoding_header(): 147 | headers = {"Accept-Encoding": "identity"} 148 | 149 | request = httpx.Request("GET", "http://example.org", headers=headers) 150 | assert request.headers["Accept-Encoding"] == "identity" 151 | 152 | 153 | def test_override_content_length_header(): 154 | async def streaming_body(data: bytes) -> typing.AsyncIterator[bytes]: 155 | yield data # pragma: no cover 156 | 157 | data = streaming_body(b"test 123") 158 | headers = {"Content-Length": "8"} 159 | 160 | request = httpx.Request("POST", "http://example.org", content=data, headers=headers) 161 | assert request.headers["Content-Length"] == "8" 162 | 163 | 164 | def test_url(): 165 | url = "http://example.org" 166 | request = httpx.Request("GET", url) 167 | assert request.url.scheme == "http" 168 | assert request.url.port is None 169 | assert request.url.path == "/" 170 | assert request.url.raw_path == b"/" 171 | 172 | url = "https://example.org/abc?foo=bar" 173 | request = httpx.Request("GET", url) 174 | assert request.url.scheme == "https" 175 | assert request.url.port is None 176 | assert request.url.path == "/abc" 177 | assert request.url.raw_path == b"/abc?foo=bar" 178 | 179 | 180 | def test_request_picklable(): 181 | request = httpx.Request("POST", "http://example.org", json={"test": 123}) 182 | pickle_request = pickle.loads(pickle.dumps(request)) 183 | assert pickle_request.method == "POST" 184 | assert pickle_request.url.path == "/" 185 | assert pickle_request.headers["Content-Type"] == "application/json" 186 | assert pickle_request.content == b'{"test":123}' 187 | assert pickle_request.stream is not None 188 | assert request.headers == { 189 | "Host": "example.org", 190 | "Content-Type": "application/json", 191 | "content-length": "12", 192 | } 193 | 194 | 195 | @pytest.mark.anyio 196 | async def test_request_async_streaming_content_picklable(): 197 | async def streaming_body(data: bytes) -> typing.AsyncIterator[bytes]: 198 | yield data 199 | 200 | data = streaming_body(b"test 123") 201 | request = httpx.Request("POST", "http://example.org", content=data) 202 | pickle_request = pickle.loads(pickle.dumps(request)) 203 | with pytest.raises(httpx.RequestNotRead): 204 | pickle_request.content # noqa: B018 205 | with pytest.raises(httpx.StreamClosed): 206 | await pickle_request.aread() 207 | 208 | request = httpx.Request("POST", "http://example.org", content=data) 209 | await request.aread() 210 | pickle_request = pickle.loads(pickle.dumps(request)) 211 | assert pickle_request.content == b"test 123" 212 | 213 | 214 | def test_request_generator_content_picklable(): 215 | def content() -> typing.Iterator[bytes]: 216 | yield b"test 123" # pragma: no cover 217 | 218 | request = httpx.Request("POST", "http://example.org", content=content()) 219 | pickle_request = pickle.loads(pickle.dumps(request)) 220 | with pytest.raises(httpx.RequestNotRead): 221 | pickle_request.content # noqa: B018 222 | with pytest.raises(httpx.StreamClosed): 223 | pickle_request.read() 224 | 225 | request = httpx.Request("POST", "http://example.org", content=content()) 226 | request.read() 227 | pickle_request = pickle.loads(pickle.dumps(request)) 228 | assert pickle_request.content == b"test 123" 229 | 230 | 231 | def test_request_params(): 232 | request = httpx.Request("GET", "http://example.com", params={}) 233 | assert str(request.url) == "http://example.com" 234 | 235 | request = httpx.Request( 236 | "GET", "http://example.com?c=3", params={"a": "1", "b": "2"} 237 | ) 238 | assert str(request.url) == "http://example.com?a=1&b=2" 239 | 240 | request = httpx.Request("GET", "http://example.com?a=1", params={}) 241 | assert str(request.url) == "http://example.com" 242 | -------------------------------------------------------------------------------- /tests/models/test_whatwg.py: -------------------------------------------------------------------------------- 1 | # The WHATWG have various tests that can be used to validate the URL parsing. 2 | # 3 | # https://url.spec.whatwg.org/ 4 | 5 | import json 6 | 7 | import pytest 8 | 9 | from httpx._urlparse import urlparse 10 | 11 | # URL test cases from... 12 | # https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json 13 | with open("tests/models/whatwg.json", "r", encoding="utf-8") as input: 14 | test_cases = json.load(input) 15 | test_cases = [ 16 | item 17 | for item in test_cases 18 | if not isinstance(item, str) and not item.get("failure") 19 | ] 20 | 21 | 22 | @pytest.mark.parametrize("test_case", test_cases) 23 | def test_urlparse(test_case): 24 | if test_case["href"] in ("a: foo.com", "lolscheme:x x#x%20x"): 25 | # Skip these two test cases. 26 | # WHATWG cases where are not using percent-encoding for the space character. 27 | # Anyone know what's going on here? 28 | return 29 | 30 | p = urlparse(test_case["href"]) 31 | 32 | # Test cases include the protocol with the trailing ":" 33 | protocol = p.scheme + ":" 34 | # Include the square brackets for IPv6 addresses. 35 | hostname = f"[{p.host}]" if ":" in p.host else p.host 36 | # The test cases use a string representation of the port. 37 | port = "" if p.port is None else str(p.port) 38 | # I have nothing to say about this one. 39 | path = p.path 40 | # The 'search' and 'hash' components in the whatwg tests are semantic, not literal. 41 | # Our parsing differentiates between no query/hash and empty-string query/hash. 42 | search = "" if p.query in (None, "") else "?" + str(p.query) 43 | hash = "" if p.fragment in (None, "") else "#" + str(p.fragment) 44 | 45 | # URL hostnames are case-insensitive. 46 | # We normalize these, unlike the WHATWG test cases. 47 | assert protocol == test_case["protocol"] 48 | assert hostname.lower() == test_case["hostname"].lower() 49 | assert port == test_case["port"] 50 | assert path == test_case["pathname"] 51 | assert search == test_case["search"] 52 | assert hash == test_case["hash"] 53 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | import httpx 6 | 7 | 8 | def test_get(server): 9 | response = httpx.get(server.url) 10 | assert response.status_code == 200 11 | assert response.reason_phrase == "OK" 12 | assert response.text == "Hello, world!" 13 | assert response.http_version == "HTTP/1.1" 14 | 15 | 16 | def test_post(server): 17 | response = httpx.post(server.url, content=b"Hello, world!") 18 | assert response.status_code == 200 19 | assert response.reason_phrase == "OK" 20 | 21 | 22 | def test_post_byte_iterator(server): 23 | def data() -> typing.Iterator[bytes]: 24 | yield b"Hello" 25 | yield b", " 26 | yield b"world!" 27 | 28 | response = httpx.post(server.url, content=data()) 29 | assert response.status_code == 200 30 | assert response.reason_phrase == "OK" 31 | 32 | 33 | def test_post_byte_stream(server): 34 | class Data(httpx.SyncByteStream): 35 | def __iter__(self): 36 | yield b"Hello" 37 | yield b", " 38 | yield b"world!" 39 | 40 | response = httpx.post(server.url, content=Data()) 41 | assert response.status_code == 200 42 | assert response.reason_phrase == "OK" 43 | 44 | 45 | def test_options(server): 46 | response = httpx.options(server.url) 47 | assert response.status_code == 200 48 | assert response.reason_phrase == "OK" 49 | 50 | 51 | def test_head(server): 52 | response = httpx.head(server.url) 53 | assert response.status_code == 200 54 | assert response.reason_phrase == "OK" 55 | 56 | 57 | def test_put(server): 58 | response = httpx.put(server.url, content=b"Hello, world!") 59 | assert response.status_code == 200 60 | assert response.reason_phrase == "OK" 61 | 62 | 63 | def test_patch(server): 64 | response = httpx.patch(server.url, content=b"Hello, world!") 65 | assert response.status_code == 200 66 | assert response.reason_phrase == "OK" 67 | 68 | 69 | def test_delete(server): 70 | response = httpx.delete(server.url) 71 | assert response.status_code == 200 72 | assert response.reason_phrase == "OK" 73 | 74 | 75 | def test_stream(server): 76 | with httpx.stream("GET", server.url) as response: 77 | response.read() 78 | 79 | assert response.status_code == 200 80 | assert response.reason_phrase == "OK" 81 | assert response.text == "Hello, world!" 82 | assert response.http_version == "HTTP/1.1" 83 | 84 | 85 | def test_get_invalid_url(): 86 | with pytest.raises(httpx.UnsupportedProtocol): 87 | httpx.get("invalid://example.org") 88 | 89 | 90 | # check that httpcore isn't imported until we do a request 91 | def test_httpcore_lazy_loading(server): 92 | import sys 93 | 94 | # unload our module if it is already loaded 95 | if "httpx" in sys.modules: 96 | del sys.modules["httpx"] 97 | del sys.modules["httpcore"] 98 | import httpx 99 | 100 | assert "httpcore" not in sys.modules 101 | _response = httpx.get(server.url) 102 | assert "httpcore" in sys.modules 103 | -------------------------------------------------------------------------------- /tests/test_asgi.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | import httpx 6 | 7 | 8 | async def hello_world(scope, receive, send): 9 | status = 200 10 | output = b"Hello, World!" 11 | headers = [(b"content-type", "text/plain"), (b"content-length", str(len(output)))] 12 | 13 | await send({"type": "http.response.start", "status": status, "headers": headers}) 14 | await send({"type": "http.response.body", "body": output}) 15 | 16 | 17 | async def echo_path(scope, receive, send): 18 | status = 200 19 | output = json.dumps({"path": scope["path"]}).encode("utf-8") 20 | headers = [(b"content-type", "text/plain"), (b"content-length", str(len(output)))] 21 | 22 | await send({"type": "http.response.start", "status": status, "headers": headers}) 23 | await send({"type": "http.response.body", "body": output}) 24 | 25 | 26 | async def echo_raw_path(scope, receive, send): 27 | status = 200 28 | output = json.dumps({"raw_path": scope["raw_path"].decode("ascii")}).encode("utf-8") 29 | headers = [(b"content-type", "text/plain"), (b"content-length", str(len(output)))] 30 | 31 | await send({"type": "http.response.start", "status": status, "headers": headers}) 32 | await send({"type": "http.response.body", "body": output}) 33 | 34 | 35 | async def echo_body(scope, receive, send): 36 | status = 200 37 | headers = [(b"content-type", "text/plain")] 38 | 39 | await send({"type": "http.response.start", "status": status, "headers": headers}) 40 | more_body = True 41 | while more_body: 42 | message = await receive() 43 | body = message.get("body", b"") 44 | more_body = message.get("more_body", False) 45 | await send({"type": "http.response.body", "body": body, "more_body": more_body}) 46 | 47 | 48 | async def echo_headers(scope, receive, send): 49 | status = 200 50 | output = json.dumps( 51 | {"headers": [[k.decode(), v.decode()] for k, v in scope["headers"]]} 52 | ).encode("utf-8") 53 | headers = [(b"content-type", "text/plain"), (b"content-length", str(len(output)))] 54 | 55 | await send({"type": "http.response.start", "status": status, "headers": headers}) 56 | await send({"type": "http.response.body", "body": output}) 57 | 58 | 59 | async def raise_exc(scope, receive, send): 60 | raise RuntimeError() 61 | 62 | 63 | async def raise_exc_after_response(scope, receive, send): 64 | status = 200 65 | output = b"Hello, World!" 66 | headers = [(b"content-type", "text/plain"), (b"content-length", str(len(output)))] 67 | 68 | await send({"type": "http.response.start", "status": status, "headers": headers}) 69 | await send({"type": "http.response.body", "body": output}) 70 | raise RuntimeError() 71 | 72 | 73 | @pytest.mark.anyio 74 | async def test_asgi_transport(): 75 | async with httpx.ASGITransport(app=hello_world) as transport: 76 | request = httpx.Request("GET", "http://www.example.com/") 77 | response = await transport.handle_async_request(request) 78 | await response.aread() 79 | assert response.status_code == 200 80 | assert response.content == b"Hello, World!" 81 | 82 | 83 | @pytest.mark.anyio 84 | async def test_asgi_transport_no_body(): 85 | async with httpx.ASGITransport(app=echo_body) as transport: 86 | request = httpx.Request("GET", "http://www.example.com/") 87 | response = await transport.handle_async_request(request) 88 | await response.aread() 89 | assert response.status_code == 200 90 | assert response.content == b"" 91 | 92 | 93 | @pytest.mark.anyio 94 | async def test_asgi(): 95 | transport = httpx.ASGITransport(app=hello_world) 96 | async with httpx.AsyncClient(transport=transport) as client: 97 | response = await client.get("http://www.example.org/") 98 | 99 | assert response.status_code == 200 100 | assert response.text == "Hello, World!" 101 | 102 | 103 | @pytest.mark.anyio 104 | async def test_asgi_urlencoded_path(): 105 | transport = httpx.ASGITransport(app=echo_path) 106 | async with httpx.AsyncClient(transport=transport) as client: 107 | url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org") 108 | response = await client.get(url) 109 | 110 | assert response.status_code == 200 111 | assert response.json() == {"path": "/user@example.org"} 112 | 113 | 114 | @pytest.mark.anyio 115 | async def test_asgi_raw_path(): 116 | transport = httpx.ASGITransport(app=echo_raw_path) 117 | async with httpx.AsyncClient(transport=transport) as client: 118 | url = httpx.URL("http://www.example.org/").copy_with(path="/user@example.org") 119 | response = await client.get(url) 120 | 121 | assert response.status_code == 200 122 | assert response.json() == {"raw_path": "/user@example.org"} 123 | 124 | 125 | @pytest.mark.anyio 126 | async def test_asgi_raw_path_should_not_include_querystring_portion(): 127 | """ 128 | See https://github.com/encode/httpx/issues/2810 129 | """ 130 | transport = httpx.ASGITransport(app=echo_raw_path) 131 | async with httpx.AsyncClient(transport=transport) as client: 132 | url = httpx.URL("http://www.example.org/path?query") 133 | response = await client.get(url) 134 | 135 | assert response.status_code == 200 136 | assert response.json() == {"raw_path": "/path"} 137 | 138 | 139 | @pytest.mark.anyio 140 | async def test_asgi_upload(): 141 | transport = httpx.ASGITransport(app=echo_body) 142 | async with httpx.AsyncClient(transport=transport) as client: 143 | response = await client.post("http://www.example.org/", content=b"example") 144 | 145 | assert response.status_code == 200 146 | assert response.text == "example" 147 | 148 | 149 | @pytest.mark.anyio 150 | async def test_asgi_headers(): 151 | transport = httpx.ASGITransport(app=echo_headers) 152 | async with httpx.AsyncClient(transport=transport) as client: 153 | response = await client.get("http://www.example.org/") 154 | 155 | assert response.status_code == 200 156 | assert response.json() == { 157 | "headers": [ 158 | ["host", "www.example.org"], 159 | ["accept", "*/*"], 160 | ["accept-encoding", "gzip, deflate, br, zstd"], 161 | ["connection", "keep-alive"], 162 | ["user-agent", f"python-httpx/{httpx.__version__}"], 163 | ] 164 | } 165 | 166 | 167 | @pytest.mark.anyio 168 | async def test_asgi_exc(): 169 | transport = httpx.ASGITransport(app=raise_exc) 170 | async with httpx.AsyncClient(transport=transport) as client: 171 | with pytest.raises(RuntimeError): 172 | await client.get("http://www.example.org/") 173 | 174 | 175 | @pytest.mark.anyio 176 | async def test_asgi_exc_after_response(): 177 | transport = httpx.ASGITransport(app=raise_exc_after_response) 178 | async with httpx.AsyncClient(transport=transport) as client: 179 | with pytest.raises(RuntimeError): 180 | await client.get("http://www.example.org/") 181 | 182 | 183 | @pytest.mark.anyio 184 | async def test_asgi_disconnect_after_response_complete(): 185 | disconnect = False 186 | 187 | async def read_body(scope, receive, send): 188 | nonlocal disconnect 189 | 190 | status = 200 191 | headers = [(b"content-type", "text/plain")] 192 | 193 | await send( 194 | {"type": "http.response.start", "status": status, "headers": headers} 195 | ) 196 | more_body = True 197 | while more_body: 198 | message = await receive() 199 | more_body = message.get("more_body", False) 200 | 201 | await send({"type": "http.response.body", "body": b"", "more_body": False}) 202 | 203 | # The ASGI spec says of the Disconnect message: 204 | # "Sent to the application when a HTTP connection is closed or if receive is 205 | # called after a response has been sent." 206 | # So if receive() is called again, the disconnect message should be received 207 | message = await receive() 208 | disconnect = message.get("type") == "http.disconnect" 209 | 210 | transport = httpx.ASGITransport(app=read_body) 211 | async with httpx.AsyncClient(transport=transport) as client: 212 | response = await client.post("http://www.example.org/", content=b"example") 213 | 214 | assert response.status_code == 200 215 | assert disconnect 216 | 217 | 218 | @pytest.mark.anyio 219 | async def test_asgi_exc_no_raise(): 220 | transport = httpx.ASGITransport(app=raise_exc, raise_app_exceptions=False) 221 | async with httpx.AsyncClient(transport=transport) as client: 222 | response = await client.get("http://www.example.org/") 223 | 224 | assert response.status_code == 500 225 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import typing 3 | from pathlib import Path 4 | 5 | import certifi 6 | import pytest 7 | 8 | import httpx 9 | 10 | 11 | def test_load_ssl_config(): 12 | context = httpx.create_ssl_context() 13 | assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED 14 | assert context.check_hostname is True 15 | 16 | 17 | def test_load_ssl_config_verify_non_existing_file(): 18 | with pytest.raises(IOError): 19 | context = httpx.create_ssl_context() 20 | context.load_verify_locations(cafile="/path/to/nowhere") 21 | 22 | 23 | def test_load_ssl_with_keylog(monkeypatch: typing.Any) -> None: 24 | monkeypatch.setenv("SSLKEYLOGFILE", "test") 25 | context = httpx.create_ssl_context() 26 | assert context.keylog_filename == "test" 27 | 28 | 29 | def test_load_ssl_config_verify_existing_file(): 30 | context = httpx.create_ssl_context() 31 | context.load_verify_locations(capath=certifi.where()) 32 | assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED 33 | assert context.check_hostname is True 34 | 35 | 36 | def test_load_ssl_config_verify_directory(): 37 | context = httpx.create_ssl_context() 38 | context.load_verify_locations(capath=Path(certifi.where()).parent) 39 | assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED 40 | assert context.check_hostname is True 41 | 42 | 43 | def test_load_ssl_config_cert_and_key(cert_pem_file, cert_private_key_file): 44 | context = httpx.create_ssl_context() 45 | context.load_cert_chain(cert_pem_file, cert_private_key_file) 46 | assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED 47 | assert context.check_hostname is True 48 | 49 | 50 | @pytest.mark.parametrize("password", [b"password", "password"]) 51 | def test_load_ssl_config_cert_and_encrypted_key( 52 | cert_pem_file, cert_encrypted_private_key_file, password 53 | ): 54 | context = httpx.create_ssl_context() 55 | context.load_cert_chain(cert_pem_file, cert_encrypted_private_key_file, password) 56 | assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED 57 | assert context.check_hostname is True 58 | 59 | 60 | def test_load_ssl_config_cert_and_key_invalid_password( 61 | cert_pem_file, cert_encrypted_private_key_file 62 | ): 63 | with pytest.raises(ssl.SSLError): 64 | context = httpx.create_ssl_context() 65 | context.load_cert_chain( 66 | cert_pem_file, cert_encrypted_private_key_file, "password1" 67 | ) 68 | 69 | 70 | def test_load_ssl_config_cert_without_key_raises(cert_pem_file): 71 | with pytest.raises(ssl.SSLError): 72 | context = httpx.create_ssl_context() 73 | context.load_cert_chain(cert_pem_file) 74 | 75 | 76 | def test_load_ssl_config_no_verify(): 77 | context = httpx.create_ssl_context(verify=False) 78 | assert context.verify_mode == ssl.VerifyMode.CERT_NONE 79 | assert context.check_hostname is False 80 | 81 | 82 | def test_SSLContext_with_get_request(server, cert_pem_file): 83 | context = httpx.create_ssl_context() 84 | context.load_verify_locations(cert_pem_file) 85 | response = httpx.get(server.url, verify=context) 86 | assert response.status_code == 200 87 | 88 | 89 | def test_limits_repr(): 90 | limits = httpx.Limits(max_connections=100) 91 | expected = ( 92 | "Limits(max_connections=100, max_keepalive_connections=None," 93 | " keepalive_expiry=5.0)" 94 | ) 95 | assert repr(limits) == expected 96 | 97 | 98 | def test_limits_eq(): 99 | limits = httpx.Limits(max_connections=100) 100 | assert limits == httpx.Limits(max_connections=100) 101 | 102 | 103 | def test_timeout_eq(): 104 | timeout = httpx.Timeout(timeout=5.0) 105 | assert timeout == httpx.Timeout(timeout=5.0) 106 | 107 | 108 | def test_timeout_all_parameters_set(): 109 | timeout = httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0) 110 | assert timeout == httpx.Timeout(timeout=5.0) 111 | 112 | 113 | def test_timeout_from_nothing(): 114 | timeout = httpx.Timeout(None) 115 | assert timeout.connect is None 116 | assert timeout.read is None 117 | assert timeout.write is None 118 | assert timeout.pool is None 119 | 120 | 121 | def test_timeout_from_none(): 122 | timeout = httpx.Timeout(timeout=None) 123 | assert timeout == httpx.Timeout(None) 124 | 125 | 126 | def test_timeout_from_one_none_value(): 127 | timeout = httpx.Timeout(None, read=None) 128 | assert timeout == httpx.Timeout(None) 129 | 130 | 131 | def test_timeout_from_one_value(): 132 | timeout = httpx.Timeout(None, read=5.0) 133 | assert timeout == httpx.Timeout(timeout=(None, 5.0, None, None)) 134 | 135 | 136 | def test_timeout_from_one_value_and_default(): 137 | timeout = httpx.Timeout(5.0, pool=60.0) 138 | assert timeout == httpx.Timeout(timeout=(5.0, 5.0, 5.0, 60.0)) 139 | 140 | 141 | def test_timeout_missing_default(): 142 | with pytest.raises(ValueError): 143 | httpx.Timeout(pool=60.0) 144 | 145 | 146 | def test_timeout_from_tuple(): 147 | timeout = httpx.Timeout(timeout=(5.0, 5.0, 5.0, 5.0)) 148 | assert timeout == httpx.Timeout(timeout=5.0) 149 | 150 | 151 | def test_timeout_from_config_instance(): 152 | timeout = httpx.Timeout(timeout=5.0) 153 | assert httpx.Timeout(timeout) == httpx.Timeout(timeout=5.0) 154 | 155 | 156 | def test_timeout_repr(): 157 | timeout = httpx.Timeout(timeout=5.0) 158 | assert repr(timeout) == "Timeout(timeout=5.0)" 159 | 160 | timeout = httpx.Timeout(None, read=5.0) 161 | assert repr(timeout) == "Timeout(connect=None, read=5.0, write=None, pool=None)" 162 | 163 | 164 | def test_proxy_from_url(): 165 | proxy = httpx.Proxy("https://example.com") 166 | 167 | assert str(proxy.url) == "https://example.com" 168 | assert proxy.auth is None 169 | assert proxy.headers == {} 170 | assert repr(proxy) == "Proxy('https://example.com')" 171 | 172 | 173 | def test_proxy_with_auth_from_url(): 174 | proxy = httpx.Proxy("https://username:password@example.com") 175 | 176 | assert str(proxy.url) == "https://example.com" 177 | assert proxy.auth == ("username", "password") 178 | assert proxy.headers == {} 179 | assert repr(proxy) == "Proxy('https://example.com', auth=('username', '********'))" 180 | 181 | 182 | def test_invalid_proxy_scheme(): 183 | with pytest.raises(ValueError): 184 | httpx.Proxy("invalid://example.com") 185 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | import httpcore 6 | import pytest 7 | 8 | import httpx 9 | 10 | if typing.TYPE_CHECKING: # pragma: no cover 11 | from conftest import TestServer 12 | 13 | 14 | def test_httpcore_all_exceptions_mapped() -> None: 15 | """ 16 | All exception classes exposed by HTTPCore are properly mapped to an HTTPX-specific 17 | exception class. 18 | """ 19 | expected_mapped_httpcore_exceptions = { 20 | value.__name__ 21 | for _, value in vars(httpcore).items() 22 | if isinstance(value, type) 23 | and issubclass(value, Exception) 24 | and value is not httpcore.ConnectionNotAvailable 25 | } 26 | 27 | httpx_exceptions = { 28 | value.__name__ 29 | for _, value in vars(httpx).items() 30 | if isinstance(value, type) and issubclass(value, Exception) 31 | } 32 | 33 | unmapped_exceptions = expected_mapped_httpcore_exceptions - httpx_exceptions 34 | 35 | if unmapped_exceptions: # pragma: no cover 36 | pytest.fail(f"Unmapped httpcore exceptions: {unmapped_exceptions}") 37 | 38 | 39 | def test_httpcore_exception_mapping(server: TestServer) -> None: 40 | """ 41 | HTTPCore exception mapping works as expected. 42 | """ 43 | impossible_port = 123456 44 | with pytest.raises(httpx.ConnectError): 45 | httpx.get(server.url.copy_with(port=impossible_port)) 46 | 47 | with pytest.raises(httpx.ReadTimeout): 48 | httpx.get( 49 | server.url.copy_with(path="/slow_response"), 50 | timeout=httpx.Timeout(5, read=0.01), 51 | ) 52 | 53 | 54 | def test_request_attribute() -> None: 55 | # Exception without request attribute 56 | exc = httpx.ReadTimeout("Read operation timed out") 57 | with pytest.raises(RuntimeError): 58 | exc.request # noqa: B018 59 | 60 | # Exception with request attribute 61 | request = httpx.Request("GET", "https://www.example.com") 62 | exc = httpx.ReadTimeout("Read operation timed out", request=request) 63 | assert exc.request == request 64 | -------------------------------------------------------------------------------- /tests/test_exported_members.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | 4 | def test_all_imports_are_exported() -> None: 5 | included_private_members = ["__description__", "__title__", "__version__"] 6 | assert httpx.__all__ == sorted( 7 | ( 8 | member 9 | for member in vars(httpx).keys() 10 | if not member.startswith("_") or member in included_private_members 11 | ), 12 | key=str.casefold, 13 | ) 14 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing 3 | 4 | from click.testing import CliRunner 5 | 6 | import httpx 7 | 8 | 9 | def splitlines(output: str) -> typing.Iterable[str]: 10 | return [line.strip() for line in output.splitlines()] 11 | 12 | 13 | def remove_date_header(lines: typing.Iterable[str]) -> typing.Iterable[str]: 14 | return [line for line in lines if not line.startswith("date:")] 15 | 16 | 17 | def test_help(): 18 | runner = CliRunner() 19 | result = runner.invoke(httpx.main, ["--help"]) 20 | assert result.exit_code == 0 21 | assert "A next generation HTTP client." in result.output 22 | 23 | 24 | def test_get(server): 25 | url = str(server.url) 26 | runner = CliRunner() 27 | result = runner.invoke(httpx.main, [url]) 28 | assert result.exit_code == 0 29 | assert remove_date_header(splitlines(result.output)) == [ 30 | "HTTP/1.1 200 OK", 31 | "server: uvicorn", 32 | "content-type: text/plain", 33 | "Transfer-Encoding: chunked", 34 | "", 35 | "Hello, world!", 36 | ] 37 | 38 | 39 | def test_json(server): 40 | url = str(server.url.copy_with(path="/json")) 41 | runner = CliRunner() 42 | result = runner.invoke(httpx.main, [url]) 43 | assert result.exit_code == 0 44 | assert remove_date_header(splitlines(result.output)) == [ 45 | "HTTP/1.1 200 OK", 46 | "server: uvicorn", 47 | "content-type: application/json", 48 | "Transfer-Encoding: chunked", 49 | "", 50 | "{", 51 | '"Hello": "world!"', 52 | "}", 53 | ] 54 | 55 | 56 | def test_binary(server): 57 | url = str(server.url.copy_with(path="/echo_binary")) 58 | runner = CliRunner() 59 | content = "Hello, world!" 60 | result = runner.invoke(httpx.main, [url, "-c", content]) 61 | assert result.exit_code == 0 62 | assert remove_date_header(splitlines(result.output)) == [ 63 | "HTTP/1.1 200 OK", 64 | "server: uvicorn", 65 | "content-type: application/octet-stream", 66 | "Transfer-Encoding: chunked", 67 | "", 68 | f"<{len(content)} bytes of binary data>", 69 | ] 70 | 71 | 72 | def test_redirects(server): 73 | url = str(server.url.copy_with(path="/redirect_301")) 74 | runner = CliRunner() 75 | result = runner.invoke(httpx.main, [url]) 76 | assert result.exit_code == 1 77 | assert remove_date_header(splitlines(result.output)) == [ 78 | "HTTP/1.1 301 Moved Permanently", 79 | "server: uvicorn", 80 | "location: /", 81 | "Transfer-Encoding: chunked", 82 | "", 83 | ] 84 | 85 | 86 | def test_follow_redirects(server): 87 | url = str(server.url.copy_with(path="/redirect_301")) 88 | runner = CliRunner() 89 | result = runner.invoke(httpx.main, [url, "--follow-redirects"]) 90 | assert result.exit_code == 0 91 | assert remove_date_header(splitlines(result.output)) == [ 92 | "HTTP/1.1 301 Moved Permanently", 93 | "server: uvicorn", 94 | "location: /", 95 | "Transfer-Encoding: chunked", 96 | "", 97 | "HTTP/1.1 200 OK", 98 | "server: uvicorn", 99 | "content-type: text/plain", 100 | "Transfer-Encoding: chunked", 101 | "", 102 | "Hello, world!", 103 | ] 104 | 105 | 106 | def test_post(server): 107 | url = str(server.url.copy_with(path="/echo_body")) 108 | runner = CliRunner() 109 | result = runner.invoke(httpx.main, [url, "-m", "POST", "-j", '{"hello": "world"}']) 110 | assert result.exit_code == 0 111 | assert remove_date_header(splitlines(result.output)) == [ 112 | "HTTP/1.1 200 OK", 113 | "server: uvicorn", 114 | "content-type: text/plain", 115 | "Transfer-Encoding: chunked", 116 | "", 117 | '{"hello":"world"}', 118 | ] 119 | 120 | 121 | def test_verbose(server): 122 | url = str(server.url) 123 | runner = CliRunner() 124 | result = runner.invoke(httpx.main, [url, "-v"]) 125 | assert result.exit_code == 0 126 | assert remove_date_header(splitlines(result.output)) == [ 127 | "* Connecting to '127.0.0.1'", 128 | "* Connected to '127.0.0.1' on port 8000", 129 | "GET / HTTP/1.1", 130 | f"Host: {server.url.netloc.decode('ascii')}", 131 | "Accept: */*", 132 | "Accept-Encoding: gzip, deflate, br, zstd", 133 | "Connection: keep-alive", 134 | f"User-Agent: python-httpx/{httpx.__version__}", 135 | "", 136 | "HTTP/1.1 200 OK", 137 | "server: uvicorn", 138 | "content-type: text/plain", 139 | "Transfer-Encoding: chunked", 140 | "", 141 | "Hello, world!", 142 | ] 143 | 144 | 145 | def test_auth(server): 146 | url = str(server.url) 147 | runner = CliRunner() 148 | result = runner.invoke(httpx.main, [url, "-v", "--auth", "username", "password"]) 149 | print(result.output) 150 | assert result.exit_code == 0 151 | assert remove_date_header(splitlines(result.output)) == [ 152 | "* Connecting to '127.0.0.1'", 153 | "* Connected to '127.0.0.1' on port 8000", 154 | "GET / HTTP/1.1", 155 | f"Host: {server.url.netloc.decode('ascii')}", 156 | "Accept: */*", 157 | "Accept-Encoding: gzip, deflate, br, zstd", 158 | "Connection: keep-alive", 159 | f"User-Agent: python-httpx/{httpx.__version__}", 160 | "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=", 161 | "", 162 | "HTTP/1.1 200 OK", 163 | "server: uvicorn", 164 | "content-type: text/plain", 165 | "Transfer-Encoding: chunked", 166 | "", 167 | "Hello, world!", 168 | ] 169 | 170 | 171 | def test_download(server): 172 | url = str(server.url) 173 | runner = CliRunner() 174 | with runner.isolated_filesystem(): 175 | runner.invoke(httpx.main, [url, "--download", "index.txt"]) 176 | assert os.path.exists("index.txt") 177 | with open("index.txt", "r") as input_file: 178 | assert input_file.read() == "Hello, world!" 179 | 180 | 181 | def test_errors(): 182 | runner = CliRunner() 183 | result = runner.invoke(httpx.main, ["invalid://example.org"]) 184 | assert result.exit_code == 1 185 | assert splitlines(result.output) == [ 186 | "UnsupportedProtocol: Request URL has an unsupported protocol 'invalid://'.", 187 | ] 188 | -------------------------------------------------------------------------------- /tests/test_status_codes.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | 4 | def test_status_code_as_int(): 5 | # mypy doesn't (yet) recognize that IntEnum members are ints, so ignore it here 6 | assert httpx.codes.NOT_FOUND == 404 # type: ignore[comparison-overlap] 7 | assert str(httpx.codes.NOT_FOUND) == "404" 8 | 9 | 10 | def test_status_code_value_lookup(): 11 | assert httpx.codes(404) == 404 12 | 13 | 14 | def test_status_code_phrase_lookup(): 15 | assert httpx.codes["NOT_FOUND"] == 404 16 | 17 | 18 | def test_lowercase_status_code(): 19 | assert httpx.codes.not_found == 404 # type: ignore 20 | 21 | 22 | def test_reason_phrase_for_status_code(): 23 | assert httpx.codes.get_reason_phrase(404) == "Not Found" 24 | 25 | 26 | def test_reason_phrase_for_unknown_status_code(): 27 | assert httpx.codes.get_reason_phrase(499) == "" 28 | -------------------------------------------------------------------------------- /tests/test_timeouts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import httpx 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_read_timeout(server): 8 | timeout = httpx.Timeout(None, read=1e-6) 9 | 10 | async with httpx.AsyncClient(timeout=timeout) as client: 11 | with pytest.raises(httpx.ReadTimeout): 12 | await client.get(server.url.copy_with(path="/slow_response")) 13 | 14 | 15 | @pytest.mark.anyio 16 | async def test_write_timeout(server): 17 | timeout = httpx.Timeout(None, write=1e-6) 18 | 19 | async with httpx.AsyncClient(timeout=timeout) as client: 20 | with pytest.raises(httpx.WriteTimeout): 21 | data = b"*" * 1024 * 1024 * 100 22 | await client.put(server.url.copy_with(path="/slow_response"), content=data) 23 | 24 | 25 | @pytest.mark.anyio 26 | @pytest.mark.network 27 | async def test_connect_timeout(server): 28 | timeout = httpx.Timeout(None, connect=1e-6) 29 | 30 | async with httpx.AsyncClient(timeout=timeout) as client: 31 | with pytest.raises(httpx.ConnectTimeout): 32 | # See https://stackoverflow.com/questions/100841/ 33 | await client.get("http://10.255.255.1/") 34 | 35 | 36 | @pytest.mark.anyio 37 | async def test_pool_timeout(server): 38 | limits = httpx.Limits(max_connections=1) 39 | timeout = httpx.Timeout(None, pool=1e-4) 40 | 41 | async with httpx.AsyncClient(limits=limits, timeout=timeout) as client: 42 | with pytest.raises(httpx.PoolTimeout): 43 | async with client.stream("GET", server.url): 44 | await client.get(server.url) 45 | 46 | 47 | @pytest.mark.anyio 48 | async def test_async_client_new_request_send_timeout(server): 49 | timeout = httpx.Timeout(1e-6) 50 | 51 | async with httpx.AsyncClient(timeout=timeout) as client: 52 | with pytest.raises(httpx.TimeoutException): 53 | await client.send( 54 | httpx.Request("GET", server.url.copy_with(path="/slow_response")) 55 | ) 56 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import random 5 | 6 | import pytest 7 | 8 | import httpx 9 | from httpx._utils import URLPattern, get_environment_proxies 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "encoding", 14 | ( 15 | "utf-32", 16 | "utf-8-sig", 17 | "utf-16", 18 | "utf-8", 19 | "utf-16-be", 20 | "utf-16-le", 21 | "utf-32-be", 22 | "utf-32-le", 23 | ), 24 | ) 25 | def test_encoded(encoding): 26 | content = '{"abc": 123}'.encode(encoding) 27 | response = httpx.Response(200, content=content) 28 | assert response.json() == {"abc": 123} 29 | 30 | 31 | def test_bad_utf_like_encoding(): 32 | content = b"\x00\x00\x00\x00" 33 | response = httpx.Response(200, content=content) 34 | with pytest.raises(json.decoder.JSONDecodeError): 35 | response.json() 36 | 37 | 38 | @pytest.mark.parametrize( 39 | ("encoding", "expected"), 40 | ( 41 | ("utf-16-be", "utf-16"), 42 | ("utf-16-le", "utf-16"), 43 | ("utf-32-be", "utf-32"), 44 | ("utf-32-le", "utf-32"), 45 | ), 46 | ) 47 | def test_guess_by_bom(encoding, expected): 48 | content = '\ufeff{"abc": 123}'.encode(encoding) 49 | response = httpx.Response(200, content=content) 50 | assert response.json() == {"abc": 123} 51 | 52 | 53 | def test_logging_request(server, caplog): 54 | caplog.set_level(logging.INFO) 55 | with httpx.Client() as client: 56 | response = client.get(server.url) 57 | assert response.status_code == 200 58 | 59 | assert caplog.record_tuples == [ 60 | ( 61 | "httpx", 62 | logging.INFO, 63 | 'HTTP Request: GET http://127.0.0.1:8000/ "HTTP/1.1 200 OK"', 64 | ) 65 | ] 66 | 67 | 68 | def test_logging_redirect_chain(server, caplog): 69 | caplog.set_level(logging.INFO) 70 | with httpx.Client(follow_redirects=True) as client: 71 | response = client.get(server.url.copy_with(path="/redirect_301")) 72 | assert response.status_code == 200 73 | 74 | assert caplog.record_tuples == [ 75 | ( 76 | "httpx", 77 | logging.INFO, 78 | "HTTP Request: GET http://127.0.0.1:8000/redirect_301" 79 | ' "HTTP/1.1 301 Moved Permanently"', 80 | ), 81 | ( 82 | "httpx", 83 | logging.INFO, 84 | 'HTTP Request: GET http://127.0.0.1:8000/ "HTTP/1.1 200 OK"', 85 | ), 86 | ] 87 | 88 | 89 | @pytest.mark.parametrize( 90 | ["environment", "proxies"], 91 | [ 92 | ({}, {}), 93 | ({"HTTP_PROXY": "http://127.0.0.1"}, {"http://": "http://127.0.0.1"}), 94 | ( 95 | {"https_proxy": "http://127.0.0.1", "HTTP_PROXY": "https://127.0.0.1"}, 96 | {"https://": "http://127.0.0.1", "http://": "https://127.0.0.1"}, 97 | ), 98 | ({"all_proxy": "http://127.0.0.1"}, {"all://": "http://127.0.0.1"}), 99 | ({"TRAVIS_APT_PROXY": "http://127.0.0.1"}, {}), 100 | ({"no_proxy": "127.0.0.1"}, {"all://127.0.0.1": None}), 101 | ({"no_proxy": "192.168.0.0/16"}, {"all://192.168.0.0/16": None}), 102 | ({"no_proxy": "::1"}, {"all://[::1]": None}), 103 | ({"no_proxy": "localhost"}, {"all://localhost": None}), 104 | ({"no_proxy": "github.com"}, {"all://*github.com": None}), 105 | ({"no_proxy": ".github.com"}, {"all://*.github.com": None}), 106 | ({"no_proxy": "http://github.com"}, {"http://github.com": None}), 107 | ], 108 | ) 109 | def test_get_environment_proxies(environment, proxies): 110 | os.environ.update(environment) 111 | 112 | assert get_environment_proxies() == proxies 113 | 114 | 115 | @pytest.mark.parametrize( 116 | ["pattern", "url", "expected"], 117 | [ 118 | ("http://example.com", "http://example.com", True), 119 | ("http://example.com", "https://example.com", False), 120 | ("http://example.com", "http://other.com", False), 121 | ("http://example.com:123", "http://example.com:123", True), 122 | ("http://example.com:123", "http://example.com:456", False), 123 | ("http://example.com:123", "http://example.com", False), 124 | ("all://example.com", "http://example.com", True), 125 | ("all://example.com", "https://example.com", True), 126 | ("http://", "http://example.com", True), 127 | ("http://", "https://example.com", False), 128 | ("all://", "https://example.com:123", True), 129 | ("", "https://example.com:123", True), 130 | ], 131 | ) 132 | def test_url_matches(pattern, url, expected): 133 | pattern = URLPattern(pattern) 134 | assert pattern.matches(httpx.URL(url)) == expected 135 | 136 | 137 | def test_pattern_priority(): 138 | matchers = [ 139 | URLPattern("all://"), 140 | URLPattern("http://"), 141 | URLPattern("http://example.com"), 142 | URLPattern("http://example.com:123"), 143 | ] 144 | random.shuffle(matchers) 145 | assert sorted(matchers) == [ 146 | URLPattern("http://example.com:123"), 147 | URLPattern("http://example.com"), 148 | URLPattern("http://"), 149 | URLPattern("all://"), 150 | ] 151 | -------------------------------------------------------------------------------- /tests/test_wsgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing 5 | import wsgiref.validate 6 | from functools import partial 7 | from io import StringIO 8 | 9 | import pytest 10 | 11 | import httpx 12 | 13 | if typing.TYPE_CHECKING: # pragma: no cover 14 | from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment 15 | 16 | 17 | def application_factory(output: typing.Iterable[bytes]) -> WSGIApplication: 18 | def application(environ, start_response): 19 | status = "200 OK" 20 | 21 | response_headers = [ 22 | ("Content-type", "text/plain"), 23 | ] 24 | 25 | start_response(status, response_headers) 26 | 27 | for item in output: 28 | yield item 29 | 30 | return wsgiref.validate.validator(application) 31 | 32 | 33 | def echo_body( 34 | environ: WSGIEnvironment, start_response: StartResponse 35 | ) -> typing.Iterable[bytes]: 36 | status = "200 OK" 37 | output = environ["wsgi.input"].read() 38 | 39 | response_headers = [ 40 | ("Content-type", "text/plain"), 41 | ] 42 | 43 | start_response(status, response_headers) 44 | 45 | return [output] 46 | 47 | 48 | def echo_body_with_response_stream( 49 | environ: WSGIEnvironment, start_response: StartResponse 50 | ) -> typing.Iterable[bytes]: 51 | status = "200 OK" 52 | 53 | response_headers = [("Content-Type", "text/plain")] 54 | 55 | start_response(status, response_headers) 56 | 57 | def output_generator(f: typing.IO[bytes]) -> typing.Iterator[bytes]: 58 | while True: 59 | output = f.read(2) 60 | if not output: 61 | break 62 | yield output 63 | 64 | return output_generator(f=environ["wsgi.input"]) 65 | 66 | 67 | def raise_exc( 68 | environ: WSGIEnvironment, 69 | start_response: StartResponse, 70 | exc: type[Exception] = ValueError, 71 | ) -> typing.Iterable[bytes]: 72 | status = "500 Server Error" 73 | output = b"Nope!" 74 | 75 | response_headers = [ 76 | ("Content-type", "text/plain"), 77 | ] 78 | 79 | try: 80 | raise exc() 81 | except exc: 82 | exc_info = sys.exc_info() 83 | start_response(status, response_headers, exc_info) 84 | 85 | return [output] 86 | 87 | 88 | def log_to_wsgi_log_buffer(environ, start_response): 89 | print("test1", file=environ["wsgi.errors"]) 90 | environ["wsgi.errors"].write("test2") 91 | return echo_body(environ, start_response) 92 | 93 | 94 | def test_wsgi(): 95 | transport = httpx.WSGITransport(app=application_factory([b"Hello, World!"])) 96 | client = httpx.Client(transport=transport) 97 | response = client.get("http://www.example.org/") 98 | assert response.status_code == 200 99 | assert response.text == "Hello, World!" 100 | 101 | 102 | def test_wsgi_upload(): 103 | transport = httpx.WSGITransport(app=echo_body) 104 | client = httpx.Client(transport=transport) 105 | response = client.post("http://www.example.org/", content=b"example") 106 | assert response.status_code == 200 107 | assert response.text == "example" 108 | 109 | 110 | def test_wsgi_upload_with_response_stream(): 111 | transport = httpx.WSGITransport(app=echo_body_with_response_stream) 112 | client = httpx.Client(transport=transport) 113 | response = client.post("http://www.example.org/", content=b"example") 114 | assert response.status_code == 200 115 | assert response.text == "example" 116 | 117 | 118 | def test_wsgi_exc(): 119 | transport = httpx.WSGITransport(app=raise_exc) 120 | client = httpx.Client(transport=transport) 121 | with pytest.raises(ValueError): 122 | client.get("http://www.example.org/") 123 | 124 | 125 | def test_wsgi_http_error(): 126 | transport = httpx.WSGITransport(app=partial(raise_exc, exc=RuntimeError)) 127 | client = httpx.Client(transport=transport) 128 | with pytest.raises(RuntimeError): 129 | client.get("http://www.example.org/") 130 | 131 | 132 | def test_wsgi_generator(): 133 | output = [b"", b"", b"Some content", b" and more content"] 134 | transport = httpx.WSGITransport(app=application_factory(output)) 135 | client = httpx.Client(transport=transport) 136 | response = client.get("http://www.example.org/") 137 | assert response.status_code == 200 138 | assert response.text == "Some content and more content" 139 | 140 | 141 | def test_wsgi_generator_empty(): 142 | output = [b"", b"", b"", b""] 143 | transport = httpx.WSGITransport(app=application_factory(output)) 144 | client = httpx.Client(transport=transport) 145 | response = client.get("http://www.example.org/") 146 | assert response.status_code == 200 147 | assert response.text == "" 148 | 149 | 150 | def test_logging(): 151 | buffer = StringIO() 152 | transport = httpx.WSGITransport(app=log_to_wsgi_log_buffer, wsgi_errors=buffer) 153 | client = httpx.Client(transport=transport) 154 | response = client.post("http://www.example.org/", content=b"example") 155 | assert response.status_code == 200 # no errors 156 | buffer.seek(0) 157 | assert buffer.read() == "test1\ntest2" 158 | 159 | 160 | @pytest.mark.parametrize( 161 | "url, expected_server_port", 162 | [ 163 | pytest.param("http://www.example.org", "80", id="auto-http"), 164 | pytest.param("https://www.example.org", "443", id="auto-https"), 165 | pytest.param("http://www.example.org:8000", "8000", id="explicit-port"), 166 | ], 167 | ) 168 | def test_wsgi_server_port(url: str, expected_server_port: str) -> None: 169 | """ 170 | SERVER_PORT is populated correctly from the requested URL. 171 | """ 172 | hello_world_app = application_factory([b"Hello, World!"]) 173 | server_port: str | None = None 174 | 175 | def app(environ, start_response): 176 | nonlocal server_port 177 | server_port = environ["SERVER_PORT"] 178 | return hello_world_app(environ, start_response) 179 | 180 | transport = httpx.WSGITransport(app=app) 181 | client = httpx.Client(transport=transport) 182 | response = client.get(url) 183 | assert response.status_code == 200 184 | assert response.text == "Hello, World!" 185 | assert server_port == expected_server_port 186 | 187 | 188 | def test_wsgi_server_protocol(): 189 | server_protocol = None 190 | 191 | def app(environ, start_response): 192 | nonlocal server_protocol 193 | server_protocol = environ["SERVER_PROTOCOL"] 194 | start_response("200 OK", [("Content-Type", "text/plain")]) 195 | return [b"success"] 196 | 197 | transport = httpx.WSGITransport(app=app) 198 | with httpx.Client(transport=transport, base_url="http://testserver") as client: 199 | response = client.get("/") 200 | 201 | assert response.status_code == 200 202 | assert response.text == "success" 203 | assert server_protocol == "HTTP/1.1" 204 | --------------------------------------------------------------------------------