Initial commit
This commit is contained in:
commit
77589cc6f7
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
10
.github/ISSUE_TEMPLATE/read-only-issues.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/read-only-issues.md
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Read-only issues
|
||||
about: Restricted Zone ⛔️
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Issues on this repository are considered read-only, and currently reserved for the maintenance team.
|
||||
28
.github/workflows/test-suite.yml
vendored
Normal file
28
.github/workflows/test-suite.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
pull_request:
|
||||
branches: ["dev", "version-*"]
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: "Python ${{ matrix.python-version }}"
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
allow-prereleases: true
|
||||
- name: "Install dependencies"
|
||||
run: "scripts/install"
|
||||
- name: "Run tests"
|
||||
run: "scripts/test"
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
*.pyc
|
||||
.coverage
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
dist/
|
||||
venv/
|
||||
build/
|
||||
72
README.md
Normal file
72
README.md
Normal file
@ -0,0 +1,72 @@
|
||||
<p align="center">
|
||||
<img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/butterfly.png" alt='HTTPX'>
|
||||
</p>
|
||||
|
||||
<p align="center"><em>HTTPX 1.0 — Design proposal.</em></p>
|
||||
|
||||
---
|
||||
|
||||
A complete HTTP framework for Python.
|
||||
|
||||
*Installation...*
|
||||
|
||||
```shell
|
||||
$ pip install --pre httpx
|
||||
```
|
||||
|
||||
*Making requests as a client...*
|
||||
|
||||
```python
|
||||
>>> r = httpx.get('https://www.example.org/')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
>>> r.status_code
|
||||
200
|
||||
>>> r.headers['content-type']
|
||||
'text/html; charset=UTF-8'
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
*Serving responses as the server...*
|
||||
|
||||
```python
|
||||
>>> def app(request):
|
||||
... content = httpx.HTML('<html><body>hello, world.</body></html>')
|
||||
... return httpx.Response(200, content=content)
|
||||
|
||||
>>> httpx.run(app)
|
||||
Serving on http://127.0.0.1:8080/ (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Documentation
|
||||
|
||||
The [HTTPX 1.0 design proposal](https://www.encode.io/httpnext/) is now available.
|
||||
|
||||
* [Quickstart](https://www.encode.io/httpnext/quickstart)
|
||||
* [Clients](https://www.encode.io/httpnext/clients)
|
||||
* [Servers](https://www.encode.io/httpnext/servers)
|
||||
* [Requests](https://www.encode.io/httpnext/requests)
|
||||
* [Responses](https://www.encode.io/httpnext/responses)
|
||||
* [URLs](https://www.encode.io/httpnext/urls)
|
||||
* [Headers](https://www.encode.io/httpnext/headers)
|
||||
* [Content Types](https://www.encode.io/httpnext/content-types)
|
||||
* [Connections](https://www.encode.io/httpnext/connections)
|
||||
* [Parsers](https://www.encode.io/httpnext/parsers)
|
||||
* [Network Backends](https://www.encode.io/httpnext/networking)
|
||||
|
||||
---
|
||||
|
||||
# Collaboration
|
||||
|
||||
The repository for this project is currently private.
|
||||
|
||||
We’re looking at creating paid opportunities for working on open source software *which are properly compensated, flexible & well balanced.*
|
||||
|
||||
If you're interested in a position working on this project, please <a href="mailto:kim@encode.io">send an intro</a>.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><i>This provisional design work is <a href="https://github.com/encode/httpnext/blob/master/LICENSE.md">not currently licensed</a> for reuse.<br/>Designed & crafted with care.</i><br/>— 🦋 —</p>
|
||||
19
docs/about.md
Normal file
19
docs/about.md
Normal file
@ -0,0 +1,19 @@
|
||||
# About
|
||||
|
||||
This work is a design proposal for an `httpx` 1.0 release.
|
||||
|
||||
---
|
||||
|
||||
## Sponsorship
|
||||
|
||||
We are currently seeking forward-looking investment that recognises the value of the infrastructure development on it's own merit. Sponsorships may be [made through GitHub](https://github.com/encode).
|
||||
|
||||
We do not offer equity, placements, or endorsments.
|
||||
|
||||
## License
|
||||
|
||||
The rights of the author have been asserted.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><i><a href="https://www.encode.io/httpnext">home</a><i></p>
|
||||
311
docs/clients.md
Normal file
311
docs/clients.md
Normal file
@ -0,0 +1,311 @@
|
||||
# Clients
|
||||
|
||||
HTTP requests are sent by using a `Client` instance. Client instances are thread safe interfaces that maintain a pool of HTTP connections.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> cli = httpx.Client()
|
||||
>>> cli
|
||||
<Client [0 active]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> cli = ahttpx.Client()
|
||||
>>> cli
|
||||
<Client [0 active]>
|
||||
```
|
||||
|
||||
The client representation provides an indication of how many connections are currently in the pool.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r = cli.get("https://www.example.com")
|
||||
>>> r = cli.get("https://www.wikipedia.com")
|
||||
>>> r = cli.get("https://www.theguardian.com/uk")
|
||||
>>> cli
|
||||
<Client [0 active, 3 idle]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r = await cli.get("https://www.example.com")
|
||||
>>> r = await cli.get("https://www.wikipedia.com")
|
||||
>>> r = await cli.get("https://www.theguardian.com/uk")
|
||||
>>> cli
|
||||
<Client [0 active, 3 idle]>
|
||||
```
|
||||
|
||||
The connections in the pool can be explicitly closed, using the `close()` method...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> cli.close()
|
||||
>>> cli
|
||||
<Client [0 active]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> await cli.close()
|
||||
>>> cli
|
||||
<Client [0 active]>
|
||||
```
|
||||
|
||||
Client instances support being used in a context managed scope. You can use this style to enforce properly scoped resources, ensuring that the connection pool is cleanly closed when no longer required.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.Client() as cli:
|
||||
... r = cli.get("https://www.example.com")
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.Client() as cli:
|
||||
... r = await cli.get("https://www.example.com")
|
||||
```
|
||||
|
||||
It is important to scope the use of client instances as widely as possible.
|
||||
|
||||
Typically you should have a single client instance that is used throughout the lifespan of your application. This ensures that connection pooling is maximised, and minmises unneccessary reloading of SSL certificate stores.
|
||||
|
||||
The recommened usage is to *either* a have single global instance created at import time, *or* a single context scoped instance that is passed around wherever it is required.
|
||||
|
||||
## Setting a base URL
|
||||
|
||||
Client instances can be configured with a base URL that is used when constructing requests...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.Client(url="https://www.httpbin.org") as cli:
|
||||
>>> r = cli.get("/json")
|
||||
>>> print(r)
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.Client(url="https://www.httpbin.org") as cli:
|
||||
>>> r = cli.get("/json")
|
||||
>>> print(r)
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
## Setting client headers
|
||||
|
||||
Client instances include a set of headers that are used on every outgoing request.
|
||||
|
||||
The default headers are:
|
||||
|
||||
* `Accept: */*` - Indicates to servers that any media type may be returned.
|
||||
* `Accept-Encoding: gzip` - Indicates to servers that gzip compression may be used on responses.
|
||||
* `Connection: keep-alive` - Indicates that HTTP/1.1 connections should be reused over multiple requests.
|
||||
* `User-Agent: python-httpx/1.0` - Identify the client as `httpx`.
|
||||
|
||||
You can override this behavior by explicitly specifying the default headers...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> headers = {"User-Agent": "dev", "Accept-Encoding": "gzip"}
|
||||
>>> with httpx.Client(headers=headers) as cli:
|
||||
>>> r = cli.get("https://www.example.com/")
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> headers = {"User-Agent": "dev", "Accept-Encoding": "gzip"}
|
||||
>>> async with ahttpx.Client(headers=headers) as cli:
|
||||
>>> r = await cli.get("https://www.example.com/")
|
||||
```
|
||||
|
||||
## Configuring the connection pool
|
||||
|
||||
The connection pool used by the client can be configured in order to customise the SSL context, the maximum number of concurrent connections, or the network backend.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> # Setup an SSL context to allow connecting to improperly configured SSL.
|
||||
>>> no_verify = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
>>> no_verify.check_hostname = False
|
||||
>>> no_verify.verify_mode = ssl.CERT_NONE
|
||||
>>> # Instantiate a client with our custom SSL context.
|
||||
>>> pool = httpx.ConnectionPool(ssl_context=no_verify)
|
||||
>>> with httpx.Client(transport=pool) as cli:
|
||||
>>> ...
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> # Setup an SSL context to allow connecting to improperly configured SSL.
|
||||
>>> no_verify = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
>>> no_verify.check_hostname = False
|
||||
>>> no_verify.verify_mode = ssl.CERT_NONE
|
||||
>>> # Instantiate a client with our custom SSL context.
|
||||
>>> pool = ahttpx.ConnectionPool(ssl_context=no_verify)
|
||||
>>> async with ahttpx.Client(transport=pool) as cli:
|
||||
>>> ...
|
||||
```
|
||||
|
||||
## Sending requests
|
||||
|
||||
* `.request()` - Send an HTTP request, reading the response to completion.
|
||||
* `.stream()` - Send an HTTP request, streaming the response.
|
||||
|
||||
Shortcut methods...
|
||||
|
||||
* `.get()` - Send an HTTP `GET` request.
|
||||
* `.post()` - Send an HTTP `POST` request.
|
||||
* `.put()` - Send an HTTP `PUT` request.
|
||||
* `.delete()` - Send an HTTP `DELETE` request.
|
||||
|
||||
---
|
||||
|
||||
## Transports
|
||||
|
||||
By default requests are sent using the `ConnectionPool` class. Alternative implementations for sending requests can be created by subclassing the `Transport` interface.
|
||||
|
||||
For example, a mock transport class that doesn't make any network requests and instead always returns a fixed response.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
class MockTransport(httpx.Transport):
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
@contextlib.contextmanager
|
||||
def send(self, request):
|
||||
yield response
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
response = httpx.Response(200, content=httpx.Text('Hello, world'))
|
||||
transport = MockTransport(response=response)
|
||||
with httpx.Client(transport=transport) as cli:
|
||||
r = cli.get('https://www.example.com')
|
||||
print(r)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
class MockTransport(ahttpx.Transport):
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
@contextlib.contextmanager
|
||||
def send(self, request):
|
||||
yield response
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
response = ahttpx.Response(200, content=httpx.Text('Hello, world'))
|
||||
transport = MockTransport(response=response)
|
||||
async with ahttpx.Client(transport=transport) as cli:
|
||||
r = await cli.get('https://www.example.com')
|
||||
print(r)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Middleware
|
||||
|
||||
In addition to maintaining an HTTP connection pool, client instances are responsible for two other pieces of functionality...
|
||||
|
||||
* Dealing with HTTP redirects.
|
||||
* Maintaining an HTTP cookie store.
|
||||
|
||||
### `RedirectMiddleware`
|
||||
|
||||
Wraps a transport class, adding support for HTTP redirect handling.
|
||||
|
||||
### `CookieMiddleware`
|
||||
|
||||
Wraps a transport class, adding support for HTTP cookie persistence.
|
||||
|
||||
---
|
||||
|
||||
## Custom client implementations
|
||||
|
||||
The `Client` implementation in `httpx` is intentionally lightweight.
|
||||
|
||||
If you're working with a large codebase you might want to create a custom client implementation in order to constrain the types of request that are sent.
|
||||
|
||||
The following example demonstrates a custom API client that only exposes `GET` and `POST` requests, and always uses JSON payloads.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
class APIClient:
|
||||
def __init__(self):
|
||||
self.url = httpx.URL('https://www.example.com')
|
||||
self.headers = httpx.Headers({
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Connection': 'keep-alive',
|
||||
'User-Agent': 'dev'
|
||||
})
|
||||
self.via = httpx.RedirectMiddleware(httpx.ConnectionPool())
|
||||
|
||||
def get(self, path: str) -> Response:
|
||||
request = httpx.Request(
|
||||
method="GET",
|
||||
url=self.url.join(path),
|
||||
headers=self.headers,
|
||||
)
|
||||
with self.via.send(request) as response:
|
||||
response.read()
|
||||
return response
|
||||
|
||||
def post(self, path: str, payload: Any) -> httpx.Response:
|
||||
request = httpx.Request(
|
||||
method="POST",
|
||||
url=self.url.join(path),
|
||||
headers=self.headers,
|
||||
content=httpx.JSON(payload),
|
||||
)
|
||||
with self.via.send(request) as response:
|
||||
response.read()
|
||||
return response
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
class APIClient:
|
||||
def __init__(self):
|
||||
self.url = ahttpx.URL('https://www.example.com')
|
||||
self.headers = ahttpx.Headers({
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Connection': 'keep-alive',
|
||||
'User-Agent': 'dev'
|
||||
})
|
||||
self.via = ahttpx.RedirectMiddleware(ahttpx.ConnectionPool())
|
||||
|
||||
async def get(self, path: str) -> Response:
|
||||
request = ahttpx.Request(
|
||||
method="GET",
|
||||
url=self.url.join(path),
|
||||
headers=self.headers,
|
||||
)
|
||||
async with self.via.send(request) as response:
|
||||
await response.read()
|
||||
return response
|
||||
|
||||
async def post(self, path: str, payload: Any) -> ahttpx.Response:
|
||||
request = ahttpx.Request(
|
||||
method="POST",
|
||||
url=self.url.join(path),
|
||||
headers=self.headers,
|
||||
content=httpx.JSON(payload),
|
||||
)
|
||||
async with self.via.send(request) as response:
|
||||
await response.read()
|
||||
return response
|
||||
```
|
||||
|
||||
You can expand on this pattern to provide behavior such as request or response schema validation, consistent timeouts, or standardised logging and exception handling.
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Quickstart](quickstart.md)</span>
|
||||
<span class="link-next">[Servers](servers.md) →</span>
|
||||
<span> </span>
|
||||
245
docs/connections.md
Normal file
245
docs/connections.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Connections
|
||||
|
||||
The mechanics of sending HTTP requests is dealt with by the `ConnectionPool` and `Connection` classes.
|
||||
|
||||
We can introspect a `Client` instance to get some visibility onto the state of the connection pool.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.Client() as cli
|
||||
>>> urls = [
|
||||
... "https://www.wikipedia.org/",
|
||||
... "https://www.theguardian.com/",
|
||||
... "https://news.ycombinator.com/",
|
||||
... ]
|
||||
... for url in urls:
|
||||
... cli.get(url)
|
||||
... print(cli.transport)
|
||||
... # <ConnectionPool [3 idle]>
|
||||
... print(cli.transport.connections)
|
||||
... # [
|
||||
... # <Connection "https://www.wikipedia.org/" IDLE>,
|
||||
... # <Connection "https://www.theguardian.com/" IDLE>,
|
||||
... # <Connection "https://news.ycombinator.com/" IDLE>,
|
||||
... # ]
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.Client() as cli
|
||||
>>> urls = [
|
||||
... "https://www.wikipedia.org/",
|
||||
... "https://www.theguardian.com/",
|
||||
... "https://news.ycombinator.com/",
|
||||
... ]
|
||||
... for url in urls:
|
||||
... await cli.get(url)
|
||||
... print(cli.transport)
|
||||
... # <ConnectionPool [3 idle]>
|
||||
... print(cli.transport.connections)
|
||||
... # [
|
||||
... # <Connection "https://www.wikipedia.org/" IDLE>,
|
||||
... # <Connection "https://www.theguardian.com/" IDLE>,
|
||||
... # <Connection "https://news.ycombinator.com/" IDLE>,
|
||||
... # ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Understanding the stack
|
||||
|
||||
The `Client` class is responsible for handling redirects and cookies.
|
||||
|
||||
It also ensures that outgoing requests include a default set of headers such as `User-Agent` and `Accept-Encoding`.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.Client() as cli:
|
||||
>>> r = cli.request("GET", "https://www.example.com/")
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.Client() as cli:
|
||||
>>> r = await cli.request("GET", "https://www.example.com/")
|
||||
```
|
||||
|
||||
The `Client` class sends requests using a `ConnectionPool`, which is responsible for managing a pool of HTTP connections. This ensures quicker and more efficient use of resources than opening and closing a TCP connection with each request. The connection pool also handles HTTP proxying if required.
|
||||
|
||||
A single connection pool is able to handle multiple concurrent requests, with locking in place to ensure that the pool does not become over-saturated.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.ConnectionPool() as pool:
|
||||
>>> r = pool.request("GET", "https://www.example.com/")
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.ConnectionPool() as pool:
|
||||
>>> r = await pool.request("GET", "https://www.example.com/")
|
||||
```
|
||||
|
||||
Individual HTTP connections can be managed directly with the `Connection` class. A single connection can only handle requests sequentially. Locking is provided to ensure that requests are strictly queued sequentially.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.open_connection("https://www.example.com/") as conn:
|
||||
>>> r = conn.request("GET", "/")
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.open_connection("https://www.example.com/") as conn:
|
||||
>>> r = await conn.request("GET", "/")
|
||||
```
|
||||
|
||||
The `NetworkBackend` is responsible for managing the TCP stream, providing a raw byte-wise interface onto the underlying socket.
|
||||
|
||||
---
|
||||
|
||||
## ConnectionPool
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> pool = httpx.ConnectionPool()
|
||||
>>> pool
|
||||
<ConnectionPool [0 active]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> pool = ahttpx.ConnectionPool()
|
||||
>>> pool
|
||||
<ConnectionPool [0 active]>
|
||||
```
|
||||
|
||||
### `.request(method, url, headers=None, content=None)`
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.ConnectionPool() as pool:
|
||||
>>> res = pool.request("GET", "https://www.example.com")
|
||||
>>> res, pool
|
||||
<Response [200 OK]>, <ConnectionPool [1 idle]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.ConnectionPool() as pool:
|
||||
>>> res = await pool.request("GET", "https://www.example.com")
|
||||
>>> res, pool
|
||||
<Response [200 OK]>, <ConnectionPool [1 idle]>
|
||||
```
|
||||
|
||||
### `.stream(method, url, headers=None, content=None)`
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.ConnectionPool() as pool:
|
||||
>>> with pool.stream("GET", "https://www.example.com") as res:
|
||||
>>> res, pool
|
||||
<Response [200 OK]>, <ConnectionPool [1 active]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.ConnectionPool() as pool:
|
||||
>>> async with await pool.stream("GET", "https://www.example.com") as res:
|
||||
>>> res, pool
|
||||
<Response [200 OK]>, <ConnectionPool [1 active]>
|
||||
```
|
||||
|
||||
### `.send(request)`
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.ConnectionPool() as pool:
|
||||
>>> req = httpx.Request("GET", "https://www.example.com")
|
||||
>>> with pool.send(req) as res:
|
||||
>>> res.read()
|
||||
>>> res, pool
|
||||
<Response [200 OK]>, <ConnectionPool [1 idle]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.ConnectionPool() as pool:
|
||||
>>> req = ahttpx.Request("GET", "https://www.example.com")
|
||||
>>> async with await pool.send(req) as res:
|
||||
>>> await res.read()
|
||||
>>> res, pool
|
||||
<Response [200 OK]>, <ConnectionPool [1 idle]>
|
||||
```
|
||||
|
||||
### `.close()`
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.ConnectionPool() as pool:
|
||||
>>> pool.close()
|
||||
<ConnectionPool [0 active]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.ConnectionPool() as pool:
|
||||
>>> await pool.close()
|
||||
<ConnectionPool [0 active]>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection
|
||||
|
||||
*TODO*
|
||||
|
||||
---
|
||||
|
||||
## Protocol upgrades
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
with httpx.open_connection("https://www.example.com/") as conn:
|
||||
with conn.upgrade("GET", "/feed", {"Upgrade": "WebSocket"}) as stream:
|
||||
...
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
async with await ahttpx.open_connection("https://www.example.com/") as conn:
|
||||
async with await conn.upgrade("GET", "/feed", {"Upgrade": "WebSocket"}) as stream:
|
||||
...
|
||||
```
|
||||
|
||||
`<Connection “https://www.example.com/feed” WEBSOCKET>`
|
||||
|
||||
## Proxy `CONNECT` requests
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
with httpx.open_connection("http://127.0.0.1:8080") as conn:
|
||||
with conn.upgrade("CONNECT", "www.encode.io:443") as stream:
|
||||
stream.start_tls(ctx, hostname="www.encode.io")
|
||||
...
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
async with await ahttpx.open_connection("http://127.0.0.1:8080") as conn:
|
||||
async with await conn.upgrade("CONNECT", "www.encode.io:443") as stream:
|
||||
await stream.start_tls(ctx, hostname="www.encode.io")
|
||||
...
|
||||
```
|
||||
|
||||
`<Connection "https://www.encode.io" VIA “http://127.0.0.1:8080” CONNECT>`
|
||||
|
||||
---
|
||||
|
||||
*Describe the `Transport` interface.*
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Streams](streams.md)</span>
|
||||
<span class="link-next">[Parsers](parsers.md) →</span>
|
||||
<span> </span>
|
||||
174
docs/content-types.md
Normal file
174
docs/content-types.md
Normal file
@ -0,0 +1,174 @@
|
||||
# Content Types
|
||||
|
||||
Some HTTP requests including `POST`, `PUT` and `PATCH` can include content in the body of the request.
|
||||
|
||||
The most common content types for upload data are...
|
||||
|
||||
* HTML form submissions use the `application/x-www-form-urlencoded` content type.
|
||||
* HTML form submissions including file uploads use the `multipart/form-data` content type.
|
||||
* JSON data uses the `application/json` content type.
|
||||
|
||||
Content can be included directly in a request by using bytes or a byte iterator and setting the appropriate `Content-Type` header.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> headers = {'Content-Type': 'application/json'}
|
||||
>>> content = json.dumps({"number": 123.5, "bool": [True, False], "text": "hello"})
|
||||
>>> response = cli.put(url, headers=headers, content=content)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> headers = {'Content-Type': 'application/json'}
|
||||
>>> content = json.dumps({"number": 123.5, "bool": [True, False], "text": "hello"})
|
||||
>>> response = await cli.put(url, headers=headers, content=content)
|
||||
```
|
||||
|
||||
There are also several classes provided for setting the request content. These implement either the `Content` or `StreamingContent` API, and handle constructing the content and setting the relevant headers.
|
||||
|
||||
* `<Form {“email”: “heya@noodles.com”}>`
|
||||
* `<Files {“upload”: File("README.md”)}>`
|
||||
* `<File “README.md” [123MB]>`
|
||||
* `<MultiPart {} {“upload”: File("README.md”)}>`
|
||||
* `<JSON {"number": 123.5, "bool": [True, False], ...}>`
|
||||
|
||||
For example, sending a JSON request...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> data = httpx.JSON({"number": 123.5, "bool": [True, False], "text": "hello"})
|
||||
>>> cli.post(url, content=data)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> data = httpx.JSON({"number": 123.5, "bool": [True, False], "text": "hello"})
|
||||
>>> await cli.post(url, content=data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form
|
||||
|
||||
The `Form` class provides an immutable multi-dict for accessing HTML form data. This class implements the `Content` interface, allowing for HTML form uploads.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = httpx.Form({'name': '...'})
|
||||
>>> form
|
||||
...
|
||||
>>> form['name']
|
||||
...
|
||||
>>> res = cli.post(url, content=form)
|
||||
...
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = httpx.Form({'name': '...'})
|
||||
>>> form
|
||||
...
|
||||
>>> form['name']
|
||||
...
|
||||
>>> res = await cli.post(url, content=form)
|
||||
...
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
The `Files` class provides an immutable multi-dict for accessing HTML form file uploads. This class implements the `StreamingContent` interface, allowing for HTML form file uploads.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> files = httpx.Files({'upload': httpx.File('data.json')})
|
||||
>>> files
|
||||
...
|
||||
>>> files['upload']
|
||||
...
|
||||
>>> res = cli.post(url, content=files)
|
||||
...
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> files = httpx.Files({'upload': httpx.File('data.json')})
|
||||
>>> files
|
||||
...
|
||||
>>> files['upload']
|
||||
...
|
||||
>>> res = await cli.post(url, content=files)
|
||||
...
|
||||
```
|
||||
|
||||
## MultiPart
|
||||
|
||||
The `MultiPart` class provides a wrapper for HTML form and files uploads. This class implements the `StreamingContent` interface, allowing for allowing for HTML form uploads including both data and file uploads.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> multipart = httpx.MultiPart(form={'name': '...'}, files={'avatar': httpx.File('image.png')})
|
||||
>>> multipart.form['name']
|
||||
...
|
||||
>>> multipart.files['avatar']
|
||||
...
|
||||
>>> res = cli.post(url, content=multipart)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> multipart = httpx.MultiPart(form={'name': '...'}, files={'avatar': httpx.File('image.png')})
|
||||
>>> multipart.form['name']
|
||||
...
|
||||
>>> multipart.files['avatar']
|
||||
...
|
||||
>>> res = await cli.post(url, content=multipart)
|
||||
```
|
||||
|
||||
## File
|
||||
|
||||
The `File` class provides a wrapper for file uploads, and is used for uploads instead of passing a file object directly.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> file = httpx.File('upload.json')
|
||||
>>> cli.post(url, content=file)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> file = httpx.File('upload.json')
|
||||
>>> await cli.post(url, content=file)
|
||||
```
|
||||
|
||||
## JSON
|
||||
|
||||
The `JSON` class provides a wrapper for JSON uploads. This class implements the `Content` interface, allowing for HTTP JSON uploads.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> data = httpx.JSON({...})
|
||||
>>> cli.put(url, content=data)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> data = httpx.JSON({...})
|
||||
>>> await cli.put(url, content=data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content
|
||||
|
||||
An interface for constructing HTTP content, along with relevant headers.
|
||||
|
||||
The following method must be implemented...
|
||||
|
||||
* `.encode()` - Returns an `httx.Stream` representing the encoded data.
|
||||
* `.content_type()` - Returns a `str` indicating the content type.
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Headers](headers.md)</span>
|
||||
<span class="link-next">[Streams](streams.md) →</span>
|
||||
<span> </span>
|
||||
54
docs/headers.md
Normal file
54
docs/headers.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Headers
|
||||
|
||||
The `Headers` class provides an immutable case-insensitive multidict interface for accessing HTTP headers.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> headers = httpx.Headers({"Accept": "*/*"})
|
||||
>>> headers
|
||||
<Headers {"Accept": "*/*"}>
|
||||
>>> headers['accept']
|
||||
'*/*'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> headers = ahttpx.Headers({"Accept": "*/*"})
|
||||
>>> headers
|
||||
<Headers {"Accept": "*/*"}>
|
||||
>>> headers['accept']
|
||||
'*/*'
|
||||
```
|
||||
|
||||
Header values should always be printable ASCII strings. Attempting to set invalid header name or value strings will raise a `ValueError`.
|
||||
|
||||
### Accessing headers
|
||||
|
||||
Headers are accessed using a standard dictionary style interface...
|
||||
|
||||
* `.get(key, default=None)` - *Return the value for a given key, or a default value. If multiple values for the key are present, only the first will be returned.*
|
||||
* `.keys()` - *Return the unique keys of the headers. Each key will be a `str`.*
|
||||
* `.values()` - *Return the values of the headers. Each value will be a `str`. If multiple values for a key are present, only the first will be returned.*
|
||||
* `.items()` - *Return the key value pairs of the headers. Each item will be a two-tuple `(str, str)`. If multiple values for a key are present, only the first will be returned.*
|
||||
|
||||
The following methods are also available for accessing headers as a multidict...
|
||||
|
||||
* `.get_all(key, comma_delimited=False)` - *Return all the values for a given key. Returned as a list of zero or more `str` instances. If `comma_delimited` is set to `True` then any comma separated header values are split into a list of strings.*
|
||||
* `.multi_items()` - *Return the key value pairs of the headers. Each item will be a two-tuple `(str, str)`. Repeated keys may occur.*
|
||||
* `.multi_dict()` - *Return the headers as a dictionary, with each value being a list of one or more `str` instances.*
|
||||
|
||||
### Modifying headers
|
||||
|
||||
The following methods can be used to create modified header instances...
|
||||
|
||||
* `.copy_set(key, value)` - *Return a new `Headers` instances, setting a header. Eg. `headers = headers.copy_set("Connection": "close")`*.
|
||||
* `.copy_setdefault(key, value)` - *Return a new `Headers` instances, setting a header if it does not yet exist. Eg. `headers = headers.copy_setdefault("Content-Type": "text/html")`*.
|
||||
* `.copy_append(key, value, comma_delimited=False)` - *Return a new `Headers` instances, setting or appending a header. If `comma_delimited` is set to `True`, then the append will be handled using comma delimiting instead of creating a new header. Eg. `headers = headers.copy_append("Accept-Encoding", "gzip", comma_delimited=True)`*.
|
||||
* `.copy_remove(key)` - *Return a new `Headers` instances, removing a header. Eg. `headers = headers.copy_remove("User-Agent")`*.
|
||||
* `.copy_update(headers)` - *Return a new `Headers` instances, updating multiple headers. Eg. `headers = headers.copy_update({"Authorization": "top secret"})`*.
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [URLs](urls.md)</span>
|
||||
<span class="link-next">[Content Types](content-types.md) →</span>
|
||||
<span> </span>
|
||||
BIN
docs/img/butterfly.png
Normal file
BIN
docs/img/butterfly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 668 KiB |
112
docs/index.md
Normal file
112
docs/index.md
Normal file
@ -0,0 +1,112 @@
|
||||
<p align="center">
|
||||
<img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/butterfly.png" alt='HTTPX'>
|
||||
</p>
|
||||
|
||||
<p align="center"><em>HTTPX 1.0 — Prelease.</em></p>
|
||||
|
||||
---
|
||||
|
||||
A complete HTTP toolkit for Python. Supporting both client & server, and available in either sync or async flavors.
|
||||
|
||||
---
|
||||
|
||||
*Installation...*
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .shell .httpx }
|
||||
$ pip install --pre httpx
|
||||
```
|
||||
|
||||
```{ .shell .ahttpx .hidden }
|
||||
$ pip install --pre ahttpx
|
||||
```
|
||||
|
||||
*Making requests as a client...*
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> import httpx
|
||||
|
||||
>>> r = httpx.get('https://www.example.org/')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
>>> r.status_code
|
||||
200
|
||||
>>> r.headers['content-type']
|
||||
'text/html; charset=UTF-8'
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> import ahttpx
|
||||
|
||||
>>> r = await ahttpx.get('https://www.example.org/')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
>>> r.status_code
|
||||
200
|
||||
>>> r.headers['content-type']
|
||||
'text/html; charset=UTF-8'
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
*Serving responses as the server...*
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> import httpx
|
||||
|
||||
>>> def app(request):
|
||||
... content = httpx.HTML('<html><body>hello, world.</body></html>')
|
||||
... return httpx.Response(200, content=content)
|
||||
|
||||
>>> httpx.run(app)
|
||||
Serving on http://127.0.0.1:8080/ (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> import ahttpx
|
||||
|
||||
>>> async def app(request):
|
||||
... content = httpx.HTML('<html><body>hello, world.</body></html>')
|
||||
... return httpx.Response(200, content=content)
|
||||
|
||||
>>> await httpx.run(app)
|
||||
Serving on http://127.0.0.1:8080/ (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Documentation
|
||||
|
||||
* [Quickstart](quickstart.md)
|
||||
* [Clients](clients.md)
|
||||
* [Servers](servers.md)
|
||||
* [Requests](requests.md)
|
||||
* [Responses](responses.md)
|
||||
* [URLs](urls.md)
|
||||
* [Headers](headers.md)
|
||||
* [Content Types](content-types.md)
|
||||
* [Streams](streams.md)
|
||||
* [Connections](connections.md)
|
||||
* [Parsers](parsers.md)
|
||||
* [Network Backends](networking.md)
|
||||
|
||||
---
|
||||
|
||||
# Collaboration
|
||||
|
||||
The repository for this project is currently private.
|
||||
|
||||
We’re looking at creating paid opportunities for working on open source software *which are properly compensated, flexible & well balanced.*
|
||||
|
||||
If you're interested in a position working on this project, please send an intro: *kim@encode.io*
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><i>This design work is <a href="https://www.encode.io/httpnext/about">not yet licensed</a> for reuse.</i><br/>— 🦋 —</p>
|
||||
381
docs/networking.md
Normal file
381
docs/networking.md
Normal file
@ -0,0 +1,381 @@
|
||||
# Network Backends
|
||||
|
||||
The lowest level network abstractions in `httpx` are the `NetworkBackend` and `NetworkStream` classes. These provide a consistent interface onto the operations for working with a network stream, typically over a TCP connection. Different runtimes (threaded, trio & asyncio) are supported via alternative implementations of the core interface.
|
||||
|
||||
---
|
||||
|
||||
## `NetworkBackend()`
|
||||
|
||||
The default backend is instantiated via the `NetworkBackend` class...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> net = httpx.NetworkBackend()
|
||||
>>> net
|
||||
<NetworkBackend [threaded]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> net = ahttpx.NetworkBackend()
|
||||
>>> net
|
||||
<NetworkBackend [asyncio]>
|
||||
```
|
||||
|
||||
### `.connect(host, port)`
|
||||
|
||||
A TCP stream is created using the `connect` method...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> net = httpx.NetworkBackend()
|
||||
>>> stream = net.connect("www.encode.io", 80)
|
||||
>>> stream
|
||||
<NetworkStream ["168.0.0.1:80"]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> net = ahttpx.NetworkBackend()
|
||||
>>> stream = await net.connect("www.encode.io", 80)
|
||||
>>> stream
|
||||
<NetworkStream ["168.0.0.1:80"]>
|
||||
```
|
||||
|
||||
Streams support being used in a context managed style. The cleanest approach to resource management is to use `.connect(...)` in the context of a `with` block.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> net = httpx.NetworkBackend()
|
||||
>>> with net.connect("dev.encode.io", 80) as stream:
|
||||
>>> ...
|
||||
>>> stream
|
||||
<NetworkStream ["168.0.0.1:80" CLOSED]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> net = ahttpx.NetworkBackend()
|
||||
>>> async with await net.connect("dev.encode.io", 80) as stream:
|
||||
>>> ...
|
||||
>>> stream
|
||||
<NetworkStream ["168.0.0.1:80" CLOSED]>
|
||||
```
|
||||
|
||||
## `NetworkStream(sock)`
|
||||
|
||||
The `NetworkStream` class provides TCP stream abstraction, by providing a thin wrapper around a socket instance.
|
||||
|
||||
Network streams do not provide any built-in thread or task locking.
|
||||
Within `httpx` thread and task saftey is handled at the `Connection` layer.
|
||||
|
||||
### `.read(max_bytes=None)`
|
||||
|
||||
Read up to `max_bytes` bytes of data from the network stream.
|
||||
If no limit is provided a default value of 64KB will be used.
|
||||
|
||||
### `.write(data)`
|
||||
|
||||
Write the given bytes of `data` to the network stream.
|
||||
|
||||
### `.start_tls(ctx, hostname)`
|
||||
|
||||
Upgrade a stream to TLS (SSL) connection for sending secure `https://` requests.
|
||||
|
||||
`<NetworkStream [“168.0.0.1:443” TLS]>`
|
||||
|
||||
### `.get_extra_info(key)`
|
||||
|
||||
Return information about the underlying resource. May include...
|
||||
|
||||
* `"client_addr"` - Return the client IP and port.
|
||||
* `"server_addr"` - Return the server IP and port.
|
||||
* `"ssl_object"` - Return an `ssl.SSLObject` instance.
|
||||
* `"socket"` - Access the raw socket instance.
|
||||
|
||||
### `.close()`
|
||||
|
||||
Close the network stream. For TLS streams this will attempt to send a closing handshake before terminating the conmection.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> net = httpx.NetworkBackend()
|
||||
>>> stream = net.connect("dev.encode.io", 80)
|
||||
>>> try:
|
||||
>>> ...
|
||||
>>> finally:
|
||||
>>> stream.close()
|
||||
>>> stream
|
||||
<NetworkStream ["168.0.0.1:80" CLOSED]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> net = ahttpx.NetworkBackend()
|
||||
>>> stream = await net.connect("dev.encode.io", 80)
|
||||
>>> try:
|
||||
>>> ...
|
||||
>>> finally:
|
||||
>>> await stream.close()
|
||||
>>> stream
|
||||
<NetworkStream ["168.0.0.1:80" CLOSED]>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeouts
|
||||
|
||||
Network timeouts are handled using a context block API.
|
||||
|
||||
This [design approach](https://vorpus.org/blog/timeouts-and-cancellation-for-humans) avoids timeouts needing to passed around throughout the stack, and provides an obvious and natural API to dealing with timeout contexts.
|
||||
|
||||
### timeout(duration)
|
||||
|
||||
The timeout context manager can be used to wrap socket operations anywhere in the stack.
|
||||
|
||||
Here's an example of enforcing an overall 3 second timeout on a request.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.Client() as cli:
|
||||
>>> with httpx.timeout(3.0):
|
||||
>>> res = cli.get('https://www.example.com')
|
||||
>>> print(res)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.Client() as cli:
|
||||
>>> async with ahttpx.timeout(3.0):
|
||||
>>> res = await cli.get('https://www.example.com')
|
||||
>>> print(res)
|
||||
```
|
||||
|
||||
Timeout contexts provide an API allowing for deadlines to be cancelled.
|
||||
|
||||
### .cancel()
|
||||
|
||||
In this example we enforce a 3 second timeout on *receiving the start of* a streaming HTTP response...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.Client() as cli:
|
||||
>>> with httpx.timeout(3.0) as t:
|
||||
>>> with cli.stream('https://www.example.com') as r:
|
||||
>>> t.cancel()
|
||||
>>> print(">>>", res)
|
||||
>>> for chunk in r.stream:
|
||||
>>> print("...", chunk)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.Client() as cli:
|
||||
>>> async with ahttpx.timeout(3.0) as t:
|
||||
>>> async with await cli.stream('https://www.example.com') as r:
|
||||
>>> t.cancel()
|
||||
>>> print(">>>", res)
|
||||
>>> async for chunk in r.stream:
|
||||
>>> print("...", chunk)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sending HTTP requests
|
||||
|
||||
Let's take a look at how we can work directly with a network backend to send an HTTP request, and recieve an HTTP response.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
import httpx
|
||||
import ssl
|
||||
import truststore
|
||||
|
||||
net = httpx.NetworkBackend()
|
||||
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
req = b'\r\n'.join([
|
||||
b'GET / HTTP/1.1',
|
||||
b'Host: www.example.com',
|
||||
b'User-Agent: python/dev',
|
||||
b'Connection: close',
|
||||
b'',
|
||||
b'',
|
||||
])
|
||||
|
||||
# Use a 10 second overall timeout for the entire request/response.
|
||||
with httpx.timeout(10.0):
|
||||
# Use a 3 second timeout for the initial connection.
|
||||
with httpx.timeout(3.0) as t:
|
||||
# Open the connection & establish SSL.
|
||||
with net.connect("www.example.com", 443) as stream:
|
||||
stream.start_tls(ctx, hostname="www.example.com")
|
||||
t.cancel()
|
||||
# Send the request & read the response.
|
||||
stream.write(req)
|
||||
buffer = []
|
||||
while part := stream.read():
|
||||
buffer.append(part)
|
||||
resp = b''.join(buffer)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
import ahttpx
|
||||
import ssl
|
||||
import truststore
|
||||
|
||||
net = ahttpx.NetworkBackend()
|
||||
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
req = b'\r\n'.join([
|
||||
b'GET / HTTP/1.1',
|
||||
b'Host: www.example.com',
|
||||
b'User-Agent: python/dev',
|
||||
b'Connection: close',
|
||||
b'',
|
||||
b'',
|
||||
])
|
||||
|
||||
# Use a 10 second overall timeout for the entire request/response.
|
||||
async with ahttpx.timeout(10.0):
|
||||
# Use a 3 second timeout for the initial connection.
|
||||
async with ahttpx.timeout(3.0) as t:
|
||||
# Open the connection & establish SSL.
|
||||
async with await net.connect("www.example.com", 443) as stream:
|
||||
await stream.start_tls(ctx, hostname="www.example.com")
|
||||
t.cancel()
|
||||
# Send the request & read the response.
|
||||
await stream.write(req)
|
||||
buffer = []
|
||||
while part := await stream.read():
|
||||
buffer.append(part)
|
||||
resp = b''.join(buffer)
|
||||
```
|
||||
|
||||
The example above is somewhat contrived, there's no HTTP parsing implemented so we can't actually determine when the response is complete. We're using a `Connection: close` header to request that the server close the connection once the response is complete.
|
||||
|
||||
A more complete example would require proper HTTP parsing. The `Connection` class implements an HTTP request/response interface, layered over a `NetworkStream`.
|
||||
|
||||
---
|
||||
|
||||
## Custom network backends
|
||||
|
||||
The interface for implementing custom network backends is provided by two classes...
|
||||
|
||||
### `NetworkBackendInterface`
|
||||
|
||||
The abstract interface implemented by `NetworkBackend`. See above for details.
|
||||
|
||||
### `NetworkStreamInterface`
|
||||
|
||||
The abstract interface implemented by `NetworkStream`. See above for details.
|
||||
|
||||
### An example backend
|
||||
|
||||
We can use these interfaces to implement custom functionality. For example, here we're providing a network backend that logs all the ingoing and outgoing bytes.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
class RecordingBackend(httpx.NetworkBackendInterface):
|
||||
def __init__(self):
|
||||
self._backend = NetworkBackend()
|
||||
|
||||
def connect(self, host, port):
|
||||
# Delegate creating connections to the default
|
||||
# network backend, and return a wrapped stream.
|
||||
stream = self._backend.connect(host, port)
|
||||
return RecordingStream(stream)
|
||||
|
||||
|
||||
class RecordingStream(httpx.NetworkStreamInterface):
|
||||
def __init__(self, stream):
|
||||
self._stream = stream
|
||||
|
||||
def read(self, max_bytes: int = None):
|
||||
# Print all incoming data to the terminal.
|
||||
data = self._stream.read(max_bytes)
|
||||
lines = data.decode('ascii', errors='replace').splitlines()
|
||||
for line in lines:
|
||||
print("<<< ", line)
|
||||
return data
|
||||
|
||||
def write(self, data):
|
||||
# Print all outgoing data to the terminal.
|
||||
lines = data.decode('ascii', errors='replace').splitlines()
|
||||
for line in lines:
|
||||
print(">>> ", line)
|
||||
self._stream.write(data)
|
||||
|
||||
def start_tls(ctx, hostname):
|
||||
self._stream.start_tls(ctx, hostname)
|
||||
|
||||
def get_extra_info(key):
|
||||
return self._stream.get_extra_info(key)
|
||||
|
||||
def close():
|
||||
self._stream.close()
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
class RecordingBackend(ahhtpx.NetworkBackendInterface):
|
||||
def __init__(self):
|
||||
self._backend = NetworkBackend()
|
||||
|
||||
async def connect(self, host, port):
|
||||
# Delegate creating connections to the default
|
||||
# network backend, and return a wrapped stream.
|
||||
stream = await self._backend.connect(host, port)
|
||||
return RecordingStream(stream)
|
||||
|
||||
|
||||
class RecordingStream(ahttpx.NetworkStreamInterface):
|
||||
def __init__(self, stream):
|
||||
self._stream = stream
|
||||
|
||||
async def read(self, max_bytes: int = None):
|
||||
# Print all incoming data to the terminal.
|
||||
data = await self._stream.read(max_bytes)
|
||||
lines = data.decode('ascii', errors='replace').splitlines()
|
||||
for line in lines:
|
||||
print("<<< ", line)
|
||||
return data
|
||||
|
||||
async def write(self, data):
|
||||
# Print all outgoing data to the terminal.
|
||||
lines = data.decode('ascii', errors='replace').splitlines()
|
||||
for line in lines:
|
||||
print(">>> ", line)
|
||||
await self._stream.write(data)
|
||||
|
||||
async def start_tls(ctx, hostname):
|
||||
await self._stream.start_tls(ctx, hostname)
|
||||
|
||||
def get_extra_info(key):
|
||||
return self._stream.get_extra_info(key)
|
||||
|
||||
async def close():
|
||||
await self._stream.close()
|
||||
```
|
||||
|
||||
We can now instantiate a client using this network backend.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> transport = httpx.ConnectionPool(backend=RecordingBackend())
|
||||
>>> cli = httpx.Client(transport=transport)
|
||||
>>> cli.get('https://www.example.com')
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> transport = ahttpx.ConnectionPool(backend=RecordingBackend())
|
||||
>>> cli = ahttpx.Client(transport=transport)
|
||||
>>> await cli.get('https://www.example.com')
|
||||
```
|
||||
|
||||
Custom network backends can also be used to provide functionality such as handling DNS caching for name lookups, or connecting via a UNIX domain socket instead of a TCP connection.
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Parsers](parsers.md)</span>
|
||||
<span> </span>
|
||||
110
docs/parsers.md
Normal file
110
docs/parsers.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Parsers
|
||||
|
||||
### Client
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
stream = httpx.DuplexStream(
|
||||
b'HTTP/1.1 200 OK\r\n'
|
||||
b'Content-Length: 23\r\n'
|
||||
b'Content-Type: application/json\r\n'
|
||||
b'\r\n'
|
||||
b'{"msg": "hello, world"}'
|
||||
)
|
||||
p = ahttpx.HTTPParser(stream, mode='CLIENT')
|
||||
|
||||
# Send the request...
|
||||
p.send_method_line(b'GET', b'/', b'HTTP/1.1')
|
||||
p.send_headers([(b'Host', b'www.example.com')])
|
||||
p.send_body(b'')
|
||||
|
||||
# Receive the response...
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = b''
|
||||
while buffer := p.recv_body():
|
||||
body += buffer
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
stream = ahttpx.DuplexStream(
|
||||
b'HTTP/1.1 200 OK\r\n'
|
||||
b'Content-Length: 23\r\n'
|
||||
b'Content-Type: application/json\r\n'
|
||||
b'\r\n'
|
||||
b'{"msg": "hello, world"}'
|
||||
)
|
||||
p = ahttpx.HTTPParser(stream, mode='CLIENT')
|
||||
|
||||
# Send the request...
|
||||
await p.send_method_line(b'GET', b'/', b'HTTP/1.1')
|
||||
await p.send_headers([(b'Host', b'www.example.com')])
|
||||
await p.send_body(b'')
|
||||
|
||||
# Receive the response...
|
||||
protocol, code, reason_phase = await p.recv_status_line()
|
||||
headers = await p.recv_headers()
|
||||
body = b''
|
||||
while buffer := await p.recv_body():
|
||||
body += buffer
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
stream = httpx.DuplexStream(
|
||||
b'GET / HTTP/1.1\r\n'
|
||||
b'Host: www.example.com\r\n'
|
||||
b'\r\n'
|
||||
)
|
||||
p = httpx.HTTPParser(stream, mode='SERVER')
|
||||
|
||||
# Receive the request...
|
||||
method, target, protocol = p.recv_method_line()
|
||||
headers = p.recv_headers()
|
||||
body = b''
|
||||
while buffer := p.recv_body():
|
||||
body += buffer
|
||||
|
||||
# Send the response...
|
||||
p.send_status_line(b'HTTP/1.1', 200, b'OK')
|
||||
p.send_headers([
|
||||
(b'Content-Length', b'23'),
|
||||
(b'Content-Type', b'application/json')
|
||||
])
|
||||
p.send_body(b'{"msg": "hello, world"}')
|
||||
p.send_body(b'')
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
stream = ahttpx.DuplexStream(
|
||||
b'GET / HTTP/1.1\r\n'
|
||||
b'Host: www.example.com\r\n'
|
||||
b'\r\n'
|
||||
)
|
||||
p = ahttpx.HTTPParser(stream, mode='SERVER')
|
||||
|
||||
# Receive the request...
|
||||
method, target, protocol = await p.recv_method_line()
|
||||
headers = await p.recv_headers()
|
||||
body = b''
|
||||
while buffer := await p.recv_body():
|
||||
body += buffer
|
||||
|
||||
# Send the response...
|
||||
await p.send_status_line(b'HTTP/1.1', 200, b'OK')
|
||||
await p.send_headers([
|
||||
(b'Content-Length', b'23'),
|
||||
(b'Content-Type', b'application/json')
|
||||
])
|
||||
await p.send_body(b'{"msg": "hello, world"}')
|
||||
await p.send_body(b'')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Connections](connections.md)</span>
|
||||
<span class="link-next">[Low Level Networking](networking.md) →</span>
|
||||
484
docs/quickstart.md
Normal file
484
docs/quickstart.md
Normal file
@ -0,0 +1,484 @@
|
||||
# QuickStart
|
||||
|
||||
Install using ...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .shell .httpx }
|
||||
$ pip install --pre httpx
|
||||
```
|
||||
|
||||
```{ .shell .ahttpx .hidden }
|
||||
$ pip install --pre ahttpx
|
||||
```
|
||||
|
||||
First, start by importing `httpx`...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> import httpx
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> import ahttpx
|
||||
```
|
||||
|
||||
Now, let’s try to get a webpage.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r = httpx.get('https://httpbin.org/get')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r = await ahttpx.get('https://httpbin.org/get')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
To make an HTTP `POST` request, including some content...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = httpx.Form({'key': 'value'})
|
||||
>>> r = httpx.post('https://httpbin.org/post', content=form)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = httpx.Form({'key': 'value'})
|
||||
>>> r = await ahttpx.post('https://httpbin.org/post', content=form)
|
||||
```
|
||||
|
||||
Shortcut methods for `PUT`, `PATCH`, and `DELETE` requests follow the same style...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r = httpx.put('https://httpbin.org/put', content=form)
|
||||
>>> r = httpx.patch('https://httpbin.org/patch', content=form)
|
||||
>>> r = httpx.delete('https://httpbin.org/delete')
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r = await ahttpx.put('https://httpbin.org/put', content=form)
|
||||
>>> r = await ahttpx.patch('https://httpbin.org/patch', content=form)
|
||||
>>> r = await ahttpx.delete('https://httpbin.org/delete')
|
||||
```
|
||||
|
||||
## Passing Parameters in URLs
|
||||
|
||||
To include URL query parameters in the request, construct a URL using the `params` keyword...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = {'key1': 'value1', 'key2': 'value2'}
|
||||
>>> url = httpx.URL('https://httpbin.org/get', params=params)
|
||||
>>> r = httpx.get(url)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = {'key1': 'value1', 'key2': 'value2'}
|
||||
>>> url = ahttpx.URL('https://httpbin.org/get', params=params)
|
||||
>>> r = await ahttpx.get(url)
|
||||
```
|
||||
|
||||
You can also pass a list of items as a value...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = {'key1': 'value1', 'key2': ['value2', 'value3']}
|
||||
>>> url = httpx.URL('https://httpbin.org/get', params=params)
|
||||
>>> r = httpx.get(url)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = {'key1': 'value1', 'key2': ['value2', 'value3']}
|
||||
>>> url = ahttpx.URL('https://httpbin.org/get', params=params)
|
||||
>>> r = await ahttpx.get(url)
|
||||
```
|
||||
|
||||
## Custom Headers
|
||||
|
||||
To include additional headers in the outgoing request, use the `headers` keyword argument...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> url = 'https://httpbin.org/headers'
|
||||
>>> headers = {'User-Agent': 'my-app/0.0.1'}
|
||||
>>> r = httpx.get(url, headers=headers)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> url = 'https://httpbin.org/headers'
|
||||
>>> headers = {'User-Agent': 'my-app/0.0.1'}
|
||||
>>> r = await ahttpx.get(url, headers=headers)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response Content
|
||||
|
||||
HTTPX will automatically handle decoding the response content into unicode text.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r = httpx.get('https://www.example.org/')
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r = await ahttpx.get('https://www.example.org/')
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
## Binary Response Content
|
||||
|
||||
The response content can also be accessed as bytes, for non-text responses.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r.body
|
||||
b'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r.body
|
||||
b'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
## JSON Response Content
|
||||
|
||||
Often Web API responses will be encoded as JSON.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r = httpx.get('https://httpbin.org/get')
|
||||
>>> r.json()
|
||||
{'args': {}, 'headers': {'Host': 'httpbin.org', 'User-Agent': 'dev', 'X-Amzn-Trace-Id': 'Root=1-679814d5-0f3d46b26686f5013e117085'}, 'origin': '21.35.60.128', 'url': 'https://httpbin.org/get'}
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r = await ahttpx.get('https://httpbin.org/get')
|
||||
>>> await r.json()
|
||||
{'args': {}, 'headers': {'Host': 'httpbin.org', 'User-Agent': 'dev', 'X-Amzn-Trace-Id': 'Root=1-679814d5-0f3d46b26686f5013e117085'}, 'origin': '21.35.60.128', 'url': 'https://httpbin.org/get'}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sending Form Encoded Data
|
||||
|
||||
Some types of HTTP requests, such as `POST` and `PUT` requests, can include data in the request body. One common way of including that is as form-encoded data, which is used for HTML forms.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = httpx.Form({'key1': 'value1', 'key2': 'value2'})
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=form)
|
||||
>>> r.json()
|
||||
{
|
||||
...
|
||||
"form": {
|
||||
"key2": "value2",
|
||||
"key1": "value1"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = ahttpx.Form({'key1': 'value1', 'key2': 'value2'})
|
||||
>>> r = await ahttpx.post("https://httpbin.org/post", content=form)
|
||||
>>> await r.json()
|
||||
{
|
||||
...
|
||||
"form": {
|
||||
"key2": "value2",
|
||||
"key1": "value1"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Form encoded data can also include multiple values from a given key.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = httpx.Form({'key1': ['value1', 'value2']})
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=form)
|
||||
>>> r.json()
|
||||
{
|
||||
...
|
||||
"form": {
|
||||
"key1": [
|
||||
"value1",
|
||||
"value2"
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = ahttpx.Form({'key1': ['value1', 'value2']})
|
||||
>>> r = await ahttpx.post("https://httpbin.org/post", content=form)
|
||||
>>> await r.json()
|
||||
{
|
||||
...
|
||||
"form": {
|
||||
"key1": [
|
||||
"value1",
|
||||
"value2"
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Multipart File Uploads
|
||||
|
||||
You can also upload files, using HTTP multipart encoding.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> files = httpx.Files({'upload': httpx.File('uploads/report.xls')})
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=files)
|
||||
>>> r.json()
|
||||
{
|
||||
...
|
||||
"files": {
|
||||
"upload": "<... binary content ...>"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> files = ahttpx.Files({'upload': httpx.File('uploads/report.xls')})
|
||||
>>> r = await ahttpx.post("https://httpbin.org/post", content=files)
|
||||
>>> await r.json()
|
||||
{
|
||||
...
|
||||
"files": {
|
||||
"upload": "<... binary content ...>"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
If you need to include non-file data fields in the multipart form, use the `data=...` parameter:
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = {'message': 'Hello, world!'}
|
||||
>>> files = {'upload': httpx.File('uploads/report.xls')}
|
||||
>>> data = httpx.MultiPart(form=form, files=files)
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=data)
|
||||
>>> r.json()
|
||||
{
|
||||
...
|
||||
"files": {
|
||||
"upload": "<... binary content ...>"
|
||||
},
|
||||
"form": {
|
||||
"message": "Hello, world!",
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = {'message': 'Hello, world!'}
|
||||
>>> files = {'upload': httpx.File('uploads/report.xls')}
|
||||
>>> data = ahttpx.MultiPart(form=form, files=files)
|
||||
>>> r = await ahttpx.post("https://httpbin.org/post", content=data)
|
||||
>>> await r.json()
|
||||
{
|
||||
...
|
||||
"files": {
|
||||
"upload": "<... binary content ...>"
|
||||
},
|
||||
"form": {
|
||||
"message": "Hello, world!",
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Sending JSON Encoded Data
|
||||
|
||||
Form encoded data is okay if all you need is a simple key-value data structure.
|
||||
For more complicated data structures you'll often want to use JSON encoding instead.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> data = {'integer': 123, 'boolean': True, 'list': ['a', 'b', 'c']}
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=httpx.JSON(data))
|
||||
>>> r.json()
|
||||
{
|
||||
...
|
||||
"json": {
|
||||
"boolean": true,
|
||||
"integer": 123,
|
||||
"list": [
|
||||
"a",
|
||||
"b",
|
||||
"c"
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> data = {'integer': 123, 'boolean': True, 'list': ['a', 'b', 'c']}
|
||||
>>> r = await ahttpx.post("https://httpbin.org/post", content=httpx.JSON(data))
|
||||
>>> await r.json()
|
||||
{
|
||||
...
|
||||
"json": {
|
||||
"boolean": true,
|
||||
"integer": 123,
|
||||
"list": [
|
||||
"a",
|
||||
"b",
|
||||
"c"
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Binary Request Data
|
||||
|
||||
For other encodings, you should use the `content=...` parameter, passing
|
||||
either a `bytes` type or a generator that yields `bytes`.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> content = b'Hello, world'
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=content)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> content = b'Hello, world'
|
||||
>>> r = await ahttpx.post("https://httpbin.org/post", content=content)
|
||||
```
|
||||
|
||||
You may also want to set a custom `Content-Type` header when uploading
|
||||
binary data.
|
||||
|
||||
---
|
||||
|
||||
## Response Status Codes
|
||||
|
||||
We can inspect the HTTP status code of the response:
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r = httpx.get('https://httpbin.org/get')
|
||||
>>> r.status_code
|
||||
200
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r = await ahttpx.get('https://httpbin.org/get')
|
||||
>>> r.status_code
|
||||
200
|
||||
```
|
||||
|
||||
## Response Headers
|
||||
|
||||
The response headers are available as a dictionary-like interface.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r.headers
|
||||
<Headers {
|
||||
'Content-Encoding': 'gzip',
|
||||
'Connection': 'close',
|
||||
'Server': 'nginx/1.0.4',
|
||||
'ETag': 'e1ca502697e5c9317743dc078f67693f',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': 2126,
|
||||
}>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r.headers
|
||||
<Headers {
|
||||
'Content-Encoding': 'gzip',
|
||||
'Connection': 'close',
|
||||
'Server': 'nginx/1.0.4',
|
||||
'ETag': 'e1ca502697e5c9317743dc078f67693f',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': 2126,
|
||||
}>
|
||||
```
|
||||
|
||||
The `Headers` data type is case-insensitive, so you can use any capitalization.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> r.headers.get('Content-Type')
|
||||
'application/json'
|
||||
|
||||
>>> r.headers.get('content-type')
|
||||
'application/json'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> r.headers.get('Content-Type')
|
||||
'application/json'
|
||||
|
||||
>>> r.headers.get('content-type')
|
||||
'application/json'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
For large downloads you may want to use streaming responses that do not load the entire response body into memory at once.
|
||||
|
||||
You can stream the binary content of the response...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||
... for data in r.stream:
|
||||
... print(data)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> async with ahttpx.stream("GET", "https://www.example.com") as r:
|
||||
... async for data in r.stream:
|
||||
... print(data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Home](index.md)</span>
|
||||
<span class="link-next">[Clients](clients.md) →</span>
|
||||
<span> </span>
|
||||
178
docs/requests.md
Normal file
178
docs/requests.md
Normal file
@ -0,0 +1,178 @@
|
||||
# Requests
|
||||
|
||||
The core elements of an HTTP request are the `method`, `url`, `headers` and `body`.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> req = httpx.Request('GET', 'https://www.example.com/')
|
||||
>>> req
|
||||
<Request [GET 'https://www.example.com/']>
|
||||
>>> req.method
|
||||
'GET'
|
||||
>>> req.url
|
||||
<URL 'https://www.example.com/'>
|
||||
>>> req.headers
|
||||
<Headers {'Host': 'www.example.com'}>
|
||||
>>> req.body
|
||||
b''
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> req = ahttpx.Request('GET', 'https://www.example.com/')
|
||||
>>> req
|
||||
<Request [GET 'https://www.example.com/']>
|
||||
>>> req.method
|
||||
'GET'
|
||||
>>> req.url
|
||||
<URL 'https://www.example.com/'>
|
||||
>>> req.headers
|
||||
<Headers {'Host': 'www.example.com'}>
|
||||
>>> req.body
|
||||
b''
|
||||
```
|
||||
|
||||
## Working with the request headers
|
||||
|
||||
The following headers have automatic behavior with `Requests` instances...
|
||||
|
||||
* `Host` - A `Host` header must always be included on a request. This header is automatically populated from the `url`, using the `url.netloc` property.
|
||||
* `Content-Length` - Requests including a request body must always include either a `Content-Length` header or a `Transfer-Encoding: chunked` header. This header is automatically populated if `content` is not `None` and the content is a known size.
|
||||
* `Transfer-Encoding` - Requests automatically include a `Transfer-Encoding: chunked` header if `content` is not `None` and the content is an unkwown size.
|
||||
* `Content-Type` - Requests automatically include a `Content-Type` header if `content` is set using the [Content Type] API.
|
||||
|
||||
## Working with the request body
|
||||
|
||||
Including binary data directly...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> headers = {'Content-Type': 'application/json'}
|
||||
>>> content = json.dumps(...)
|
||||
>>> httpx.Request('POST', 'https://echo.encode.io/', content=content)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> headers = {'Content-Type': 'application/json'}
|
||||
>>> content = json.dumps(...)
|
||||
>>> ahttpx.Request('POST', 'https://echo.encode.io/', content=content)
|
||||
```
|
||||
|
||||
## Working with content types
|
||||
|
||||
Including JSON request content...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> data = httpx.JSON(...)
|
||||
>>> httpx.Request('POST', 'https://echo.encode.io/', content=data)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> data = ahttpx.JSON(...)
|
||||
>>> ahttpx.Request('POST', 'https://echo.encode.io/', content=data)
|
||||
```
|
||||
|
||||
Including form encoded request content...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> data = httpx.Form(...)
|
||||
>>> httpx.Request('PUT', 'https://echo.encode.io/', content=data)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> data = ahttpx.Form(...)
|
||||
>>> ahttpx.Request('PUT', 'https://echo.encode.io/', content=data)
|
||||
```
|
||||
|
||||
Including multipart file uploads...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = httpx.MultiPart(form={...}, files={...})
|
||||
>>> with httpx.Request('POST', 'https://echo.encode.io/', content=form) as req:
|
||||
>>> req.headers
|
||||
{...}
|
||||
>>> req.stream
|
||||
<MultiPartStream [0% of ...MB]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = ahttpx.MultiPart(form={...}, files={...})
|
||||
>>> async with ahttpx.Request('POST', 'https://echo.encode.io/', content=form) as req:
|
||||
>>> req.headers
|
||||
{...}
|
||||
>>> req.stream
|
||||
<MultiPartStream [0% of ...MB]>
|
||||
```
|
||||
|
||||
Including direct file uploads...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> file = httpx.File('upload.json')
|
||||
>>> with httpx.Request('POST', 'https://echo.encode.io/', content=file) as req:
|
||||
>>> req.headers
|
||||
{...}
|
||||
>>> req.stream
|
||||
<FileStream [0% of ...MB]>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> file = ahttpx.File('upload.json')
|
||||
>>> async with ahttpx.Request('POST', 'https://echo.encode.io/', content=file) as req:
|
||||
>>> req.headers
|
||||
{...}
|
||||
>>> req.stream
|
||||
<FileStream [0% of ...MB]>
|
||||
```
|
||||
|
||||
## Accessing request content
|
||||
|
||||
*In progress...*
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> data = request.json()
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> data = await request.json()
|
||||
```
|
||||
|
||||
...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> form = request.form()
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> form = await request.form()
|
||||
```
|
||||
|
||||
...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> files = request.files()
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> files = await request.files()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Servers](servers.md)</span>
|
||||
<span class="link-next">[Responses](responses.md) →</span>
|
||||
<span> </span>
|
||||
131
docs/responses.md
Normal file
131
docs/responses.md
Normal file
@ -0,0 +1,131 @@
|
||||
# Responses
|
||||
|
||||
The core elements of an HTTP response are the `status_code`, `headers` and `body`.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> resp = httpx.Response(200, headers={'Content-Type': 'text/plain'}, content=b'hello, world')
|
||||
>>> resp
|
||||
<Response [200 OK]>
|
||||
>>> resp.status_code
|
||||
200
|
||||
>>> resp.headers
|
||||
<Headers {'Content-Type': 'text/html'}>
|
||||
>>> resp.body
|
||||
b'hello, world'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> resp = ahttpx.Response(200, headers={'Content-Type': 'text/plain'}, content=b'hello, world')
|
||||
>>> resp
|
||||
<Response [200 OK]>
|
||||
>>> resp.status_code
|
||||
200
|
||||
>>> resp.headers
|
||||
<Headers {'Content-Type': 'text/html'}>
|
||||
>>> resp.body
|
||||
b'hello, world'
|
||||
```
|
||||
|
||||
## Working with the response headers
|
||||
|
||||
The following headers have automatic behavior with `Response` instances...
|
||||
|
||||
* `Content-Length` - Responses including a response body must always include either a `Content-Length` header or a `Transfer-Encoding: chunked` header. This header is automatically populated if `content` is not `None` and the content is a known size.
|
||||
* `Transfer-Encoding` - Responses automatically include a `Transfer-Encoding: chunked` header if `content` is not `None` and the content is an unkwown size.
|
||||
* `Content-Type` - Responses automatically include a `Content-Type` header if `content` is set using the [Content Type] API.
|
||||
|
||||
## Working with content types
|
||||
|
||||
Including HTML content...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> content = httpx.HTML('<html><head>...</head><body>...</body></html>')
|
||||
>>> response = httpx.Response(200, content=content)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> content = ahttpx.HTML('<html><head>...</head><body>...</body></html>')
|
||||
>>> response = ahttpx.Response(200, content=content)
|
||||
```
|
||||
|
||||
Including plain text content...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> content = httpx.Text('hello, world')
|
||||
>>> response = httpx.Response(200, content=content)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> content = ahttpx.Text('hello, world')
|
||||
>>> response = ahttpx.Response(200, content=content)
|
||||
```
|
||||
|
||||
Including JSON data...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> content = httpx.JSON({'message': 'hello, world'})
|
||||
>>> response = httpx.Response(200, content=content)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> content = ahttpx.JSON({'message': 'hello, world'})
|
||||
>>> response = ahttpx.Response(200, content=content)
|
||||
```
|
||||
|
||||
Including content from a file...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> content = httpx.File('index.html')
|
||||
>>> with httpx.Response(200, content=content) as response:
|
||||
... pass
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> content = ahttpx.File('index.html')
|
||||
>>> async with ahttpx.Response(200, content=content) as response:
|
||||
... pass
|
||||
```
|
||||
|
||||
## Accessing response content
|
||||
|
||||
...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> response.body
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> response.body
|
||||
```
|
||||
|
||||
...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> response.text
|
||||
...
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> response.text
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Requests](requests.md)</span>
|
||||
<span class="link-next">[URLs](urls.md) →</span>
|
||||
<span> </span>
|
||||
85
docs/servers.md
Normal file
85
docs/servers.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Servers
|
||||
|
||||
The HTTP server provides a simple request/response API.
|
||||
This gives you a lightweight way to build web applications or APIs.
|
||||
|
||||
### `serve_http(endpoint)`
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> website = """
|
||||
... <html>
|
||||
... <head>
|
||||
... <style>
|
||||
... body {
|
||||
... font-family: courier;
|
||||
... text-align: center;
|
||||
... padding: 3rem;
|
||||
... background: #111;
|
||||
... color: #ddd;
|
||||
... font-size: 3rem;
|
||||
... }
|
||||
... </style>
|
||||
... </head>
|
||||
... <body>
|
||||
... <div>hello, world</div>
|
||||
... </body>
|
||||
... </html>
|
||||
... """
|
||||
|
||||
>>> def hello_world(request):
|
||||
... content = httpx.HTML(website)
|
||||
... return httpx.Response(200, content=content)
|
||||
|
||||
>>> with httpx.serve_http(hello_world) as server:
|
||||
... print(f"Serving on {server.url} (Press CTRL+C to quit)")
|
||||
... server.wait()
|
||||
Serving on http://127.0.0.1:8080/ (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> import httpx
|
||||
|
||||
>>> website = """
|
||||
... <html>
|
||||
... <head>
|
||||
... <style>
|
||||
... body {
|
||||
... font-family: courier;
|
||||
... text-align: center;
|
||||
... padding: 3rem;
|
||||
... background: #111;
|
||||
... color: #ddd;
|
||||
... font-size: 3rem;
|
||||
... }
|
||||
... </style>
|
||||
... </head>
|
||||
... <body>
|
||||
... <div>hello, world</div>
|
||||
... </body>
|
||||
... </html>
|
||||
... """
|
||||
|
||||
>>> async def hello_world(request):
|
||||
... if request.path != '/':
|
||||
... content = httpx.Text("Not found")
|
||||
... return httpx.Response(404, content=content)
|
||||
... content = httpx.HTML(website)
|
||||
... return httpx.Response(200, content=content)
|
||||
|
||||
>>> async with httpx.serve_http(hello_world) as server:
|
||||
... print(f"Serving on {server.url} (Press CTRL+C to quit)")
|
||||
... await server.wait()
|
||||
Serving on http://127.0.0.1:8080/ (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Docs in progress...*
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Clients](clients.md)</span>
|
||||
<span class="link-next">[Requests](requests.md) →</span>
|
||||
<span> </span>
|
||||
88
docs/streams.md
Normal file
88
docs/streams.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Streams
|
||||
|
||||
Streams provide a minimal file-like interface for reading bytes from a data source. They are used as the abstraction for reading the body of a request or response.
|
||||
|
||||
The interfaces here are simplified versions of Python's standard I/O operations.
|
||||
|
||||
## Stream
|
||||
|
||||
The base `Stream` class. The core of the interface is a subset of Python's `io.IOBase`...
|
||||
|
||||
* `.read(size=-1)` - *(bytes)* Return the bytes from the data stream. If the `size` argument is omitted or negative then the entire stream will be read. If `size` is an positive integer then the call returns at most `size` bytes. A return value of `b''` indicates the end of the stream has been reached.
|
||||
* `.write(self, data: bytes)` - *None* Write the given bytes to the data stream. May raise `NotImplmentedError` if this is not a writeable stream.
|
||||
* `.close()` - Close the stream. Any further operations will raise a `ValueError`.
|
||||
|
||||
Additionally, the following property is also defined...
|
||||
|
||||
* `.size` - *(int or None)* Return an integer indicating the size of the stream, or `None` if the size is unknown. When working with HTTP this is used to either set a `Content-Length: <size>` header, or a `Content-Encoding: chunked` header.
|
||||
|
||||
The `Stream` interface and `ContentType` interface are related, with streams being used as the abstraction for the bytewise representation, and content types being used to encapsulate the parsed data structure.
|
||||
|
||||
For example, encoding some `JSON` data...
|
||||
|
||||
```python
|
||||
>>> data = httpx.JSON({'name': 'zelda', 'score': '478'})
|
||||
>>> stream = data.encode()
|
||||
>>> stream.read()
|
||||
b'{"name":"zelda","score":"478"}'
|
||||
>>> stream.content_type
|
||||
'application/json'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ByteStream
|
||||
|
||||
A byte stream returning fixed byte content. Similar to Python's `io.BytesIO` class.
|
||||
|
||||
```python
|
||||
>>> s = httpx.ByteStream(b'{"msg": "Hello, world!"}')
|
||||
>>> s.read()
|
||||
b'{"msg": "Hello, world!"}'
|
||||
```
|
||||
|
||||
## FileStream
|
||||
|
||||
A byte stream returning content from a file.
|
||||
|
||||
The standard pattern for instantiating a `FileStream` is to use `File` as a context manager:
|
||||
|
||||
```python
|
||||
>>> with httpx.File('upload.json') as s:
|
||||
... s.read()
|
||||
b'{"msg": "Hello, world!"}'
|
||||
```
|
||||
|
||||
## MultiPartStream
|
||||
|
||||
A byte stream returning multipart upload data.
|
||||
|
||||
The standard pattern for instantiating a `MultiPartStream` is to use `MultiPart` as a context manager:
|
||||
|
||||
```python
|
||||
>>> files = {'avatar-upload': 'image.png'}
|
||||
>>> with httpx.MultiPart(files=files) as s:
|
||||
... s.read()
|
||||
# ...
|
||||
```
|
||||
|
||||
## HTTPStream
|
||||
|
||||
A byte stream returning unparsed content from an HTTP request or response.
|
||||
|
||||
```python
|
||||
>>> with httpx.Client() as cli:
|
||||
... r = cli.get('https://www.example.com/')
|
||||
... r.stream.read()
|
||||
# ...
|
||||
```
|
||||
|
||||
## GZipStream
|
||||
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Content Types](content-types.md)</span>
|
||||
<span class="link-next">[Connections](connections.md) →</span>
|
||||
<span> </span>
|
||||
186
docs/templates/base.html
vendored
Normal file
186
docs/templates/base.html
vendored
Normal file
@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>%F0%9F%8C%B1</text></svg>">
|
||||
<title>httpx</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com/">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin="">
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/highlightjs-copy/dist/highlightjs-copy.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://unpkg.com/highlightjs-copy/dist/highlightjs-copy.min.js"></script>
|
||||
|
||||
<script>
|
||||
function hook(text, el) {
|
||||
const lines = text.split('\n');
|
||||
|
||||
if (lines[0].startsWith('>>> ')) {
|
||||
return lines
|
||||
.filter(line => line.startsWith('>>> ') || line.startsWith('... '))
|
||||
.map(line => line.replace(/^>>> |^\.{3} /, ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (lines[0].startsWith('$ ')) {
|
||||
return lines
|
||||
.map(line => line.startsWith('$ ') ? line.slice(2) : line)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function httpx() {
|
||||
const httpx = document.querySelectorAll('.httpx');
|
||||
httpx.forEach(function(el) {
|
||||
el.classList.remove('hidden');
|
||||
});
|
||||
const ahttpx = document.querySelectorAll('.ahttpx');
|
||||
ahttpx.forEach(function(el) {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
document.cookie = "selection=httpx; path=/;"
|
||||
}
|
||||
function ahttpx() {
|
||||
const httpx = document.querySelectorAll('.httpx');
|
||||
httpx.forEach(function(el) {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
const ahttpx = document.querySelectorAll('.ahttpx');
|
||||
ahttpx.forEach(function(el) {
|
||||
el.classList.remove('hidden');
|
||||
});
|
||||
document.cookie = "selection=ahttpx; path=/;"
|
||||
}
|
||||
hljs.addPlugin(new CopyButtonPlugin({autohide: false, hook: hook}));
|
||||
hljs.highlightAll();
|
||||
</script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Poppins", Sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #ffffffcc;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: "Poppins", Sans-serif;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.3;
|
||||
color: #474747;
|
||||
}
|
||||
|
||||
h1 { text-align: center; font-weight: 300; font-size: 4rem; }
|
||||
h2 { font-size: 2rem; }
|
||||
h3 { font-size: 1.5rem; }
|
||||
h4 { font-size: 1.25rem; }
|
||||
|
||||
h1 img {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #FF9300;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: none;
|
||||
border-top: 1px solid #FF9300;
|
||||
}
|
||||
|
||||
div.tabs {
|
||||
font-size: small;
|
||||
font-family: courier;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid grey;
|
||||
}
|
||||
|
||||
div.tabs a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.tabs a.hidden {
|
||||
color: grey;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span.link-prev {
|
||||
float: left;
|
||||
}
|
||||
|
||||
span.link-next {
|
||||
float: right;
|
||||
clear: right;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
main {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.75rem; }
|
||||
h3 { font-size: 1.375rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
|
||||
p {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{ content }}
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.cookie.match("selection=ahttpx")) {
|
||||
ahttpx();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body></html>
|
||||
240
docs/urls.md
Normal file
240
docs/urls.md
Normal file
@ -0,0 +1,240 @@
|
||||
# URLs
|
||||
|
||||
The `URL` class handles URL validation and parsing.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> url = httpx.URL('https://www.example.com/')
|
||||
>>> url
|
||||
<URL 'https://www.example.com/'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> url = ahttpx.URL('https://www.example.com/')
|
||||
>>> url
|
||||
<URL 'https://www.example.com/'>
|
||||
```
|
||||
|
||||
URL components are normalised, following the same rules as internet browsers.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> url = httpx.URL('https://www.EXAMPLE.com:443/path/../main')
|
||||
>>> url
|
||||
<URL 'https://www.example.com/main'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> url = ahttpx.URL('https://www.EXAMPLE.com:443/path/../main')
|
||||
>>> url
|
||||
<URL 'https://www.example.com/main'>
|
||||
```
|
||||
|
||||
Both absolute and relative URLs are valid.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> url = httpx.URL('/README.md')
|
||||
>>> url
|
||||
<URL '/README.md'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> url = ahttpx.URL('/README.md')
|
||||
>>> url
|
||||
<URL '/README.md'>
|
||||
```
|
||||
|
||||
Coercing a URL to a `str` will always result in a printable ASCII string.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> url = httpx.URL('https://example.com/path to here?search=🦋')
|
||||
>>> str(url)
|
||||
'https://example.com/path%20to%20here?search=%F0%9F%A6%8B'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> url = ahttpx.URL('https://example.com/path to here?search=🦋')
|
||||
>>> str(url)
|
||||
'https://example.com/path%20to%20here?search=%F0%9F%A6%8B'
|
||||
```
|
||||
|
||||
### URL components
|
||||
|
||||
The following properties are available for accessing the component parts of a URL.
|
||||
|
||||
* `.scheme` - *str. ASCII. Normalised to lowercase.*
|
||||
* `.userinfo` - *str. ASCII. URL encoded.*
|
||||
* `.username` - *str. Unicode.*
|
||||
* `.password` - *str. Unicode.*
|
||||
* `.host` - *str. ASCII. IDNA encoded.*
|
||||
* `.port` - *int or None. Scheme default ports are normalised to None.*
|
||||
* `.authority` - *str. ASCII. IDNA encoded. Eg. "example.com", "example.com:1337", "xn--p1ai".*
|
||||
* `.path` - *str. Unicode.*
|
||||
* `.query` - *str. ASCII. URL encoded.*
|
||||
* `.target` - *str. ASCII. URL encoded.*
|
||||
* `.fragment` - *str. ASCII. URL encoded.*
|
||||
|
||||
A parsed representation of the query parameters is accessible with the `.params` property.
|
||||
|
||||
* `.params` - [`QueryParams`](#query-parameters)
|
||||
|
||||
URLs can be instantiated from their components...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> httpx.URL(scheme="https", host="example.com", path="/")
|
||||
<URL 'https://example.com/'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> ahttpx.URL(scheme="https", host="example.com", path="/")
|
||||
<URL 'https://example.com/'>
|
||||
```
|
||||
|
||||
Or using both the string form and query parameters...
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> httpx.URL("https://example.com/", params={"search": "some text"})
|
||||
<URL 'https://example.com/?search=some+text'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> ahttpx.URL("https://example.com/", params={"search": "some text"})
|
||||
<URL 'https://example.com/?search=some+text'>
|
||||
```
|
||||
|
||||
### Modifying URLs
|
||||
|
||||
Instances of `URL` are immutable, meaning their value cannot be changed. Instead new modified instances may be created.
|
||||
|
||||
* `.copy_with(**components)` - *Return a new URL, updating one or more components. Eg. `url = url.copy_with(scheme="https")`*.
|
||||
* `.copy_set_param(key, value)` - *Return a new URL, setting a query parameter. Eg. `url = url.copy_set_param("sort_by", "price")`*.
|
||||
* `.copy_append_param(key, value)` - *Return a new URL, setting or appending a query parameter. Eg. `url = url.copy_append_param("tag", "sale")`*.
|
||||
* `.copy_remove_param(key)` - *Return a new URL, removing a query parameter. Eg. `url = url.copy_remove_param("max_price")`*.
|
||||
* `.copy_update_params(params)` - *Return a new URL, updating the query parameters. Eg. `url = url.copy_update_params({"color_scheme": "dark"})`*.
|
||||
* `.join(url)` - *Return a new URL, given this URL as the base and another URL as the target. Eg. `url = url.join("../navigation")`*.
|
||||
|
||||
---
|
||||
|
||||
## Query Parameters
|
||||
|
||||
The `QueryParams` class provides an immutable multi-dict for accessing URL query parameters.
|
||||
|
||||
They can be instantiated from a dictionary.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = httpx.QueryParams({"color": "black", "size": "medium"})
|
||||
>>> params
|
||||
<QueryParams 'color=black&size=medium'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = ahttpx.QueryParams({"color": "black", "size": "medium"})
|
||||
>>> params
|
||||
<QueryParams 'color=black&size=medium'>
|
||||
```
|
||||
|
||||
Multiple values for a single key are valid.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = httpx.QueryParams({"filter": ["60GHz", "75GHz", "100GHz"]})
|
||||
>>> params
|
||||
<QueryParams 'filter=60GHz&filter=75GHz&filter=100GHz'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = ahttpx.QueryParams({"filter": ["60GHz", "75GHz", "100GHz"]})
|
||||
>>> params
|
||||
<QueryParams 'filter=60GHz&filter=75GHz&filter=100GHz'>
|
||||
```
|
||||
|
||||
They can also be instantiated directly from a query string.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = httpx.QueryParams("color=black&size=medium")
|
||||
>>> params
|
||||
<QueryParams 'color=black&size=medium'>
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = ahttpx.QueryParams("color=black&size=medium")
|
||||
>>> params
|
||||
<QueryParams 'color=black&size=medium'>
|
||||
```
|
||||
|
||||
Keys and values are always represented as strings.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = httpx.QueryParams("sort_by=published&author=natalie")
|
||||
>>> params["sort_by"]
|
||||
'published'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = ahttpx.QueryParams("sort_by=published&author=natalie")
|
||||
>>> params["sort_by"]
|
||||
'published'
|
||||
```
|
||||
|
||||
When coercing query parameters to strings you'll see the same escaping behavior as HTML form submissions. The result will always be a printable ASCII string.
|
||||
|
||||
<div class="tabs"><a onclick="httpx()" class="httpx">httpx</a> <a onclick="ahttpx()" class="ahttpx hidden">ahttpx</a></div>
|
||||
|
||||
```{ .python .httpx }
|
||||
>>> params = httpx.QueryParams({"email": "user@example.com", "search": "How HTTP works!"})
|
||||
>>> str(params)
|
||||
'email=user%40example.com&search=How+HTTP+works%21'
|
||||
```
|
||||
|
||||
```{ .python .ahttpx .hidden }
|
||||
>>> params = ahttpx.QueryParams({"email": "user@example.com", "search": "How HTTP works!"})
|
||||
>>> str(params)
|
||||
'email=user%40example.com&search=How+HTTP+works%21'
|
||||
```
|
||||
|
||||
### Accessing query parameters
|
||||
|
||||
Query parameters are accessed using a standard dictionary style interface...
|
||||
|
||||
* `.get(key, default=None)` - *Return the value for a given key, or a default value. If multiple values for the key are present, only the first will be returned.*
|
||||
* `.keys()` - *Return the unique keys of the query parameters. Each key will be a `str` instance.*
|
||||
* `.values()` - *Return the values of the query parameters. Each value will be a list of one or more `str` instances.*
|
||||
* `.items()` - *Return the key value pairs of the query parameters. Each item will be a two-tuple including a `str` instance as the key, and a list of one or more `str` instances as the value.*
|
||||
|
||||
The following methods are also available for accessing query parameters as a multidict...
|
||||
|
||||
* `.get_all(key)` - *Return all the values for a given key. Returned as a list of zero or more `str` instances.*
|
||||
* `.multi_items()` - *Return the key value pairs of the query parameters. Each item will be a two-tuple `(str, str)`. Repeated keys may occur.*
|
||||
* `.multi_dict()` - *Return the query parameters as a dictionary, with each value being a list of one or more `str` instances.*
|
||||
|
||||
### Modifying query parameters
|
||||
|
||||
The following methods can be used to create modified query parameter instances...
|
||||
|
||||
* `.copy_set(key, value)`
|
||||
* `.copy_append(key, value)`
|
||||
* `.copy_remove(key)`
|
||||
* `.copy_update(params)`
|
||||
|
||||
---
|
||||
|
||||
<span class="link-prev">← [Responses](responses.md)</span>
|
||||
<span class="link-next">[Headers](headers.md) →</span>
|
||||
<span> </span>
|
||||
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "httpx"
|
||||
description = "HTTP, for Python."
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dependencies = [
|
||||
"certifi",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "src/httpx/__version__.py"
|
||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@ -0,0 +1,17 @@
|
||||
-e .
|
||||
|
||||
# Build...
|
||||
build==1.2.2
|
||||
|
||||
# Test...
|
||||
mypy==1.15.0
|
||||
pytest==8.3.5
|
||||
pytest-cov==6.1.1
|
||||
|
||||
# Sync & Async mirroring...
|
||||
unasync==0.6.0
|
||||
|
||||
# Documentation...
|
||||
click==8.2.1
|
||||
jinja2==3.1.6
|
||||
markdown==3.8
|
||||
32
scripts/build
Executable file
32
scripts/build
Executable file
@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
|
||||
PKG=$1
|
||||
|
||||
if [ "$PKG" != "httpx" ] && [ "$PKG" != "ahttpx" ] ; then
|
||||
echo "build [httpx|ahttpx]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PREFIX=""
|
||||
if [ -d 'venv' ] ; then
|
||||
export PREFIX="venv/bin/"
|
||||
fi
|
||||
|
||||
# Create pyproject-httpx.toml and pyproject-ahttpx.toml
|
||||
cp pyproject.toml pyproject-httpx.toml
|
||||
cat pyproject-httpx.toml | sed 's/name = "httpx"/name = "ahttpx"/' > pyproject-ahttpx.toml
|
||||
|
||||
# Build the releases
|
||||
if [ "$PKG" == "httpx" ]; then
|
||||
${PREFIX}python -m build
|
||||
fi
|
||||
if [ "$PKG" == "ahttpx" ]; then
|
||||
cp pyproject-ahttpx.toml pyproject.toml
|
||||
${PREFIX}python -m build
|
||||
cp pyproject-httpx.toml pyproject.toml
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm pyproject-httpx.toml pyproject-ahttpx.toml
|
||||
|
||||
echo $PKG
|
||||
153
scripts/docs
Executable file
153
scripts/docs
Executable file
@ -0,0 +1,153 @@
|
||||
#!venv/bin/python
|
||||
import pathlib
|
||||
import posixpath
|
||||
|
||||
import click
|
||||
import ghp_import
|
||||
import logging
|
||||
import httpx
|
||||
import jinja2
|
||||
import markdown
|
||||
|
||||
import xml.etree.ElementTree as etree
|
||||
|
||||
|
||||
pages = {
|
||||
'/': 'docs/index.md',
|
||||
'/quickstart': 'docs/quickstart.md',
|
||||
'/clients': 'docs/clients.md',
|
||||
'/servers': 'docs/servers.md',
|
||||
'/requests': 'docs/requests.md',
|
||||
'/responses': 'docs/responses.md',
|
||||
'/urls': 'docs/urls.md',
|
||||
'/headers': 'docs/headers.md',
|
||||
'/content-types': 'docs/content-types.md',
|
||||
'/streams': 'docs/streams.md',
|
||||
'/connections': 'docs/connections.md',
|
||||
'/parsers': 'docs/parsers.md',
|
||||
'/networking': 'docs/networking.md',
|
||||
'/about': 'docs/about.md',
|
||||
}
|
||||
|
||||
def path_to_url(path):
|
||||
if path == "index.md":
|
||||
return "/"
|
||||
return f"/{path[:-3]}"
|
||||
|
||||
|
||||
class URLsProcessor(markdown.treeprocessors.Treeprocessor):
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
|
||||
def run(self, root: etree.Element) -> etree.Element:
|
||||
for element in root.iter():
|
||||
if element.tag == 'a':
|
||||
key = 'href'
|
||||
elif element.tag == 'img':
|
||||
key = 'src'
|
||||
else:
|
||||
continue
|
||||
|
||||
url_or_path = element.get(key)
|
||||
if url_or_path is not None:
|
||||
output_url = self.rewrite_url(url_or_path)
|
||||
element.set(key, output_url)
|
||||
|
||||
return root
|
||||
|
||||
def rewrite_url(self, href: str) -> str:
|
||||
if not href.endswith('.md'):
|
||||
return href
|
||||
|
||||
current_url = path_to_url(self.state.file)
|
||||
linked_url = path_to_url(href)
|
||||
return posixpath.relpath(linked_url, start=current_url)
|
||||
|
||||
|
||||
class BuildState:
|
||||
def __init__(self):
|
||||
self.file = ''
|
||||
|
||||
|
||||
state = BuildState()
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader('docs/templates'),
|
||||
autoescape=False
|
||||
)
|
||||
template = env.get_template('base.html')
|
||||
md = markdown.Markdown(extensions=['fenced_code'])
|
||||
md.treeprocessors.register(
|
||||
item=URLsProcessor(state),
|
||||
name='urls',
|
||||
priority=10,
|
||||
)
|
||||
|
||||
|
||||
def not_found():
|
||||
text = httpx.Text('Not Found')
|
||||
return httpx.Response(404, content=text)
|
||||
|
||||
|
||||
def web_server(request):
|
||||
if request.url.path not in pages:
|
||||
return not_found()
|
||||
|
||||
file = pages[request.url.path]
|
||||
text = pathlib.Path(file).read_text()
|
||||
|
||||
state.file = file
|
||||
content = md.convert(text)
|
||||
html = template.render(content=content).encode('utf-8')
|
||||
content = httpx.HTML(html)
|
||||
return httpx.Response(200, content=html)
|
||||
|
||||
|
||||
@click.group()
|
||||
def main():
|
||||
pass
|
||||
|
||||
|
||||
@main.command()
|
||||
def build():
|
||||
pathlib.Path("build").mkdir(exist_ok=True)
|
||||
|
||||
for url, path in pages.items():
|
||||
basename = url.lstrip("/")
|
||||
output = f"build/{basename}.html" if basename else "build/index.html"
|
||||
text = pathlib.Path(path).read_text()
|
||||
content = md.convert(text)
|
||||
html = template.render(content=content)
|
||||
pathlib.Path(output).write_text(html)
|
||||
print(f"Built {output}")
|
||||
|
||||
|
||||
@main.command()
|
||||
def serve():
|
||||
logging.basicConfig(
|
||||
format="%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
level=logging.INFO
|
||||
)
|
||||
|
||||
with httpx.serve_http(web_server) as server:
|
||||
server.wait()
|
||||
|
||||
|
||||
@main.command()
|
||||
def deploy():
|
||||
ghp_import.ghp_import(
|
||||
"build",
|
||||
mesg="Documentation deploy",
|
||||
remote="origin",
|
||||
branch="gh-pages",
|
||||
push=True,
|
||||
force=False,
|
||||
use_shell=False,
|
||||
no_history=False,
|
||||
nojekyll=True,
|
||||
)
|
||||
print(f"Deployed to GitHub")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
scripts/install
Executable file
13
scripts/install
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -x
|
||||
|
||||
if [ -z "$GITHUB_ACTIONS" ]; then
|
||||
python3 -m venv venv
|
||||
PIP="venv/bin/pip"
|
||||
else
|
||||
PIP="pip"
|
||||
fi
|
||||
|
||||
"$PIP" install -U pip
|
||||
"$PIP" install -r requirements.txt
|
||||
15
scripts/publish
Executable file
15
scripts/publish
Executable file
@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
PKG=$1
|
||||
|
||||
if [ "$PKG" != "httpx" ] && [ "$PKG" != "ahttpx" ] ; then
|
||||
echo "publish [httpx|ahttpx]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PREFIX=""
|
||||
if [ -d 'venv' ] ; then
|
||||
export PREFIX="venv/bin/"
|
||||
fi
|
||||
${PREFIX}pip install -q twine
|
||||
${PREFIX}twine upload dist/$PKG-*
|
||||
10
scripts/test
Executable file
10
scripts/test
Executable file
@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
export PREFIX=""
|
||||
if [ -d 'venv' ] ; then
|
||||
export PREFIX="venv/bin/"
|
||||
fi
|
||||
|
||||
${PREFIX}mypy src/httpx
|
||||
${PREFIX}mypy src/ahttpx
|
||||
${PREFIX}pytest --cov src/httpx tests
|
||||
29
scripts/unasync
Executable file
29
scripts/unasync
Executable file
@ -0,0 +1,29 @@
|
||||
#!venv/bin/python
|
||||
import unasync
|
||||
|
||||
unasync.unasync_files(
|
||||
fpath_list = [
|
||||
"src/ahttpx/__init__.py",
|
||||
"src/ahttpx/__version__.py",
|
||||
"src/ahttpx/_client.py",
|
||||
"src/ahttpx/_content.py",
|
||||
"src/ahttpx/_headers.py",
|
||||
"src/ahttpx/_parsers.py",
|
||||
"src/ahttpx/_pool.py",
|
||||
"src/ahttpx/_quickstart.py",
|
||||
"src/ahttpx/_response.py",
|
||||
"src/ahttpx/_request.py",
|
||||
"src/ahttpx/_server.py",
|
||||
"src/ahttpx/_streams.py",
|
||||
"src/ahttpx/_urlencode.py",
|
||||
"src/ahttpx/_urlparse.py",
|
||||
"src/ahttpx/_urls.py",
|
||||
],
|
||||
rules = [
|
||||
unasync.Rule(
|
||||
"src/ahttpx/",
|
||||
"src/httpx/",
|
||||
additional_replacements={"ahttpx": "httpx"}
|
||||
),
|
||||
]
|
||||
)
|
||||
65
src/ahttpx/__init__.py
Normal file
65
src/ahttpx/__init__.py
Normal file
@ -0,0 +1,65 @@
|
||||
from .__version__ import __title__, __version__
|
||||
from ._client import * # Client
|
||||
from ._content import * # Content, File, Files, Form, HTML, JSON, MultiPart, Text
|
||||
from ._headers import * # Headers
|
||||
from ._network import * # NetworkBackend, NetworkStream, timeout
|
||||
from ._parsers import * # HTTPParser, ProtocolError
|
||||
from ._pool import * # Connection, ConnectionPool, Transport
|
||||
from ._quickstart import * # get, post, put, patch, delete
|
||||
from ._response import * # Response
|
||||
from ._request import * # Request
|
||||
from ._streams import * # ByteStream, DuplexStream, FileStream, HTTPStream, Stream
|
||||
from ._server import * # serve_http, run
|
||||
from ._urlencode import * # quote, unquote, urldecode, urlencode
|
||||
from ._urls import * # QueryParams, URL
|
||||
|
||||
|
||||
__all__ = [
|
||||
"__title__",
|
||||
"__version__",
|
||||
"ByteStream",
|
||||
"Client",
|
||||
"Connection",
|
||||
"ConnectionPool",
|
||||
"Content",
|
||||
"delete",
|
||||
"DuplexStream",
|
||||
"File",
|
||||
"FileStream",
|
||||
"Files",
|
||||
"Form",
|
||||
"get",
|
||||
"Headers",
|
||||
"HTML",
|
||||
"HTTPParser",
|
||||
"HTTPStream",
|
||||
"JSON",
|
||||
"MultiPart",
|
||||
"NetworkBackend",
|
||||
"NetworkStream",
|
||||
"open_connection",
|
||||
"post",
|
||||
"ProtocolError",
|
||||
"put",
|
||||
"patch",
|
||||
"Response",
|
||||
"Request",
|
||||
"run",
|
||||
"serve_http",
|
||||
"Stream",
|
||||
"Text",
|
||||
"timeout",
|
||||
"Transport",
|
||||
"QueryParams",
|
||||
"quote",
|
||||
"unquote",
|
||||
"URL",
|
||||
"urldecode",
|
||||
"urlencode",
|
||||
]
|
||||
|
||||
|
||||
__locals = locals()
|
||||
for __name in __all__:
|
||||
if not __name.startswith('__'):
|
||||
setattr(__locals[__name], "__module__", "httpx")
|
||||
2
src/ahttpx/__version__.py
Normal file
2
src/ahttpx/__version__.py
Normal file
@ -0,0 +1,2 @@
|
||||
__title__ = "ahttpx"
|
||||
__version__ = "1.0.dev3"
|
||||
156
src/ahttpx/_client.py
Normal file
156
src/ahttpx/_client.py
Normal file
@ -0,0 +1,156 @@
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._content import Content
|
||||
from ._headers import Headers
|
||||
from ._pool import ConnectionPool, Transport
|
||||
from ._request import Request
|
||||
from ._response import Response
|
||||
from ._streams import Stream
|
||||
from ._urls import URL
|
||||
|
||||
__all__ = ["Client"]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(
|
||||
self,
|
||||
url: URL | str | None = None,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
transport: Transport | None = None,
|
||||
):
|
||||
if url is None:
|
||||
url = ""
|
||||
if headers is None:
|
||||
headers = {"User-Agent": "dev"}
|
||||
if transport is None:
|
||||
transport = ConnectionPool()
|
||||
|
||||
self.url = URL(url)
|
||||
self.headers = Headers(headers)
|
||||
self.transport = transport
|
||||
self.via = RedirectMiddleware(self.transport)
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Request:
|
||||
return Request(
|
||||
method=method,
|
||||
url=self.url.join(url),
|
||||
headers=self.headers.copy_update(headers),
|
||||
content=content,
|
||||
)
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = self.build_request(method, url, headers=headers, content=content)
|
||||
async with await self.via.send(request) as response:
|
||||
await response.read()
|
||||
return response
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = self.build_request(method, url, headers=headers, content=content)
|
||||
return await self.via.send(request)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
return await self.request("GET", url, headers=headers)
|
||||
|
||||
async def post(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
return await self.request("POST", url, headers=headers, content=content)
|
||||
|
||||
async def put(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
return await self.request("PUT", url, headers=headers, content=content)
|
||||
|
||||
async def patch(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
return await self.request("PATCH", url, headers=headers, content=content)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
return await self.request("DELETE", url, headers=headers)
|
||||
|
||||
async def close(self):
|
||||
await self.transport.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
await self.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Client [{self.transport.description()}]>"
|
||||
|
||||
|
||||
class RedirectMiddleware(Transport):
|
||||
def __init__(self, transport: Transport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def is_redirect(self, response: Response) -> bool:
|
||||
return (
|
||||
response.status_code in (301, 302, 303, 307, 308)
|
||||
and "Location" in response.headers
|
||||
)
|
||||
|
||||
def build_redirect_request(self, request: Request, response: Response) -> Request:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def send(self, request: Request) -> Response:
|
||||
while True:
|
||||
response = await self._transport.send(request)
|
||||
|
||||
if not self.is_redirect(response):
|
||||
return response
|
||||
|
||||
# If we have a redirect, then we read the body of the response.
|
||||
# Ensures that the HTTP connection is available for a new
|
||||
# request/response cycle.
|
||||
await response.read()
|
||||
await response.close()
|
||||
|
||||
# We've made a request-response and now need to issue a redirect request.
|
||||
request = self.build_redirect_request(request, response)
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
378
src/ahttpx/_content.py
Normal file
378
src/ahttpx/_content.py
Normal file
@ -0,0 +1,378 @@
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
|
||||
from ._streams import Stream, ByteStream, FileStream, MultiPartStream
|
||||
from ._urlencode import urldecode, urlencode
|
||||
|
||||
__all__ = [
|
||||
"Content",
|
||||
"Form",
|
||||
"File",
|
||||
"Files",
|
||||
"JSON",
|
||||
"MultiPart",
|
||||
"Text",
|
||||
"HTML",
|
||||
]
|
||||
|
||||
# https://github.com/nginx/nginx/blob/master/conf/mime.types
|
||||
_content_types = {
|
||||
".json": "application/json",
|
||||
".js": "application/javascript",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".png": "image/png",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
|
||||
class Content:
|
||||
def encode(self) -> Stream:
|
||||
raise NotImplementedError()
|
||||
|
||||
def content_type(self) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Form(typing.Mapping[str, str], Content):
|
||||
"""
|
||||
HTML form data, as an immutable multi-dict.
|
||||
Form parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
form: (
|
||||
typing.Mapping[str, str | typing.Sequence[str]]
|
||||
| typing.Sequence[tuple[str, str]]
|
||||
| str
|
||||
| None
|
||||
) = None,
|
||||
) -> None:
|
||||
d: dict[str, list[str]] = {}
|
||||
|
||||
if form is None:
|
||||
d = {}
|
||||
elif isinstance(form, str):
|
||||
d = urldecode(form)
|
||||
elif isinstance(form, typing.Mapping):
|
||||
# Convert dict inputs like:
|
||||
# {"a": "123", "b": ["456", "789"]}
|
||||
# To dict inputs where values are always lists, like:
|
||||
# {"a": ["123"], "b": ["456", "789"]}
|
||||
d = {k: [v] if isinstance(v, str) else list(v) for k, v in form.items()}
|
||||
else:
|
||||
# Convert list inputs like:
|
||||
# [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
# To a dict representation, like:
|
||||
# {"a": ["123", "456"], "b": ["789"]}
|
||||
for k, v in form:
|
||||
d.setdefault(k, []).append(v)
|
||||
|
||||
self._dict = d
|
||||
|
||||
# Content API
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = str(self).encode("ascii")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "application/x-www-form-urlencoded"
|
||||
|
||||
# Dict operations
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
if key in self._dict:
|
||||
return self._dict[key][0]
|
||||
return default
|
||||
|
||||
# Multi-dict operations
|
||||
|
||||
def multi_items(self) -> list[tuple[str, str]]:
|
||||
multi_items: list[tuple[str, str]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def multi_dict(self) -> dict[str, list[str]]:
|
||||
return {k: list(v) for k, v in self._dict.items()}
|
||||
|
||||
def get_list(self, key: str) -> list[str]:
|
||||
return list(self._dict.get(key, []))
|
||||
|
||||
# Update operations
|
||||
|
||||
def copy_set(self, key: str, value: str) -> "Form":
|
||||
d = self.multi_dict()
|
||||
d[key] = [value]
|
||||
return Form(d)
|
||||
|
||||
def copy_append(self, key: str, value: str) -> "Form":
|
||||
d = self.multi_dict()
|
||||
d[key] = d.get(key, []) + [value]
|
||||
return Form(d)
|
||||
|
||||
def copy_remove(self, key: str) -> "Form":
|
||||
d = self.multi_dict()
|
||||
d.pop(key, None)
|
||||
return Form(d)
|
||||
|
||||
# Accessors & built-ins
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, Form) and
|
||||
sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return urlencode(self.multi_dict())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Form {self.multi_items()!r}>"
|
||||
|
||||
|
||||
class File(Content):
|
||||
"""
|
||||
Wrapper class used for files in uploads and multipart requests.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self._path = path
|
||||
|
||||
def name(self) -> str:
|
||||
return os.path.basename(self._path)
|
||||
|
||||
def size(self) -> int:
|
||||
return os.path.getsize(self._path)
|
||||
|
||||
def encode(self) -> Stream:
|
||||
return FileStream(self._path)
|
||||
|
||||
def content_type(self) -> str:
|
||||
_, ext = os.path.splitext(self._path)
|
||||
ct = _content_types.get(ext, "application/octet-stream")
|
||||
if ct.startswith('text/'):
|
||||
ct += "; charset='utf-8'"
|
||||
return ct
|
||||
|
||||
def __lt__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, File) and other._path < self._path
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, File) and other._path == self._path
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<File {self._path!r}>"
|
||||
|
||||
|
||||
class Files(typing.Mapping[str, File], Content):
|
||||
"""
|
||||
File parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
files: (
|
||||
typing.Mapping[str, File | typing.Sequence[File]]
|
||||
| typing.Sequence[tuple[str, File]]
|
||||
| None
|
||||
) = None,
|
||||
boundary: str = ''
|
||||
) -> None:
|
||||
d: dict[str, list[File]] = {}
|
||||
|
||||
if files is None:
|
||||
d = {}
|
||||
elif isinstance(files, typing.Mapping):
|
||||
d = {k: [v] if isinstance(v, File) else list(v) for k, v in files.items()}
|
||||
else:
|
||||
d = {}
|
||||
for k, v in files:
|
||||
d.setdefault(k, []).append(v)
|
||||
|
||||
self._dict = d
|
||||
self._boundary = boundary or os.urandom(16).hex()
|
||||
|
||||
# Standard dict interface
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[File]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, File]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
if key in self._dict:
|
||||
return self._dict[key][0]
|
||||
return None
|
||||
|
||||
# Multi dict interface
|
||||
def multi_items(self) -> list[tuple[str, File]]:
|
||||
multi_items: list[tuple[str, File]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def multi_dict(self) -> dict[str, list[File]]:
|
||||
return {k: list(v) for k, v in self._dict.items()}
|
||||
|
||||
def get_list(self, key: str) -> list[File]:
|
||||
return list(self._dict.get(key, []))
|
||||
|
||||
# Content interface
|
||||
def encode(self) -> Stream:
|
||||
return MultiPart(files=self).encode()
|
||||
|
||||
def content_type(self) -> str:
|
||||
return f"multipart/form-data; boundary={self._boundary}"
|
||||
|
||||
# Builtins
|
||||
def __getitem__(self, key: str) -> File:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, Files) and
|
||||
sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Files {self.multi_items()!r}>"
|
||||
|
||||
|
||||
class JSON(Content):
|
||||
def __init__(self, data: typing.Any) -> None:
|
||||
self._data = data
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = json.dumps(
|
||||
self._data,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False
|
||||
).encode("utf-8")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "application/json"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<JSON {self._data!r}>"
|
||||
|
||||
|
||||
class Text(Content):
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = self._text.encode("utf-8")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "text/plain; charset='utf-8'"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Text {self._text!r}>"
|
||||
|
||||
|
||||
class HTML(Content):
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = self._text.encode("utf-8")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "text/html; charset='utf-8'"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<HTML {self._text!r}>"
|
||||
|
||||
|
||||
class MultiPart(Content):
|
||||
def __init__(
|
||||
self,
|
||||
form: (
|
||||
Form
|
||||
| typing.Mapping[str, str | typing.Sequence[str]]
|
||||
| typing.Sequence[tuple[str, str]]
|
||||
| str
|
||||
| None
|
||||
) = None,
|
||||
files: (
|
||||
Files
|
||||
| typing.Mapping[str, File | typing.Sequence[File]]
|
||||
| typing.Sequence[tuple[str, File]]
|
||||
| None
|
||||
) = None,
|
||||
boundary: str | None = None
|
||||
):
|
||||
self._form = form if isinstance(form , Form) else Form(form)
|
||||
self._files = files if isinstance(files, Files) else Files(files)
|
||||
self._boundary = os.urandom(16).hex() if boundary is None else boundary
|
||||
|
||||
@property
|
||||
def form(self) -> Form:
|
||||
return self._form
|
||||
|
||||
@property
|
||||
def files(self) -> Files:
|
||||
return self._files
|
||||
|
||||
def encode(self) -> Stream:
|
||||
form = [(key, value) for key, value in self._form.items()]
|
||||
files = [(key, file._path) for key, file in self._files.items()]
|
||||
return MultiPartStream(form, files, boundary=self._boundary)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return f"multipart/form-data; boundary={self._boundary}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MultiPart form={self._form.multi_items()!r}, files={self._files.multi_items()!r}>"
|
||||
243
src/ahttpx/_headers.py
Normal file
243
src/ahttpx/_headers.py
Normal file
@ -0,0 +1,243 @@
|
||||
import re
|
||||
import typing
|
||||
|
||||
|
||||
__all__ = ["Headers"]
|
||||
|
||||
|
||||
VALID_HEADER_CHARS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"0123456789"
|
||||
"!#$%&'*+-.^_`|~"
|
||||
)
|
||||
|
||||
|
||||
# TODO...
|
||||
#
|
||||
# * Comma folded values, eg. `Vary: ...`
|
||||
# * Multiple Set-Cookie headers.
|
||||
# * Non-ascii support.
|
||||
# * Ordering, including `Host` header exception.
|
||||
|
||||
|
||||
def headername(name: str) -> str:
|
||||
if name.strip(VALID_HEADER_CHARS) or not name:
|
||||
raise ValueError(f"Invalid HTTP header name {name!r}.")
|
||||
return name
|
||||
|
||||
|
||||
def headervalue(value: str) -> str:
|
||||
value = value.strip(" ")
|
||||
if not value or not value.isascii() or not value.isprintable():
|
||||
raise ValueError(f"Invalid HTTP header value {value!r}.")
|
||||
return value
|
||||
|
||||
|
||||
class Headers(typing.Mapping[str, str]):
|
||||
def __init__(
|
||||
self,
|
||||
headers: typing.Mapping[str, str] | typing.Sequence[tuple[str, str]] | None = None,
|
||||
) -> None:
|
||||
# {'accept': ('Accept', '*/*')}
|
||||
d: dict[str, str] = {}
|
||||
|
||||
if isinstance(headers, typing.Mapping):
|
||||
# Headers({
|
||||
# 'Content-Length': '1024',
|
||||
# 'Content-Type': 'text/plain; charset=utf-8',
|
||||
# )
|
||||
d = {headername(k): headervalue(v) for k, v in headers.items()}
|
||||
elif headers is not None:
|
||||
# Headers([
|
||||
# ('Location', 'https://www.example.com'),
|
||||
# ('Set-Cookie', 'session_id=3498jj489jhb98jn'),
|
||||
# ])
|
||||
d = {headername(k): headervalue(v) for k, v in headers}
|
||||
|
||||
self._dict = d
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
"""
|
||||
Return all the header keys.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.keys()) == ["Accept", "User-Agent"]
|
||||
"""
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
"""
|
||||
Return all the header values.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.values()) == ["*/*", "python/httpx"]
|
||||
"""
|
||||
return self._dict.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
"""
|
||||
Return all headers as (key, value) tuples.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")]
|
||||
"""
|
||||
return self._dict.items()
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
Get a value from the query param for a given key. If the key occurs
|
||||
more than once, then only the first value is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert h.get("User-Agent") == "python/httpx"
|
||||
"""
|
||||
for k, v in self._dict.items():
|
||||
if k.lower() == key.lower():
|
||||
return v
|
||||
return default
|
||||
|
||||
def copy_set(self, key: str, value: str) -> "Headers":
|
||||
"""
|
||||
Return a new Headers instance, setting the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Expires": "0"})
|
||||
h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT")
|
||||
assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"})
|
||||
"""
|
||||
l = []
|
||||
seen = False
|
||||
|
||||
# Either insert...
|
||||
for k, v in self._dict.items():
|
||||
if k.lower() == key.lower():
|
||||
l.append((key, value))
|
||||
seen = True
|
||||
else:
|
||||
l.append((k, v))
|
||||
|
||||
# Or append...
|
||||
if not seen:
|
||||
l.append((key, value))
|
||||
|
||||
return Headers(l)
|
||||
|
||||
def copy_remove(self, key: str) -> "Headers":
|
||||
"""
|
||||
Return a new Headers instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*"})
|
||||
h = h.copy_remove("Accept")
|
||||
assert h == httpx.Headers({})
|
||||
"""
|
||||
h = {k: v for k, v in self._dict.items() if k.lower() != key.lower()}
|
||||
return Headers(h)
|
||||
|
||||
def copy_update(self, update: "Headers" | typing.Mapping[str, str] | None) -> "Headers":
|
||||
"""
|
||||
Return a new Headers instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
h = h.copy_update({"Accept-Encoding": "gzip"})
|
||||
assert h == httpx.Headers({"Accept": "*/*", "Accept-Encoding": "gzip", "User-Agent": "python/httpx"})
|
||||
"""
|
||||
if update is None:
|
||||
return self
|
||||
|
||||
new = update if isinstance(update, Headers) else Headers(update)
|
||||
|
||||
# Remove updated items using a case-insensitive approach...
|
||||
keys = set([key.lower() for key in new.keys()])
|
||||
h = {k: v for k, v in self._dict.items() if k.lower() not in keys}
|
||||
|
||||
# Perform the actual update...
|
||||
h.update(dict(new))
|
||||
|
||||
return Headers(h)
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
match = key.lower()
|
||||
for k, v in self._dict.items():
|
||||
if k.lower() == match:
|
||||
return v
|
||||
raise KeyError(key)
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
match = key.lower()
|
||||
return any(k.lower() == match for k in self._dict.keys())
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
self_lower = {k.lower(): v for k, v in self.items()}
|
||||
other_lower = {k.lower(): v for k, v in Headers(other).items()}
|
||||
return self_lower == other_lower
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Headers {dict(self)!r}>"
|
||||
|
||||
|
||||
def parse_opts_header(header: str) -> tuple[str, dict[str, str]]:
|
||||
# The Content-Type header is described in RFC 2616 'Content-Type'
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-14.17
|
||||
|
||||
# The 'type/subtype; parameter' format is described in RFC 2616 'Media Types'
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-3.7
|
||||
|
||||
# Parameter quoting is described in RFC 2616 'Transfer Codings'
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-3.6
|
||||
|
||||
header = header.strip()
|
||||
content_type = ''
|
||||
params = {}
|
||||
|
||||
# Match the content type (up to the first semicolon or end)
|
||||
match = re.match(r'^([^;]+)', header)
|
||||
if match:
|
||||
content_type = match.group(1).strip().lower()
|
||||
rest = header[match.end():]
|
||||
else:
|
||||
return '', {}
|
||||
|
||||
# Parse parameters, accounting for quoted strings
|
||||
param_pattern = re.compile(r'''
|
||||
;\s* # Semicolon + optional whitespace
|
||||
(?P<key>[^=;\s]+) # Parameter key
|
||||
= # Equal sign
|
||||
(?P<value> # Parameter value:
|
||||
"(?:[^"\\]|\\.)*" # Quoted string with escapes
|
||||
| # OR
|
||||
[^;]* # Unquoted string (until semicolon)
|
||||
)
|
||||
''', re.VERBOSE)
|
||||
|
||||
for match in param_pattern.finditer(rest):
|
||||
key = match.group('key').lower()
|
||||
value = match.group('value').strip()
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
# Remove surrounding quotes and unescape
|
||||
value = re.sub(r'\\(.)', r'\1', value[1:-1])
|
||||
params[key] = value
|
||||
|
||||
return content_type, params
|
||||
120
src/ahttpx/_network.py
Normal file
120
src/ahttpx/_network.py
Normal file
@ -0,0 +1,120 @@
|
||||
import asyncio
|
||||
import ssl
|
||||
import types
|
||||
import typing
|
||||
|
||||
import certifi
|
||||
|
||||
from ._streams import Stream
|
||||
|
||||
|
||||
__all__ = ["NetworkBackend", "NetworkStream", "timeout"]
|
||||
|
||||
|
||||
class NetworkStream(Stream):
|
||||
def __init__(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, address: str = ''
|
||||
) -> None:
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
self._address = address
|
||||
self._tls = False
|
||||
self._closed = False
|
||||
|
||||
async def read(self, size: int = -1) -> bytes:
|
||||
if size < 0:
|
||||
size = 64 * 1024
|
||||
return await self._reader.read(size)
|
||||
|
||||
async def write(self, buffer: bytes) -> None:
|
||||
self._writer.write(buffer)
|
||||
await self._writer.drain()
|
||||
|
||||
async def close(self) -> None:
|
||||
if not self._closed:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
self._closed = True
|
||||
|
||||
def __repr__(self):
|
||||
description = ""
|
||||
description += " TLS" if self._tls else ""
|
||||
description += " CLOSED" if self._closed else ""
|
||||
return f"<NetworkStream [{self._address!r}{description}]>"
|
||||
|
||||
def __del__(self):
|
||||
if not self._closed:
|
||||
import warnings
|
||||
warnings.warn("NetworkStream was garbage collected without being closed.")
|
||||
|
||||
# Context managed usage...
|
||||
async def __aenter__(self) -> "NetworkStream":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
):
|
||||
await self.close()
|
||||
|
||||
|
||||
class NetworkServer:
|
||||
def __init__(self, host: str, port: int, server: asyncio.Server):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._server = server
|
||||
|
||||
# Context managed usage...
|
||||
async def __aenter__(self) -> "NetworkServer":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
):
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
|
||||
|
||||
class NetworkBackend:
|
||||
def __init__(self, ssl_ctx: ssl.SSLContext | None = None):
|
||||
self._ssl_ctx = self.create_default_context() if ssl_ctx is None else ssl_ctx
|
||||
|
||||
def create_default_context(self) -> ssl.SSLContext:
|
||||
import certifi
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
async def connect(self, host: str, port: int) -> NetworkStream:
|
||||
"""
|
||||
Connect to the given address, returning a Stream instance.
|
||||
"""
|
||||
address = f"{host}:{port}"
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
return NetworkStream(reader, writer, address=address)
|
||||
|
||||
async def connect_tls(self, host: str, port: int, hostname: str = '') -> NetworkStream:
|
||||
"""
|
||||
Connect to the given address, returning a Stream instance.
|
||||
"""
|
||||
address = f"{host}:{port}"
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
await writer.start_tls(self._ssl_ctx, server_hostname=hostname)
|
||||
return NetworkStream(reader, writer, address=address)
|
||||
|
||||
async def serve(self, host: str, port: int, handler: typing.Callable[[NetworkStream], None]) -> NetworkServer:
|
||||
async def callback(reader, writer):
|
||||
stream = NetworkStream(reader, writer)
|
||||
await handler(stream)
|
||||
|
||||
server = await asyncio.start_server(callback, host, port)
|
||||
return NetworkServer(host, port, server)
|
||||
|
||||
|
||||
Semaphore = asyncio.Semaphore
|
||||
Lock = asyncio.Lock
|
||||
timeout = asyncio.timeout
|
||||
sleep = asyncio.sleep
|
||||
515
src/ahttpx/_parsers.py
Normal file
515
src/ahttpx/_parsers.py
Normal file
@ -0,0 +1,515 @@
|
||||
import enum
|
||||
|
||||
from ._streams import Stream
|
||||
|
||||
__all__ = ['HTTPParser', 'Mode', 'ProtocolError']
|
||||
|
||||
|
||||
# TODO...
|
||||
|
||||
# * Upgrade
|
||||
# * CONNECT
|
||||
|
||||
# * Support 'Expect: 100 Continue'
|
||||
# * Add 'Error' state transitions
|
||||
# * Add tests to trickle data
|
||||
# * Add type annotations
|
||||
|
||||
# * Optional... HTTP/1.0 support
|
||||
# * Read trailing headers on Transfer-Encoding: chunked. Not just '\r\n'.
|
||||
# * When writing Transfer-Encoding: chunked, split large writes into buffer size.
|
||||
# * When reading Transfer-Encoding: chunked, handle incomplete reads from large chunk sizes.
|
||||
# * .read() doesn't document if will always return maximum available.
|
||||
|
||||
# * validate method, target, protocol in request line
|
||||
# * validate protocol, status_code, reason_phrase in response line
|
||||
# * validate name, value on headers
|
||||
|
||||
|
||||
class State(enum.Enum):
|
||||
WAIT = 0
|
||||
SEND_METHOD_LINE = 1
|
||||
SEND_STATUS_LINE = 2
|
||||
SEND_HEADERS = 3
|
||||
SEND_BODY = 4
|
||||
RECV_METHOD_LINE = 5
|
||||
RECV_STATUS_LINE = 6
|
||||
RECV_HEADERS = 7
|
||||
RECV_BODY = 8
|
||||
DONE = 9
|
||||
CLOSED = 10
|
||||
|
||||
|
||||
class Mode(enum.Enum):
|
||||
CLIENT = 0
|
||||
SERVER = 1
|
||||
|
||||
|
||||
# The usual transitions will be...
|
||||
|
||||
# IDLE, IDLE
|
||||
# SEND_HEADERS, IDLE
|
||||
# SEND_BODY, IDLE
|
||||
# DONE, IDLE
|
||||
# DONE, SEND_HEADERS
|
||||
# DONE, SEND_BODY
|
||||
# DONE, DONE
|
||||
|
||||
# Then either back to IDLE, IDLE
|
||||
# or move to CLOSED, CLOSED
|
||||
|
||||
# 1. It is also valid for the server to start
|
||||
# sending the response without waiting for the
|
||||
# complete request.
|
||||
# 2. 1xx status codes are interim states, and
|
||||
# transition from SEND_HEADERS back to IDLE
|
||||
# 3. ...
|
||||
|
||||
class ProtocolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPParser:
|
||||
"""
|
||||
Usage...
|
||||
|
||||
client = HTTPParser(writer, reader)
|
||||
client.send_method_line()
|
||||
client.send_headers()
|
||||
client.send_body()
|
||||
client.recv_status_line()
|
||||
client.recv_headers()
|
||||
client.recv_body()
|
||||
client.complete()
|
||||
client.close()
|
||||
"""
|
||||
def __init__(self, stream: Stream, mode: str) -> None:
|
||||
self.stream = stream
|
||||
self.parser = ReadAheadParser(stream)
|
||||
self.mode = {'CLIENT': Mode.CLIENT, 'SERVER': Mode.SERVER}[mode]
|
||||
|
||||
# Track state...
|
||||
if self.mode == Mode.CLIENT:
|
||||
self.send_state: State = State.SEND_METHOD_LINE
|
||||
self.recv_state: State = State.WAIT
|
||||
else:
|
||||
self.recv_state = State.RECV_METHOD_LINE
|
||||
self.send_state = State.WAIT
|
||||
|
||||
# Track message framing...
|
||||
self.send_content_length: int | None = 0
|
||||
self.recv_content_length: int | None = 0
|
||||
self.send_seen_length = 0
|
||||
self.recv_seen_length = 0
|
||||
|
||||
# Track connection keep alive...
|
||||
self.send_keep_alive = True
|
||||
self.recv_keep_alive = True
|
||||
|
||||
# Special states...
|
||||
self.processing_1xx = False
|
||||
|
||||
async def send_method_line(self, method: bytes, target: bytes, protocol: bytes) -> None:
|
||||
"""
|
||||
Send the initial request line:
|
||||
|
||||
>>> p.send_method_line(b'GET', b'/', b'HTTP/1.1')
|
||||
|
||||
Sending state will switch to SEND_HEADERS state.
|
||||
"""
|
||||
if self.send_state != State.SEND_METHOD_LINE:
|
||||
msg = f"Called 'send_method_line' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Send initial request line, eg. "GET / HTTP/1.1"
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Sent unsupported protocol version")
|
||||
data = b" ".join([method, target, protocol]) + b"\r\n"
|
||||
await self.stream.write(data)
|
||||
|
||||
self.send_state = State.SEND_HEADERS
|
||||
self.recv_state = State.RECV_STATUS_LINE
|
||||
|
||||
async def send_status_line(self, protocol: bytes, status_code: int, reason: bytes) -> None:
|
||||
"""
|
||||
Send the initial response line:
|
||||
|
||||
>>> p.send_method_line(b'HTTP/1.1', 200, b'OK')
|
||||
|
||||
Sending state will switch to SEND_HEADERS state.
|
||||
"""
|
||||
if self.send_state != State.SEND_STATUS_LINE:
|
||||
msg = f"Called 'send_status_line' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Send initial request line, eg. "GET / HTTP/1.1"
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Sent unsupported protocol version")
|
||||
status_code_bytes = str(status_code).encode('ascii')
|
||||
data = b" ".join([protocol, status_code_bytes, reason]) + b"\r\n"
|
||||
await self.stream.write(data)
|
||||
|
||||
self.send_state = State.SEND_HEADERS
|
||||
|
||||
async def send_headers(self, headers: list[tuple[bytes, bytes]]) -> None:
|
||||
"""
|
||||
Send the request headers:
|
||||
|
||||
>>> p.send_headers([(b'Host', b'www.example.com')])
|
||||
|
||||
Sending state will switch to SEND_BODY state.
|
||||
"""
|
||||
if self.send_state != State.SEND_HEADERS:
|
||||
msg = f"Called 'send_headers' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Update header state
|
||||
seen_host = False
|
||||
for name, value in headers:
|
||||
lname = name.lower()
|
||||
if lname == b'host':
|
||||
seen_host = True
|
||||
elif lname == b'content-length':
|
||||
self.send_content_length = bounded_int(
|
||||
value,
|
||||
max_digits=20,
|
||||
exc_text="Sent invalid Content-Length"
|
||||
)
|
||||
elif lname == b'connection' and value == b'close':
|
||||
self.send_keep_alive = False
|
||||
elif lname == b'transfer-encoding' and value == b'chunked':
|
||||
self.send_content_length = None
|
||||
|
||||
if self.mode == Mode.CLIENT and not seen_host:
|
||||
raise ProtocolError("Request missing 'Host' header")
|
||||
|
||||
# Send request headers
|
||||
lines = [name + b": " + value + b"\r\n" for name, value in headers]
|
||||
data = b"".join(lines) + b"\r\n"
|
||||
await self.stream.write(data)
|
||||
|
||||
self.send_state = State.SEND_BODY
|
||||
|
||||
async def send_body(self, body: bytes) -> None:
|
||||
"""
|
||||
Send the request body. An empty bytes argument indicates the end of the stream:
|
||||
|
||||
>>> p.send_body(b'')
|
||||
|
||||
Sending state will switch to DONE.
|
||||
"""
|
||||
if self.send_state != State.SEND_BODY:
|
||||
msg = f"Called 'send_body' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
if self.send_content_length is None:
|
||||
# Transfer-Encoding: chunked
|
||||
self.send_seen_length += len(body)
|
||||
marker = f'{len(body):x}\r\n'.encode('ascii')
|
||||
await self.stream.write(marker + body + b'\r\n')
|
||||
|
||||
else:
|
||||
# Content-Length: xxx
|
||||
self.send_seen_length += len(body)
|
||||
if self.send_seen_length > self.send_content_length:
|
||||
msg = 'Too much data sent for declared Content-Length'
|
||||
raise ProtocolError(msg)
|
||||
if self.send_seen_length < self.send_content_length and body == b'':
|
||||
msg = 'Not enough data sent for declared Content-Length'
|
||||
raise ProtocolError(msg)
|
||||
if body:
|
||||
await self.stream.write(body)
|
||||
|
||||
if body == b'':
|
||||
# Handle body close
|
||||
self.send_state = State.DONE
|
||||
|
||||
async def recv_method_line(self) -> tuple[bytes, bytes, bytes]:
|
||||
"""
|
||||
Receive the initial request method line:
|
||||
|
||||
>>> method, target, protocol = p.recv_status_line()
|
||||
|
||||
Receive state will switch to RECV_HEADERS.
|
||||
"""
|
||||
if self.recv_state != State.RECV_METHOD_LINE:
|
||||
msg = f"Called 'recv_method_line' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Read initial response line, eg. "GET / HTTP/1.1"
|
||||
exc_text = "reading request method line"
|
||||
line = await self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text)
|
||||
method, target, protocol = line.split(b" ", 2)
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Received unsupported protocol version")
|
||||
|
||||
self.recv_state = State.RECV_HEADERS
|
||||
self.send_state = State.SEND_STATUS_LINE
|
||||
return method, target, protocol
|
||||
|
||||
async def recv_status_line(self) -> tuple[bytes, int, bytes]:
|
||||
"""
|
||||
Receive the initial response status line:
|
||||
|
||||
>>> protocol, status_code, reason_phrase = p.recv_status_line()
|
||||
|
||||
Receive state will switch to RECV_HEADERS.
|
||||
"""
|
||||
if self.recv_state != State.RECV_STATUS_LINE:
|
||||
msg = f"Called 'recv_status_line' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Read initial response line, eg. "HTTP/1.1 200 OK"
|
||||
exc_text = "reading response status line"
|
||||
line = await self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text)
|
||||
protocol, status_code_str, reason_phrase = line.split(b" ", 2)
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Received unsupported protocol version")
|
||||
|
||||
status_code = bounded_int(
|
||||
status_code_str,
|
||||
max_digits=3,
|
||||
exc_text="Received invalid status code"
|
||||
)
|
||||
if status_code < 100:
|
||||
raise ProtocolError("Received invalid status code")
|
||||
# 1xx status codes preceed the final response status code
|
||||
self.processing_1xx = status_code < 200
|
||||
|
||||
self.recv_state = State.RECV_HEADERS
|
||||
return protocol, status_code, reason_phrase
|
||||
|
||||
async def recv_headers(self) -> list[tuple[bytes, bytes]]:
|
||||
"""
|
||||
Receive the response headers:
|
||||
|
||||
>>> headers = p.recv_status_line()
|
||||
|
||||
Receive state will switch to RECV_BODY by default.
|
||||
Receive state will revert to RECV_STATUS_CODE for interim 1xx responses.
|
||||
"""
|
||||
if self.recv_state != State.RECV_HEADERS:
|
||||
msg = f"Called 'recv_headers' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Read response headers
|
||||
headers = []
|
||||
exc_text = "reading response headers"
|
||||
while line := await self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text):
|
||||
name, value = line.split(b":", 1)
|
||||
value = value.strip(b" ")
|
||||
headers.append((name, value))
|
||||
|
||||
# Update header state
|
||||
seen_host = False
|
||||
for name, value in headers:
|
||||
lname = name.lower()
|
||||
if lname == b'host':
|
||||
seen_host = True
|
||||
elif lname == b'content-length':
|
||||
self.recv_content_length = bounded_int(
|
||||
value,
|
||||
max_digits=20,
|
||||
exc_text="Received invalid Content-Length"
|
||||
)
|
||||
elif lname == b'connection' and value == b'close':
|
||||
self.recv_keep_alive = False
|
||||
elif lname == b'transfer-encoding' and value == b'chunked':
|
||||
self.recv_content_length = None
|
||||
|
||||
if self.mode == Mode.SERVER and not seen_host:
|
||||
raise ProtocolError("Request missing 'Host' header")
|
||||
|
||||
if self.processing_1xx:
|
||||
# 1xx status codes preceed the final response status code
|
||||
self.processing_1xx = False
|
||||
self.recv_state = State.RECV_STATUS_LINE
|
||||
else:
|
||||
self.recv_state = State.RECV_BODY
|
||||
return headers
|
||||
|
||||
async def recv_body(self) -> bytes:
|
||||
"""
|
||||
Receive the response body. An empty byte string indicates the end of the stream:
|
||||
|
||||
>>> buffer = bytearray()
|
||||
>>> while body := p.recv_body()
|
||||
>>> buffer.extend(body)
|
||||
|
||||
The server will switch to DONE.
|
||||
"""
|
||||
if self.recv_state != State.RECV_BODY:
|
||||
msg = f"Called 'recv_body' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
if self.recv_content_length is None:
|
||||
# Transfer-Encoding: chunked
|
||||
exc_text = 'reading chunk size'
|
||||
line = await self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text)
|
||||
sizestr, _, _ = line.partition(b";")
|
||||
|
||||
exc_text = "Received invalid chunk size"
|
||||
size = bounded_hex(sizestr, max_digits=8, exc_text=exc_text)
|
||||
if size > 0:
|
||||
body = await self.parser.read(size=size)
|
||||
exc_text = 'reading chunk data'
|
||||
await self.parser.read_until(b"\r\n", max_size=2, exc_text=exc_text)
|
||||
self.recv_seen_length += len(body)
|
||||
else:
|
||||
body = b''
|
||||
exc_text = 'reading chunk termination'
|
||||
await self.parser.read_until(b"\r\n", max_size=2, exc_text=exc_text)
|
||||
|
||||
else:
|
||||
# Content-Length: xxx
|
||||
remaining = self.recv_content_length - self.recv_seen_length
|
||||
size = min(remaining, 4096)
|
||||
body = await self.parser.read(size=size)
|
||||
self.recv_seen_length += len(body)
|
||||
if self.recv_seen_length < self.recv_content_length and body == b'':
|
||||
msg = 'Not enough data received for declared Content-Length'
|
||||
raise ProtocolError(msg)
|
||||
|
||||
if body == b'':
|
||||
# Handle body close
|
||||
self.recv_state = State.DONE
|
||||
return body
|
||||
|
||||
async def complete(self):
|
||||
is_fully_complete = self.send_state == State.DONE and self.recv_state == State.DONE
|
||||
is_keepalive = self.send_keep_alive and self.recv_keep_alive
|
||||
|
||||
if not (is_fully_complete and is_keepalive):
|
||||
await self.close()
|
||||
return
|
||||
|
||||
if self.mode == Mode.CLIENT:
|
||||
self.send_state = State.SEND_METHOD_LINE
|
||||
self.recv_state = State.WAIT
|
||||
else:
|
||||
self.recv_state = State.RECV_METHOD_LINE
|
||||
self.send_state = State.WAIT
|
||||
|
||||
self.send_content_length = 0
|
||||
self.recv_content_length = 0
|
||||
self.send_seen_length = 0
|
||||
self.recv_seen_length = 0
|
||||
self.send_keep_alive = True
|
||||
self.recv_keep_alive = True
|
||||
self.processing_1xx = False
|
||||
|
||||
async def close(self):
|
||||
if self.send_state != State.CLOSED:
|
||||
self.send_state = State.CLOSED
|
||||
self.recv_state = State.CLOSED
|
||||
await self.stream.close()
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
return (
|
||||
self.send_state == State.SEND_METHOD_LINE or
|
||||
self.recv_state == State.RECV_METHOD_LINE
|
||||
)
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return self.send_state == State.CLOSED
|
||||
|
||||
def description(self) -> str:
|
||||
return {
|
||||
State.SEND_METHOD_LINE: "idle",
|
||||
State.CLOSED: "closed",
|
||||
}.get(self.send_state, "active")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
cl_state = self.send_state.name
|
||||
sr_state = self.recv_state.name
|
||||
detail = f"client {cl_state}, server {sr_state}"
|
||||
return f'<HTTPParser [{detail}]>'
|
||||
|
||||
|
||||
class ReadAheadParser:
|
||||
"""
|
||||
A buffered I/O stream, with methods for read-ahead parsing.
|
||||
"""
|
||||
def __init__(self, stream: Stream) -> None:
|
||||
self._buffer = b''
|
||||
self._stream = stream
|
||||
self._chunk_size = 4096
|
||||
|
||||
async def _read_some(self) -> bytes:
|
||||
if self._buffer:
|
||||
ret, self._buffer = self._buffer, b''
|
||||
return ret
|
||||
return await self._stream.read(self._chunk_size)
|
||||
|
||||
def _push_back(self, buffer):
|
||||
assert self._buffer == b''
|
||||
self._buffer = buffer
|
||||
|
||||
async def read(self, size: int) -> bytes:
|
||||
"""
|
||||
Read and return up to 'size' bytes from the stream, with I/O buffering provided.
|
||||
|
||||
* Returns b'' to indicate connection close.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
while len(buffer) < size:
|
||||
chunk = await self._read_some()
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
|
||||
if len(buffer) > size:
|
||||
buffer, push_back = buffer[:size], buffer[size:]
|
||||
self._push_back(bytes(push_back))
|
||||
return bytes(buffer)
|
||||
|
||||
async def read_until(self, marker: bytes, max_size: int, exc_text: str) -> bytes:
|
||||
"""
|
||||
Read and return bytes from the stream, delimited by marker.
|
||||
|
||||
* The marker is not included in the return bytes.
|
||||
* The marker is consumed from the I/O stream.
|
||||
* Raises `ProtocolError` if the stream closes before a marker occurance.
|
||||
* Raises `ProtocolError` if marker did not occur within 'max_size + len(marker)' bytes.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
while len(buffer) <= max_size:
|
||||
chunk = await self._read_some()
|
||||
if not chunk:
|
||||
# stream closed before marker found.
|
||||
raise ProtocolError(f"Stream closed early {exc_text}")
|
||||
start_search = max(len(buffer) - len(marker), 0)
|
||||
buffer.extend(chunk)
|
||||
index = buffer.find(marker, start_search)
|
||||
|
||||
if index > max_size:
|
||||
# marker was found, though 'max_size' exceeded.
|
||||
raise ProtocolError(f"Exceeded maximum size {exc_text}")
|
||||
elif index >= 0:
|
||||
endindex = index + len(marker)
|
||||
self._push_back(bytes(buffer[endindex:]))
|
||||
return bytes(buffer[:index])
|
||||
|
||||
raise ProtocolError(f"Exceeded maximum size {exc_text}")
|
||||
|
||||
|
||||
def bounded_int(intstr: bytes, max_digits: int, exc_text: str):
|
||||
if len(intstr) > max_digits:
|
||||
# Length of bytestring exceeds maximum.
|
||||
raise ProtocolError(exc_text)
|
||||
if len(intstr.strip(b'0123456789')) != 0:
|
||||
# Contains invalid characters.
|
||||
raise ProtocolError(exc_text)
|
||||
|
||||
return int(intstr)
|
||||
|
||||
|
||||
def bounded_hex(hexstr: bytes, max_digits: int, exc_text: str):
|
||||
if len(hexstr) > max_digits:
|
||||
# Length of bytestring exceeds maximum.
|
||||
raise ProtocolError(exc_text)
|
||||
if len(hexstr.strip(b'0123456789abcdefABCDEF')) != 0:
|
||||
# Contains invalid characters.
|
||||
raise ProtocolError(exc_text)
|
||||
|
||||
return int(hexstr, base=16)
|
||||
284
src/ahttpx/_pool.py
Normal file
284
src/ahttpx/_pool.py
Normal file
@ -0,0 +1,284 @@
|
||||
import time
|
||||
import typing
|
||||
import types
|
||||
|
||||
from ._content import Content
|
||||
from ._headers import Headers
|
||||
from ._network import Lock, NetworkBackend, Semaphore
|
||||
from ._parsers import HTTPParser
|
||||
from ._response import Response
|
||||
from ._request import Request
|
||||
from ._streams import HTTPStream, Stream
|
||||
from ._urls import URL
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Transport",
|
||||
"ConnectionPool",
|
||||
"Connection",
|
||||
"open_connection",
|
||||
]
|
||||
|
||||
|
||||
class Transport:
|
||||
async def send(self, request: Request) -> Response:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | dict[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
async with await self.send(request) as response:
|
||||
await response.read()
|
||||
return response
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | dict[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
response = await self.send(request)
|
||||
return response
|
||||
|
||||
|
||||
class ConnectionPool(Transport):
|
||||
def __init__(self, backend: NetworkBackend | None = None):
|
||||
if backend is None:
|
||||
backend = NetworkBackend()
|
||||
|
||||
self._connections: list[Connection] = []
|
||||
self._network_backend = backend
|
||||
self._limit_concurrency = Semaphore(100)
|
||||
self._closed = False
|
||||
|
||||
# Public API...
|
||||
async def send(self, request: Request) -> Response:
|
||||
if self._closed:
|
||||
raise RuntimeError("ConnectionPool is closed.")
|
||||
|
||||
# TODO: concurrency limiting
|
||||
await self._cleanup()
|
||||
connection = await self._get_connection(request)
|
||||
response = await connection.send(request)
|
||||
return response
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
closing = list(self._connections)
|
||||
self._connections = []
|
||||
for conn in closing:
|
||||
await conn.close()
|
||||
|
||||
# Create or reuse connections as required...
|
||||
async def _get_connection(self, request: Request) -> "Connection":
|
||||
# Attempt to reuse an existing connection.
|
||||
url = request.url
|
||||
origin = URL(scheme=url.scheme, host=url.host, port=url.port)
|
||||
now = time.monotonic()
|
||||
for conn in self._connections:
|
||||
if conn.origin() == origin and conn.is_idle() and not conn.is_expired(now):
|
||||
return conn
|
||||
|
||||
# Or else create a new connection.
|
||||
conn = await open_connection(
|
||||
origin,
|
||||
hostname=request.headers["Host"],
|
||||
backend=self._network_backend
|
||||
)
|
||||
self._connections.append(conn)
|
||||
return conn
|
||||
|
||||
# Connection pool management...
|
||||
async def _cleanup(self) -> None:
|
||||
now = time.monotonic()
|
||||
for conn in list(self._connections):
|
||||
if conn.is_expired(now):
|
||||
await conn.close()
|
||||
if conn.is_closed():
|
||||
self._connections.remove(conn)
|
||||
|
||||
@property
|
||||
def connections(self) -> typing.List['Connection']:
|
||||
return [c for c in self._connections]
|
||||
|
||||
def description(self) -> str:
|
||||
counts = {"active": 0}
|
||||
for status in [c.description() for c in self._connections]:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
return ", ".join(f"{count} {status}" for status, count in counts.items())
|
||||
|
||||
# Builtins...
|
||||
def __repr__(self) -> str:
|
||||
return f"<ConnectionPool [{self.description()}]>"
|
||||
|
||||
def __del__(self):
|
||||
if not self._closed:
|
||||
import warnings
|
||||
warnings.warn("ConnectionPool was garbage collected without being closed.")
|
||||
|
||||
async def __aenter__(self) -> "ConnectionPool":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
class Connection(Transport):
|
||||
def __init__(self, stream: Stream, origin: URL | str):
|
||||
self._stream = stream
|
||||
self._origin = URL(origin)
|
||||
self._keepalive_duration = 5.0
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
self._request_lock = Lock()
|
||||
self._parser = HTTPParser(stream, mode='CLIENT')
|
||||
|
||||
# API for connection pool management...
|
||||
def origin(self) -> URL:
|
||||
return self._origin
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
return self._parser.is_idle()
|
||||
|
||||
def is_expired(self, when: float) -> bool:
|
||||
return self._parser.is_idle() and when > self._idle_expiry
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return self._parser.is_closed()
|
||||
|
||||
def description(self) -> str:
|
||||
return self._parser.description()
|
||||
|
||||
# API entry points...
|
||||
async def send(self, request: Request) -> Response:
|
||||
#async with self._request_lock:
|
||||
# try:
|
||||
await self._send_head(request)
|
||||
await self._send_body(request)
|
||||
code, headers = await self._recv_head()
|
||||
stream = HTTPStream(self._recv_body, self._complete)
|
||||
# TODO...
|
||||
return Response(code, headers=headers, content=stream)
|
||||
# finally:
|
||||
# await self._cycle_complete()
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._request_lock:
|
||||
await self._close()
|
||||
|
||||
# Top-level API for working directly with a connection.
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
url = self._origin.join(url)
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
async with await self.send(request) as response:
|
||||
await response.read()
|
||||
return response
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
url = self._origin.join(url)
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
return await self.send(request)
|
||||
|
||||
# Send the request...
|
||||
async def _send_head(self, request: Request) -> None:
|
||||
method = request.method.encode('ascii')
|
||||
target = request.url.target.encode('ascii')
|
||||
protocol = b'HTTP/1.1'
|
||||
await self._parser.send_method_line(method, target, protocol)
|
||||
headers = [
|
||||
(k.encode('ascii'), v.encode('ascii'))
|
||||
for k, v in request.headers.items()
|
||||
]
|
||||
await self._parser.send_headers(headers)
|
||||
|
||||
async def _send_body(self, request: Request) -> None:
|
||||
while data := await request.stream.read(64 * 1024):
|
||||
await self._parser.send_body(data)
|
||||
await self._parser.send_body(b'')
|
||||
|
||||
# Receive the response...
|
||||
async def _recv_head(self) -> tuple[int, Headers]:
|
||||
_, code, _ = await self._parser.recv_status_line()
|
||||
h = await self._parser.recv_headers()
|
||||
headers = Headers([
|
||||
(k.decode('ascii'), v.decode('ascii'))
|
||||
for k, v in h
|
||||
])
|
||||
return code, headers
|
||||
|
||||
async def _recv_body(self) -> bytes:
|
||||
return await self._parser.recv_body()
|
||||
|
||||
# Request/response cycle complete...
|
||||
async def _complete(self) -> None:
|
||||
await self._parser.complete()
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
|
||||
async def _close(self) -> None:
|
||||
await self._parser.close()
|
||||
|
||||
# Builtins...
|
||||
def __repr__(self) -> str:
|
||||
return f"<Connection [{self._origin} {self.description()}]>"
|
||||
|
||||
async def __aenter__(self) -> "Connection":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
):
|
||||
await self.close()
|
||||
|
||||
|
||||
async def open_connection(
|
||||
url: URL | str,
|
||||
hostname: str = '',
|
||||
backend: NetworkBackend | None = None,
|
||||
) -> Connection:
|
||||
|
||||
if isinstance(url, str):
|
||||
url = URL(url)
|
||||
|
||||
if url.scheme not in ("http", "https"):
|
||||
raise ValueError("URL scheme must be 'http://' or 'https://'.")
|
||||
if backend is None:
|
||||
backend = NetworkBackend()
|
||||
|
||||
host = url.host
|
||||
port = url.port or {"http": 80, "https": 443}[url.scheme]
|
||||
|
||||
if url.scheme == "https":
|
||||
stream = await backend.connect_tls(host, port, hostname)
|
||||
else:
|
||||
stream = await backend.connect(host, port)
|
||||
|
||||
return Connection(stream, url)
|
||||
49
src/ahttpx/_quickstart.py
Normal file
49
src/ahttpx/_quickstart.py
Normal file
@ -0,0 +1,49 @@
|
||||
import typing
|
||||
|
||||
from ._client import Client
|
||||
from ._content import Content
|
||||
from ._headers import Headers
|
||||
from ._streams import Stream
|
||||
from ._urls import URL
|
||||
|
||||
|
||||
__all__ = ['get', 'post', 'put', 'patch', 'delete']
|
||||
|
||||
|
||||
async def get(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
async with Client() as client:
|
||||
return await client.request("GET", url=url, headers=headers)
|
||||
|
||||
async def post(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
async with Client() as client:
|
||||
return await client.request("POST", url, headers=headers, content=content)
|
||||
|
||||
async def put(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
async with Client() as client:
|
||||
return await client.request("PUT", url, headers=headers, content=content)
|
||||
|
||||
async def patch(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
async with Client() as client:
|
||||
return await client.request("PATCH", url, headers=headers, content=content)
|
||||
|
||||
async def delete(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
async with Client() as client:
|
||||
return await client.request("DELETE", url=url, headers=headers)
|
||||
93
src/ahttpx/_request.py
Normal file
93
src/ahttpx/_request.py
Normal file
@ -0,0 +1,93 @@
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._content import Content
|
||||
from ._streams import ByteStream, Stream
|
||||
from ._headers import Headers
|
||||
from ._urls import URL
|
||||
|
||||
__all__ = ["Request"]
|
||||
|
||||
|
||||
class Request:
|
||||
def __init__(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
self.method = method
|
||||
self.url = URL(url)
|
||||
self.headers = Headers(headers)
|
||||
self.stream: Stream = ByteStream(b"")
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-14.23
|
||||
# RFC 2616, Section 14.23, Host.
|
||||
#
|
||||
# A client MUST include a Host header field in all HTTP/1.1 request messages.
|
||||
if "Host" not in self.headers:
|
||||
self.headers = self.headers.copy_set("Host", self.url.netloc)
|
||||
|
||||
if content is not None:
|
||||
if isinstance(content, bytes):
|
||||
self.stream = ByteStream(content)
|
||||
elif isinstance(content, Stream):
|
||||
self.stream = content
|
||||
elif isinstance(content, Content):
|
||||
ct = content.content_type()
|
||||
self.stream = content.encode()
|
||||
self.headers = self.headers.copy_set("Content-Type", ct)
|
||||
else:
|
||||
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-4.3
|
||||
# RFC 2616, Section 4.3, Message Body.
|
||||
#
|
||||
# The presence of a message-body in a request is signaled by the
|
||||
# inclusion of a Content-Length or Transfer-Encoding header field in
|
||||
# the request's message-headers.
|
||||
content_length: int | None = self.stream.size
|
||||
if content_length is None:
|
||||
self.headers = self.headers.copy_set("Transfer-Encoding", "chunked")
|
||||
elif content_length > 0:
|
||||
self.headers = self.headers.copy_set("Content-Length", str(content_length))
|
||||
|
||||
elif method in ("POST", "PUT", "PATCH"):
|
||||
# https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2
|
||||
# RFC 7230, Section 3.3.2, Content Length.
|
||||
#
|
||||
# A user agent SHOULD send a Content-Length in a request message when no
|
||||
# Transfer-Encoding is sent and the request method defines a meaning for
|
||||
# an enclosed payload body. For example, a Content-Length header field is
|
||||
# normally sent in a POST request even when the value is 0.
|
||||
# (indicating an empty payload body).
|
||||
self.headers = self.headers.copy_set("Content-Length", "0")
|
||||
|
||||
@property
|
||||
def body(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
raise RuntimeError("'.body' cannot be accessed without calling '.read()'")
|
||||
return self._body
|
||||
|
||||
async def read(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
self._body = await self.stream.read()
|
||||
self.stream = ByteStream(self._body)
|
||||
return self._body
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stream.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
await self.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Request [{self.method} {str(self.url)!r}]>"
|
||||
158
src/ahttpx/_response.py
Normal file
158
src/ahttpx/_response.py
Normal file
@ -0,0 +1,158 @@
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._content import Content
|
||||
from ._streams import ByteStream, Stream
|
||||
from ._headers import Headers, parse_opts_header
|
||||
|
||||
__all__ = ["Response"]
|
||||
|
||||
# We're using the same set as stdlib `http.HTTPStatus` here...
|
||||
#
|
||||
# https://github.com/python/cpython/blob/main/Lib/http/__init__.py
|
||||
_codes = {
|
||||
100: "Continue",
|
||||
101: "Switching Protocols",
|
||||
102: "Processing",
|
||||
103: "Early Hints",
|
||||
200: "OK",
|
||||
201: "Created",
|
||||
202: "Accepted",
|
||||
203: "Non-Authoritative Information",
|
||||
204: "No Content",
|
||||
205: "Reset Content",
|
||||
206: "Partial Content",
|
||||
207: "Multi-Status",
|
||||
208: "Already Reported",
|
||||
226: "IM Used",
|
||||
300: "Multiple Choices",
|
||||
301: "Moved Permanently",
|
||||
302: "Found",
|
||||
303: "See Other",
|
||||
304: "Not Modified",
|
||||
305: "Use Proxy",
|
||||
307: "Temporary Redirect",
|
||||
308: "Permanent Redirect",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
406: "Not Acceptable",
|
||||
407: "Proxy Authentication Required",
|
||||
408: "Request Timeout",
|
||||
409: "Conflict",
|
||||
410: "Gone",
|
||||
411: "Length Required",
|
||||
412: "Precondition Failed",
|
||||
413: "Content Too Large",
|
||||
414: "URI Too Long",
|
||||
415: "Unsupported Media Type",
|
||||
416: "Range Not Satisfiable",
|
||||
417: "Expectation Failed",
|
||||
418: "I'm a Teapot",
|
||||
421: "Misdirected Request",
|
||||
422: "Unprocessable Content",
|
||||
423: "Locked",
|
||||
424: "Failed Dependency",
|
||||
425: "Too Early",
|
||||
426: "Upgrade Required",
|
||||
428: "Precondition Required",
|
||||
429: "Too Many Requests",
|
||||
431: "Request Header Fields Too Large",
|
||||
451: "Unavailable For Legal Reasons",
|
||||
500: "Internal Server Error",
|
||||
501: "Not Implemented",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
505: "HTTP Version Not Supported",
|
||||
506: "Variant Also Negotiates",
|
||||
507: "Insufficient Storage",
|
||||
508: "Loop Detected",
|
||||
510: "Not Extended",
|
||||
511: "Network Authentication Required",
|
||||
}
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
*,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = Headers(headers)
|
||||
self.stream: Stream = ByteStream(b"")
|
||||
|
||||
if content is not None:
|
||||
if isinstance(content, bytes):
|
||||
self.stream = ByteStream(content)
|
||||
elif isinstance(content, Stream):
|
||||
self.stream = content
|
||||
elif isinstance(content, Content):
|
||||
ct = content.content_type()
|
||||
self.stream = content.encode()
|
||||
self.headers = self.headers.copy_set("Content-Type", ct)
|
||||
else:
|
||||
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-4.3
|
||||
# RFC 2616, Section 4.3, Message Body.
|
||||
#
|
||||
# All 1xx (informational), 204 (no content), and 304 (not modified) responses
|
||||
# MUST NOT include a message-body. All other responses do include a
|
||||
# message-body, although it MAY be of zero length.
|
||||
if status_code >= 200 and status_code != 204 and status_code != 304:
|
||||
content_length: int | None = self.stream.size
|
||||
if content_length is None:
|
||||
self.headers = self.headers.copy_set("Transfer-Encoding", "chunked")
|
||||
else:
|
||||
self.headers = self.headers.copy_set("Content-Length", str(content_length))
|
||||
|
||||
@property
|
||||
def reason_phrase(self):
|
||||
return _codes.get(self.status_code, "Unknown Status Code")
|
||||
|
||||
@property
|
||||
def body(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
raise RuntimeError("'.body' cannot be accessed without calling '.read()'")
|
||||
return self._body
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
if not hasattr(self, '_body'):
|
||||
raise RuntimeError("'.text' cannot be accessed without calling '.read()'")
|
||||
if not hasattr(self, '_text'):
|
||||
ct = self.headers.get('Content-Type', '')
|
||||
media, opts = parse_opts_header(ct)
|
||||
charset = 'utf-8'
|
||||
if media.startswith('text/'):
|
||||
charset = opts.get('charset', 'utf-8')
|
||||
self._text = self._body.decode(charset)
|
||||
return self._text
|
||||
|
||||
async def read(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
self._body = await self.stream.read()
|
||||
return self._body
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stream.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
await self.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Response [{self.status_code} {self.reason_phrase}]>"
|
||||
126
src/ahttpx/_server.py
Normal file
126
src/ahttpx/_server.py
Normal file
@ -0,0 +1,126 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ._content import Text
|
||||
from ._parsers import HTTPParser
|
||||
from ._request import Request
|
||||
from ._response import Response
|
||||
from ._network import NetworkBackend, sleep
|
||||
from ._streams import HTTPStream
|
||||
|
||||
__all__ = [
|
||||
"serve_http", "run"
|
||||
]
|
||||
|
||||
logger = logging.getLogger("httpx.server")
|
||||
|
||||
|
||||
class ConnectionClosed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPConnection:
|
||||
def __init__(self, stream, endpoint):
|
||||
self._stream = stream
|
||||
self._endpoint = endpoint
|
||||
self._parser = HTTPParser(stream, mode='SERVER')
|
||||
self._keepalive_duration = 5.0
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
|
||||
# API entry points...
|
||||
async def handle_requests(self):
|
||||
try:
|
||||
while not self._parser.is_closed():
|
||||
method, url, headers = await self._recv_head()
|
||||
stream = HTTPStream(self._recv_body, self._complete)
|
||||
# TODO: Handle endpoint exceptions
|
||||
async with Request(method, url, headers=headers, content=stream) as request:
|
||||
try:
|
||||
response = await self._endpoint(request)
|
||||
status_line = f"{request.method} {request.url.target} [{response.status_code} {response.reason_phrase}]"
|
||||
logger.info(status_line)
|
||||
except Exception:
|
||||
logger.error("Internal Server Error", exc_info=True)
|
||||
content = Text("Internal Server Error")
|
||||
err = Response(code=500, content=content)
|
||||
await self._send_head(err)
|
||||
await self._send_body(err)
|
||||
else:
|
||||
await self._send_head(response)
|
||||
await self._send_body(response)
|
||||
except Exception:
|
||||
logger.error("Internal Server Error", exc_info=True)
|
||||
|
||||
async def close(self):
|
||||
self._parser.close()
|
||||
|
||||
# Receive the request...
|
||||
async def _recv_head(self) -> tuple[str, str, list[tuple[str, str]]]:
|
||||
method, target, _ = await self._parser.recv_method_line()
|
||||
m = method.decode('ascii')
|
||||
t = target.decode('ascii')
|
||||
headers = await self._parser.recv_headers()
|
||||
h = [
|
||||
(k.decode('latin-1'), v.decode('latin-1'))
|
||||
for k, v in headers
|
||||
]
|
||||
return m, t, h
|
||||
|
||||
async def _recv_body(self):
|
||||
return await self._parser.recv_body()
|
||||
|
||||
# Return the response...
|
||||
async def _send_head(self, response: Response):
|
||||
protocol = b"HTTP/1.1"
|
||||
status = response.status_code
|
||||
reason = response.reason_phrase.encode('ascii')
|
||||
await self._parser.send_status_line(protocol, status, reason)
|
||||
headers = [
|
||||
(k.encode('ascii'), v.encode('ascii'))
|
||||
for k, v in response.headers.items()
|
||||
]
|
||||
await self._parser.send_headers(headers)
|
||||
|
||||
async def _send_body(self, response: Response):
|
||||
while data := await response.stream.read(64 * 1024):
|
||||
await self._parser.send_body(data)
|
||||
await self._parser.send_body(b'')
|
||||
|
||||
# Start it all over again...
|
||||
async def _complete(self):
|
||||
await self._parser.complete
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
|
||||
|
||||
class HTTPServer:
|
||||
def __init__(self, host, port):
|
||||
self.url = f"http://{host}:{port}/"
|
||||
|
||||
async def wait(self):
|
||||
while(True):
|
||||
await sleep(1)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def serve_http(endpoint):
|
||||
async def handler(stream):
|
||||
connection = HTTPConnection(stream, endpoint)
|
||||
await connection.handle_requests()
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
|
||||
backend = NetworkBackend()
|
||||
async with await backend.serve("127.0.0.1", 8080, handler) as server:
|
||||
server = HTTPServer(server.host, server.port)
|
||||
logger.info(f"Serving on {server.url} (Press CTRL+C to quit)")
|
||||
yield server
|
||||
|
||||
|
||||
async def run(app):
|
||||
async with await serve_http(app) as server:
|
||||
server.wait()
|
||||
235
src/ahttpx/_streams.py
Normal file
235
src/ahttpx/_streams.py
Normal file
@ -0,0 +1,235 @@
|
||||
import io
|
||||
import types
|
||||
import os
|
||||
|
||||
|
||||
class Stream:
|
||||
async def read(self, size: int=-1) -> bytes:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def close(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
await self.close()
|
||||
|
||||
|
||||
class ByteStream(Stream):
|
||||
def __init__(self, data: bytes = b''):
|
||||
self._buffer = io.BytesIO(data)
|
||||
self._size = len(data)
|
||||
|
||||
async def read(self, size: int=-1) -> bytes:
|
||||
return self._buffer.read(size)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._buffer.close()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return self._size
|
||||
|
||||
|
||||
class DuplexStream(Stream):
|
||||
"""
|
||||
DuplexStream supports both `read` and `write` operations,
|
||||
which are applied to seperate buffers.
|
||||
|
||||
This stream can be used for testing network parsers.
|
||||
"""
|
||||
|
||||
def __init__(self, data: bytes = b''):
|
||||
self._read_buffer = io.BytesIO(data)
|
||||
self._write_buffer = io.BytesIO()
|
||||
|
||||
async def read(self, size: int=-1) -> bytes:
|
||||
return self._read_buffer.read(size)
|
||||
|
||||
async def write(self, buffer: bytes):
|
||||
return self._write_buffer.write(buffer)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._read_buffer.close()
|
||||
self._write_buffer.close()
|
||||
|
||||
def input_bytes(self) -> bytes:
|
||||
return self._read_buffer.getvalue()
|
||||
|
||||
def output_bytes(self) -> bytes:
|
||||
return self._write_buffer.getvalue()
|
||||
|
||||
|
||||
class FileStream(Stream):
|
||||
def __init__(self, path):
|
||||
self._path = path
|
||||
self._fileobj = None
|
||||
self._size = None
|
||||
|
||||
async def read(self, size: int=-1) -> bytes:
|
||||
if self._fileobj is None:
|
||||
raise ValueError('I/O operation on unopened file')
|
||||
return self._fileobj.read(size)
|
||||
|
||||
async def open(self):
|
||||
self._fileobj = open(self._path, 'rb')
|
||||
self._size = os.path.getsize(self._path)
|
||||
return self
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._fileobj is not None:
|
||||
self._fileobj.close()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return self._size
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.open()
|
||||
return self
|
||||
|
||||
|
||||
class HTTPStream(Stream):
|
||||
def __init__(self, next_chunk, complete):
|
||||
self._next_chunk = next_chunk
|
||||
self._complete = complete
|
||||
self._buffer = io.BytesIO()
|
||||
|
||||
async def read(self, size=-1) -> bytes:
|
||||
sections = []
|
||||
length = 0
|
||||
|
||||
# If we have any data in the buffer read that and clear the buffer.
|
||||
buffered = self._buffer.read()
|
||||
if buffered:
|
||||
sections.append(buffered)
|
||||
length += len(buffered)
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate(0)
|
||||
|
||||
# Read each chunk in turn.
|
||||
while (size < 0) or (length < size):
|
||||
section = await self._next_chunk()
|
||||
sections.append(section)
|
||||
length += len(section)
|
||||
if section == b'':
|
||||
break
|
||||
|
||||
# If we've more data than requested, then push some back into the buffer.
|
||||
output = b''.join(sections)
|
||||
if size > -1 and len(output) > size:
|
||||
output, remainder = output[:size], output[size:]
|
||||
self._buffer.write(remainder)
|
||||
self._buffer.seek(0)
|
||||
|
||||
return output
|
||||
|
||||
async def close(self) -> None:
|
||||
self._buffer.close()
|
||||
if self._complete is not None:
|
||||
await self._complete()
|
||||
|
||||
|
||||
class MultiPartStream(Stream):
|
||||
def __init__(self, form: list[tuple[str, str]], files: list[tuple[str, str]], boundary=''):
|
||||
self._form = list(form)
|
||||
self._files = list(files)
|
||||
self._boundary = boundary or os.urandom(16).hex()
|
||||
# Mutable state...
|
||||
self._form_progress = list(self._form)
|
||||
self._files_progress = list(self._files)
|
||||
self._filestream: FileStream | None = None
|
||||
self._complete = False
|
||||
self._buffer = io.BytesIO()
|
||||
|
||||
async def read(self, size=-1) -> bytes:
|
||||
sections = []
|
||||
length = 0
|
||||
|
||||
# If we have any data in the buffer read that and clear the buffer.
|
||||
buffered = self._buffer.read()
|
||||
if buffered:
|
||||
sections.append(buffered)
|
||||
length += len(buffered)
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate(0)
|
||||
|
||||
# Read each multipart section in turn.
|
||||
while (size < 0) or (length < size):
|
||||
section = await self._read_next_section()
|
||||
sections.append(section)
|
||||
length += len(section)
|
||||
if section == b'':
|
||||
break
|
||||
|
||||
# If we've more data than requested, then push some back into the buffer.
|
||||
output = b''.join(sections)
|
||||
if size > -1 and len(output) > size:
|
||||
output, remainder = output[:size], output[size:]
|
||||
self._buffer.write(remainder)
|
||||
self._buffer.seek(0)
|
||||
|
||||
return output
|
||||
|
||||
async def _read_next_section(self) -> bytes:
|
||||
if self._form_progress:
|
||||
# return a form item
|
||||
key, value = self._form_progress.pop(0)
|
||||
name = key.translate({10: "%0A", 13: "%0D", 34: "%22"})
|
||||
return (
|
||||
f"--{self._boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n'
|
||||
f"\r\n"
|
||||
f"{value}\r\n"
|
||||
).encode("utf-8")
|
||||
elif self._files_progress and self._filestream is None:
|
||||
# return start of a file item
|
||||
key, value = self._files_progress.pop(0)
|
||||
self._filestream = await FileStream(value).open()
|
||||
name = key.translate({10: "%0A", 13: "%0D", 34: "%22"})
|
||||
filename = os.path.basename(value)
|
||||
return (
|
||||
f"--{self._boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
||||
f"\r\n"
|
||||
).encode("utf-8")
|
||||
elif self._filestream is not None:
|
||||
chunk = await self._filestream.read(64*1024)
|
||||
if chunk != b'':
|
||||
# return some bytes from file
|
||||
return chunk
|
||||
else:
|
||||
# return end of file item
|
||||
await self._filestream.close()
|
||||
self._filestream = None
|
||||
return b"\r\n"
|
||||
elif not self._complete:
|
||||
# return final section of multipart
|
||||
self._complete = True
|
||||
return f"--{self._boundary}--\r\n".encode("utf-8")
|
||||
# return EOF marker
|
||||
return b""
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._filestream is not None:
|
||||
await self._filestream.close()
|
||||
self._filestream = None
|
||||
self._buffer.close()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return None
|
||||
85
src/ahttpx/_urlencode.py
Normal file
85
src/ahttpx/_urlencode.py
Normal file
@ -0,0 +1,85 @@
|
||||
import re
|
||||
|
||||
__all__ = ["quote", "unquote", "urldecode", "urlencode"]
|
||||
|
||||
|
||||
# Matchs a sequence of one or more '%xx' escapes.
|
||||
PERCENT_ENCODED_REGEX = re.compile("(%[A-Fa-f0-9][A-Fa-f0-9])+")
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
|
||||
SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
|
||||
|
||||
def urlencode(multidict, safe=SAFE):
|
||||
pairs = []
|
||||
for key, values in multidict.items():
|
||||
pairs.extend([(key, value) for value in values])
|
||||
|
||||
safe += "+"
|
||||
pairs = [(k.replace(" ", "+"), v.replace(" ", "+")) for k, v in pairs]
|
||||
|
||||
return "&".join(
|
||||
f"{quote(key, safe)}={quote(val, safe)}"
|
||||
for key, val in pairs
|
||||
)
|
||||
|
||||
|
||||
def urldecode(string):
|
||||
parts = [part.partition("=") for part in string.split("&") if part]
|
||||
pairs = [
|
||||
(unquote(key), unquote(val))
|
||||
for key, _, val in parts
|
||||
]
|
||||
|
||||
pairs = [(k.replace("+", " "), v.replace("+", " ")) for k, v in pairs]
|
||||
|
||||
ret = {}
|
||||
for k, v in pairs:
|
||||
ret.setdefault(k, []).append(v)
|
||||
return ret
|
||||
|
||||
|
||||
def quote(string, safe=SAFE):
|
||||
# Fast path if the string is already safe.
|
||||
if not string.strip(safe):
|
||||
return string
|
||||
|
||||
# Replace any characters not in the safe set with '%xx' escape sequences.
|
||||
return "".join([
|
||||
char if char in safe else percent(char)
|
||||
for char in string
|
||||
])
|
||||
|
||||
|
||||
def unquote(string):
|
||||
# Fast path if the string is not quoted.
|
||||
if '%' not in string:
|
||||
return string
|
||||
|
||||
# Unquote.
|
||||
parts = []
|
||||
current_position = 0
|
||||
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
|
||||
start_position, end_position = match.start(), match.end()
|
||||
matched_text = match.group(0)
|
||||
# Include any text up to the '%xx' escape sequence.
|
||||
if start_position != current_position:
|
||||
leading_text = string[current_position:start_position]
|
||||
parts.append(leading_text)
|
||||
|
||||
# Decode the '%xx' escape sequence.
|
||||
hex = matched_text.replace('%', '')
|
||||
decoded = bytes.fromhex(hex).decode('utf-8')
|
||||
parts.append(decoded)
|
||||
current_position = end_position
|
||||
|
||||
# Include any text after the final '%xx' escape sequence.
|
||||
if current_position != len(string):
|
||||
trailing_text = string[current_position:]
|
||||
parts.append(trailing_text)
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def percent(c):
|
||||
return ''.join(f"%{b:02X}" for b in c.encode("utf-8"))
|
||||
534
src/ahttpx/_urlparse.py
Normal file
534
src/ahttpx/_urlparse.py
Normal file
@ -0,0 +1,534 @@
|
||||
"""
|
||||
An implementation of `urlparse` that provides URL validation and normalization
|
||||
as described by RFC3986.
|
||||
|
||||
We rely on this implementation rather than the one in Python's stdlib, because:
|
||||
|
||||
* It provides more complete URL validation.
|
||||
* It properly differentiates between an empty querystring and an absent querystring,
|
||||
to distinguish URLs with a trailing '?'.
|
||||
* It handles scheme, hostname, port, and path normalization.
|
||||
* It supports IDNA hostnames, normalizing them to their encoded form.
|
||||
* The API supports passing individual components, as well as the complete URL string.
|
||||
|
||||
Previously we relied on the excellent `rfc3986` package to handle URL parsing and
|
||||
validation, but this module provides a simpler alternative, with less indirection
|
||||
required.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import typing
|
||||
|
||||
|
||||
class InvalidURL(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
MAX_URL_LENGTH = 65536
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3
|
||||
UNRESERVED_CHARACTERS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
SUB_DELIMS = "!$&'()*+,;="
|
||||
|
||||
PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}")
|
||||
|
||||
# https://url.spec.whatwg.org/#percent-encoded-bytes
|
||||
|
||||
# The fragment percent-encode set is the C0 control percent-encode set
|
||||
# and U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`).
|
||||
FRAG_SAFE = "".join(
|
||||
[chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x3C, 0x3E, 0x60)]
|
||||
)
|
||||
|
||||
# The query percent-encode set is the C0 control percent-encode set
|
||||
# and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
|
||||
QUERY_SAFE = "".join(
|
||||
[chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E)]
|
||||
)
|
||||
|
||||
# The path percent-encode set is the query percent-encode set
|
||||
# and U+003F (?), U+0060 (`), U+007B ({), and U+007D (}).
|
||||
PATH_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + (0x3F, 0x60, 0x7B, 0x7D)
|
||||
]
|
||||
)
|
||||
|
||||
# The userinfo percent-encode set is the path percent-encode set
|
||||
# and U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@),
|
||||
# U+005B ([) to U+005E (^), inclusive, and U+007C (|).
|
||||
USERNAME_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
PASSWORD_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
# Note... The terminology 'userinfo' percent-encode set in the WHATWG document
|
||||
# is used for the username and password quoting. For the joint userinfo component
|
||||
# we remove U+003A (:) from the safe set.
|
||||
USERINFO_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# {scheme}: (optional)
|
||||
# //{authority} (optional)
|
||||
# {path}
|
||||
# ?{query} (optional)
|
||||
# #{fragment} (optional)
|
||||
URL_REGEX = re.compile(
|
||||
(
|
||||
r"(?:(?P<scheme>{scheme}):)?"
|
||||
r"(?://(?P<authority>{authority}))?"
|
||||
r"(?P<path>{path})"
|
||||
r"(?:\?(?P<query>{query}))?"
|
||||
r"(?:#(?P<fragment>{fragment}))?"
|
||||
).format(
|
||||
scheme="([a-zA-Z][a-zA-Z0-9+.-]*)?",
|
||||
authority="[^/?#]*",
|
||||
path="[^?#]*",
|
||||
query="[^#]*",
|
||||
fragment=".*",
|
||||
)
|
||||
)
|
||||
|
||||
# {userinfo}@ (optional)
|
||||
# {host}
|
||||
# :{port} (optional)
|
||||
AUTHORITY_REGEX = re.compile(
|
||||
(
|
||||
r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
|
||||
).format(
|
||||
userinfo=".*", # Any character sequence.
|
||||
host="(\\[.*\\]|[^:@]*)", # Either any character sequence excluding ':' or '@',
|
||||
# or an IPv6 address enclosed within square brackets.
|
||||
port=".*", # Any character sequence.
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# If we call urlparse with an individual component, then we need to regex
|
||||
# validate that component individually.
|
||||
# Note that we're duplicating the same strings as above. Shock! Horror!!
|
||||
COMPONENT_REGEX = {
|
||||
"scheme": re.compile("([a-zA-Z][a-zA-Z0-9+.-]*)?"),
|
||||
"authority": re.compile("[^/?#]*"),
|
||||
"path": re.compile("[^?#]*"),
|
||||
"query": re.compile("[^#]*"),
|
||||
"fragment": re.compile(".*"),
|
||||
"userinfo": re.compile("[^@]*"),
|
||||
"host": re.compile("(\\[.*\\]|[^:]*)"),
|
||||
"port": re.compile(".*"),
|
||||
}
|
||||
|
||||
|
||||
# We use these simple regexs as a first pass before handing off to
|
||||
# the stdlib 'ipaddress' module for IP address validation.
|
||||
IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
|
||||
|
||||
|
||||
class ParseResult(typing.NamedTuple):
|
||||
scheme: str
|
||||
userinfo: str
|
||||
host: str
|
||||
port: int | None
|
||||
path: str
|
||||
query: str | None
|
||||
fragment: str | None
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
f"{self.userinfo}@" if self.userinfo else "",
|
||||
f"[{self.host}]" if ":" in self.host else self.host,
|
||||
f":{self.port}" if self.port is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def netloc(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
f"[{self.host}]" if ":" in self.host else self.host,
|
||||
f":{self.port}" if self.port is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
def copy_with(self, **kwargs: str | None) -> "ParseResult":
|
||||
if not kwargs:
|
||||
return self
|
||||
|
||||
defaults = {
|
||||
"scheme": self.scheme,
|
||||
"authority": self.authority,
|
||||
"path": self.path,
|
||||
"query": self.query,
|
||||
"fragment": self.fragment,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return urlparse("", **defaults)
|
||||
|
||||
def __str__(self) -> str:
|
||||
authority = self.authority
|
||||
return "".join(
|
||||
[
|
||||
f"{self.scheme}:" if self.scheme else "",
|
||||
f"//{authority}" if authority else "",
|
||||
self.path,
|
||||
f"?{self.query}" if self.query is not None else "",
|
||||
f"#{self.fragment}" if self.fragment is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
||||
# Initial basic checks on allowable URLs.
|
||||
# ---------------------------------------
|
||||
|
||||
# Hard limit the maximum allowable URL length.
|
||||
if len(url) > MAX_URL_LENGTH:
|
||||
raise InvalidURL("URL too long")
|
||||
|
||||
# If a URL includes any ASCII control characters including \t, \r, \n,
|
||||
# then treat it as invalid.
|
||||
if any(char.isascii() and not char.isprintable() for char in url):
|
||||
char = next(char for char in url if char.isascii() and not char.isprintable())
|
||||
idx = url.find(char)
|
||||
error = (
|
||||
f"Invalid non-printable ASCII character in URL, {char!r} at position {idx}."
|
||||
)
|
||||
raise InvalidURL(error)
|
||||
|
||||
# Some keyword arguments require special handling.
|
||||
# ------------------------------------------------
|
||||
|
||||
# Coerce "port" to a string, if it is provided as an integer.
|
||||
if "port" in kwargs:
|
||||
port = kwargs["port"]
|
||||
kwargs["port"] = str(port) if isinstance(port, int) else port
|
||||
|
||||
# Replace "netloc" with "host and "port".
|
||||
if "netloc" in kwargs:
|
||||
netloc = kwargs.pop("netloc") or ""
|
||||
kwargs["host"], _, kwargs["port"] = netloc.partition(":")
|
||||
|
||||
# Replace "username" and/or "password" with "userinfo".
|
||||
if "username" in kwargs or "password" in kwargs:
|
||||
username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE)
|
||||
password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE)
|
||||
kwargs["userinfo"] = f"{username}:{password}" if password else username
|
||||
|
||||
# Replace "raw_path" with "path" and "query".
|
||||
if "raw_path" in kwargs:
|
||||
raw_path = kwargs.pop("raw_path") or ""
|
||||
kwargs["path"], seperator, kwargs["query"] = raw_path.partition("?")
|
||||
if not seperator:
|
||||
kwargs["query"] = None
|
||||
|
||||
# Ensure that IPv6 "host" addresses are always escaped with "[...]".
|
||||
if "host" in kwargs:
|
||||
host = kwargs.get("host") or ""
|
||||
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
||||
kwargs["host"] = f"[{host}]"
|
||||
|
||||
# If any keyword arguments are provided, ensure they are valid.
|
||||
# -------------------------------------------------------------
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
if len(value) > MAX_URL_LENGTH:
|
||||
raise InvalidURL(f"URL component '{key}' too long")
|
||||
|
||||
# If a component includes any ASCII control characters including \t, \r, \n,
|
||||
# then treat it as invalid.
|
||||
if any(char.isascii() and not char.isprintable() for char in value):
|
||||
char = next(
|
||||
char for char in value if char.isascii() and not char.isprintable()
|
||||
)
|
||||
idx = value.find(char)
|
||||
error = (
|
||||
f"Invalid non-printable ASCII character in URL {key} component, "
|
||||
f"{char!r} at position {idx}."
|
||||
)
|
||||
raise InvalidURL(error)
|
||||
|
||||
# Ensure that keyword arguments match as a valid regex.
|
||||
if not COMPONENT_REGEX[key].fullmatch(value):
|
||||
raise InvalidURL(f"Invalid URL component '{key}'")
|
||||
|
||||
# The URL_REGEX will always match, but may have empty components.
|
||||
url_match = URL_REGEX.match(url)
|
||||
assert url_match is not None
|
||||
url_dict = url_match.groupdict()
|
||||
|
||||
# * 'scheme', 'authority', and 'path' may be empty strings.
|
||||
# * 'query' may be 'None', indicating no trailing "?" portion.
|
||||
# Any string including the empty string, indicates a trailing "?".
|
||||
# * 'fragment' may be 'None', indicating no trailing "#" portion.
|
||||
# Any string including the empty string, indicates a trailing "#".
|
||||
scheme = kwargs.get("scheme", url_dict["scheme"]) or ""
|
||||
authority = kwargs.get("authority", url_dict["authority"]) or ""
|
||||
path = kwargs.get("path", url_dict["path"]) or ""
|
||||
query = kwargs.get("query", url_dict["query"])
|
||||
frag = kwargs.get("fragment", url_dict["fragment"])
|
||||
|
||||
# The AUTHORITY_REGEX will always match, but may have empty components.
|
||||
authority_match = AUTHORITY_REGEX.match(authority)
|
||||
assert authority_match is not None
|
||||
authority_dict = authority_match.groupdict()
|
||||
|
||||
# * 'userinfo' and 'host' may be empty strings.
|
||||
# * 'port' may be 'None'.
|
||||
userinfo = kwargs.get("userinfo", authority_dict["userinfo"]) or ""
|
||||
host = kwargs.get("host", authority_dict["host"]) or ""
|
||||
port = kwargs.get("port", authority_dict["port"])
|
||||
|
||||
# Normalize and validate each component.
|
||||
# We end up with a parsed representation of the URL,
|
||||
# with components that are plain ASCII bytestrings.
|
||||
parsed_scheme: str = scheme.lower()
|
||||
parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE)
|
||||
parsed_host: str = encode_host(host)
|
||||
parsed_port: int | None = normalize_port(port, scheme)
|
||||
|
||||
has_scheme = parsed_scheme != ""
|
||||
has_authority = (
|
||||
parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
|
||||
)
|
||||
validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
|
||||
if has_scheme or has_authority:
|
||||
path = normalize_path(path)
|
||||
|
||||
parsed_path: str = quote(path, safe=PATH_SAFE)
|
||||
parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE)
|
||||
parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE)
|
||||
|
||||
# The parsed ASCII bytestrings are our canonical form.
|
||||
# All properties of the URL are derived from these.
|
||||
return ParseResult(
|
||||
parsed_scheme,
|
||||
parsed_userinfo,
|
||||
parsed_host,
|
||||
parsed_port,
|
||||
parsed_path,
|
||||
parsed_query,
|
||||
parsed_frag,
|
||||
)
|
||||
|
||||
|
||||
def encode_host(host: str) -> str:
|
||||
if not host:
|
||||
return ""
|
||||
|
||||
elif IPv4_STYLE_HOSTNAME.match(host):
|
||||
# Validate IPv4 hostnames like #.#.#.#
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
|
||||
try:
|
||||
ipaddress.IPv4Address(host)
|
||||
except ipaddress.AddressValueError:
|
||||
raise InvalidURL(f"Invalid IPv4 address: {host!r}")
|
||||
return host
|
||||
|
||||
elif IPv6_STYLE_HOSTNAME.match(host):
|
||||
# Validate IPv6 hostnames like [...]
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# "A host identified by an Internet Protocol literal address, version 6
|
||||
# [RFC3513] or later, is distinguished by enclosing the IP literal
|
||||
# within square brackets ("[" and "]"). This is the only place where
|
||||
# square bracket characters are allowed in the URI syntax."
|
||||
try:
|
||||
ipaddress.IPv6Address(host[1:-1])
|
||||
except ipaddress.AddressValueError:
|
||||
raise InvalidURL(f"Invalid IPv6 address: {host!r}")
|
||||
return host[1:-1]
|
||||
|
||||
elif not host.isascii():
|
||||
try:
|
||||
import idna # type: ignore
|
||||
except ImportError:
|
||||
raise InvalidURL(
|
||||
f"Cannot handle URL with IDNA hostname: {host!r}. "
|
||||
f"Package 'idna' is not installed."
|
||||
)
|
||||
|
||||
# IDNA hostnames
|
||||
try:
|
||||
return idna.encode(host.lower()).decode("ascii")
|
||||
except idna.IDNAError:
|
||||
raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
|
||||
|
||||
# Regular ASCII hostnames
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# reg-name = *( unreserved / pct-encoded / sub-delims )
|
||||
WHATWG_SAFE = '"`{}%|\\'
|
||||
return quote(host.lower(), safe=SUB_DELIMS + WHATWG_SAFE)
|
||||
|
||||
|
||||
def normalize_port(port: str | int | None, scheme: str) -> int | None:
|
||||
# From https://tools.ietf.org/html/rfc3986#section-3.2.3
|
||||
#
|
||||
# "A scheme may define a default port. For example, the "http" scheme
|
||||
# defines a default port of "80", corresponding to its reserved TCP
|
||||
# port number. The type of port designated by the port number (e.g.,
|
||||
# TCP, UDP, SCTP) is defined by the URI scheme. URI producers and
|
||||
# normalizers should omit the port component and its ":" delimiter if
|
||||
# port is empty or if its value would be the same as that of the
|
||||
# scheme's default."
|
||||
if port is None or port == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
port_as_int = int(port)
|
||||
except ValueError:
|
||||
raise InvalidURL(f"Invalid port: {port!r}")
|
||||
|
||||
# See https://url.spec.whatwg.org/#url-miscellaneous
|
||||
default_port = {"ftp": 21, "http": 80, "https": 443, "ws": 80, "wss": 443}.get(
|
||||
scheme
|
||||
)
|
||||
if port_as_int == default_port:
|
||||
return None
|
||||
return port_as_int
|
||||
|
||||
|
||||
def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
|
||||
"""
|
||||
Path validation rules that depend on if the URL contains
|
||||
a scheme or authority component.
|
||||
|
||||
See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
|
||||
"""
|
||||
if has_authority:
|
||||
# If a URI contains an authority component, then the path component
|
||||
# must either be empty or begin with a slash ("/") character."
|
||||
if path and not path.startswith("/"):
|
||||
raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
|
||||
|
||||
if not has_scheme and not has_authority:
|
||||
# If a URI does not contain an authority component, then the path cannot begin
|
||||
# with two slash characters ("//").
|
||||
if path.startswith("//"):
|
||||
raise InvalidURL("Relative URLs cannot have a path starting with '//'")
|
||||
|
||||
# In addition, a URI reference (Section 4.1) may be a relative-path reference,
|
||||
# in which case the first path segment cannot contain a colon (":") character.
|
||||
if path.startswith(":"):
|
||||
raise InvalidURL("Relative URLs cannot have a path starting with ':'")
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""
|
||||
Drop "." and ".." segments from a URL path.
|
||||
|
||||
For example:
|
||||
|
||||
normalize_path("/path/./to/somewhere/..") == "/path/to"
|
||||
"""
|
||||
# Fast return when no '.' characters in the path.
|
||||
if "." not in path:
|
||||
return path
|
||||
|
||||
components = path.split("/")
|
||||
|
||||
# Fast return when no '.' or '..' components in the path.
|
||||
if "." not in components and ".." not in components:
|
||||
return path
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
|
||||
output: list[str] = []
|
||||
for component in components:
|
||||
if component == ".":
|
||||
pass
|
||||
elif component == "..":
|
||||
if output and output != [""]:
|
||||
output.pop()
|
||||
else:
|
||||
output.append(component)
|
||||
return "/".join(output)
|
||||
|
||||
|
||||
def PERCENT(string: str) -> str:
|
||||
return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")])
|
||||
|
||||
|
||||
def percent_encoded(string: str, safe: str) -> str:
|
||||
"""
|
||||
Use percent-encoding to quote a string.
|
||||
"""
|
||||
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
|
||||
|
||||
# Fast path for strings that don't need escaping.
|
||||
if not string.rstrip(NON_ESCAPED_CHARS):
|
||||
return string
|
||||
|
||||
return "".join(
|
||||
[char if char in NON_ESCAPED_CHARS else PERCENT(char) for char in string]
|
||||
)
|
||||
|
||||
|
||||
def quote(string: str, safe: str) -> str:
|
||||
"""
|
||||
Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
|
||||
|
||||
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
|
||||
|
||||
* `string`: The string to be percent-escaped.
|
||||
* `safe`: A string containing characters that may be treated as safe, and do not
|
||||
need to be escaped. Unreserved characters are always treated as safe.
|
||||
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
|
||||
"""
|
||||
parts = []
|
||||
current_position = 0
|
||||
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
|
||||
start_position, end_position = match.start(), match.end()
|
||||
matched_text = match.group(0)
|
||||
# Add any text up to the '%xx' escape sequence.
|
||||
if start_position != current_position:
|
||||
leading_text = string[current_position:start_position]
|
||||
parts.append(percent_encoded(leading_text, safe=safe))
|
||||
|
||||
# Add the '%xx' escape sequence.
|
||||
parts.append(matched_text)
|
||||
current_position = end_position
|
||||
|
||||
# Add any text after the final '%xx' escape sequence.
|
||||
if current_position != len(string):
|
||||
trailing_text = string[current_position:]
|
||||
parts.append(percent_encoded(trailing_text, safe=safe))
|
||||
|
||||
return "".join(parts)
|
||||
552
src/ahttpx/_urls.py
Normal file
552
src/ahttpx/_urls.py
Normal file
@ -0,0 +1,552 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ._urlparse import urlparse
|
||||
from ._urlencode import unquote, urldecode, urlencode
|
||||
|
||||
__all__ = ["QueryParams", "URL"]
|
||||
|
||||
|
||||
class URL:
|
||||
"""
|
||||
url = httpx.URL("HTTPS://jo%40email.com:a%20secret@müller.de:1234/pa%20th?search=ab#anchorlink")
|
||||
|
||||
assert url.scheme == "https"
|
||||
assert url.username == "jo@email.com"
|
||||
assert url.password == "a secret"
|
||||
assert url.userinfo == b"jo%40email.com:a%20secret"
|
||||
assert url.host == "müller.de"
|
||||
assert url.raw_host == b"xn--mller-kva.de"
|
||||
assert url.port == 1234
|
||||
assert url.netloc == b"xn--mller-kva.de:1234"
|
||||
assert url.path == "/pa th"
|
||||
assert url.query == b"?search=ab"
|
||||
assert url.raw_path == b"/pa%20th?search=ab"
|
||||
assert url.fragment == "anchorlink"
|
||||
|
||||
The components of a URL are broken down like this:
|
||||
|
||||
https://jo%40email.com:a%20secret@müller.de:1234/pa%20th?search=ab#anchorlink
|
||||
[scheme] [ username ] [password] [ host ][port][ path ] [ query ] [fragment]
|
||||
[ userinfo ] [ netloc ][ raw_path ]
|
||||
|
||||
Note that:
|
||||
|
||||
* `url.scheme` is normalized to always be lowercased.
|
||||
|
||||
* `url.host` is normalized to always be lowercased. Internationalized domain
|
||||
names are represented in unicode, without IDNA encoding applied. For instance:
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
|
||||
* `url.raw_host` is normalized to always be lowercased, and is IDNA encoded.
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
|
||||
* `url.port` is either None or an integer. URLs that include the default port for
|
||||
"http", "https", "ws", "wss", and "ftp" schemes have their port
|
||||
normalized to `None`.
|
||||
|
||||
assert httpx.URL("http://example.com") == httpx.URL("http://example.com:80")
|
||||
assert httpx.URL("http://example.com").port is None
|
||||
assert httpx.URL("http://example.com:80").port is None
|
||||
|
||||
* `url.userinfo` is raw bytes, without URL escaping. Usually you'll want to work
|
||||
with `url.username` and `url.password` instead, which handle the URL escaping.
|
||||
|
||||
* `url.raw_path` is raw bytes of both the path and query, without URL escaping.
|
||||
This portion is used as the target when constructing HTTP requests. Usually you'll
|
||||
want to work with `url.path` instead.
|
||||
|
||||
* `url.query` is raw bytes, without URL escaping. A URL query string portion can
|
||||
only be properly URL escaped when decoding the parameter names and values
|
||||
themselves.
|
||||
"""
|
||||
|
||||
def __init__(self, url: "URL" | str = "", **kwargs: typing.Any) -> None:
|
||||
if kwargs:
|
||||
allowed = {
|
||||
"scheme": str,
|
||||
"username": str,
|
||||
"password": str,
|
||||
"userinfo": bytes,
|
||||
"host": str,
|
||||
"port": int,
|
||||
"netloc": str,
|
||||
"path": str,
|
||||
"query": bytes,
|
||||
"raw_path": bytes,
|
||||
"fragment": str,
|
||||
"params": object,
|
||||
}
|
||||
|
||||
# Perform type checking for all supported keyword arguments.
|
||||
for key, value in kwargs.items():
|
||||
if key not in allowed:
|
||||
message = f"{key!r} is an invalid keyword argument for URL()"
|
||||
raise TypeError(message)
|
||||
if value is not None and not isinstance(value, allowed[key]):
|
||||
expected = allowed[key].__name__
|
||||
seen = type(value).__name__
|
||||
message = f"Argument {key!r} must be {expected} but got {seen}"
|
||||
raise TypeError(message)
|
||||
if isinstance(value, bytes):
|
||||
kwargs[key] = value.decode("ascii")
|
||||
|
||||
if "params" in kwargs:
|
||||
# Replace any "params" keyword with the raw "query" instead.
|
||||
#
|
||||
# Ensure that empty params use `kwargs["query"] = None` rather
|
||||
# than `kwargs["query"] = ""`, so that generated URLs do not
|
||||
# include an empty trailing "?".
|
||||
params = kwargs.pop("params")
|
||||
kwargs["query"] = None if not params else str(QueryParams(params))
|
||||
|
||||
if isinstance(url, str):
|
||||
self._uri_reference = urlparse(url, **kwargs)
|
||||
elif isinstance(url, URL):
|
||||
self._uri_reference = url._uri_reference.copy_with(**kwargs)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Invalid type for url. Expected str or httpx.URL,"
|
||||
f" got {type(url)}: {url!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def scheme(self) -> str:
|
||||
"""
|
||||
The URL scheme, such as "http", "https".
|
||||
Always normalised to lowercase.
|
||||
"""
|
||||
return self._uri_reference.scheme
|
||||
|
||||
@property
|
||||
def userinfo(self) -> bytes:
|
||||
"""
|
||||
The URL userinfo as a raw bytestring.
|
||||
For example: b"jo%40email.com:a%20secret".
|
||||
"""
|
||||
return self._uri_reference.userinfo.encode("ascii")
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
"""
|
||||
The URL username as a string, with URL decoding applied.
|
||||
For example: "jo@email.com"
|
||||
"""
|
||||
userinfo = self._uri_reference.userinfo
|
||||
return unquote(userinfo.partition(":")[0])
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
"""
|
||||
The URL password as a string, with URL decoding applied.
|
||||
For example: "a secret"
|
||||
"""
|
||||
userinfo = self._uri_reference.userinfo
|
||||
return unquote(userinfo.partition(":")[2])
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""
|
||||
The URL host as a string.
|
||||
Always normalized to lowercase. Possibly IDNA encoded.
|
||||
|
||||
Examples:
|
||||
|
||||
url = httpx.URL("http://www.EXAMPLE.org")
|
||||
assert url.host == "www.example.org"
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.host == "xn--fiqs8s"
|
||||
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.host == "xn--fiqs8s"
|
||||
|
||||
url = httpx.URL("https://[::ffff:192.168.0.1]")
|
||||
assert url.host == "::ffff:192.168.0.1"
|
||||
"""
|
||||
return self._uri_reference.host
|
||||
|
||||
@property
|
||||
def port(self) -> int | None:
|
||||
"""
|
||||
The URL port as an integer.
|
||||
|
||||
Note that the URL class performs port normalization as per the WHATWG spec.
|
||||
Default ports for "http", "https", "ws", "wss", and "ftp" schemes are always
|
||||
treated as `None`.
|
||||
|
||||
For example:
|
||||
|
||||
assert httpx.URL("http://www.example.com") == httpx.URL("http://www.example.com:80")
|
||||
assert httpx.URL("http://www.example.com:80").port is None
|
||||
"""
|
||||
return self._uri_reference.port
|
||||
|
||||
@property
|
||||
def netloc(self) -> str:
|
||||
"""
|
||||
Either `<host>` or `<host>:<port>` as bytes.
|
||||
Always normalized to lowercase, and IDNA encoded.
|
||||
|
||||
This property may be used for generating the value of a request
|
||||
"Host" header.
|
||||
"""
|
||||
return self._uri_reference.netloc
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""
|
||||
The URL path as a string. Excluding the query string, and URL decoded.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://example.com/pa%20th")
|
||||
assert url.path == "/pa th"
|
||||
"""
|
||||
path = self._uri_reference.path or "/"
|
||||
return unquote(path)
|
||||
|
||||
@property
|
||||
def query(self) -> bytes:
|
||||
"""
|
||||
The URL query string, as raw bytes, excluding the leading b"?".
|
||||
|
||||
This is necessarily a bytewise interface, because we cannot
|
||||
perform URL decoding of this representation until we've parsed
|
||||
the keys and values into a QueryParams instance.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://example.com/?filter=some%20search%20terms")
|
||||
assert url.query == b"filter=some%20search%20terms"
|
||||
"""
|
||||
query = self._uri_reference.query or ""
|
||||
return query.encode("ascii")
|
||||
|
||||
@property
|
||||
def params(self) -> "QueryParams":
|
||||
"""
|
||||
The URL query parameters, neatly parsed and packaged into an immutable
|
||||
multidict representation.
|
||||
"""
|
||||
return QueryParams(self._uri_reference.query)
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""
|
||||
The complete URL path and query string as raw bytes.
|
||||
Used as the target when constructing HTTP requests.
|
||||
|
||||
For example:
|
||||
|
||||
GET /users?search=some%20text HTTP/1.1
|
||||
Host: www.example.org
|
||||
Connection: close
|
||||
"""
|
||||
target = self._uri_reference.path or "/"
|
||||
if self._uri_reference.query is not None:
|
||||
target += "?" + self._uri_reference.query
|
||||
return target
|
||||
|
||||
@property
|
||||
def fragment(self) -> str:
|
||||
"""
|
||||
The URL fragments, as used in HTML anchors.
|
||||
As a string, without the leading '#'.
|
||||
"""
|
||||
return unquote(self._uri_reference.fragment or "")
|
||||
|
||||
@property
|
||||
def is_absolute_url(self) -> bool:
|
||||
"""
|
||||
Return `True` for absolute URLs such as 'http://example.com/path',
|
||||
and `False` for relative URLs such as '/path'.
|
||||
"""
|
||||
# We don't use `.is_absolute` from `rfc3986` because it treats
|
||||
# URLs with a fragment portion as not absolute.
|
||||
# What we actually care about is if the URL provides
|
||||
# a scheme and hostname to which connections should be made.
|
||||
return bool(self._uri_reference.scheme and self._uri_reference.host)
|
||||
|
||||
@property
|
||||
def is_relative_url(self) -> bool:
|
||||
"""
|
||||
Return `False` for absolute URLs such as 'http://example.com/path',
|
||||
and `True` for relative URLs such as '/path'.
|
||||
"""
|
||||
return not self.is_absolute_url
|
||||
|
||||
def copy_with(self, **kwargs: typing.Any) -> "URL":
|
||||
"""
|
||||
Copy this URL, returning a new URL with some components altered.
|
||||
Accepts the same set of parameters as the components that are made
|
||||
available via properties on the `URL` class.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://www.example.com").copy_with(
|
||||
username="jo@gmail.com", password="a secret"
|
||||
)
|
||||
assert url == "https://jo%40email.com:a%20secret@www.example.com"
|
||||
"""
|
||||
return URL(self, **kwargs)
|
||||
|
||||
def copy_set_param(self, key: str, value: typing.Any = None) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_set(key, value))
|
||||
|
||||
def copy_append_param(self, key: str, value: typing.Any = None) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_append(key, value))
|
||||
|
||||
def copy_remove_param(self, key: str) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_remove(key))
|
||||
|
||||
def copy_merge_params(
|
||||
self,
|
||||
params: "QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | None,
|
||||
) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_update(params))
|
||||
|
||||
def join(self, url: "URL" | str) -> "URL":
|
||||
"""
|
||||
Return an absolute URL, using this URL as the base.
|
||||
|
||||
Eg.
|
||||
|
||||
url = httpx.URL("https://www.example.com/test")
|
||||
url = url.join("/new/path")
|
||||
assert url == "https://www.example.com/new/path"
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
|
||||
return URL(urljoin(str(self), str(URL(url))))
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, (URL, str)) and str(self) == str(URL(other))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._uri_reference)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<URL {str(self)!r}>"
|
||||
|
||||
|
||||
class QueryParams(typing.Mapping[str, str]):
|
||||
"""
|
||||
URL query parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: (
|
||||
"QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | str | None
|
||||
) = None,
|
||||
) -> None:
|
||||
d: dict[str, list[str]] = {}
|
||||
|
||||
if params is None:
|
||||
d = {}
|
||||
elif isinstance(params, str):
|
||||
d = urldecode(params)
|
||||
elif isinstance(params, QueryParams):
|
||||
d = params.multi_dict()
|
||||
elif isinstance(params, dict):
|
||||
# Convert dict inputs like:
|
||||
# {"a": "123", "b": ["456", "789"]}
|
||||
# To dict inputs where values are always lists, like:
|
||||
# {"a": ["123"], "b": ["456", "789"]}
|
||||
d = {k: [v] if isinstance(v, str) else list(v) for k, v in params.items()}
|
||||
else:
|
||||
# Convert list inputs like:
|
||||
# [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
# To a dict representation, like:
|
||||
# {"a": ["123", "456"], "b": ["789"]}
|
||||
for k, v in params:
|
||||
d.setdefault(k, []).append(v)
|
||||
|
||||
self._dict = d
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
"""
|
||||
Return all the keys in the query params.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.keys()) == ["a", "b"]
|
||||
"""
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
"""
|
||||
Return all the values in the query params. If a key occurs more than once
|
||||
only the first item for that key is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.values()) == ["123", "789"]
|
||||
"""
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
"""
|
||||
Return all items in the query params. If a key occurs more than once
|
||||
only the first item for that key is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.items()) == [("a", "123"), ("b", "789")]
|
||||
"""
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def multi_items(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Return all items in the query params. Allow duplicate keys to occur.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.multi_items()) == [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
"""
|
||||
multi_items: list[tuple[str, str]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def multi_dict(self) -> dict[str, list[str]]:
|
||||
return {k: list(v) for k, v in self._dict.items()}
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
Get a value from the query param for a given key. If the key occurs
|
||||
more than once, then only the first value is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert q.get("a") == "123"
|
||||
"""
|
||||
if key in self._dict:
|
||||
return self._dict[key][0]
|
||||
return default
|
||||
|
||||
def get_list(self, key: str) -> list[str]:
|
||||
"""
|
||||
Get all values from the query param for a given key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert q.get_list("a") == ["123", "456"]
|
||||
"""
|
||||
return list(self._dict.get(key, []))
|
||||
|
||||
def copy_set(self, key: str, value: str) -> "QueryParams":
|
||||
"""
|
||||
Return a new QueryParams instance, setting the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.set("a", "456")
|
||||
assert q == httpx.QueryParams("a=456")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict[key] = [value]
|
||||
return q
|
||||
|
||||
def copy_append(self, key: str, value: str) -> "QueryParams":
|
||||
"""
|
||||
Return a new QueryParams instance, setting or appending the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.append("a", "456")
|
||||
assert q == httpx.QueryParams("a=123&a=456")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict[key] = q.get_list(key) + [value]
|
||||
return q
|
||||
|
||||
def copy_remove(self, key: str) -> QueryParams:
|
||||
"""
|
||||
Return a new QueryParams instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.remove("a")
|
||||
assert q == httpx.QueryParams("")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict.pop(str(key), None)
|
||||
return q
|
||||
|
||||
def copy_update(
|
||||
self,
|
||||
params: (
|
||||
"QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | None
|
||||
) = None,
|
||||
) -> "QueryParams":
|
||||
"""
|
||||
Return a new QueryParams instance, updated with.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_update({"b": "456"})
|
||||
assert q == httpx.QueryParams("a=123&b=456")
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_update({"a": "456", "b": "789"})
|
||||
assert q == httpx.QueryParams("a=456&b=789")
|
||||
"""
|
||||
q = QueryParams(params)
|
||||
q._dict = {**self._dict, **q._dict}
|
||||
return q
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
return sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
|
||||
def __str__(self) -> str:
|
||||
return urlencode(self.multi_dict())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<QueryParams {str(self)!r}>"
|
||||
65
src/httpx/__init__.py
Normal file
65
src/httpx/__init__.py
Normal file
@ -0,0 +1,65 @@
|
||||
from .__version__ import __title__, __version__
|
||||
from ._client import * # Client
|
||||
from ._content import * # Content, File, Files, Form, HTML, JSON, MultiPart, Text
|
||||
from ._headers import * # Headers
|
||||
from ._network import * # NetworkBackend, NetworkStream, timeout
|
||||
from ._parsers import * # HTTPParser, ProtocolError
|
||||
from ._pool import * # Connection, ConnectionPool, Transport
|
||||
from ._quickstart import * # get, post, put, patch, delete
|
||||
from ._response import * # Response
|
||||
from ._request import * # Request
|
||||
from ._streams import * # ByteStream, DuplexStream, FileStream, HTTPStream, Stream
|
||||
from ._server import * # serve_http, run
|
||||
from ._urlencode import * # quote, unquote, urldecode, urlencode
|
||||
from ._urls import * # QueryParams, URL
|
||||
|
||||
|
||||
__all__ = [
|
||||
"__title__",
|
||||
"__version__",
|
||||
"ByteStream",
|
||||
"Client",
|
||||
"Connection",
|
||||
"ConnectionPool",
|
||||
"Content",
|
||||
"delete",
|
||||
"DuplexStream",
|
||||
"File",
|
||||
"FileStream",
|
||||
"Files",
|
||||
"Form",
|
||||
"get",
|
||||
"Headers",
|
||||
"HTML",
|
||||
"HTTPParser",
|
||||
"HTTPStream",
|
||||
"JSON",
|
||||
"MultiPart",
|
||||
"NetworkBackend",
|
||||
"NetworkStream",
|
||||
"open_connection",
|
||||
"post",
|
||||
"ProtocolError",
|
||||
"put",
|
||||
"patch",
|
||||
"Response",
|
||||
"Request",
|
||||
"run",
|
||||
"serve_http",
|
||||
"Stream",
|
||||
"Text",
|
||||
"timeout",
|
||||
"Transport",
|
||||
"QueryParams",
|
||||
"quote",
|
||||
"unquote",
|
||||
"URL",
|
||||
"urldecode",
|
||||
"urlencode",
|
||||
]
|
||||
|
||||
|
||||
__locals = locals()
|
||||
for __name in __all__:
|
||||
if not __name.startswith('__'):
|
||||
setattr(__locals[__name], "__module__", "httpx")
|
||||
2
src/httpx/__version__.py
Normal file
2
src/httpx/__version__.py
Normal file
@ -0,0 +1,2 @@
|
||||
__title__ = "httpx"
|
||||
__version__ = "1.0.dev3"
|
||||
156
src/httpx/_client.py
Normal file
156
src/httpx/_client.py
Normal file
@ -0,0 +1,156 @@
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._content import Content
|
||||
from ._headers import Headers
|
||||
from ._pool import ConnectionPool, Transport
|
||||
from ._request import Request
|
||||
from ._response import Response
|
||||
from ._streams import Stream
|
||||
from ._urls import URL
|
||||
|
||||
__all__ = ["Client"]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(
|
||||
self,
|
||||
url: URL | str | None = None,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
transport: Transport | None = None,
|
||||
):
|
||||
if url is None:
|
||||
url = ""
|
||||
if headers is None:
|
||||
headers = {"User-Agent": "dev"}
|
||||
if transport is None:
|
||||
transport = ConnectionPool()
|
||||
|
||||
self.url = URL(url)
|
||||
self.headers = Headers(headers)
|
||||
self.transport = transport
|
||||
self.via = RedirectMiddleware(self.transport)
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Request:
|
||||
return Request(
|
||||
method=method,
|
||||
url=self.url.join(url),
|
||||
headers=self.headers.copy_update(headers),
|
||||
content=content,
|
||||
)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = self.build_request(method, url, headers=headers, content=content)
|
||||
with self.via.send(request) as response:
|
||||
response.read()
|
||||
return response
|
||||
|
||||
def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = self.build_request(method, url, headers=headers, content=content)
|
||||
return self.via.send(request)
|
||||
|
||||
def get(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
return self.request("GET", url, headers=headers)
|
||||
|
||||
def post(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
return self.request("POST", url, headers=headers, content=content)
|
||||
|
||||
def put(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
return self.request("PUT", url, headers=headers, content=content)
|
||||
|
||||
def patch(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
return self.request("PATCH", url, headers=headers, content=content)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
return self.request("DELETE", url, headers=headers)
|
||||
|
||||
def close(self):
|
||||
self.transport.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
self.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Client [{self.transport.description()}]>"
|
||||
|
||||
|
||||
class RedirectMiddleware(Transport):
|
||||
def __init__(self, transport: Transport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def is_redirect(self, response: Response) -> bool:
|
||||
return (
|
||||
response.status_code in (301, 302, 303, 307, 308)
|
||||
and "Location" in response.headers
|
||||
)
|
||||
|
||||
def build_redirect_request(self, request: Request, response: Response) -> Request:
|
||||
raise NotImplementedError()
|
||||
|
||||
def send(self, request: Request) -> Response:
|
||||
while True:
|
||||
response = self._transport.send(request)
|
||||
|
||||
if not self.is_redirect(response):
|
||||
return response
|
||||
|
||||
# If we have a redirect, then we read the body of the response.
|
||||
# Ensures that the HTTP connection is available for a new
|
||||
# request/response cycle.
|
||||
response.read()
|
||||
response.close()
|
||||
|
||||
# We've made a request-response and now need to issue a redirect request.
|
||||
request = self.build_redirect_request(request, response)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
378
src/httpx/_content.py
Normal file
378
src/httpx/_content.py
Normal file
@ -0,0 +1,378 @@
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
|
||||
from ._streams import Stream, ByteStream, FileStream, MultiPartStream
|
||||
from ._urlencode import urldecode, urlencode
|
||||
|
||||
__all__ = [
|
||||
"Content",
|
||||
"Form",
|
||||
"File",
|
||||
"Files",
|
||||
"JSON",
|
||||
"MultiPart",
|
||||
"Text",
|
||||
"HTML",
|
||||
]
|
||||
|
||||
# https://github.com/nginx/nginx/blob/master/conf/mime.types
|
||||
_content_types = {
|
||||
".json": "application/json",
|
||||
".js": "application/javascript",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".png": "image/png",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
|
||||
class Content:
|
||||
def encode(self) -> Stream:
|
||||
raise NotImplementedError()
|
||||
|
||||
def content_type(self) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Form(typing.Mapping[str, str], Content):
|
||||
"""
|
||||
HTML form data, as an immutable multi-dict.
|
||||
Form parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
form: (
|
||||
typing.Mapping[str, str | typing.Sequence[str]]
|
||||
| typing.Sequence[tuple[str, str]]
|
||||
| str
|
||||
| None
|
||||
) = None,
|
||||
) -> None:
|
||||
d: dict[str, list[str]] = {}
|
||||
|
||||
if form is None:
|
||||
d = {}
|
||||
elif isinstance(form, str):
|
||||
d = urldecode(form)
|
||||
elif isinstance(form, typing.Mapping):
|
||||
# Convert dict inputs like:
|
||||
# {"a": "123", "b": ["456", "789"]}
|
||||
# To dict inputs where values are always lists, like:
|
||||
# {"a": ["123"], "b": ["456", "789"]}
|
||||
d = {k: [v] if isinstance(v, str) else list(v) for k, v in form.items()}
|
||||
else:
|
||||
# Convert list inputs like:
|
||||
# [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
# To a dict representation, like:
|
||||
# {"a": ["123", "456"], "b": ["789"]}
|
||||
for k, v in form:
|
||||
d.setdefault(k, []).append(v)
|
||||
|
||||
self._dict = d
|
||||
|
||||
# Content API
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = str(self).encode("ascii")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "application/x-www-form-urlencoded"
|
||||
|
||||
# Dict operations
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
if key in self._dict:
|
||||
return self._dict[key][0]
|
||||
return default
|
||||
|
||||
# Multi-dict operations
|
||||
|
||||
def multi_items(self) -> list[tuple[str, str]]:
|
||||
multi_items: list[tuple[str, str]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def multi_dict(self) -> dict[str, list[str]]:
|
||||
return {k: list(v) for k, v in self._dict.items()}
|
||||
|
||||
def get_list(self, key: str) -> list[str]:
|
||||
return list(self._dict.get(key, []))
|
||||
|
||||
# Update operations
|
||||
|
||||
def copy_set(self, key: str, value: str) -> "Form":
|
||||
d = self.multi_dict()
|
||||
d[key] = [value]
|
||||
return Form(d)
|
||||
|
||||
def copy_append(self, key: str, value: str) -> "Form":
|
||||
d = self.multi_dict()
|
||||
d[key] = d.get(key, []) + [value]
|
||||
return Form(d)
|
||||
|
||||
def copy_remove(self, key: str) -> "Form":
|
||||
d = self.multi_dict()
|
||||
d.pop(key, None)
|
||||
return Form(d)
|
||||
|
||||
# Accessors & built-ins
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, Form) and
|
||||
sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return urlencode(self.multi_dict())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Form {self.multi_items()!r}>"
|
||||
|
||||
|
||||
class File(Content):
|
||||
"""
|
||||
Wrapper class used for files in uploads and multipart requests.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self._path = path
|
||||
|
||||
def name(self) -> str:
|
||||
return os.path.basename(self._path)
|
||||
|
||||
def size(self) -> int:
|
||||
return os.path.getsize(self._path)
|
||||
|
||||
def encode(self) -> Stream:
|
||||
return FileStream(self._path)
|
||||
|
||||
def content_type(self) -> str:
|
||||
_, ext = os.path.splitext(self._path)
|
||||
ct = _content_types.get(ext, "application/octet-stream")
|
||||
if ct.startswith('text/'):
|
||||
ct += "; charset='utf-8'"
|
||||
return ct
|
||||
|
||||
def __lt__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, File) and other._path < self._path
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, File) and other._path == self._path
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<File {self._path!r}>"
|
||||
|
||||
|
||||
class Files(typing.Mapping[str, File], Content):
|
||||
"""
|
||||
File parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
files: (
|
||||
typing.Mapping[str, File | typing.Sequence[File]]
|
||||
| typing.Sequence[tuple[str, File]]
|
||||
| None
|
||||
) = None,
|
||||
boundary: str = ''
|
||||
) -> None:
|
||||
d: dict[str, list[File]] = {}
|
||||
|
||||
if files is None:
|
||||
d = {}
|
||||
elif isinstance(files, typing.Mapping):
|
||||
d = {k: [v] if isinstance(v, File) else list(v) for k, v in files.items()}
|
||||
else:
|
||||
d = {}
|
||||
for k, v in files:
|
||||
d.setdefault(k, []).append(v)
|
||||
|
||||
self._dict = d
|
||||
self._boundary = boundary or os.urandom(16).hex()
|
||||
|
||||
# Standard dict interface
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[File]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, File]:
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
if key in self._dict:
|
||||
return self._dict[key][0]
|
||||
return None
|
||||
|
||||
# Multi dict interface
|
||||
def multi_items(self) -> list[tuple[str, File]]:
|
||||
multi_items: list[tuple[str, File]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def multi_dict(self) -> dict[str, list[File]]:
|
||||
return {k: list(v) for k, v in self._dict.items()}
|
||||
|
||||
def get_list(self, key: str) -> list[File]:
|
||||
return list(self._dict.get(key, []))
|
||||
|
||||
# Content interface
|
||||
def encode(self) -> Stream:
|
||||
return MultiPart(files=self).encode()
|
||||
|
||||
def content_type(self) -> str:
|
||||
return f"multipart/form-data; boundary={self._boundary}"
|
||||
|
||||
# Builtins
|
||||
def __getitem__(self, key: str) -> File:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, Files) and
|
||||
sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Files {self.multi_items()!r}>"
|
||||
|
||||
|
||||
class JSON(Content):
|
||||
def __init__(self, data: typing.Any) -> None:
|
||||
self._data = data
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = json.dumps(
|
||||
self._data,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False
|
||||
).encode("utf-8")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "application/json"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<JSON {self._data!r}>"
|
||||
|
||||
|
||||
class Text(Content):
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = self._text.encode("utf-8")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "text/plain; charset='utf-8'"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Text {self._text!r}>"
|
||||
|
||||
|
||||
class HTML(Content):
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def encode(self) -> Stream:
|
||||
content = self._text.encode("utf-8")
|
||||
return ByteStream(content)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return "text/html; charset='utf-8'"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<HTML {self._text!r}>"
|
||||
|
||||
|
||||
class MultiPart(Content):
|
||||
def __init__(
|
||||
self,
|
||||
form: (
|
||||
Form
|
||||
| typing.Mapping[str, str | typing.Sequence[str]]
|
||||
| typing.Sequence[tuple[str, str]]
|
||||
| str
|
||||
| None
|
||||
) = None,
|
||||
files: (
|
||||
Files
|
||||
| typing.Mapping[str, File | typing.Sequence[File]]
|
||||
| typing.Sequence[tuple[str, File]]
|
||||
| None
|
||||
) = None,
|
||||
boundary: str | None = None
|
||||
):
|
||||
self._form = form if isinstance(form , Form) else Form(form)
|
||||
self._files = files if isinstance(files, Files) else Files(files)
|
||||
self._boundary = os.urandom(16).hex() if boundary is None else boundary
|
||||
|
||||
@property
|
||||
def form(self) -> Form:
|
||||
return self._form
|
||||
|
||||
@property
|
||||
def files(self) -> Files:
|
||||
return self._files
|
||||
|
||||
def encode(self) -> Stream:
|
||||
form = [(key, value) for key, value in self._form.items()]
|
||||
files = [(key, file._path) for key, file in self._files.items()]
|
||||
return MultiPartStream(form, files, boundary=self._boundary)
|
||||
|
||||
def content_type(self) -> str:
|
||||
return f"multipart/form-data; boundary={self._boundary}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MultiPart form={self._form.multi_items()!r}, files={self._files.multi_items()!r}>"
|
||||
243
src/httpx/_headers.py
Normal file
243
src/httpx/_headers.py
Normal file
@ -0,0 +1,243 @@
|
||||
import re
|
||||
import typing
|
||||
|
||||
|
||||
__all__ = ["Headers"]
|
||||
|
||||
|
||||
VALID_HEADER_CHARS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"0123456789"
|
||||
"!#$%&'*+-.^_`|~"
|
||||
)
|
||||
|
||||
|
||||
# TODO...
|
||||
#
|
||||
# * Comma folded values, eg. `Vary: ...`
|
||||
# * Multiple Set-Cookie headers.
|
||||
# * Non-ascii support.
|
||||
# * Ordering, including `Host` header exception.
|
||||
|
||||
|
||||
def headername(name: str) -> str:
|
||||
if name.strip(VALID_HEADER_CHARS) or not name:
|
||||
raise ValueError(f"Invalid HTTP header name {name!r}.")
|
||||
return name
|
||||
|
||||
|
||||
def headervalue(value: str) -> str:
|
||||
value = value.strip(" ")
|
||||
if not value or not value.isascii() or not value.isprintable():
|
||||
raise ValueError(f"Invalid HTTP header value {value!r}.")
|
||||
return value
|
||||
|
||||
|
||||
class Headers(typing.Mapping[str, str]):
|
||||
def __init__(
|
||||
self,
|
||||
headers: typing.Mapping[str, str] | typing.Sequence[tuple[str, str]] | None = None,
|
||||
) -> None:
|
||||
# {'accept': ('Accept', '*/*')}
|
||||
d: dict[str, str] = {}
|
||||
|
||||
if isinstance(headers, typing.Mapping):
|
||||
# Headers({
|
||||
# 'Content-Length': '1024',
|
||||
# 'Content-Type': 'text/plain; charset=utf-8',
|
||||
# )
|
||||
d = {headername(k): headervalue(v) for k, v in headers.items()}
|
||||
elif headers is not None:
|
||||
# Headers([
|
||||
# ('Location', 'https://www.example.com'),
|
||||
# ('Set-Cookie', 'session_id=3498jj489jhb98jn'),
|
||||
# ])
|
||||
d = {headername(k): headervalue(v) for k, v in headers}
|
||||
|
||||
self._dict = d
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
"""
|
||||
Return all the header keys.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.keys()) == ["Accept", "User-Agent"]
|
||||
"""
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
"""
|
||||
Return all the header values.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.values()) == ["*/*", "python/httpx"]
|
||||
"""
|
||||
return self._dict.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
"""
|
||||
Return all headers as (key, value) tuples.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")]
|
||||
"""
|
||||
return self._dict.items()
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
Get a value from the query param for a given key. If the key occurs
|
||||
more than once, then only the first value is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert h.get("User-Agent") == "python/httpx"
|
||||
"""
|
||||
for k, v in self._dict.items():
|
||||
if k.lower() == key.lower():
|
||||
return v
|
||||
return default
|
||||
|
||||
def copy_set(self, key: str, value: str) -> "Headers":
|
||||
"""
|
||||
Return a new Headers instance, setting the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Expires": "0"})
|
||||
h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT")
|
||||
assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"})
|
||||
"""
|
||||
l = []
|
||||
seen = False
|
||||
|
||||
# Either insert...
|
||||
for k, v in self._dict.items():
|
||||
if k.lower() == key.lower():
|
||||
l.append((key, value))
|
||||
seen = True
|
||||
else:
|
||||
l.append((k, v))
|
||||
|
||||
# Or append...
|
||||
if not seen:
|
||||
l.append((key, value))
|
||||
|
||||
return Headers(l)
|
||||
|
||||
def copy_remove(self, key: str) -> "Headers":
|
||||
"""
|
||||
Return a new Headers instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*"})
|
||||
h = h.copy_remove("Accept")
|
||||
assert h == httpx.Headers({})
|
||||
"""
|
||||
h = {k: v for k, v in self._dict.items() if k.lower() != key.lower()}
|
||||
return Headers(h)
|
||||
|
||||
def copy_update(self, update: "Headers" | typing.Mapping[str, str] | None) -> "Headers":
|
||||
"""
|
||||
Return a new Headers instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
h = h.copy_update({"Accept-Encoding": "gzip"})
|
||||
assert h == httpx.Headers({"Accept": "*/*", "Accept-Encoding": "gzip", "User-Agent": "python/httpx"})
|
||||
"""
|
||||
if update is None:
|
||||
return self
|
||||
|
||||
new = update if isinstance(update, Headers) else Headers(update)
|
||||
|
||||
# Remove updated items using a case-insensitive approach...
|
||||
keys = set([key.lower() for key in new.keys()])
|
||||
h = {k: v for k, v in self._dict.items() if k.lower() not in keys}
|
||||
|
||||
# Perform the actual update...
|
||||
h.update(dict(new))
|
||||
|
||||
return Headers(h)
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
match = key.lower()
|
||||
for k, v in self._dict.items():
|
||||
if k.lower() == match:
|
||||
return v
|
||||
raise KeyError(key)
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
match = key.lower()
|
||||
return any(k.lower() == match for k in self._dict.keys())
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
self_lower = {k.lower(): v for k, v in self.items()}
|
||||
other_lower = {k.lower(): v for k, v in Headers(other).items()}
|
||||
return self_lower == other_lower
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Headers {dict(self)!r}>"
|
||||
|
||||
|
||||
def parse_opts_header(header: str) -> tuple[str, dict[str, str]]:
|
||||
# The Content-Type header is described in RFC 2616 'Content-Type'
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-14.17
|
||||
|
||||
# The 'type/subtype; parameter' format is described in RFC 2616 'Media Types'
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-3.7
|
||||
|
||||
# Parameter quoting is described in RFC 2616 'Transfer Codings'
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-3.6
|
||||
|
||||
header = header.strip()
|
||||
content_type = ''
|
||||
params = {}
|
||||
|
||||
# Match the content type (up to the first semicolon or end)
|
||||
match = re.match(r'^([^;]+)', header)
|
||||
if match:
|
||||
content_type = match.group(1).strip().lower()
|
||||
rest = header[match.end():]
|
||||
else:
|
||||
return '', {}
|
||||
|
||||
# Parse parameters, accounting for quoted strings
|
||||
param_pattern = re.compile(r'''
|
||||
;\s* # Semicolon + optional whitespace
|
||||
(?P<key>[^=;\s]+) # Parameter key
|
||||
= # Equal sign
|
||||
(?P<value> # Parameter value:
|
||||
"(?:[^"\\]|\\.)*" # Quoted string with escapes
|
||||
| # OR
|
||||
[^;]* # Unquoted string (until semicolon)
|
||||
)
|
||||
''', re.VERBOSE)
|
||||
|
||||
for match in param_pattern.finditer(rest):
|
||||
key = match.group('key').lower()
|
||||
value = match.group('value').strip()
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
# Remove surrounding quotes and unescape
|
||||
value = re.sub(r'\\(.)', r'\1', value[1:-1])
|
||||
params[key] = value
|
||||
|
||||
return content_type, params
|
||||
243
src/httpx/_network.py
Normal file
243
src/httpx/_network.py
Normal file
@ -0,0 +1,243 @@
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import contextvars
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._streams import Stream
|
||||
|
||||
|
||||
__all__ = ["NetworkBackend", "NetworkStream", "timeout"]
|
||||
|
||||
_timeout_stack: contextvars.ContextVar[list[float]] = contextvars.ContextVar("timeout_context", default=[])
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def timeout(duration: float) -> typing.Iterator[None]:
|
||||
"""
|
||||
A context managed timeout API.
|
||||
|
||||
with timeout(1.0):
|
||||
...
|
||||
"""
|
||||
now = time.monotonic()
|
||||
until = now + duration
|
||||
stack = typing.cast(list[float], _timeout_stack.get())
|
||||
stack = [until] + stack
|
||||
token = _timeout_stack.set(stack)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_timeout_stack.reset(token)
|
||||
|
||||
|
||||
def get_current_timeout() -> float | None:
|
||||
stack = _timeout_stack.get()
|
||||
if not stack:
|
||||
return None
|
||||
soonest = min(stack)
|
||||
now = time.monotonic()
|
||||
remaining = soonest - now
|
||||
if remaining <= 0.0:
|
||||
raise TimeoutError()
|
||||
return remaining
|
||||
|
||||
|
||||
class NetworkStream(Stream):
|
||||
def __init__(self, sock: socket.socket, address: tuple[str, int]) -> None:
|
||||
self._socket = sock
|
||||
self._address = address
|
||||
self._is_tls = False
|
||||
self._is_closed = False
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._address[0]
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._address[1]
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size < 0:
|
||||
size = 64 * 1024
|
||||
timeout = get_current_timeout()
|
||||
self._socket.settimeout(timeout)
|
||||
content = self._socket.recv(size)
|
||||
return content
|
||||
|
||||
def write(self, buffer: bytes) -> None:
|
||||
while buffer:
|
||||
timeout = get_current_timeout()
|
||||
self._socket.settimeout(timeout)
|
||||
n = self._socket.send(buffer)
|
||||
buffer = buffer[n:]
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._is_closed:
|
||||
self._is_closed = True
|
||||
self._socket.close()
|
||||
|
||||
def __repr__(self):
|
||||
description = ""
|
||||
description += " TLS" if self._is_tls else ""
|
||||
description += " CLOSED" if self._is_closed else ""
|
||||
return f"<NetworkStream [{self.host}:{self.port}{description}]>"
|
||||
|
||||
def __del__(self):
|
||||
if not self._is_closed:
|
||||
import warnings
|
||||
warnings.warn(f"NetworkStream was garbage collected without being closed.")
|
||||
|
||||
def __enter__(self) -> "NetworkStream":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
):
|
||||
self.close()
|
||||
|
||||
|
||||
class NetworkListener:
|
||||
def __init__(self, sock: socket.socket, address: tuple[str, int]) -> None:
|
||||
self._server_socket = sock
|
||||
self._address = address
|
||||
self._is_closed = False
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self._address[0]
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self._address[1]
|
||||
|
||||
def accept(self) -> NetworkStream | None:
|
||||
"""
|
||||
Blocks until an incoming connection is accepted, and returns the NetworkStream.
|
||||
Stops blocking and returns `None` once the listener is closed.
|
||||
"""
|
||||
while not self._is_closed:
|
||||
r, _, _ = select.select([self._server_socket], [], [], 3)
|
||||
if r:
|
||||
sock, address = self._server_socket.accept()
|
||||
return NetworkStream(sock, address)
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self._is_closed = True
|
||||
self._server_socket.close()
|
||||
|
||||
def __del__(self):
|
||||
if not self._is_closed:
|
||||
import warnings
|
||||
warnings.warn("NetworkListener was garbage collected without being closed.")
|
||||
|
||||
def __enter__(self) -> "NetworkListener":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
):
|
||||
self.close()
|
||||
|
||||
|
||||
class NetworkServer:
|
||||
def __init__(self, listener: NetworkListener, handler: typing.Callable[[NetworkStream], None]) -> None:
|
||||
self.listener = listener
|
||||
self.handler = handler
|
||||
self._max_workers = 5
|
||||
self._executor = None
|
||||
self._thread = None
|
||||
self._streams = list[NetworkStream]
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self.listener.host
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self.listener.port
|
||||
|
||||
def __enter__(self):
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=self._max_workers)
|
||||
self._executor.submit(self._serve)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.listener.close()
|
||||
self._executor.shutdown(wait=True)
|
||||
|
||||
def _serve(self):
|
||||
while stream := self.listener.accept():
|
||||
self._executor.submit(self._handler, stream)
|
||||
|
||||
def _handler(self, stream):
|
||||
try:
|
||||
self.handler(stream)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
|
||||
class NetworkBackend:
|
||||
def __init__(self, ssl_ctx: ssl.SSLContext | None = None):
|
||||
self._ssl_ctx = self.create_default_context() if ssl_ctx is None else ssl_ctx
|
||||
|
||||
def create_default_context(self) -> ssl.SSLContext:
|
||||
import certifi
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
def connect(self, host: str, port: int) -> NetworkStream:
|
||||
"""
|
||||
Connect to the given address, returning a NetworkStream instance.
|
||||
"""
|
||||
address = (host, port)
|
||||
timeout = get_current_timeout()
|
||||
sock = socket.create_connection(address, timeout=timeout)
|
||||
return NetworkStream(sock, address)
|
||||
|
||||
def connect_tls(self, host: str, port: int, hostname: str = '') -> NetworkStream:
|
||||
"""
|
||||
Connect to the given address, returning a NetworkStream instance.
|
||||
"""
|
||||
address = (host, port)
|
||||
hostname = hostname or host
|
||||
timeout = get_current_timeout()
|
||||
sock = socket.create_connection(address, timeout=timeout)
|
||||
sock = self._ssl_ctx.wrap_socket(sock, server_hostname=hostname)
|
||||
return NetworkStream(sock, address)
|
||||
|
||||
def listen(self, host: str, port: int) -> NetworkListener:
|
||||
"""
|
||||
List on the given address, returning a NetworkListener instance.
|
||||
"""
|
||||
address = (host, port)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(address)
|
||||
sock.listen(5)
|
||||
sock.setblocking(False)
|
||||
return NetworkListener(sock, address)
|
||||
|
||||
def serve(self, host: str, port: int, handler: typing.Callable[[NetworkStream], None]) -> NetworkServer:
|
||||
listener = self.listen(host, port)
|
||||
return NetworkServer(listener, handler)
|
||||
|
||||
def __repr__(self):
|
||||
return "<NetworkBackend [threaded]>"
|
||||
|
||||
|
||||
Semaphore = threading.Semaphore
|
||||
Lock = threading.Lock
|
||||
sleep = time.sleep
|
||||
515
src/httpx/_parsers.py
Normal file
515
src/httpx/_parsers.py
Normal file
@ -0,0 +1,515 @@
|
||||
import enum
|
||||
|
||||
from ._streams import Stream
|
||||
|
||||
__all__ = ['HTTPParser', 'Mode', 'ProtocolError']
|
||||
|
||||
|
||||
# TODO...
|
||||
|
||||
# * Upgrade
|
||||
# * CONNECT
|
||||
|
||||
# * Support 'Expect: 100 Continue'
|
||||
# * Add 'Error' state transitions
|
||||
# * Add tests to trickle data
|
||||
# * Add type annotations
|
||||
|
||||
# * Optional... HTTP/1.0 support
|
||||
# * Read trailing headers on Transfer-Encoding: chunked. Not just '\r\n'.
|
||||
# * When writing Transfer-Encoding: chunked, split large writes into buffer size.
|
||||
# * When reading Transfer-Encoding: chunked, handle incomplete reads from large chunk sizes.
|
||||
# * .read() doesn't document if will always return maximum available.
|
||||
|
||||
# * validate method, target, protocol in request line
|
||||
# * validate protocol, status_code, reason_phrase in response line
|
||||
# * validate name, value on headers
|
||||
|
||||
|
||||
class State(enum.Enum):
|
||||
WAIT = 0
|
||||
SEND_METHOD_LINE = 1
|
||||
SEND_STATUS_LINE = 2
|
||||
SEND_HEADERS = 3
|
||||
SEND_BODY = 4
|
||||
RECV_METHOD_LINE = 5
|
||||
RECV_STATUS_LINE = 6
|
||||
RECV_HEADERS = 7
|
||||
RECV_BODY = 8
|
||||
DONE = 9
|
||||
CLOSED = 10
|
||||
|
||||
|
||||
class Mode(enum.Enum):
|
||||
CLIENT = 0
|
||||
SERVER = 1
|
||||
|
||||
|
||||
# The usual transitions will be...
|
||||
|
||||
# IDLE, IDLE
|
||||
# SEND_HEADERS, IDLE
|
||||
# SEND_BODY, IDLE
|
||||
# DONE, IDLE
|
||||
# DONE, SEND_HEADERS
|
||||
# DONE, SEND_BODY
|
||||
# DONE, DONE
|
||||
|
||||
# Then either back to IDLE, IDLE
|
||||
# or move to CLOSED, CLOSED
|
||||
|
||||
# 1. It is also valid for the server to start
|
||||
# sending the response without waiting for the
|
||||
# complete request.
|
||||
# 2. 1xx status codes are interim states, and
|
||||
# transition from SEND_HEADERS back to IDLE
|
||||
# 3. ...
|
||||
|
||||
class ProtocolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPParser:
|
||||
"""
|
||||
Usage...
|
||||
|
||||
client = HTTPParser(writer, reader)
|
||||
client.send_method_line()
|
||||
client.send_headers()
|
||||
client.send_body()
|
||||
client.recv_status_line()
|
||||
client.recv_headers()
|
||||
client.recv_body()
|
||||
client.complete()
|
||||
client.close()
|
||||
"""
|
||||
def __init__(self, stream: Stream, mode: str) -> None:
|
||||
self.stream = stream
|
||||
self.parser = ReadAheadParser(stream)
|
||||
self.mode = {'CLIENT': Mode.CLIENT, 'SERVER': Mode.SERVER}[mode]
|
||||
|
||||
# Track state...
|
||||
if self.mode == Mode.CLIENT:
|
||||
self.send_state: State = State.SEND_METHOD_LINE
|
||||
self.recv_state: State = State.WAIT
|
||||
else:
|
||||
self.recv_state = State.RECV_METHOD_LINE
|
||||
self.send_state = State.WAIT
|
||||
|
||||
# Track message framing...
|
||||
self.send_content_length: int | None = 0
|
||||
self.recv_content_length: int | None = 0
|
||||
self.send_seen_length = 0
|
||||
self.recv_seen_length = 0
|
||||
|
||||
# Track connection keep alive...
|
||||
self.send_keep_alive = True
|
||||
self.recv_keep_alive = True
|
||||
|
||||
# Special states...
|
||||
self.processing_1xx = False
|
||||
|
||||
def send_method_line(self, method: bytes, target: bytes, protocol: bytes) -> None:
|
||||
"""
|
||||
Send the initial request line:
|
||||
|
||||
>>> p.send_method_line(b'GET', b'/', b'HTTP/1.1')
|
||||
|
||||
Sending state will switch to SEND_HEADERS state.
|
||||
"""
|
||||
if self.send_state != State.SEND_METHOD_LINE:
|
||||
msg = f"Called 'send_method_line' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Send initial request line, eg. "GET / HTTP/1.1"
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Sent unsupported protocol version")
|
||||
data = b" ".join([method, target, protocol]) + b"\r\n"
|
||||
self.stream.write(data)
|
||||
|
||||
self.send_state = State.SEND_HEADERS
|
||||
self.recv_state = State.RECV_STATUS_LINE
|
||||
|
||||
def send_status_line(self, protocol: bytes, status_code: int, reason: bytes) -> None:
|
||||
"""
|
||||
Send the initial response line:
|
||||
|
||||
>>> p.send_method_line(b'HTTP/1.1', 200, b'OK')
|
||||
|
||||
Sending state will switch to SEND_HEADERS state.
|
||||
"""
|
||||
if self.send_state != State.SEND_STATUS_LINE:
|
||||
msg = f"Called 'send_status_line' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Send initial request line, eg. "GET / HTTP/1.1"
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Sent unsupported protocol version")
|
||||
status_code_bytes = str(status_code).encode('ascii')
|
||||
data = b" ".join([protocol, status_code_bytes, reason]) + b"\r\n"
|
||||
self.stream.write(data)
|
||||
|
||||
self.send_state = State.SEND_HEADERS
|
||||
|
||||
def send_headers(self, headers: list[tuple[bytes, bytes]]) -> None:
|
||||
"""
|
||||
Send the request headers:
|
||||
|
||||
>>> p.send_headers([(b'Host', b'www.example.com')])
|
||||
|
||||
Sending state will switch to SEND_BODY state.
|
||||
"""
|
||||
if self.send_state != State.SEND_HEADERS:
|
||||
msg = f"Called 'send_headers' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Update header state
|
||||
seen_host = False
|
||||
for name, value in headers:
|
||||
lname = name.lower()
|
||||
if lname == b'host':
|
||||
seen_host = True
|
||||
elif lname == b'content-length':
|
||||
self.send_content_length = bounded_int(
|
||||
value,
|
||||
max_digits=20,
|
||||
exc_text="Sent invalid Content-Length"
|
||||
)
|
||||
elif lname == b'connection' and value == b'close':
|
||||
self.send_keep_alive = False
|
||||
elif lname == b'transfer-encoding' and value == b'chunked':
|
||||
self.send_content_length = None
|
||||
|
||||
if self.mode == Mode.CLIENT and not seen_host:
|
||||
raise ProtocolError("Request missing 'Host' header")
|
||||
|
||||
# Send request headers
|
||||
lines = [name + b": " + value + b"\r\n" for name, value in headers]
|
||||
data = b"".join(lines) + b"\r\n"
|
||||
self.stream.write(data)
|
||||
|
||||
self.send_state = State.SEND_BODY
|
||||
|
||||
def send_body(self, body: bytes) -> None:
|
||||
"""
|
||||
Send the request body. An empty bytes argument indicates the end of the stream:
|
||||
|
||||
>>> p.send_body(b'')
|
||||
|
||||
Sending state will switch to DONE.
|
||||
"""
|
||||
if self.send_state != State.SEND_BODY:
|
||||
msg = f"Called 'send_body' in invalid state {self.send_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
if self.send_content_length is None:
|
||||
# Transfer-Encoding: chunked
|
||||
self.send_seen_length += len(body)
|
||||
marker = f'{len(body):x}\r\n'.encode('ascii')
|
||||
self.stream.write(marker + body + b'\r\n')
|
||||
|
||||
else:
|
||||
# Content-Length: xxx
|
||||
self.send_seen_length += len(body)
|
||||
if self.send_seen_length > self.send_content_length:
|
||||
msg = 'Too much data sent for declared Content-Length'
|
||||
raise ProtocolError(msg)
|
||||
if self.send_seen_length < self.send_content_length and body == b'':
|
||||
msg = 'Not enough data sent for declared Content-Length'
|
||||
raise ProtocolError(msg)
|
||||
if body:
|
||||
self.stream.write(body)
|
||||
|
||||
if body == b'':
|
||||
# Handle body close
|
||||
self.send_state = State.DONE
|
||||
|
||||
def recv_method_line(self) -> tuple[bytes, bytes, bytes]:
|
||||
"""
|
||||
Receive the initial request method line:
|
||||
|
||||
>>> method, target, protocol = p.recv_status_line()
|
||||
|
||||
Receive state will switch to RECV_HEADERS.
|
||||
"""
|
||||
if self.recv_state != State.RECV_METHOD_LINE:
|
||||
msg = f"Called 'recv_method_line' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Read initial response line, eg. "GET / HTTP/1.1"
|
||||
exc_text = "reading request method line"
|
||||
line = self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text)
|
||||
method, target, protocol = line.split(b" ", 2)
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Received unsupported protocol version")
|
||||
|
||||
self.recv_state = State.RECV_HEADERS
|
||||
self.send_state = State.SEND_STATUS_LINE
|
||||
return method, target, protocol
|
||||
|
||||
def recv_status_line(self) -> tuple[bytes, int, bytes]:
|
||||
"""
|
||||
Receive the initial response status line:
|
||||
|
||||
>>> protocol, status_code, reason_phrase = p.recv_status_line()
|
||||
|
||||
Receive state will switch to RECV_HEADERS.
|
||||
"""
|
||||
if self.recv_state != State.RECV_STATUS_LINE:
|
||||
msg = f"Called 'recv_status_line' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Read initial response line, eg. "HTTP/1.1 200 OK"
|
||||
exc_text = "reading response status line"
|
||||
line = self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text)
|
||||
protocol, status_code_str, reason_phrase = line.split(b" ", 2)
|
||||
if protocol != b'HTTP/1.1':
|
||||
raise ProtocolError("Received unsupported protocol version")
|
||||
|
||||
status_code = bounded_int(
|
||||
status_code_str,
|
||||
max_digits=3,
|
||||
exc_text="Received invalid status code"
|
||||
)
|
||||
if status_code < 100:
|
||||
raise ProtocolError("Received invalid status code")
|
||||
# 1xx status codes preceed the final response status code
|
||||
self.processing_1xx = status_code < 200
|
||||
|
||||
self.recv_state = State.RECV_HEADERS
|
||||
return protocol, status_code, reason_phrase
|
||||
|
||||
def recv_headers(self) -> list[tuple[bytes, bytes]]:
|
||||
"""
|
||||
Receive the response headers:
|
||||
|
||||
>>> headers = p.recv_status_line()
|
||||
|
||||
Receive state will switch to RECV_BODY by default.
|
||||
Receive state will revert to RECV_STATUS_CODE for interim 1xx responses.
|
||||
"""
|
||||
if self.recv_state != State.RECV_HEADERS:
|
||||
msg = f"Called 'recv_headers' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
# Read response headers
|
||||
headers = []
|
||||
exc_text = "reading response headers"
|
||||
while line := self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text):
|
||||
name, value = line.split(b":", 1)
|
||||
value = value.strip(b" ")
|
||||
headers.append((name, value))
|
||||
|
||||
# Update header state
|
||||
seen_host = False
|
||||
for name, value in headers:
|
||||
lname = name.lower()
|
||||
if lname == b'host':
|
||||
seen_host = True
|
||||
elif lname == b'content-length':
|
||||
self.recv_content_length = bounded_int(
|
||||
value,
|
||||
max_digits=20,
|
||||
exc_text="Received invalid Content-Length"
|
||||
)
|
||||
elif lname == b'connection' and value == b'close':
|
||||
self.recv_keep_alive = False
|
||||
elif lname == b'transfer-encoding' and value == b'chunked':
|
||||
self.recv_content_length = None
|
||||
|
||||
if self.mode == Mode.SERVER and not seen_host:
|
||||
raise ProtocolError("Request missing 'Host' header")
|
||||
|
||||
if self.processing_1xx:
|
||||
# 1xx status codes preceed the final response status code
|
||||
self.processing_1xx = False
|
||||
self.recv_state = State.RECV_STATUS_LINE
|
||||
else:
|
||||
self.recv_state = State.RECV_BODY
|
||||
return headers
|
||||
|
||||
def recv_body(self) -> bytes:
|
||||
"""
|
||||
Receive the response body. An empty byte string indicates the end of the stream:
|
||||
|
||||
>>> buffer = bytearray()
|
||||
>>> while body := p.recv_body()
|
||||
>>> buffer.extend(body)
|
||||
|
||||
The server will switch to DONE.
|
||||
"""
|
||||
if self.recv_state != State.RECV_BODY:
|
||||
msg = f"Called 'recv_body' in invalid state {self.recv_state}"
|
||||
raise ProtocolError(msg)
|
||||
|
||||
if self.recv_content_length is None:
|
||||
# Transfer-Encoding: chunked
|
||||
exc_text = 'reading chunk size'
|
||||
line = self.parser.read_until(b"\r\n", max_size=4096, exc_text=exc_text)
|
||||
sizestr, _, _ = line.partition(b";")
|
||||
|
||||
exc_text = "Received invalid chunk size"
|
||||
size = bounded_hex(sizestr, max_digits=8, exc_text=exc_text)
|
||||
if size > 0:
|
||||
body = self.parser.read(size=size)
|
||||
exc_text = 'reading chunk data'
|
||||
self.parser.read_until(b"\r\n", max_size=2, exc_text=exc_text)
|
||||
self.recv_seen_length += len(body)
|
||||
else:
|
||||
body = b''
|
||||
exc_text = 'reading chunk termination'
|
||||
self.parser.read_until(b"\r\n", max_size=2, exc_text=exc_text)
|
||||
|
||||
else:
|
||||
# Content-Length: xxx
|
||||
remaining = self.recv_content_length - self.recv_seen_length
|
||||
size = min(remaining, 4096)
|
||||
body = self.parser.read(size=size)
|
||||
self.recv_seen_length += len(body)
|
||||
if self.recv_seen_length < self.recv_content_length and body == b'':
|
||||
msg = 'Not enough data received for declared Content-Length'
|
||||
raise ProtocolError(msg)
|
||||
|
||||
if body == b'':
|
||||
# Handle body close
|
||||
self.recv_state = State.DONE
|
||||
return body
|
||||
|
||||
def complete(self):
|
||||
is_fully_complete = self.send_state == State.DONE and self.recv_state == State.DONE
|
||||
is_keepalive = self.send_keep_alive and self.recv_keep_alive
|
||||
|
||||
if not (is_fully_complete and is_keepalive):
|
||||
self.close()
|
||||
return
|
||||
|
||||
if self.mode == Mode.CLIENT:
|
||||
self.send_state = State.SEND_METHOD_LINE
|
||||
self.recv_state = State.WAIT
|
||||
else:
|
||||
self.recv_state = State.RECV_METHOD_LINE
|
||||
self.send_state = State.WAIT
|
||||
|
||||
self.send_content_length = 0
|
||||
self.recv_content_length = 0
|
||||
self.send_seen_length = 0
|
||||
self.recv_seen_length = 0
|
||||
self.send_keep_alive = True
|
||||
self.recv_keep_alive = True
|
||||
self.processing_1xx = False
|
||||
|
||||
def close(self):
|
||||
if self.send_state != State.CLOSED:
|
||||
self.send_state = State.CLOSED
|
||||
self.recv_state = State.CLOSED
|
||||
self.stream.close()
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
return (
|
||||
self.send_state == State.SEND_METHOD_LINE or
|
||||
self.recv_state == State.RECV_METHOD_LINE
|
||||
)
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return self.send_state == State.CLOSED
|
||||
|
||||
def description(self) -> str:
|
||||
return {
|
||||
State.SEND_METHOD_LINE: "idle",
|
||||
State.CLOSED: "closed",
|
||||
}.get(self.send_state, "active")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
cl_state = self.send_state.name
|
||||
sr_state = self.recv_state.name
|
||||
detail = f"client {cl_state}, server {sr_state}"
|
||||
return f'<HTTPParser [{detail}]>'
|
||||
|
||||
|
||||
class ReadAheadParser:
|
||||
"""
|
||||
A buffered I/O stream, with methods for read-ahead parsing.
|
||||
"""
|
||||
def __init__(self, stream: Stream) -> None:
|
||||
self._buffer = b''
|
||||
self._stream = stream
|
||||
self._chunk_size = 4096
|
||||
|
||||
def _read_some(self) -> bytes:
|
||||
if self._buffer:
|
||||
ret, self._buffer = self._buffer, b''
|
||||
return ret
|
||||
return self._stream.read(self._chunk_size)
|
||||
|
||||
def _push_back(self, buffer):
|
||||
assert self._buffer == b''
|
||||
self._buffer = buffer
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
"""
|
||||
Read and return up to 'size' bytes from the stream, with I/O buffering provided.
|
||||
|
||||
* Returns b'' to indicate connection close.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
while len(buffer) < size:
|
||||
chunk = self._read_some()
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
|
||||
if len(buffer) > size:
|
||||
buffer, push_back = buffer[:size], buffer[size:]
|
||||
self._push_back(bytes(push_back))
|
||||
return bytes(buffer)
|
||||
|
||||
def read_until(self, marker: bytes, max_size: int, exc_text: str) -> bytes:
|
||||
"""
|
||||
Read and return bytes from the stream, delimited by marker.
|
||||
|
||||
* The marker is not included in the return bytes.
|
||||
* The marker is consumed from the I/O stream.
|
||||
* Raises `ProtocolError` if the stream closes before a marker occurance.
|
||||
* Raises `ProtocolError` if marker did not occur within 'max_size + len(marker)' bytes.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
while len(buffer) <= max_size:
|
||||
chunk = self._read_some()
|
||||
if not chunk:
|
||||
# stream closed before marker found.
|
||||
raise ProtocolError(f"Stream closed early {exc_text}")
|
||||
start_search = max(len(buffer) - len(marker), 0)
|
||||
buffer.extend(chunk)
|
||||
index = buffer.find(marker, start_search)
|
||||
|
||||
if index > max_size:
|
||||
# marker was found, though 'max_size' exceeded.
|
||||
raise ProtocolError(f"Exceeded maximum size {exc_text}")
|
||||
elif index >= 0:
|
||||
endindex = index + len(marker)
|
||||
self._push_back(bytes(buffer[endindex:]))
|
||||
return bytes(buffer[:index])
|
||||
|
||||
raise ProtocolError(f"Exceeded maximum size {exc_text}")
|
||||
|
||||
|
||||
def bounded_int(intstr: bytes, max_digits: int, exc_text: str):
|
||||
if len(intstr) > max_digits:
|
||||
# Length of bytestring exceeds maximum.
|
||||
raise ProtocolError(exc_text)
|
||||
if len(intstr.strip(b'0123456789')) != 0:
|
||||
# Contains invalid characters.
|
||||
raise ProtocolError(exc_text)
|
||||
|
||||
return int(intstr)
|
||||
|
||||
|
||||
def bounded_hex(hexstr: bytes, max_digits: int, exc_text: str):
|
||||
if len(hexstr) > max_digits:
|
||||
# Length of bytestring exceeds maximum.
|
||||
raise ProtocolError(exc_text)
|
||||
if len(hexstr.strip(b'0123456789abcdefABCDEF')) != 0:
|
||||
# Contains invalid characters.
|
||||
raise ProtocolError(exc_text)
|
||||
|
||||
return int(hexstr, base=16)
|
||||
284
src/httpx/_pool.py
Normal file
284
src/httpx/_pool.py
Normal file
@ -0,0 +1,284 @@
|
||||
import time
|
||||
import typing
|
||||
import types
|
||||
|
||||
from ._content import Content
|
||||
from ._headers import Headers
|
||||
from ._network import Lock, NetworkBackend, Semaphore
|
||||
from ._parsers import HTTPParser
|
||||
from ._response import Response
|
||||
from ._request import Request
|
||||
from ._streams import HTTPStream, Stream
|
||||
from ._urls import URL
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Transport",
|
||||
"ConnectionPool",
|
||||
"Connection",
|
||||
"open_connection",
|
||||
]
|
||||
|
||||
|
||||
class Transport:
|
||||
def send(self, request: Request) -> Response:
|
||||
raise NotImplementedError()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | dict[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
with self.send(request) as response:
|
||||
response.read()
|
||||
return response
|
||||
|
||||
def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | dict[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
response = self.send(request)
|
||||
return response
|
||||
|
||||
|
||||
class ConnectionPool(Transport):
|
||||
def __init__(self, backend: NetworkBackend | None = None):
|
||||
if backend is None:
|
||||
backend = NetworkBackend()
|
||||
|
||||
self._connections: list[Connection] = []
|
||||
self._network_backend = backend
|
||||
self._limit_concurrency = Semaphore(100)
|
||||
self._closed = False
|
||||
|
||||
# Public API...
|
||||
def send(self, request: Request) -> Response:
|
||||
if self._closed:
|
||||
raise RuntimeError("ConnectionPool is closed.")
|
||||
|
||||
# TODO: concurrency limiting
|
||||
self._cleanup()
|
||||
connection = self._get_connection(request)
|
||||
response = connection.send(request)
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
closing = list(self._connections)
|
||||
self._connections = []
|
||||
for conn in closing:
|
||||
conn.close()
|
||||
|
||||
# Create or reuse connections as required...
|
||||
def _get_connection(self, request: Request) -> "Connection":
|
||||
# Attempt to reuse an existing connection.
|
||||
url = request.url
|
||||
origin = URL(scheme=url.scheme, host=url.host, port=url.port)
|
||||
now = time.monotonic()
|
||||
for conn in self._connections:
|
||||
if conn.origin() == origin and conn.is_idle() and not conn.is_expired(now):
|
||||
return conn
|
||||
|
||||
# Or else create a new connection.
|
||||
conn = open_connection(
|
||||
origin,
|
||||
hostname=request.headers["Host"],
|
||||
backend=self._network_backend
|
||||
)
|
||||
self._connections.append(conn)
|
||||
return conn
|
||||
|
||||
# Connection pool management...
|
||||
def _cleanup(self) -> None:
|
||||
now = time.monotonic()
|
||||
for conn in list(self._connections):
|
||||
if conn.is_expired(now):
|
||||
conn.close()
|
||||
if conn.is_closed():
|
||||
self._connections.remove(conn)
|
||||
|
||||
@property
|
||||
def connections(self) -> typing.List['Connection']:
|
||||
return [c for c in self._connections]
|
||||
|
||||
def description(self) -> str:
|
||||
counts = {"active": 0}
|
||||
for status in [c.description() for c in self._connections]:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
return ", ".join(f"{count} {status}" for status, count in counts.items())
|
||||
|
||||
# Builtins...
|
||||
def __repr__(self) -> str:
|
||||
return f"<ConnectionPool [{self.description()}]>"
|
||||
|
||||
def __del__(self):
|
||||
if not self._closed:
|
||||
import warnings
|
||||
warnings.warn("ConnectionPool was garbage collected without being closed.")
|
||||
|
||||
def __enter__(self) -> "ConnectionPool":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class Connection(Transport):
|
||||
def __init__(self, stream: Stream, origin: URL | str):
|
||||
self._stream = stream
|
||||
self._origin = URL(origin)
|
||||
self._keepalive_duration = 5.0
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
self._request_lock = Lock()
|
||||
self._parser = HTTPParser(stream, mode='CLIENT')
|
||||
|
||||
# API for connection pool management...
|
||||
def origin(self) -> URL:
|
||||
return self._origin
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
return self._parser.is_idle()
|
||||
|
||||
def is_expired(self, when: float) -> bool:
|
||||
return self._parser.is_idle() and when > self._idle_expiry
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return self._parser.is_closed()
|
||||
|
||||
def description(self) -> str:
|
||||
return self._parser.description()
|
||||
|
||||
# API entry points...
|
||||
def send(self, request: Request) -> Response:
|
||||
#async with self._request_lock:
|
||||
# try:
|
||||
self._send_head(request)
|
||||
self._send_body(request)
|
||||
code, headers = self._recv_head()
|
||||
stream = HTTPStream(self._recv_body, self._complete)
|
||||
# TODO...
|
||||
return Response(code, headers=headers, content=stream)
|
||||
# finally:
|
||||
# await self._cycle_complete()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._request_lock:
|
||||
self._close()
|
||||
|
||||
# Top-level API for working directly with a connection.
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
url = self._origin.join(url)
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
with self.send(request) as response:
|
||||
response.read()
|
||||
return response
|
||||
|
||||
def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
) -> Response:
|
||||
url = self._origin.join(url)
|
||||
request = Request(method, url, headers=headers, content=content)
|
||||
return self.send(request)
|
||||
|
||||
# Send the request...
|
||||
def _send_head(self, request: Request) -> None:
|
||||
method = request.method.encode('ascii')
|
||||
target = request.url.target.encode('ascii')
|
||||
protocol = b'HTTP/1.1'
|
||||
self._parser.send_method_line(method, target, protocol)
|
||||
headers = [
|
||||
(k.encode('ascii'), v.encode('ascii'))
|
||||
for k, v in request.headers.items()
|
||||
]
|
||||
self._parser.send_headers(headers)
|
||||
|
||||
def _send_body(self, request: Request) -> None:
|
||||
while data := request.stream.read(64 * 1024):
|
||||
self._parser.send_body(data)
|
||||
self._parser.send_body(b'')
|
||||
|
||||
# Receive the response...
|
||||
def _recv_head(self) -> tuple[int, Headers]:
|
||||
_, code, _ = self._parser.recv_status_line()
|
||||
h = self._parser.recv_headers()
|
||||
headers = Headers([
|
||||
(k.decode('ascii'), v.decode('ascii'))
|
||||
for k, v in h
|
||||
])
|
||||
return code, headers
|
||||
|
||||
def _recv_body(self) -> bytes:
|
||||
return self._parser.recv_body()
|
||||
|
||||
# Request/response cycle complete...
|
||||
def _complete(self) -> None:
|
||||
self._parser.complete()
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
|
||||
def _close(self) -> None:
|
||||
self._parser.close()
|
||||
|
||||
# Builtins...
|
||||
def __repr__(self) -> str:
|
||||
return f"<Connection [{self._origin} {self.description()}]>"
|
||||
|
||||
def __enter__(self) -> "Connection":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None,
|
||||
):
|
||||
self.close()
|
||||
|
||||
|
||||
def open_connection(
|
||||
url: URL | str,
|
||||
hostname: str = '',
|
||||
backend: NetworkBackend | None = None,
|
||||
) -> Connection:
|
||||
|
||||
if isinstance(url, str):
|
||||
url = URL(url)
|
||||
|
||||
if url.scheme not in ("http", "https"):
|
||||
raise ValueError("URL scheme must be 'http://' or 'https://'.")
|
||||
if backend is None:
|
||||
backend = NetworkBackend()
|
||||
|
||||
host = url.host
|
||||
port = url.port or {"http": 80, "https": 443}[url.scheme]
|
||||
|
||||
if url.scheme == "https":
|
||||
stream = backend.connect_tls(host, port, hostname)
|
||||
else:
|
||||
stream = backend.connect(host, port)
|
||||
|
||||
return Connection(stream, url)
|
||||
49
src/httpx/_quickstart.py
Normal file
49
src/httpx/_quickstart.py
Normal file
@ -0,0 +1,49 @@
|
||||
import typing
|
||||
|
||||
from ._client import Client
|
||||
from ._content import Content
|
||||
from ._headers import Headers
|
||||
from ._streams import Stream
|
||||
from ._urls import URL
|
||||
|
||||
|
||||
__all__ = ['get', 'post', 'put', 'patch', 'delete']
|
||||
|
||||
|
||||
def get(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
with Client() as client:
|
||||
return client.request("GET", url=url, headers=headers)
|
||||
|
||||
def post(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
with Client() as client:
|
||||
return client.request("POST", url, headers=headers, content=content)
|
||||
|
||||
def put(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
with Client() as client:
|
||||
return client.request("PUT", url, headers=headers, content=content)
|
||||
|
||||
def patch(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
with Client() as client:
|
||||
return client.request("PATCH", url, headers=headers, content=content)
|
||||
|
||||
def delete(
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
):
|
||||
with Client() as client:
|
||||
return client.request("DELETE", url=url, headers=headers)
|
||||
93
src/httpx/_request.py
Normal file
93
src/httpx/_request.py
Normal file
@ -0,0 +1,93 @@
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._content import Content
|
||||
from ._streams import ByteStream, Stream
|
||||
from ._headers import Headers
|
||||
from ._urls import URL
|
||||
|
||||
__all__ = ["Request"]
|
||||
|
||||
|
||||
class Request:
|
||||
def __init__(
|
||||
self,
|
||||
method: str,
|
||||
url: URL | str,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
self.method = method
|
||||
self.url = URL(url)
|
||||
self.headers = Headers(headers)
|
||||
self.stream: Stream = ByteStream(b"")
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-14.23
|
||||
# RFC 2616, Section 14.23, Host.
|
||||
#
|
||||
# A client MUST include a Host header field in all HTTP/1.1 request messages.
|
||||
if "Host" not in self.headers:
|
||||
self.headers = self.headers.copy_set("Host", self.url.netloc)
|
||||
|
||||
if content is not None:
|
||||
if isinstance(content, bytes):
|
||||
self.stream = ByteStream(content)
|
||||
elif isinstance(content, Stream):
|
||||
self.stream = content
|
||||
elif isinstance(content, Content):
|
||||
ct = content.content_type()
|
||||
self.stream = content.encode()
|
||||
self.headers = self.headers.copy_set("Content-Type", ct)
|
||||
else:
|
||||
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-4.3
|
||||
# RFC 2616, Section 4.3, Message Body.
|
||||
#
|
||||
# The presence of a message-body in a request is signaled by the
|
||||
# inclusion of a Content-Length or Transfer-Encoding header field in
|
||||
# the request's message-headers.
|
||||
content_length: int | None = self.stream.size
|
||||
if content_length is None:
|
||||
self.headers = self.headers.copy_set("Transfer-Encoding", "chunked")
|
||||
elif content_length > 0:
|
||||
self.headers = self.headers.copy_set("Content-Length", str(content_length))
|
||||
|
||||
elif method in ("POST", "PUT", "PATCH"):
|
||||
# https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2
|
||||
# RFC 7230, Section 3.3.2, Content Length.
|
||||
#
|
||||
# A user agent SHOULD send a Content-Length in a request message when no
|
||||
# Transfer-Encoding is sent and the request method defines a meaning for
|
||||
# an enclosed payload body. For example, a Content-Length header field is
|
||||
# normally sent in a POST request even when the value is 0.
|
||||
# (indicating an empty payload body).
|
||||
self.headers = self.headers.copy_set("Content-Length", "0")
|
||||
|
||||
@property
|
||||
def body(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
raise RuntimeError("'.body' cannot be accessed without calling '.read()'")
|
||||
return self._body
|
||||
|
||||
def read(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
self._body = self.stream.read()
|
||||
self.stream = ByteStream(self._body)
|
||||
return self._body
|
||||
|
||||
def close(self) -> None:
|
||||
self.stream.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
self.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Request [{self.method} {str(self.url)!r}]>"
|
||||
158
src/httpx/_response.py
Normal file
158
src/httpx/_response.py
Normal file
@ -0,0 +1,158 @@
|
||||
import types
|
||||
import typing
|
||||
|
||||
from ._content import Content
|
||||
from ._streams import ByteStream, Stream
|
||||
from ._headers import Headers, parse_opts_header
|
||||
|
||||
__all__ = ["Response"]
|
||||
|
||||
# We're using the same set as stdlib `http.HTTPStatus` here...
|
||||
#
|
||||
# https://github.com/python/cpython/blob/main/Lib/http/__init__.py
|
||||
_codes = {
|
||||
100: "Continue",
|
||||
101: "Switching Protocols",
|
||||
102: "Processing",
|
||||
103: "Early Hints",
|
||||
200: "OK",
|
||||
201: "Created",
|
||||
202: "Accepted",
|
||||
203: "Non-Authoritative Information",
|
||||
204: "No Content",
|
||||
205: "Reset Content",
|
||||
206: "Partial Content",
|
||||
207: "Multi-Status",
|
||||
208: "Already Reported",
|
||||
226: "IM Used",
|
||||
300: "Multiple Choices",
|
||||
301: "Moved Permanently",
|
||||
302: "Found",
|
||||
303: "See Other",
|
||||
304: "Not Modified",
|
||||
305: "Use Proxy",
|
||||
307: "Temporary Redirect",
|
||||
308: "Permanent Redirect",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
406: "Not Acceptable",
|
||||
407: "Proxy Authentication Required",
|
||||
408: "Request Timeout",
|
||||
409: "Conflict",
|
||||
410: "Gone",
|
||||
411: "Length Required",
|
||||
412: "Precondition Failed",
|
||||
413: "Content Too Large",
|
||||
414: "URI Too Long",
|
||||
415: "Unsupported Media Type",
|
||||
416: "Range Not Satisfiable",
|
||||
417: "Expectation Failed",
|
||||
418: "I'm a Teapot",
|
||||
421: "Misdirected Request",
|
||||
422: "Unprocessable Content",
|
||||
423: "Locked",
|
||||
424: "Failed Dependency",
|
||||
425: "Too Early",
|
||||
426: "Upgrade Required",
|
||||
428: "Precondition Required",
|
||||
429: "Too Many Requests",
|
||||
431: "Request Header Fields Too Large",
|
||||
451: "Unavailable For Legal Reasons",
|
||||
500: "Internal Server Error",
|
||||
501: "Not Implemented",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
505: "HTTP Version Not Supported",
|
||||
506: "Variant Also Negotiates",
|
||||
507: "Insufficient Storage",
|
||||
508: "Loop Detected",
|
||||
510: "Not Extended",
|
||||
511: "Network Authentication Required",
|
||||
}
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
*,
|
||||
headers: Headers | typing.Mapping[str, str] | None = None,
|
||||
content: Content | Stream | bytes | None = None,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = Headers(headers)
|
||||
self.stream: Stream = ByteStream(b"")
|
||||
|
||||
if content is not None:
|
||||
if isinstance(content, bytes):
|
||||
self.stream = ByteStream(content)
|
||||
elif isinstance(content, Stream):
|
||||
self.stream = content
|
||||
elif isinstance(content, Content):
|
||||
ct = content.content_type()
|
||||
self.stream = content.encode()
|
||||
self.headers = self.headers.copy_set("Content-Type", ct)
|
||||
else:
|
||||
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2616#section-4.3
|
||||
# RFC 2616, Section 4.3, Message Body.
|
||||
#
|
||||
# All 1xx (informational), 204 (no content), and 304 (not modified) responses
|
||||
# MUST NOT include a message-body. All other responses do include a
|
||||
# message-body, although it MAY be of zero length.
|
||||
if status_code >= 200 and status_code != 204 and status_code != 304:
|
||||
content_length: int | None = self.stream.size
|
||||
if content_length is None:
|
||||
self.headers = self.headers.copy_set("Transfer-Encoding", "chunked")
|
||||
else:
|
||||
self.headers = self.headers.copy_set("Content-Length", str(content_length))
|
||||
|
||||
@property
|
||||
def reason_phrase(self):
|
||||
return _codes.get(self.status_code, "Unknown Status Code")
|
||||
|
||||
@property
|
||||
def body(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
raise RuntimeError("'.body' cannot be accessed without calling '.read()'")
|
||||
return self._body
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
if not hasattr(self, '_body'):
|
||||
raise RuntimeError("'.text' cannot be accessed without calling '.read()'")
|
||||
if not hasattr(self, '_text'):
|
||||
ct = self.headers.get('Content-Type', '')
|
||||
media, opts = parse_opts_header(ct)
|
||||
charset = 'utf-8'
|
||||
if media.startswith('text/'):
|
||||
charset = opts.get('charset', 'utf-8')
|
||||
self._text = self._body.decode(charset)
|
||||
return self._text
|
||||
|
||||
def read(self) -> bytes:
|
||||
if not hasattr(self, '_body'):
|
||||
self._body = self.stream.read()
|
||||
return self._body
|
||||
|
||||
def close(self) -> None:
|
||||
self.stream.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
self.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Response [{self.status_code} {self.reason_phrase}]>"
|
||||
126
src/httpx/_server.py
Normal file
126
src/httpx/_server.py
Normal file
@ -0,0 +1,126 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ._content import Text
|
||||
from ._parsers import HTTPParser
|
||||
from ._request import Request
|
||||
from ._response import Response
|
||||
from ._network import NetworkBackend, sleep
|
||||
from ._streams import HTTPStream
|
||||
|
||||
__all__ = [
|
||||
"serve_http", "run"
|
||||
]
|
||||
|
||||
logger = logging.getLogger("httpx.server")
|
||||
|
||||
|
||||
class ConnectionClosed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPConnection:
|
||||
def __init__(self, stream, endpoint):
|
||||
self._stream = stream
|
||||
self._endpoint = endpoint
|
||||
self._parser = HTTPParser(stream, mode='SERVER')
|
||||
self._keepalive_duration = 5.0
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
|
||||
# API entry points...
|
||||
def handle_requests(self):
|
||||
try:
|
||||
while not self._parser.is_closed():
|
||||
method, url, headers = self._recv_head()
|
||||
stream = HTTPStream(self._recv_body, self._complete)
|
||||
# TODO: Handle endpoint exceptions
|
||||
with Request(method, url, headers=headers, content=stream) as request:
|
||||
try:
|
||||
response = self._endpoint(request)
|
||||
status_line = f"{request.method} {request.url.target} [{response.status_code} {response.reason_phrase}]"
|
||||
logger.info(status_line)
|
||||
except Exception:
|
||||
logger.error("Internal Server Error", exc_info=True)
|
||||
content = Text("Internal Server Error")
|
||||
err = Response(code=500, content=content)
|
||||
self._send_head(err)
|
||||
self._send_body(err)
|
||||
else:
|
||||
self._send_head(response)
|
||||
self._send_body(response)
|
||||
except Exception:
|
||||
logger.error("Internal Server Error", exc_info=True)
|
||||
|
||||
def close(self):
|
||||
self._parser.close()
|
||||
|
||||
# Receive the request...
|
||||
def _recv_head(self) -> tuple[str, str, list[tuple[str, str]]]:
|
||||
method, target, _ = self._parser.recv_method_line()
|
||||
m = method.decode('ascii')
|
||||
t = target.decode('ascii')
|
||||
headers = self._parser.recv_headers()
|
||||
h = [
|
||||
(k.decode('latin-1'), v.decode('latin-1'))
|
||||
for k, v in headers
|
||||
]
|
||||
return m, t, h
|
||||
|
||||
def _recv_body(self):
|
||||
return self._parser.recv_body()
|
||||
|
||||
# Return the response...
|
||||
def _send_head(self, response: Response):
|
||||
protocol = b"HTTP/1.1"
|
||||
status = response.status_code
|
||||
reason = response.reason_phrase.encode('ascii')
|
||||
self._parser.send_status_line(protocol, status, reason)
|
||||
headers = [
|
||||
(k.encode('ascii'), v.encode('ascii'))
|
||||
for k, v in response.headers.items()
|
||||
]
|
||||
self._parser.send_headers(headers)
|
||||
|
||||
def _send_body(self, response: Response):
|
||||
while data := response.stream.read(64 * 1024):
|
||||
self._parser.send_body(data)
|
||||
self._parser.send_body(b'')
|
||||
|
||||
# Start it all over again...
|
||||
def _complete(self):
|
||||
self._parser.complete
|
||||
self._idle_expiry = time.monotonic() + self._keepalive_duration
|
||||
|
||||
|
||||
class HTTPServer:
|
||||
def __init__(self, host, port):
|
||||
self.url = f"http://{host}:{port}/"
|
||||
|
||||
def wait(self):
|
||||
while(True):
|
||||
sleep(1)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serve_http(endpoint):
|
||||
def handler(stream):
|
||||
connection = HTTPConnection(stream, endpoint)
|
||||
connection.handle_requests()
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
|
||||
backend = NetworkBackend()
|
||||
with backend.serve("127.0.0.1", 8080, handler) as server:
|
||||
server = HTTPServer(server.host, server.port)
|
||||
logger.info(f"Serving on {server.url} (Press CTRL+C to quit)")
|
||||
yield server
|
||||
|
||||
|
||||
def run(app):
|
||||
with serve_http(app) as server:
|
||||
server.wait()
|
||||
235
src/httpx/_streams.py
Normal file
235
src/httpx/_streams.py
Normal file
@ -0,0 +1,235 @@
|
||||
import io
|
||||
import types
|
||||
import os
|
||||
|
||||
|
||||
class Stream:
|
||||
def read(self, size: int=-1) -> bytes:
|
||||
raise NotImplementedError()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def close(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: types.TracebackType | None = None
|
||||
):
|
||||
self.close()
|
||||
|
||||
|
||||
class ByteStream(Stream):
|
||||
def __init__(self, data: bytes = b''):
|
||||
self._buffer = io.BytesIO(data)
|
||||
self._size = len(data)
|
||||
|
||||
def read(self, size: int=-1) -> bytes:
|
||||
return self._buffer.read(size)
|
||||
|
||||
def close(self) -> None:
|
||||
self._buffer.close()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return self._size
|
||||
|
||||
|
||||
class DuplexStream(Stream):
|
||||
"""
|
||||
DuplexStream supports both `read` and `write` operations,
|
||||
which are applied to seperate buffers.
|
||||
|
||||
This stream can be used for testing network parsers.
|
||||
"""
|
||||
|
||||
def __init__(self, data: bytes = b''):
|
||||
self._read_buffer = io.BytesIO(data)
|
||||
self._write_buffer = io.BytesIO()
|
||||
|
||||
def read(self, size: int=-1) -> bytes:
|
||||
return self._read_buffer.read(size)
|
||||
|
||||
def write(self, buffer: bytes):
|
||||
return self._write_buffer.write(buffer)
|
||||
|
||||
def close(self) -> None:
|
||||
self._read_buffer.close()
|
||||
self._write_buffer.close()
|
||||
|
||||
def input_bytes(self) -> bytes:
|
||||
return self._read_buffer.getvalue()
|
||||
|
||||
def output_bytes(self) -> bytes:
|
||||
return self._write_buffer.getvalue()
|
||||
|
||||
|
||||
class FileStream(Stream):
|
||||
def __init__(self, path):
|
||||
self._path = path
|
||||
self._fileobj = None
|
||||
self._size = None
|
||||
|
||||
def read(self, size: int=-1) -> bytes:
|
||||
if self._fileobj is None:
|
||||
raise ValueError('I/O operation on unopened file')
|
||||
return self._fileobj.read(size)
|
||||
|
||||
def open(self):
|
||||
self._fileobj = open(self._path, 'rb')
|
||||
self._size = os.path.getsize(self._path)
|
||||
return self
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fileobj is not None:
|
||||
self._fileobj.close()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return self._size
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
|
||||
class HTTPStream(Stream):
|
||||
def __init__(self, next_chunk, complete):
|
||||
self._next_chunk = next_chunk
|
||||
self._complete = complete
|
||||
self._buffer = io.BytesIO()
|
||||
|
||||
def read(self, size=-1) -> bytes:
|
||||
sections = []
|
||||
length = 0
|
||||
|
||||
# If we have any data in the buffer read that and clear the buffer.
|
||||
buffered = self._buffer.read()
|
||||
if buffered:
|
||||
sections.append(buffered)
|
||||
length += len(buffered)
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate(0)
|
||||
|
||||
# Read each chunk in turn.
|
||||
while (size < 0) or (length < size):
|
||||
section = self._next_chunk()
|
||||
sections.append(section)
|
||||
length += len(section)
|
||||
if section == b'':
|
||||
break
|
||||
|
||||
# If we've more data than requested, then push some back into the buffer.
|
||||
output = b''.join(sections)
|
||||
if size > -1 and len(output) > size:
|
||||
output, remainder = output[:size], output[size:]
|
||||
self._buffer.write(remainder)
|
||||
self._buffer.seek(0)
|
||||
|
||||
return output
|
||||
|
||||
def close(self) -> None:
|
||||
self._buffer.close()
|
||||
if self._complete is not None:
|
||||
self._complete()
|
||||
|
||||
|
||||
class MultiPartStream(Stream):
|
||||
def __init__(self, form: list[tuple[str, str]], files: list[tuple[str, str]], boundary=''):
|
||||
self._form = list(form)
|
||||
self._files = list(files)
|
||||
self._boundary = boundary or os.urandom(16).hex()
|
||||
# Mutable state...
|
||||
self._form_progress = list(self._form)
|
||||
self._files_progress = list(self._files)
|
||||
self._filestream: FileStream | None = None
|
||||
self._complete = False
|
||||
self._buffer = io.BytesIO()
|
||||
|
||||
def read(self, size=-1) -> bytes:
|
||||
sections = []
|
||||
length = 0
|
||||
|
||||
# If we have any data in the buffer read that and clear the buffer.
|
||||
buffered = self._buffer.read()
|
||||
if buffered:
|
||||
sections.append(buffered)
|
||||
length += len(buffered)
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate(0)
|
||||
|
||||
# Read each multipart section in turn.
|
||||
while (size < 0) or (length < size):
|
||||
section = self._read_next_section()
|
||||
sections.append(section)
|
||||
length += len(section)
|
||||
if section == b'':
|
||||
break
|
||||
|
||||
# If we've more data than requested, then push some back into the buffer.
|
||||
output = b''.join(sections)
|
||||
if size > -1 and len(output) > size:
|
||||
output, remainder = output[:size], output[size:]
|
||||
self._buffer.write(remainder)
|
||||
self._buffer.seek(0)
|
||||
|
||||
return output
|
||||
|
||||
def _read_next_section(self) -> bytes:
|
||||
if self._form_progress:
|
||||
# return a form item
|
||||
key, value = self._form_progress.pop(0)
|
||||
name = key.translate({10: "%0A", 13: "%0D", 34: "%22"})
|
||||
return (
|
||||
f"--{self._boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n'
|
||||
f"\r\n"
|
||||
f"{value}\r\n"
|
||||
).encode("utf-8")
|
||||
elif self._files_progress and self._filestream is None:
|
||||
# return start of a file item
|
||||
key, value = self._files_progress.pop(0)
|
||||
self._filestream = FileStream(value).open()
|
||||
name = key.translate({10: "%0A", 13: "%0D", 34: "%22"})
|
||||
filename = os.path.basename(value)
|
||||
return (
|
||||
f"--{self._boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
||||
f"\r\n"
|
||||
).encode("utf-8")
|
||||
elif self._filestream is not None:
|
||||
chunk = self._filestream.read(64*1024)
|
||||
if chunk != b'':
|
||||
# return some bytes from file
|
||||
return chunk
|
||||
else:
|
||||
# return end of file item
|
||||
self._filestream.close()
|
||||
self._filestream = None
|
||||
return b"\r\n"
|
||||
elif not self._complete:
|
||||
# return final section of multipart
|
||||
self._complete = True
|
||||
return f"--{self._boundary}--\r\n".encode("utf-8")
|
||||
# return EOF marker
|
||||
return b""
|
||||
|
||||
def close(self) -> None:
|
||||
if self._filestream is not None:
|
||||
self._filestream.close()
|
||||
self._filestream = None
|
||||
self._buffer.close()
|
||||
|
||||
@property
|
||||
def size(self) -> int | None:
|
||||
return None
|
||||
85
src/httpx/_urlencode.py
Normal file
85
src/httpx/_urlencode.py
Normal file
@ -0,0 +1,85 @@
|
||||
import re
|
||||
|
||||
__all__ = ["quote", "unquote", "urldecode", "urlencode"]
|
||||
|
||||
|
||||
# Matchs a sequence of one or more '%xx' escapes.
|
||||
PERCENT_ENCODED_REGEX = re.compile("(%[A-Fa-f0-9][A-Fa-f0-9])+")
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
|
||||
SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
|
||||
|
||||
def urlencode(multidict, safe=SAFE):
|
||||
pairs = []
|
||||
for key, values in multidict.items():
|
||||
pairs.extend([(key, value) for value in values])
|
||||
|
||||
safe += "+"
|
||||
pairs = [(k.replace(" ", "+"), v.replace(" ", "+")) for k, v in pairs]
|
||||
|
||||
return "&".join(
|
||||
f"{quote(key, safe)}={quote(val, safe)}"
|
||||
for key, val in pairs
|
||||
)
|
||||
|
||||
|
||||
def urldecode(string):
|
||||
parts = [part.partition("=") for part in string.split("&") if part]
|
||||
pairs = [
|
||||
(unquote(key), unquote(val))
|
||||
for key, _, val in parts
|
||||
]
|
||||
|
||||
pairs = [(k.replace("+", " "), v.replace("+", " ")) for k, v in pairs]
|
||||
|
||||
ret = {}
|
||||
for k, v in pairs:
|
||||
ret.setdefault(k, []).append(v)
|
||||
return ret
|
||||
|
||||
|
||||
def quote(string, safe=SAFE):
|
||||
# Fast path if the string is already safe.
|
||||
if not string.strip(safe):
|
||||
return string
|
||||
|
||||
# Replace any characters not in the safe set with '%xx' escape sequences.
|
||||
return "".join([
|
||||
char if char in safe else percent(char)
|
||||
for char in string
|
||||
])
|
||||
|
||||
|
||||
def unquote(string):
|
||||
# Fast path if the string is not quoted.
|
||||
if '%' not in string:
|
||||
return string
|
||||
|
||||
# Unquote.
|
||||
parts = []
|
||||
current_position = 0
|
||||
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
|
||||
start_position, end_position = match.start(), match.end()
|
||||
matched_text = match.group(0)
|
||||
# Include any text up to the '%xx' escape sequence.
|
||||
if start_position != current_position:
|
||||
leading_text = string[current_position:start_position]
|
||||
parts.append(leading_text)
|
||||
|
||||
# Decode the '%xx' escape sequence.
|
||||
hex = matched_text.replace('%', '')
|
||||
decoded = bytes.fromhex(hex).decode('utf-8')
|
||||
parts.append(decoded)
|
||||
current_position = end_position
|
||||
|
||||
# Include any text after the final '%xx' escape sequence.
|
||||
if current_position != len(string):
|
||||
trailing_text = string[current_position:]
|
||||
parts.append(trailing_text)
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def percent(c):
|
||||
return ''.join(f"%{b:02X}" for b in c.encode("utf-8"))
|
||||
534
src/httpx/_urlparse.py
Normal file
534
src/httpx/_urlparse.py
Normal file
@ -0,0 +1,534 @@
|
||||
"""
|
||||
An implementation of `urlparse` that provides URL validation and normalization
|
||||
as described by RFC3986.
|
||||
|
||||
We rely on this implementation rather than the one in Python's stdlib, because:
|
||||
|
||||
* It provides more complete URL validation.
|
||||
* It properly differentiates between an empty querystring and an absent querystring,
|
||||
to distinguish URLs with a trailing '?'.
|
||||
* It handles scheme, hostname, port, and path normalization.
|
||||
* It supports IDNA hostnames, normalizing them to their encoded form.
|
||||
* The API supports passing individual components, as well as the complete URL string.
|
||||
|
||||
Previously we relied on the excellent `rfc3986` package to handle URL parsing and
|
||||
validation, but this module provides a simpler alternative, with less indirection
|
||||
required.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import typing
|
||||
|
||||
|
||||
class InvalidURL(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
MAX_URL_LENGTH = 65536
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3
|
||||
UNRESERVED_CHARACTERS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
SUB_DELIMS = "!$&'()*+,;="
|
||||
|
||||
PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}")
|
||||
|
||||
# https://url.spec.whatwg.org/#percent-encoded-bytes
|
||||
|
||||
# The fragment percent-encode set is the C0 control percent-encode set
|
||||
# and U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`).
|
||||
FRAG_SAFE = "".join(
|
||||
[chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x3C, 0x3E, 0x60)]
|
||||
)
|
||||
|
||||
# The query percent-encode set is the C0 control percent-encode set
|
||||
# and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
|
||||
QUERY_SAFE = "".join(
|
||||
[chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E)]
|
||||
)
|
||||
|
||||
# The path percent-encode set is the query percent-encode set
|
||||
# and U+003F (?), U+0060 (`), U+007B ({), and U+007D (}).
|
||||
PATH_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + (0x3F, 0x60, 0x7B, 0x7D)
|
||||
]
|
||||
)
|
||||
|
||||
# The userinfo percent-encode set is the path percent-encode set
|
||||
# and U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@),
|
||||
# U+005B ([) to U+005E (^), inclusive, and U+007C (|).
|
||||
USERNAME_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
PASSWORD_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
# Note... The terminology 'userinfo' percent-encode set in the WHATWG document
|
||||
# is used for the username and password quoting. For the joint userinfo component
|
||||
# we remove U+003A (:) from the safe set.
|
||||
USERINFO_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# {scheme}: (optional)
|
||||
# //{authority} (optional)
|
||||
# {path}
|
||||
# ?{query} (optional)
|
||||
# #{fragment} (optional)
|
||||
URL_REGEX = re.compile(
|
||||
(
|
||||
r"(?:(?P<scheme>{scheme}):)?"
|
||||
r"(?://(?P<authority>{authority}))?"
|
||||
r"(?P<path>{path})"
|
||||
r"(?:\?(?P<query>{query}))?"
|
||||
r"(?:#(?P<fragment>{fragment}))?"
|
||||
).format(
|
||||
scheme="([a-zA-Z][a-zA-Z0-9+.-]*)?",
|
||||
authority="[^/?#]*",
|
||||
path="[^?#]*",
|
||||
query="[^#]*",
|
||||
fragment=".*",
|
||||
)
|
||||
)
|
||||
|
||||
# {userinfo}@ (optional)
|
||||
# {host}
|
||||
# :{port} (optional)
|
||||
AUTHORITY_REGEX = re.compile(
|
||||
(
|
||||
r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
|
||||
).format(
|
||||
userinfo=".*", # Any character sequence.
|
||||
host="(\\[.*\\]|[^:@]*)", # Either any character sequence excluding ':' or '@',
|
||||
# or an IPv6 address enclosed within square brackets.
|
||||
port=".*", # Any character sequence.
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# If we call urlparse with an individual component, then we need to regex
|
||||
# validate that component individually.
|
||||
# Note that we're duplicating the same strings as above. Shock! Horror!!
|
||||
COMPONENT_REGEX = {
|
||||
"scheme": re.compile("([a-zA-Z][a-zA-Z0-9+.-]*)?"),
|
||||
"authority": re.compile("[^/?#]*"),
|
||||
"path": re.compile("[^?#]*"),
|
||||
"query": re.compile("[^#]*"),
|
||||
"fragment": re.compile(".*"),
|
||||
"userinfo": re.compile("[^@]*"),
|
||||
"host": re.compile("(\\[.*\\]|[^:]*)"),
|
||||
"port": re.compile(".*"),
|
||||
}
|
||||
|
||||
|
||||
# We use these simple regexs as a first pass before handing off to
|
||||
# the stdlib 'ipaddress' module for IP address validation.
|
||||
IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
|
||||
|
||||
|
||||
class ParseResult(typing.NamedTuple):
|
||||
scheme: str
|
||||
userinfo: str
|
||||
host: str
|
||||
port: int | None
|
||||
path: str
|
||||
query: str | None
|
||||
fragment: str | None
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
f"{self.userinfo}@" if self.userinfo else "",
|
||||
f"[{self.host}]" if ":" in self.host else self.host,
|
||||
f":{self.port}" if self.port is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def netloc(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
f"[{self.host}]" if ":" in self.host else self.host,
|
||||
f":{self.port}" if self.port is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
def copy_with(self, **kwargs: str | None) -> "ParseResult":
|
||||
if not kwargs:
|
||||
return self
|
||||
|
||||
defaults = {
|
||||
"scheme": self.scheme,
|
||||
"authority": self.authority,
|
||||
"path": self.path,
|
||||
"query": self.query,
|
||||
"fragment": self.fragment,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return urlparse("", **defaults)
|
||||
|
||||
def __str__(self) -> str:
|
||||
authority = self.authority
|
||||
return "".join(
|
||||
[
|
||||
f"{self.scheme}:" if self.scheme else "",
|
||||
f"//{authority}" if authority else "",
|
||||
self.path,
|
||||
f"?{self.query}" if self.query is not None else "",
|
||||
f"#{self.fragment}" if self.fragment is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
||||
# Initial basic checks on allowable URLs.
|
||||
# ---------------------------------------
|
||||
|
||||
# Hard limit the maximum allowable URL length.
|
||||
if len(url) > MAX_URL_LENGTH:
|
||||
raise InvalidURL("URL too long")
|
||||
|
||||
# If a URL includes any ASCII control characters including \t, \r, \n,
|
||||
# then treat it as invalid.
|
||||
if any(char.isascii() and not char.isprintable() for char in url):
|
||||
char = next(char for char in url if char.isascii() and not char.isprintable())
|
||||
idx = url.find(char)
|
||||
error = (
|
||||
f"Invalid non-printable ASCII character in URL, {char!r} at position {idx}."
|
||||
)
|
||||
raise InvalidURL(error)
|
||||
|
||||
# Some keyword arguments require special handling.
|
||||
# ------------------------------------------------
|
||||
|
||||
# Coerce "port" to a string, if it is provided as an integer.
|
||||
if "port" in kwargs:
|
||||
port = kwargs["port"]
|
||||
kwargs["port"] = str(port) if isinstance(port, int) else port
|
||||
|
||||
# Replace "netloc" with "host and "port".
|
||||
if "netloc" in kwargs:
|
||||
netloc = kwargs.pop("netloc") or ""
|
||||
kwargs["host"], _, kwargs["port"] = netloc.partition(":")
|
||||
|
||||
# Replace "username" and/or "password" with "userinfo".
|
||||
if "username" in kwargs or "password" in kwargs:
|
||||
username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE)
|
||||
password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE)
|
||||
kwargs["userinfo"] = f"{username}:{password}" if password else username
|
||||
|
||||
# Replace "raw_path" with "path" and "query".
|
||||
if "raw_path" in kwargs:
|
||||
raw_path = kwargs.pop("raw_path") or ""
|
||||
kwargs["path"], seperator, kwargs["query"] = raw_path.partition("?")
|
||||
if not seperator:
|
||||
kwargs["query"] = None
|
||||
|
||||
# Ensure that IPv6 "host" addresses are always escaped with "[...]".
|
||||
if "host" in kwargs:
|
||||
host = kwargs.get("host") or ""
|
||||
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
||||
kwargs["host"] = f"[{host}]"
|
||||
|
||||
# If any keyword arguments are provided, ensure they are valid.
|
||||
# -------------------------------------------------------------
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
if len(value) > MAX_URL_LENGTH:
|
||||
raise InvalidURL(f"URL component '{key}' too long")
|
||||
|
||||
# If a component includes any ASCII control characters including \t, \r, \n,
|
||||
# then treat it as invalid.
|
||||
if any(char.isascii() and not char.isprintable() for char in value):
|
||||
char = next(
|
||||
char for char in value if char.isascii() and not char.isprintable()
|
||||
)
|
||||
idx = value.find(char)
|
||||
error = (
|
||||
f"Invalid non-printable ASCII character in URL {key} component, "
|
||||
f"{char!r} at position {idx}."
|
||||
)
|
||||
raise InvalidURL(error)
|
||||
|
||||
# Ensure that keyword arguments match as a valid regex.
|
||||
if not COMPONENT_REGEX[key].fullmatch(value):
|
||||
raise InvalidURL(f"Invalid URL component '{key}'")
|
||||
|
||||
# The URL_REGEX will always match, but may have empty components.
|
||||
url_match = URL_REGEX.match(url)
|
||||
assert url_match is not None
|
||||
url_dict = url_match.groupdict()
|
||||
|
||||
# * 'scheme', 'authority', and 'path' may be empty strings.
|
||||
# * 'query' may be 'None', indicating no trailing "?" portion.
|
||||
# Any string including the empty string, indicates a trailing "?".
|
||||
# * 'fragment' may be 'None', indicating no trailing "#" portion.
|
||||
# Any string including the empty string, indicates a trailing "#".
|
||||
scheme = kwargs.get("scheme", url_dict["scheme"]) or ""
|
||||
authority = kwargs.get("authority", url_dict["authority"]) or ""
|
||||
path = kwargs.get("path", url_dict["path"]) or ""
|
||||
query = kwargs.get("query", url_dict["query"])
|
||||
frag = kwargs.get("fragment", url_dict["fragment"])
|
||||
|
||||
# The AUTHORITY_REGEX will always match, but may have empty components.
|
||||
authority_match = AUTHORITY_REGEX.match(authority)
|
||||
assert authority_match is not None
|
||||
authority_dict = authority_match.groupdict()
|
||||
|
||||
# * 'userinfo' and 'host' may be empty strings.
|
||||
# * 'port' may be 'None'.
|
||||
userinfo = kwargs.get("userinfo", authority_dict["userinfo"]) or ""
|
||||
host = kwargs.get("host", authority_dict["host"]) or ""
|
||||
port = kwargs.get("port", authority_dict["port"])
|
||||
|
||||
# Normalize and validate each component.
|
||||
# We end up with a parsed representation of the URL,
|
||||
# with components that are plain ASCII bytestrings.
|
||||
parsed_scheme: str = scheme.lower()
|
||||
parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE)
|
||||
parsed_host: str = encode_host(host)
|
||||
parsed_port: int | None = normalize_port(port, scheme)
|
||||
|
||||
has_scheme = parsed_scheme != ""
|
||||
has_authority = (
|
||||
parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
|
||||
)
|
||||
validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
|
||||
if has_scheme or has_authority:
|
||||
path = normalize_path(path)
|
||||
|
||||
parsed_path: str = quote(path, safe=PATH_SAFE)
|
||||
parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE)
|
||||
parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE)
|
||||
|
||||
# The parsed ASCII bytestrings are our canonical form.
|
||||
# All properties of the URL are derived from these.
|
||||
return ParseResult(
|
||||
parsed_scheme,
|
||||
parsed_userinfo,
|
||||
parsed_host,
|
||||
parsed_port,
|
||||
parsed_path,
|
||||
parsed_query,
|
||||
parsed_frag,
|
||||
)
|
||||
|
||||
|
||||
def encode_host(host: str) -> str:
|
||||
if not host:
|
||||
return ""
|
||||
|
||||
elif IPv4_STYLE_HOSTNAME.match(host):
|
||||
# Validate IPv4 hostnames like #.#.#.#
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
|
||||
try:
|
||||
ipaddress.IPv4Address(host)
|
||||
except ipaddress.AddressValueError:
|
||||
raise InvalidURL(f"Invalid IPv4 address: {host!r}")
|
||||
return host
|
||||
|
||||
elif IPv6_STYLE_HOSTNAME.match(host):
|
||||
# Validate IPv6 hostnames like [...]
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# "A host identified by an Internet Protocol literal address, version 6
|
||||
# [RFC3513] or later, is distinguished by enclosing the IP literal
|
||||
# within square brackets ("[" and "]"). This is the only place where
|
||||
# square bracket characters are allowed in the URI syntax."
|
||||
try:
|
||||
ipaddress.IPv6Address(host[1:-1])
|
||||
except ipaddress.AddressValueError:
|
||||
raise InvalidURL(f"Invalid IPv6 address: {host!r}")
|
||||
return host[1:-1]
|
||||
|
||||
elif not host.isascii():
|
||||
try:
|
||||
import idna # type: ignore
|
||||
except ImportError:
|
||||
raise InvalidURL(
|
||||
f"Cannot handle URL with IDNA hostname: {host!r}. "
|
||||
f"Package 'idna' is not installed."
|
||||
)
|
||||
|
||||
# IDNA hostnames
|
||||
try:
|
||||
return idna.encode(host.lower()).decode("ascii")
|
||||
except idna.IDNAError:
|
||||
raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
|
||||
|
||||
# Regular ASCII hostnames
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# reg-name = *( unreserved / pct-encoded / sub-delims )
|
||||
WHATWG_SAFE = '"`{}%|\\'
|
||||
return quote(host.lower(), safe=SUB_DELIMS + WHATWG_SAFE)
|
||||
|
||||
|
||||
def normalize_port(port: str | int | None, scheme: str) -> int | None:
|
||||
# From https://tools.ietf.org/html/rfc3986#section-3.2.3
|
||||
#
|
||||
# "A scheme may define a default port. For example, the "http" scheme
|
||||
# defines a default port of "80", corresponding to its reserved TCP
|
||||
# port number. The type of port designated by the port number (e.g.,
|
||||
# TCP, UDP, SCTP) is defined by the URI scheme. URI producers and
|
||||
# normalizers should omit the port component and its ":" delimiter if
|
||||
# port is empty or if its value would be the same as that of the
|
||||
# scheme's default."
|
||||
if port is None or port == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
port_as_int = int(port)
|
||||
except ValueError:
|
||||
raise InvalidURL(f"Invalid port: {port!r}")
|
||||
|
||||
# See https://url.spec.whatwg.org/#url-miscellaneous
|
||||
default_port = {"ftp": 21, "http": 80, "https": 443, "ws": 80, "wss": 443}.get(
|
||||
scheme
|
||||
)
|
||||
if port_as_int == default_port:
|
||||
return None
|
||||
return port_as_int
|
||||
|
||||
|
||||
def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
|
||||
"""
|
||||
Path validation rules that depend on if the URL contains
|
||||
a scheme or authority component.
|
||||
|
||||
See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
|
||||
"""
|
||||
if has_authority:
|
||||
# If a URI contains an authority component, then the path component
|
||||
# must either be empty or begin with a slash ("/") character."
|
||||
if path and not path.startswith("/"):
|
||||
raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
|
||||
|
||||
if not has_scheme and not has_authority:
|
||||
# If a URI does not contain an authority component, then the path cannot begin
|
||||
# with two slash characters ("//").
|
||||
if path.startswith("//"):
|
||||
raise InvalidURL("Relative URLs cannot have a path starting with '//'")
|
||||
|
||||
# In addition, a URI reference (Section 4.1) may be a relative-path reference,
|
||||
# in which case the first path segment cannot contain a colon (":") character.
|
||||
if path.startswith(":"):
|
||||
raise InvalidURL("Relative URLs cannot have a path starting with ':'")
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""
|
||||
Drop "." and ".." segments from a URL path.
|
||||
|
||||
For example:
|
||||
|
||||
normalize_path("/path/./to/somewhere/..") == "/path/to"
|
||||
"""
|
||||
# Fast return when no '.' characters in the path.
|
||||
if "." not in path:
|
||||
return path
|
||||
|
||||
components = path.split("/")
|
||||
|
||||
# Fast return when no '.' or '..' components in the path.
|
||||
if "." not in components and ".." not in components:
|
||||
return path
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
|
||||
output: list[str] = []
|
||||
for component in components:
|
||||
if component == ".":
|
||||
pass
|
||||
elif component == "..":
|
||||
if output and output != [""]:
|
||||
output.pop()
|
||||
else:
|
||||
output.append(component)
|
||||
return "/".join(output)
|
||||
|
||||
|
||||
def PERCENT(string: str) -> str:
|
||||
return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")])
|
||||
|
||||
|
||||
def percent_encoded(string: str, safe: str) -> str:
|
||||
"""
|
||||
Use percent-encoding to quote a string.
|
||||
"""
|
||||
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
|
||||
|
||||
# Fast path for strings that don't need escaping.
|
||||
if not string.rstrip(NON_ESCAPED_CHARS):
|
||||
return string
|
||||
|
||||
return "".join(
|
||||
[char if char in NON_ESCAPED_CHARS else PERCENT(char) for char in string]
|
||||
)
|
||||
|
||||
|
||||
def quote(string: str, safe: str) -> str:
|
||||
"""
|
||||
Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
|
||||
|
||||
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
|
||||
|
||||
* `string`: The string to be percent-escaped.
|
||||
* `safe`: A string containing characters that may be treated as safe, and do not
|
||||
need to be escaped. Unreserved characters are always treated as safe.
|
||||
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
|
||||
"""
|
||||
parts = []
|
||||
current_position = 0
|
||||
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
|
||||
start_position, end_position = match.start(), match.end()
|
||||
matched_text = match.group(0)
|
||||
# Add any text up to the '%xx' escape sequence.
|
||||
if start_position != current_position:
|
||||
leading_text = string[current_position:start_position]
|
||||
parts.append(percent_encoded(leading_text, safe=safe))
|
||||
|
||||
# Add the '%xx' escape sequence.
|
||||
parts.append(matched_text)
|
||||
current_position = end_position
|
||||
|
||||
# Add any text after the final '%xx' escape sequence.
|
||||
if current_position != len(string):
|
||||
trailing_text = string[current_position:]
|
||||
parts.append(percent_encoded(trailing_text, safe=safe))
|
||||
|
||||
return "".join(parts)
|
||||
552
src/httpx/_urls.py
Normal file
552
src/httpx/_urls.py
Normal file
@ -0,0 +1,552 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ._urlparse import urlparse
|
||||
from ._urlencode import unquote, urldecode, urlencode
|
||||
|
||||
__all__ = ["QueryParams", "URL"]
|
||||
|
||||
|
||||
class URL:
|
||||
"""
|
||||
url = httpx.URL("HTTPS://jo%40email.com:a%20secret@müller.de:1234/pa%20th?search=ab#anchorlink")
|
||||
|
||||
assert url.scheme == "https"
|
||||
assert url.username == "jo@email.com"
|
||||
assert url.password == "a secret"
|
||||
assert url.userinfo == b"jo%40email.com:a%20secret"
|
||||
assert url.host == "müller.de"
|
||||
assert url.raw_host == b"xn--mller-kva.de"
|
||||
assert url.port == 1234
|
||||
assert url.netloc == b"xn--mller-kva.de:1234"
|
||||
assert url.path == "/pa th"
|
||||
assert url.query == b"?search=ab"
|
||||
assert url.raw_path == b"/pa%20th?search=ab"
|
||||
assert url.fragment == "anchorlink"
|
||||
|
||||
The components of a URL are broken down like this:
|
||||
|
||||
https://jo%40email.com:a%20secret@müller.de:1234/pa%20th?search=ab#anchorlink
|
||||
[scheme] [ username ] [password] [ host ][port][ path ] [ query ] [fragment]
|
||||
[ userinfo ] [ netloc ][ raw_path ]
|
||||
|
||||
Note that:
|
||||
|
||||
* `url.scheme` is normalized to always be lowercased.
|
||||
|
||||
* `url.host` is normalized to always be lowercased. Internationalized domain
|
||||
names are represented in unicode, without IDNA encoding applied. For instance:
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
|
||||
* `url.raw_host` is normalized to always be lowercased, and is IDNA encoded.
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
|
||||
* `url.port` is either None or an integer. URLs that include the default port for
|
||||
"http", "https", "ws", "wss", and "ftp" schemes have their port
|
||||
normalized to `None`.
|
||||
|
||||
assert httpx.URL("http://example.com") == httpx.URL("http://example.com:80")
|
||||
assert httpx.URL("http://example.com").port is None
|
||||
assert httpx.URL("http://example.com:80").port is None
|
||||
|
||||
* `url.userinfo` is raw bytes, without URL escaping. Usually you'll want to work
|
||||
with `url.username` and `url.password` instead, which handle the URL escaping.
|
||||
|
||||
* `url.raw_path` is raw bytes of both the path and query, without URL escaping.
|
||||
This portion is used as the target when constructing HTTP requests. Usually you'll
|
||||
want to work with `url.path` instead.
|
||||
|
||||
* `url.query` is raw bytes, without URL escaping. A URL query string portion can
|
||||
only be properly URL escaped when decoding the parameter names and values
|
||||
themselves.
|
||||
"""
|
||||
|
||||
def __init__(self, url: "URL" | str = "", **kwargs: typing.Any) -> None:
|
||||
if kwargs:
|
||||
allowed = {
|
||||
"scheme": str,
|
||||
"username": str,
|
||||
"password": str,
|
||||
"userinfo": bytes,
|
||||
"host": str,
|
||||
"port": int,
|
||||
"netloc": str,
|
||||
"path": str,
|
||||
"query": bytes,
|
||||
"raw_path": bytes,
|
||||
"fragment": str,
|
||||
"params": object,
|
||||
}
|
||||
|
||||
# Perform type checking for all supported keyword arguments.
|
||||
for key, value in kwargs.items():
|
||||
if key not in allowed:
|
||||
message = f"{key!r} is an invalid keyword argument for URL()"
|
||||
raise TypeError(message)
|
||||
if value is not None and not isinstance(value, allowed[key]):
|
||||
expected = allowed[key].__name__
|
||||
seen = type(value).__name__
|
||||
message = f"Argument {key!r} must be {expected} but got {seen}"
|
||||
raise TypeError(message)
|
||||
if isinstance(value, bytes):
|
||||
kwargs[key] = value.decode("ascii")
|
||||
|
||||
if "params" in kwargs:
|
||||
# Replace any "params" keyword with the raw "query" instead.
|
||||
#
|
||||
# Ensure that empty params use `kwargs["query"] = None` rather
|
||||
# than `kwargs["query"] = ""`, so that generated URLs do not
|
||||
# include an empty trailing "?".
|
||||
params = kwargs.pop("params")
|
||||
kwargs["query"] = None if not params else str(QueryParams(params))
|
||||
|
||||
if isinstance(url, str):
|
||||
self._uri_reference = urlparse(url, **kwargs)
|
||||
elif isinstance(url, URL):
|
||||
self._uri_reference = url._uri_reference.copy_with(**kwargs)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Invalid type for url. Expected str or httpx.URL,"
|
||||
f" got {type(url)}: {url!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def scheme(self) -> str:
|
||||
"""
|
||||
The URL scheme, such as "http", "https".
|
||||
Always normalised to lowercase.
|
||||
"""
|
||||
return self._uri_reference.scheme
|
||||
|
||||
@property
|
||||
def userinfo(self) -> bytes:
|
||||
"""
|
||||
The URL userinfo as a raw bytestring.
|
||||
For example: b"jo%40email.com:a%20secret".
|
||||
"""
|
||||
return self._uri_reference.userinfo.encode("ascii")
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
"""
|
||||
The URL username as a string, with URL decoding applied.
|
||||
For example: "jo@email.com"
|
||||
"""
|
||||
userinfo = self._uri_reference.userinfo
|
||||
return unquote(userinfo.partition(":")[0])
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
"""
|
||||
The URL password as a string, with URL decoding applied.
|
||||
For example: "a secret"
|
||||
"""
|
||||
userinfo = self._uri_reference.userinfo
|
||||
return unquote(userinfo.partition(":")[2])
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""
|
||||
The URL host as a string.
|
||||
Always normalized to lowercase. Possibly IDNA encoded.
|
||||
|
||||
Examples:
|
||||
|
||||
url = httpx.URL("http://www.EXAMPLE.org")
|
||||
assert url.host == "www.example.org"
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.host == "xn--fiqs8s"
|
||||
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.host == "xn--fiqs8s"
|
||||
|
||||
url = httpx.URL("https://[::ffff:192.168.0.1]")
|
||||
assert url.host == "::ffff:192.168.0.1"
|
||||
"""
|
||||
return self._uri_reference.host
|
||||
|
||||
@property
|
||||
def port(self) -> int | None:
|
||||
"""
|
||||
The URL port as an integer.
|
||||
|
||||
Note that the URL class performs port normalization as per the WHATWG spec.
|
||||
Default ports for "http", "https", "ws", "wss", and "ftp" schemes are always
|
||||
treated as `None`.
|
||||
|
||||
For example:
|
||||
|
||||
assert httpx.URL("http://www.example.com") == httpx.URL("http://www.example.com:80")
|
||||
assert httpx.URL("http://www.example.com:80").port is None
|
||||
"""
|
||||
return self._uri_reference.port
|
||||
|
||||
@property
|
||||
def netloc(self) -> str:
|
||||
"""
|
||||
Either `<host>` or `<host>:<port>` as bytes.
|
||||
Always normalized to lowercase, and IDNA encoded.
|
||||
|
||||
This property may be used for generating the value of a request
|
||||
"Host" header.
|
||||
"""
|
||||
return self._uri_reference.netloc
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""
|
||||
The URL path as a string. Excluding the query string, and URL decoded.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://example.com/pa%20th")
|
||||
assert url.path == "/pa th"
|
||||
"""
|
||||
path = self._uri_reference.path or "/"
|
||||
return unquote(path)
|
||||
|
||||
@property
|
||||
def query(self) -> bytes:
|
||||
"""
|
||||
The URL query string, as raw bytes, excluding the leading b"?".
|
||||
|
||||
This is necessarily a bytewise interface, because we cannot
|
||||
perform URL decoding of this representation until we've parsed
|
||||
the keys and values into a QueryParams instance.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://example.com/?filter=some%20search%20terms")
|
||||
assert url.query == b"filter=some%20search%20terms"
|
||||
"""
|
||||
query = self._uri_reference.query or ""
|
||||
return query.encode("ascii")
|
||||
|
||||
@property
|
||||
def params(self) -> "QueryParams":
|
||||
"""
|
||||
The URL query parameters, neatly parsed and packaged into an immutable
|
||||
multidict representation.
|
||||
"""
|
||||
return QueryParams(self._uri_reference.query)
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""
|
||||
The complete URL path and query string as raw bytes.
|
||||
Used as the target when constructing HTTP requests.
|
||||
|
||||
For example:
|
||||
|
||||
GET /users?search=some%20text HTTP/1.1
|
||||
Host: www.example.org
|
||||
Connection: close
|
||||
"""
|
||||
target = self._uri_reference.path or "/"
|
||||
if self._uri_reference.query is not None:
|
||||
target += "?" + self._uri_reference.query
|
||||
return target
|
||||
|
||||
@property
|
||||
def fragment(self) -> str:
|
||||
"""
|
||||
The URL fragments, as used in HTML anchors.
|
||||
As a string, without the leading '#'.
|
||||
"""
|
||||
return unquote(self._uri_reference.fragment or "")
|
||||
|
||||
@property
|
||||
def is_absolute_url(self) -> bool:
|
||||
"""
|
||||
Return `True` for absolute URLs such as 'http://example.com/path',
|
||||
and `False` for relative URLs such as '/path'.
|
||||
"""
|
||||
# We don't use `.is_absolute` from `rfc3986` because it treats
|
||||
# URLs with a fragment portion as not absolute.
|
||||
# What we actually care about is if the URL provides
|
||||
# a scheme and hostname to which connections should be made.
|
||||
return bool(self._uri_reference.scheme and self._uri_reference.host)
|
||||
|
||||
@property
|
||||
def is_relative_url(self) -> bool:
|
||||
"""
|
||||
Return `False` for absolute URLs such as 'http://example.com/path',
|
||||
and `True` for relative URLs such as '/path'.
|
||||
"""
|
||||
return not self.is_absolute_url
|
||||
|
||||
def copy_with(self, **kwargs: typing.Any) -> "URL":
|
||||
"""
|
||||
Copy this URL, returning a new URL with some components altered.
|
||||
Accepts the same set of parameters as the components that are made
|
||||
available via properties on the `URL` class.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://www.example.com").copy_with(
|
||||
username="jo@gmail.com", password="a secret"
|
||||
)
|
||||
assert url == "https://jo%40email.com:a%20secret@www.example.com"
|
||||
"""
|
||||
return URL(self, **kwargs)
|
||||
|
||||
def copy_set_param(self, key: str, value: typing.Any = None) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_set(key, value))
|
||||
|
||||
def copy_append_param(self, key: str, value: typing.Any = None) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_append(key, value))
|
||||
|
||||
def copy_remove_param(self, key: str) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_remove(key))
|
||||
|
||||
def copy_merge_params(
|
||||
self,
|
||||
params: "QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | None,
|
||||
) -> "URL":
|
||||
return self.copy_with(params=self.params.copy_update(params))
|
||||
|
||||
def join(self, url: "URL" | str) -> "URL":
|
||||
"""
|
||||
Return an absolute URL, using this URL as the base.
|
||||
|
||||
Eg.
|
||||
|
||||
url = httpx.URL("https://www.example.com/test")
|
||||
url = url.join("/new/path")
|
||||
assert url == "https://www.example.com/new/path"
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
|
||||
return URL(urljoin(str(self), str(URL(url))))
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, (URL, str)) and str(self) == str(URL(other))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._uri_reference)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<URL {str(self)!r}>"
|
||||
|
||||
|
||||
class QueryParams(typing.Mapping[str, str]):
|
||||
"""
|
||||
URL query parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: (
|
||||
"QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | str | None
|
||||
) = None,
|
||||
) -> None:
|
||||
d: dict[str, list[str]] = {}
|
||||
|
||||
if params is None:
|
||||
d = {}
|
||||
elif isinstance(params, str):
|
||||
d = urldecode(params)
|
||||
elif isinstance(params, QueryParams):
|
||||
d = params.multi_dict()
|
||||
elif isinstance(params, dict):
|
||||
# Convert dict inputs like:
|
||||
# {"a": "123", "b": ["456", "789"]}
|
||||
# To dict inputs where values are always lists, like:
|
||||
# {"a": ["123"], "b": ["456", "789"]}
|
||||
d = {k: [v] if isinstance(v, str) else list(v) for k, v in params.items()}
|
||||
else:
|
||||
# Convert list inputs like:
|
||||
# [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
# To a dict representation, like:
|
||||
# {"a": ["123", "456"], "b": ["789"]}
|
||||
for k, v in params:
|
||||
d.setdefault(k, []).append(v)
|
||||
|
||||
self._dict = d
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
"""
|
||||
Return all the keys in the query params.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.keys()) == ["a", "b"]
|
||||
"""
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
"""
|
||||
Return all the values in the query params. If a key occurs more than once
|
||||
only the first item for that key is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.values()) == ["123", "789"]
|
||||
"""
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
"""
|
||||
Return all items in the query params. If a key occurs more than once
|
||||
only the first item for that key is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.items()) == [("a", "123"), ("b", "789")]
|
||||
"""
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def multi_items(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Return all items in the query params. Allow duplicate keys to occur.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.multi_items()) == [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
"""
|
||||
multi_items: list[tuple[str, str]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def multi_dict(self) -> dict[str, list[str]]:
|
||||
return {k: list(v) for k, v in self._dict.items()}
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
Get a value from the query param for a given key. If the key occurs
|
||||
more than once, then only the first value is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert q.get("a") == "123"
|
||||
"""
|
||||
if key in self._dict:
|
||||
return self._dict[key][0]
|
||||
return default
|
||||
|
||||
def get_list(self, key: str) -> list[str]:
|
||||
"""
|
||||
Get all values from the query param for a given key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert q.get_list("a") == ["123", "456"]
|
||||
"""
|
||||
return list(self._dict.get(key, []))
|
||||
|
||||
def copy_set(self, key: str, value: str) -> "QueryParams":
|
||||
"""
|
||||
Return a new QueryParams instance, setting the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.set("a", "456")
|
||||
assert q == httpx.QueryParams("a=456")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict[key] = [value]
|
||||
return q
|
||||
|
||||
def copy_append(self, key: str, value: str) -> "QueryParams":
|
||||
"""
|
||||
Return a new QueryParams instance, setting or appending the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.append("a", "456")
|
||||
assert q == httpx.QueryParams("a=123&a=456")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict[key] = q.get_list(key) + [value]
|
||||
return q
|
||||
|
||||
def copy_remove(self, key: str) -> QueryParams:
|
||||
"""
|
||||
Return a new QueryParams instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.remove("a")
|
||||
assert q == httpx.QueryParams("")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict.pop(str(key), None)
|
||||
return q
|
||||
|
||||
def copy_update(
|
||||
self,
|
||||
params: (
|
||||
"QueryParams" | dict[str, str | list[str]] | list[tuple[str, str]] | None
|
||||
) = None,
|
||||
) -> "QueryParams":
|
||||
"""
|
||||
Return a new QueryParams instance, updated with.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_update({"b": "456"})
|
||||
assert q == httpx.QueryParams("a=123&b=456")
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_update({"a": "456", "b": "789"})
|
||||
assert q == httpx.QueryParams("a=456&b=789")
|
||||
"""
|
||||
q = QueryParams(params)
|
||||
q._dict = {**self._dict, **q._dict}
|
||||
return q
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
return sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
|
||||
def __str__(self) -> str:
|
||||
return urlencode(self.multi_dict())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<QueryParams {str(self)!r}>"
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
112
tests/test_client.py
Normal file
112
tests/test_client.py
Normal file
@ -0,0 +1,112 @@
|
||||
import json
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def echo(request):
|
||||
request.read()
|
||||
response = httpx.Response(200, content=httpx.JSON({
|
||||
'method': request.method,
|
||||
'query-params': dict(request.url.params.items()),
|
||||
'content-type': request.headers.get('Content-Type'),
|
||||
'json': json.loads(request.body) if request.body else None,
|
||||
}))
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with httpx.Client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
with httpx.serve_http(echo) as server:
|
||||
yield server
|
||||
|
||||
|
||||
def test_client(client):
|
||||
assert repr(client) == "<Client [0 active]>"
|
||||
|
||||
|
||||
def test_get(client, server):
|
||||
r = client.get(server.url)
|
||||
assert r.status_code == 200
|
||||
assert r.body == b'{"method":"GET","query-params":{},"content-type":null,"json":null}'
|
||||
assert r.text == '{"method":"GET","query-params":{},"content-type":null,"json":null}'
|
||||
|
||||
|
||||
def test_post(client, server):
|
||||
data = httpx.JSON({"data": 123})
|
||||
r = client.post(server.url, content=data)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'POST',
|
||||
'query-params': {},
|
||||
'content-type': 'application/json',
|
||||
'json': {"data": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_put(client, server):
|
||||
data = httpx.JSON({"data": 123})
|
||||
r = client.put(server.url, content=data)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'PUT',
|
||||
'query-params': {},
|
||||
'content-type': 'application/json',
|
||||
'json': {"data": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_patch(client, server):
|
||||
data = httpx.JSON({"data": 123})
|
||||
r = client.patch(server.url, content=data)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'PATCH',
|
||||
'query-params': {},
|
||||
'content-type': 'application/json',
|
||||
'json': {"data": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_delete(client, server):
|
||||
r = client.delete(server.url)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'DELETE',
|
||||
'query-params': {},
|
||||
'content-type': None,
|
||||
'json': None,
|
||||
}
|
||||
|
||||
|
||||
def test_request(client, server):
|
||||
r = client.request("GET", server.url)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'GET',
|
||||
'query-params': {},
|
||||
'content-type': None,
|
||||
'json': None,
|
||||
}
|
||||
|
||||
|
||||
def test_stream(client, server):
|
||||
with client.stream("GET", server.url) as r:
|
||||
assert r.status_code == 200
|
||||
r.read()
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'GET',
|
||||
'query-params': {},
|
||||
'content-type': None,
|
||||
'json': None,
|
||||
}
|
||||
|
||||
|
||||
def test_get_with_invalid_scheme(client):
|
||||
with pytest.raises(ValueError):
|
||||
client.get("nope://www.example.com")
|
||||
285
tests/test_content.py
Normal file
285
tests/test_content.py
Normal file
@ -0,0 +1,285 @@
|
||||
import httpx
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
||||
|
||||
# HTML
|
||||
|
||||
def test_html():
|
||||
html = httpx.HTML("<html><body>Hello, world</body></html>")
|
||||
|
||||
stream = html.encode()
|
||||
content_type = html.content_type()
|
||||
|
||||
assert stream.read() == b'<html><body>Hello, world</body></html>'
|
||||
assert content_type == "text/html; charset='utf-8'"
|
||||
|
||||
|
||||
# Text
|
||||
|
||||
def test_text():
|
||||
text = httpx.Text("Hello, world")
|
||||
|
||||
stream = text.encode()
|
||||
content_type = text.content_type()
|
||||
|
||||
assert stream.read() == b'Hello, world'
|
||||
assert content_type == "text/plain; charset='utf-8'"
|
||||
|
||||
|
||||
# JSON
|
||||
|
||||
def test_json():
|
||||
data = httpx.JSON({'data': 123})
|
||||
|
||||
stream = data.encode()
|
||||
content_type = data.content_type()
|
||||
|
||||
assert stream.read() == b'{"data":123}'
|
||||
assert content_type == "application/json"
|
||||
|
||||
|
||||
# Form
|
||||
|
||||
def test_form():
|
||||
f = httpx.Form("a=123&a=456&b=789")
|
||||
assert str(f) == "a=123&a=456&b=789"
|
||||
assert repr(f) == "<Form [('a', '123'), ('a', '456'), ('b', '789')]>"
|
||||
assert f.multi_dict() == {
|
||||
"a": ["123", "456"],
|
||||
"b": ["789"]
|
||||
}
|
||||
|
||||
|
||||
def test_form_from_dict():
|
||||
f = httpx.Form({
|
||||
"a": ["123", "456"],
|
||||
"b": "789"
|
||||
})
|
||||
assert str(f) == "a=123&a=456&b=789"
|
||||
assert repr(f) == "<Form [('a', '123'), ('a', '456'), ('b', '789')]>"
|
||||
assert f.multi_dict() == {
|
||||
"a": ["123", "456"],
|
||||
"b": ["789"]
|
||||
}
|
||||
|
||||
|
||||
def test_form_from_list():
|
||||
f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
assert str(f) == "a=123&a=456&b=789"
|
||||
assert repr(f) == "<Form [('a', '123'), ('a', '456'), ('b', '789')]>"
|
||||
assert f.multi_dict() == {
|
||||
"a": ["123", "456"],
|
||||
"b": ["789"]
|
||||
}
|
||||
|
||||
|
||||
def test_empty_form():
|
||||
f = httpx.Form()
|
||||
assert str(f) == ''
|
||||
assert repr(f) == "<Form []>"
|
||||
assert f.multi_dict() == {}
|
||||
|
||||
|
||||
def test_form_accessors():
|
||||
f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
assert "a" in f
|
||||
assert "A" not in f
|
||||
assert "c" not in f
|
||||
assert f["a"] == "123"
|
||||
assert f.get("a") == "123"
|
||||
assert f.get("nope", default=None) is None
|
||||
|
||||
|
||||
def test_form_dict():
|
||||
f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
assert list(f.keys()) == ["a", "b"]
|
||||
assert list(f.values()) == ["123", "789"]
|
||||
assert list(f.items()) == [("a", "123"), ("b", "789")]
|
||||
assert list(f) == ["a", "b"]
|
||||
assert dict(f) == {"a": "123", "b": "789"}
|
||||
|
||||
|
||||
def test_form_multidict():
|
||||
f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
assert f.get_list("a") == ["123", "456"]
|
||||
assert f.multi_items() == [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
assert f.multi_dict() == {"a": ["123", "456"], "b": ["789"]}
|
||||
|
||||
|
||||
def test_form_builtins():
|
||||
f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
assert len(f) == 2
|
||||
assert bool(f)
|
||||
assert hash(f)
|
||||
assert f == httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
|
||||
|
||||
def test_form_copy_operations():
|
||||
f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")])
|
||||
assert f.copy_set("a", "abc") == httpx.Form([("a", "abc"), ("b", "789")])
|
||||
assert f.copy_append("a", "abc") == httpx.Form([("a", "123"), ("a", "456"), ("a", "abc"), ("b", "789")])
|
||||
assert f.copy_remove("a") == httpx.Form([("b", "789")])
|
||||
|
||||
|
||||
def test_form_encode():
|
||||
form = httpx.Form({'email': 'address@example.com'})
|
||||
assert form['email'] == "address@example.com"
|
||||
|
||||
stream = form.encode()
|
||||
content_type = form.content_type()
|
||||
|
||||
assert stream.read() == b"email=address%40example.com"
|
||||
assert content_type == "application/x-www-form-urlencoded"
|
||||
|
||||
|
||||
# Files
|
||||
|
||||
def test_files():
|
||||
f = httpx.Files()
|
||||
assert f.multi_dict() == {}
|
||||
assert repr(f) == "<Files []>"
|
||||
|
||||
|
||||
def test_files_from_dict():
|
||||
f = httpx.Files({
|
||||
"a": [
|
||||
httpx.File("123.json"),
|
||||
httpx.File("456.json"),
|
||||
],
|
||||
"b": httpx.File("789.json")
|
||||
})
|
||||
assert f.multi_dict() == {
|
||||
"a": [
|
||||
httpx.File("123.json"),
|
||||
httpx.File("456.json"),
|
||||
],
|
||||
"b": [
|
||||
httpx.File("789.json"),
|
||||
]
|
||||
}
|
||||
assert repr(f) == (
|
||||
"<Files [('a', <File '123.json'>), ('a', <File '456.json'>), ('b', <File '789.json'>)]>"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_files_from_list():
|
||||
f = httpx.Files([
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json"))
|
||||
])
|
||||
assert f.multi_dict() == {
|
||||
"a": [
|
||||
httpx.File("123.json"),
|
||||
httpx.File("456.json"),
|
||||
],
|
||||
"b": [
|
||||
httpx.File("789.json"),
|
||||
]
|
||||
}
|
||||
assert repr(f) == (
|
||||
"<Files [('a', <File '123.json'>), ('a', <File '456.json'>), ('b', <File '789.json'>)]>"
|
||||
)
|
||||
|
||||
|
||||
def test_files_accessors():
|
||||
f = httpx.Files([
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json"))
|
||||
])
|
||||
assert "a" in f
|
||||
assert "A" not in f
|
||||
assert "c" not in f
|
||||
assert f["a"] == httpx.File("123.json")
|
||||
assert f.get("a") == httpx.File("123.json")
|
||||
assert f.get("nope", default=None) is None
|
||||
|
||||
|
||||
def test_files_dict():
|
||||
f = httpx.Files([
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json"))
|
||||
])
|
||||
assert list(f.keys()) == ["a", "b"]
|
||||
assert list(f.values()) == [httpx.File("123.json"), httpx.File("789.json")]
|
||||
assert list(f.items()) == [("a", httpx.File("123.json")), ("b", httpx.File("789.json"))]
|
||||
assert list(f) == ["a", "b"]
|
||||
assert dict(f) == {"a": httpx.File("123.json"), "b": httpx.File("789.json")}
|
||||
|
||||
|
||||
def test_files_multidict():
|
||||
f = httpx.Files([
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json"))
|
||||
])
|
||||
assert f.get_list("a") == [
|
||||
httpx.File("123.json"),
|
||||
httpx.File("456.json"),
|
||||
]
|
||||
assert f.multi_items() == [
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json")),
|
||||
]
|
||||
assert f.multi_dict() == {
|
||||
"a": [
|
||||
httpx.File("123.json"),
|
||||
httpx.File("456.json"),
|
||||
],
|
||||
"b": [
|
||||
httpx.File("789.json"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_files_builtins():
|
||||
f = httpx.Files([
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json"))
|
||||
])
|
||||
assert len(f) == 2
|
||||
assert bool(f)
|
||||
assert f == httpx.Files([
|
||||
("a", httpx.File("123.json")),
|
||||
("a", httpx.File("456.json")),
|
||||
("b", httpx.File("789.json")),
|
||||
])
|
||||
|
||||
|
||||
def test_multipart():
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
f.write(b"Hello, world")
|
||||
f.seek(0)
|
||||
|
||||
multipart = httpx.MultiPart(
|
||||
form={'email': 'me@example.com'},
|
||||
files={'upload': httpx.File(f.name)},
|
||||
boundary='BOUNDARY',
|
||||
)
|
||||
assert multipart.form['email'] == "me@example.com"
|
||||
assert multipart.files['upload'] == httpx.File(f.name)
|
||||
|
||||
fname = os.path.basename(f.name).encode('utf-8')
|
||||
stream = multipart.encode()
|
||||
content_type = multipart.content_type()
|
||||
|
||||
content_type == "multipart/form-data; boundary=BOUNDARY"
|
||||
content = stream.read()
|
||||
assert content == (
|
||||
b'--BOUNDARY\r\n'
|
||||
b'Content-Disposition: form-data; name="email"\r\n'
|
||||
b'\r\n'
|
||||
b'me@example.com\r\n'
|
||||
b'--BOUNDARY\r\n'
|
||||
b'Content-Disposition: form-data; name="upload"; filename="' + fname + b'"\r\n'
|
||||
b'\r\n'
|
||||
b'Hello, world\r\n'
|
||||
b'--BOUNDARY--\r\n'
|
||||
)
|
||||
109
tests/test_headers.py
Normal file
109
tests/test_headers.py
Normal file
@ -0,0 +1,109 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def test_headers_from_dict():
|
||||
headers = httpx.Headers({
|
||||
'Content-Length': '1024',
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
})
|
||||
assert headers['Content-Length'] == '1024'
|
||||
assert headers['Content-Type'] == 'text/plain; charset=utf-8'
|
||||
|
||||
|
||||
def test_headers_from_list():
|
||||
headers = httpx.Headers([
|
||||
('Location', 'https://www.example.com'),
|
||||
('Set-Cookie', 'session_id=3498jj489jhb98jn'),
|
||||
])
|
||||
assert headers['Location'] == 'https://www.example.com'
|
||||
assert headers['Set-Cookie'] == 'session_id=3498jj489jhb98jn'
|
||||
|
||||
|
||||
def test_header_keys():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.keys()) == ["Accept", "User-Agent"]
|
||||
|
||||
|
||||
def test_header_values():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.values()) == ["*/*", "python/httpx"]
|
||||
|
||||
|
||||
def test_header_items():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")]
|
||||
|
||||
|
||||
def test_header_get():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert h.get("User-Agent") == "python/httpx"
|
||||
assert h.get("user-agent") == "python/httpx"
|
||||
assert h.get("missing") is None
|
||||
|
||||
|
||||
def test_header_copy_set():
|
||||
h = httpx.Headers({"Expires": "0"})
|
||||
h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT")
|
||||
assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"})
|
||||
|
||||
h = httpx.Headers({"Expires": "0"})
|
||||
h = h.copy_set("expires", "Wed, 21 Oct 2015 07:28:00 GMT")
|
||||
assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"})
|
||||
|
||||
|
||||
def test_header_copy_remove():
|
||||
h = httpx.Headers({"Accept": "*/*"})
|
||||
h = h.copy_remove("Accept")
|
||||
assert h == httpx.Headers({})
|
||||
|
||||
h = httpx.Headers({"Accept": "*/*"})
|
||||
h = h.copy_remove("accept")
|
||||
assert h == httpx.Headers({})
|
||||
|
||||
|
||||
def test_header_getitem():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert h["User-Agent"] == "python/httpx"
|
||||
assert h["user-agent"] == "python/httpx"
|
||||
with pytest.raises(KeyError):
|
||||
h["missing"]
|
||||
|
||||
|
||||
def test_header_contains():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert "User-Agent" in h
|
||||
assert "user-agent" in h
|
||||
assert "missing" not in h
|
||||
|
||||
|
||||
def test_header_bool():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert bool(h)
|
||||
h = httpx.Headers()
|
||||
assert not bool(h)
|
||||
|
||||
|
||||
def test_header_iter():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert [k for k in h] == ["Accept", "User-Agent"]
|
||||
|
||||
|
||||
def test_header_len():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert len(h) == 2
|
||||
|
||||
|
||||
def test_header_repr():
|
||||
h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
|
||||
assert repr(h) == "<Headers {'Accept': '*/*', 'User-Agent': 'python/httpx'}>"
|
||||
|
||||
|
||||
def test_header_invalid_name():
|
||||
with pytest.raises(ValueError):
|
||||
httpx.Headers({"Accept\n": "*/*"})
|
||||
|
||||
|
||||
def test_header_invalid_value():
|
||||
with pytest.raises(ValueError):
|
||||
httpx.Headers({"Accept": "*/*\n"})
|
||||
101
tests/test_network.py
Normal file
101
tests/test_network.py
Normal file
@ -0,0 +1,101 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def echo(stream):
|
||||
while buffer := stream.read():
|
||||
stream.write(buffer)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
net = httpx.NetworkBackend()
|
||||
with net.serve("127.0.0.1", 8080, echo) as server:
|
||||
yield server
|
||||
|
||||
|
||||
def test_network_backend():
|
||||
net = httpx.NetworkBackend()
|
||||
assert repr(net) == "<NetworkBackend [threaded]>"
|
||||
|
||||
|
||||
def test_network_backend_connect(server):
|
||||
net = httpx.NetworkBackend()
|
||||
stream = net.connect(server.host, server.port)
|
||||
try:
|
||||
assert repr(stream) == f"<NetworkStream [{server.host}:{server.port}]>"
|
||||
stream.write(b"Hello, world.")
|
||||
content = stream.read()
|
||||
assert content == b"Hello, world."
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
|
||||
def test_network_backend_context_managed(server):
|
||||
net = httpx.NetworkBackend()
|
||||
with net.connect(server.host, server.port) as stream:
|
||||
stream.write(b"Hello, world.")
|
||||
content = stream.read()
|
||||
assert content == b"Hello, world."
|
||||
assert repr(stream) == f"<NetworkStream [{server.host}:{server.port} CLOSED]>"
|
||||
|
||||
|
||||
def test_network_backend_timeout(server):
|
||||
net = httpx.NetworkBackend()
|
||||
with httpx.timeout(0.0):
|
||||
with pytest.raises(TimeoutError):
|
||||
with net.connect(server.host, server.port) as stream:
|
||||
pass
|
||||
|
||||
with httpx.timeout(10.0):
|
||||
with net.connect(server.host, server.port) as stream:
|
||||
pass
|
||||
|
||||
|
||||
# >>> net = httpx.NetworkBackend()
|
||||
# >>> stream = net.connect("dev.encode.io", 80)
|
||||
# >>> try:
|
||||
# >>> ...
|
||||
# >>> finally:
|
||||
# >>> stream.close()
|
||||
# >>> stream
|
||||
# <NetworkStream ["168.0.0.1:80" CLOSED]>
|
||||
|
||||
# import httpx
|
||||
# import ssl
|
||||
# import truststore
|
||||
|
||||
# net = httpx.NetworkBackend()
|
||||
# ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
# req = b'\r\n'.join([
|
||||
# b'GET / HTTP/1.1',
|
||||
# b'Host: www.example.com',
|
||||
# b'User-Agent: python/dev',
|
||||
# b'Connection: close',
|
||||
# b'',
|
||||
# ])
|
||||
|
||||
# # Use a 10 second overall timeout for the entire request/response.
|
||||
# with timeout(10.0):
|
||||
# # Use a 3 second timeout for the initial connection.
|
||||
# with timeout(3.0) as t:
|
||||
# # Open the connection & establish SSL.
|
||||
# with net.open_stream("www.example.com", 443) as stream:
|
||||
# stream.start_tls(ctx, hostname="www.example.com")
|
||||
# t.cancel()
|
||||
# # Send the request & read the response.
|
||||
# stream.write(req)
|
||||
# buffer = []
|
||||
# while part := stream.read():
|
||||
# buffer.append(part)
|
||||
# resp = b''.join(buffer)
|
||||
|
||||
|
||||
# def test_fixture(tcp_echo_server):
|
||||
# host, port = (tcp_echo_server.host, tcp_echo_server.port)
|
||||
|
||||
# net = httpx.NetworkBackend()
|
||||
# with net.connect(host, port) as stream:
|
||||
# stream.write(b"123")
|
||||
# buffer = stream.read()
|
||||
# assert buffer == b"123"
|
||||
748
tests/test_parsers.py
Normal file
748
tests/test_parsers.py
Normal file
@ -0,0 +1,748 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
class TrickleIO(httpx.Stream):
|
||||
def __init__(self, stream: httpx.Stream):
|
||||
self._stream = stream
|
||||
|
||||
def read(self, size) -> bytes:
|
||||
return self._stream.read(1)
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._stream.write(data)
|
||||
|
||||
def close(self) -> None:
|
||||
self._stream.close()
|
||||
|
||||
|
||||
def test_parser():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"POST", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Content-Length", b"23"),
|
||||
])
|
||||
p.send_body(b'{"msg": "hello, world"}')
|
||||
p.send_body(b'')
|
||||
|
||||
assert stream.input_bytes() == (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
assert stream.output_bytes() == (
|
||||
b"POST / HTTP/1.1\r\n"
|
||||
b"Host: example.com\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Content-Length: 23\r\n"
|
||||
b"\r\n"
|
||||
b'{"msg": "hello, world"}'
|
||||
)
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b'OK'
|
||||
assert headers == [
|
||||
(b'Content-Length', b'12'),
|
||||
(b'Content-Type', b'text/plain'),
|
||||
]
|
||||
assert body == b'hello, world'
|
||||
assert terminator == b''
|
||||
|
||||
assert not p.is_idle()
|
||||
p.complete()
|
||||
assert p.is_idle()
|
||||
|
||||
|
||||
def test_parser_server():
|
||||
stream = httpx.DuplexStream(
|
||||
b"GET / HTTP/1.1\r\n"
|
||||
b"Host: www.example.com\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='SERVER')
|
||||
method, target, protocol = p.recv_method_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
|
||||
assert method == b'GET'
|
||||
assert target == b'/'
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert headers == [
|
||||
(b'Host', b'www.example.com'),
|
||||
]
|
||||
assert body == b''
|
||||
|
||||
p.send_status_line(b"HTTP/1.1", 200, b"OK")
|
||||
p.send_headers([
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Content-Length", b"23"),
|
||||
])
|
||||
p.send_body(b'{"msg": "hello, world"}')
|
||||
p.send_body(b'')
|
||||
|
||||
assert stream.input_bytes() == (
|
||||
b"GET / HTTP/1.1\r\n"
|
||||
b"Host: www.example.com\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
assert stream.output_bytes() == (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Content-Length: 23\r\n"
|
||||
b"\r\n"
|
||||
b'{"msg": "hello, world"}'
|
||||
)
|
||||
|
||||
assert not p.is_idle()
|
||||
p.complete()
|
||||
assert p.is_idle()
|
||||
|
||||
|
||||
def test_parser_trickle():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(TrickleIO(stream), mode='CLIENT')
|
||||
p.send_method_line(b"POST", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Content-Length", b"23"),
|
||||
])
|
||||
p.send_body(b'{"msg": "hello, world"}')
|
||||
p.send_body(b'')
|
||||
|
||||
assert stream.input_bytes() == (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
assert stream.output_bytes() == (
|
||||
b"POST / HTTP/1.1\r\n"
|
||||
b"Host: example.com\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Content-Length: 23\r\n"
|
||||
b"\r\n"
|
||||
b'{"msg": "hello, world"}'
|
||||
)
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b'OK'
|
||||
assert headers == [
|
||||
(b'Content-Length', b'12'),
|
||||
(b'Content-Type', b'text/plain'),
|
||||
]
|
||||
assert body == b'hello, world'
|
||||
assert terminator == b''
|
||||
|
||||
|
||||
def test_parser_transfer_encoding_chunked():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"\r\n"
|
||||
b"c\r\n"
|
||||
b"hello, world\r\n"
|
||||
b"0\r\n\r\n"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"POST", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Transfer-Encoding", b"chunked"),
|
||||
])
|
||||
p.send_body(b'{"msg": "hello, world"}')
|
||||
p.send_body(b'')
|
||||
|
||||
assert stream.input_bytes() == (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"\r\n"
|
||||
b"c\r\n"
|
||||
b"hello, world\r\n"
|
||||
b"0\r\n\r\n"
|
||||
)
|
||||
assert stream.output_bytes() == (
|
||||
b"POST / HTTP/1.1\r\n"
|
||||
b"Host: example.com\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"\r\n"
|
||||
b'17\r\n'
|
||||
b'{"msg": "hello, world"}\r\n'
|
||||
b'0\r\n\r\n'
|
||||
)
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b'OK'
|
||||
assert headers == [
|
||||
(b'Content-Type', b'text/plain'),
|
||||
(b'Transfer-Encoding', b'chunked'),
|
||||
]
|
||||
assert body == b'hello, world'
|
||||
assert terminator == b''
|
||||
|
||||
|
||||
def test_parser_transfer_encoding_chunked_trickle():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"\r\n"
|
||||
b"c\r\n"
|
||||
b"hello, world\r\n"
|
||||
b"0\r\n\r\n"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(TrickleIO(stream), mode='CLIENT')
|
||||
p.send_method_line(b"POST", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Transfer-Encoding", b"chunked"),
|
||||
])
|
||||
p.send_body(b'{"msg": "hello, world"}')
|
||||
p.send_body(b'')
|
||||
|
||||
assert stream.input_bytes() == (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"\r\n"
|
||||
b"c\r\n"
|
||||
b"hello, world\r\n"
|
||||
b"0\r\n\r\n"
|
||||
)
|
||||
assert stream.output_bytes() == (
|
||||
b"POST / HTTP/1.1\r\n"
|
||||
b"Host: example.com\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"\r\n"
|
||||
b'17\r\n'
|
||||
b'{"msg": "hello, world"}\r\n'
|
||||
b'0\r\n\r\n'
|
||||
)
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b'OK'
|
||||
assert headers == [
|
||||
(b'Content-Type', b'text/plain'),
|
||||
(b'Transfer-Encoding', b'chunked'),
|
||||
]
|
||||
assert body == b'hello, world'
|
||||
assert terminator == b''
|
||||
|
||||
|
||||
def test_parser_repr():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Content-Length: 23\r\n"
|
||||
b"\r\n"
|
||||
b'{"msg": "hello, world"}'
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
assert repr(p) == "<HTTPParser [client SEND_METHOD_LINE, server WAIT]>"
|
||||
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
assert repr(p) == "<HTTPParser [client SEND_HEADERS, server RECV_STATUS_LINE]>"
|
||||
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
assert repr(p) == "<HTTPParser [client SEND_BODY, server RECV_STATUS_LINE]>"
|
||||
|
||||
p.send_body(b'')
|
||||
assert repr(p) == "<HTTPParser [client DONE, server RECV_STATUS_LINE]>"
|
||||
|
||||
p.recv_status_line()
|
||||
assert repr(p) == "<HTTPParser [client DONE, server RECV_HEADERS]>"
|
||||
|
||||
p.recv_headers()
|
||||
assert repr(p) == "<HTTPParser [client DONE, server RECV_BODY]>"
|
||||
|
||||
p.recv_body()
|
||||
assert repr(p) == "<HTTPParser [client DONE, server RECV_BODY]>"
|
||||
|
||||
p.recv_body()
|
||||
assert repr(p) == "<HTTPParser [client DONE, server DONE]>"
|
||||
|
||||
p.complete()
|
||||
assert repr(p) == "<HTTPParser [client SEND_METHOD_LINE, server WAIT]>"
|
||||
|
||||
|
||||
def test_parser_invalid_transitions():
|
||||
stream = httpx.DuplexStream()
|
||||
|
||||
with pytest.raises(httpx.ProtocolError):
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b'GET', b'/', b'HTTP/1.1')
|
||||
p.send_method_line(b'GET', b'/', b'HTTP/1.1')
|
||||
|
||||
with pytest.raises(httpx.ProtocolError):
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_headers([])
|
||||
|
||||
with pytest.raises(httpx.ProtocolError):
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_body(b'')
|
||||
|
||||
with pytest.raises(httpx.ProtocolError):
|
||||
reader = httpx.ByteStream(b'HTTP/1.1 200 OK\r\n')
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.recv_status_line()
|
||||
|
||||
with pytest.raises(httpx.ProtocolError):
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.recv_headers()
|
||||
|
||||
with pytest.raises(httpx.ProtocolError):
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.recv_body()
|
||||
|
||||
|
||||
def test_parser_invalid_status_line():
|
||||
# ...
|
||||
stream = httpx.DuplexStream(b'...')
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
msg = 'Stream closed early reading response status line'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_status_line()
|
||||
|
||||
# ...
|
||||
stream = httpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
msg = 'Exceeded maximum size reading response status line'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_status_line()
|
||||
|
||||
# ...
|
||||
stream = httpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000 + b'\r\n')
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
msg = 'Exceeded maximum size reading response status line'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_status_line()
|
||||
|
||||
|
||||
def test_parser_sent_unsupported_protocol():
|
||||
# Currently only HTTP/1.1 is supported.
|
||||
stream = httpx.DuplexStream()
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
msg = 'Sent unsupported protocol version'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.0")
|
||||
|
||||
|
||||
def test_parser_recv_unsupported_protocol():
|
||||
# Currently only HTTP/1.1 is supported.
|
||||
stream = httpx.DuplexStream(b"HTTP/1.0 200 OK\r\n")
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
msg = 'Received unsupported protocol version'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_status_line()
|
||||
|
||||
|
||||
def test_parser_large_body():
|
||||
body = b"x" * 6988
|
||||
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 6988\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n" + body
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
# Checkout our buffer sizes.
|
||||
p.recv_status_line()
|
||||
p.recv_headers()
|
||||
assert len(p.recv_body()) == 4096
|
||||
assert len(p.recv_body()) == 2892
|
||||
assert len(p.recv_body()) == 0
|
||||
|
||||
|
||||
def test_parser_stream_large_body():
|
||||
body = b"x" * 6956
|
||||
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"1b2c\r\n" + body + b'\r\n0\r\n\r\n'
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
# Checkout our buffer sizes.
|
||||
p.recv_status_line()
|
||||
p.recv_headers()
|
||||
# assert len(p.recv_body()) == 4096
|
||||
# assert len(p.recv_body()) == 2860
|
||||
assert len(p.recv_body()) == 6956
|
||||
assert len(p.recv_body()) == 0
|
||||
|
||||
|
||||
def test_parser_not_enough_data_received():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 188\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"truncated"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
# Checkout our buffer sizes.
|
||||
p.recv_status_line()
|
||||
p.recv_headers()
|
||||
p.recv_body()
|
||||
msg = 'Not enough data received for declared Content-Length'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_body()
|
||||
|
||||
|
||||
def test_parser_not_enough_data_sent():
|
||||
stream = httpx.DuplexStream()
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"POST", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Content-Length", b"23"),
|
||||
])
|
||||
p.send_body(b'{"msg": "too smol"}')
|
||||
msg = 'Not enough data sent for declared Content-Length'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.send_body(b'')
|
||||
|
||||
|
||||
def test_parser_too_much_data_sent():
|
||||
stream = httpx.DuplexStream()
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"POST", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Type", b"application/json"),
|
||||
(b"Content-Length", b"19"),
|
||||
])
|
||||
msg = 'Too much data sent for declared Content-Length'
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.send_body(b'{"msg": "too chonky"}')
|
||||
|
||||
|
||||
def test_parser_missing_host_header():
|
||||
stream = httpx.DuplexStream()
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
msg = "Request missing 'Host' header"
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.send_headers([])
|
||||
|
||||
|
||||
def test_client_connection_close():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Connection", b"close"),
|
||||
])
|
||||
p.send_body(b'')
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b"OK"
|
||||
assert headers == [
|
||||
(b'Content-Length', b'12'),
|
||||
(b'Content-Type', b'text/plain'),
|
||||
]
|
||||
assert body == b"hello, world"
|
||||
assert terminator == b""
|
||||
|
||||
assert repr(p) == "<HTTPParser [client DONE, server DONE]>"
|
||||
|
||||
p.complete()
|
||||
assert repr(p) == "<HTTPParser [client CLOSED, server CLOSED]>"
|
||||
assert p.is_closed()
|
||||
|
||||
|
||||
def test_server_connection_close():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Connection: close\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b"OK"
|
||||
assert headers == [
|
||||
(b'Content-Length', b'12'),
|
||||
(b'Content-Type', b'text/plain'),
|
||||
(b'Connection', b'close'),
|
||||
]
|
||||
assert body == b"hello, world"
|
||||
assert terminator == b""
|
||||
|
||||
assert repr(p) == "<HTTPParser [client DONE, server DONE]>"
|
||||
p.complete()
|
||||
assert repr(p) == "<HTTPParser [client CLOSED, server CLOSED]>"
|
||||
|
||||
|
||||
def test_invalid_status_code():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 99 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Connection", b"close"),
|
||||
])
|
||||
p.send_body(b'')
|
||||
|
||||
msg = "Received invalid status code"
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_status_line()
|
||||
|
||||
|
||||
def test_1xx_status_code():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 103 Early Hints\r\n"
|
||||
b"Link: </style.css>; rel=preload; as=style\r\n"
|
||||
b"Link: </script.js>; rel=preload; as=script\r\n"
|
||||
b"\r\n"
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: 12\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([(b"Host", b"example.com")])
|
||||
p.send_body(b'')
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 103
|
||||
assert reason_phase == b'Early Hints'
|
||||
assert headers == [
|
||||
(b'Link', b'</style.css>; rel=preload; as=style'),
|
||||
(b'Link', b'</script.js>; rel=preload; as=script'),
|
||||
]
|
||||
|
||||
protocol, code, reason_phase = p.recv_status_line()
|
||||
headers = p.recv_headers()
|
||||
body = p.recv_body()
|
||||
terminator = p.recv_body()
|
||||
|
||||
assert protocol == b'HTTP/1.1'
|
||||
assert code == 200
|
||||
assert reason_phase == b"OK"
|
||||
assert headers == [
|
||||
(b'Content-Length', b'12'),
|
||||
(b'Content-Type', b'text/plain'),
|
||||
]
|
||||
assert body == b"hello, world"
|
||||
assert terminator == b""
|
||||
|
||||
|
||||
def test_received_invalid_content_length():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Length: -999\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"hello, world"
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Connection", b"close"),
|
||||
])
|
||||
p.send_body(b'')
|
||||
|
||||
p.recv_status_line()
|
||||
msg = "Received invalid Content-Length"
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_headers()
|
||||
|
||||
|
||||
def test_sent_invalid_content_length():
|
||||
stream = httpx.DuplexStream()
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
msg = "Sent invalid Content-Length"
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
# Limited to 20 digits.
|
||||
# 100 million terabytes should be enough for anyone.
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Content-Length", b"100000000000000000000"),
|
||||
])
|
||||
|
||||
|
||||
def test_received_invalid_characters_in_chunk_size():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"0xFF\r\n..."
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Connection", b"close"),
|
||||
])
|
||||
p.send_body(b'')
|
||||
|
||||
p.recv_status_line()
|
||||
p.recv_headers()
|
||||
msg = "Received invalid chunk size"
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_body()
|
||||
|
||||
|
||||
def test_received_oversized_chunk():
|
||||
stream = httpx.DuplexStream(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"\r\n"
|
||||
b"FFFFFFFFFF\r\n..."
|
||||
)
|
||||
|
||||
p = httpx.HTTPParser(stream, mode='CLIENT')
|
||||
p.send_method_line(b"GET", b"/", b"HTTP/1.1")
|
||||
p.send_headers([
|
||||
(b"Host", b"example.com"),
|
||||
(b"Connection", b"close"),
|
||||
])
|
||||
p.send_body(b'')
|
||||
|
||||
p.recv_status_line()
|
||||
p.recv_headers()
|
||||
msg = "Received invalid chunk size"
|
||||
with pytest.raises(httpx.ProtocolError, match=msg):
|
||||
p.recv_body()
|
||||
126
tests/test_pool.py
Normal file
126
tests/test_pool.py
Normal file
@ -0,0 +1,126 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def hello_world(request):
|
||||
content = httpx.Text('Hello, world.')
|
||||
return httpx.Response(200, content=content)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
with httpx.serve_http(hello_world) as server:
|
||||
yield server
|
||||
|
||||
|
||||
def test_connection_pool_request(server):
|
||||
with httpx.ConnectionPool() as pool:
|
||||
assert repr(pool) == "<ConnectionPool [0 active]>"
|
||||
assert len(pool.connections) == 0
|
||||
|
||||
r = pool.request("GET", server.url)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert repr(pool) == "<ConnectionPool [0 active, 1 idle]>"
|
||||
assert len(pool.connections) == 1
|
||||
|
||||
|
||||
def test_connection_pool_connection_close(server):
|
||||
with httpx.ConnectionPool() as pool:
|
||||
assert repr(pool) == "<ConnectionPool [0 active]>"
|
||||
assert len(pool.connections) == 0
|
||||
|
||||
r = pool.request("GET", server.url, headers={"Connection": "close"})
|
||||
|
||||
# TODO: Really we want closed connections proactively removed from the pool,
|
||||
assert r.status_code == 200
|
||||
assert repr(pool) == "<ConnectionPool [0 active, 1 closed]>"
|
||||
assert len(pool.connections) == 1
|
||||
|
||||
|
||||
def test_connection_pool_stream(server):
|
||||
with httpx.ConnectionPool() as pool:
|
||||
assert repr(pool) == "<ConnectionPool [0 active]>"
|
||||
assert len(pool.connections) == 0
|
||||
|
||||
with pool.stream("GET", server.url) as r:
|
||||
assert r.status_code == 200
|
||||
assert repr(pool) == "<ConnectionPool [1 active]>"
|
||||
assert len(pool.connections) == 1
|
||||
r.read()
|
||||
|
||||
assert repr(pool) == "<ConnectionPool [0 active, 1 idle]>"
|
||||
assert len(pool.connections) == 1
|
||||
|
||||
|
||||
def test_connection_pool_cannot_request_after_closed(server):
|
||||
with httpx.ConnectionPool() as pool:
|
||||
pool
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
pool.request("GET", server.url)
|
||||
|
||||
|
||||
def test_connection_pool_should_have_managed_lifespan(server):
|
||||
pool = httpx.ConnectionPool()
|
||||
with pytest.warns(UserWarning):
|
||||
del pool
|
||||
|
||||
|
||||
def test_connection_request(server):
|
||||
with httpx.open_connection(server.url) as conn:
|
||||
assert repr(conn) == f"<Connection [{server.url} idle]>"
|
||||
|
||||
r = conn.request("GET", "/")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert repr(conn) == f"<Connection [{server.url} idle]>"
|
||||
|
||||
|
||||
def test_connection_stream(server):
|
||||
with httpx.open_connection(server.url) as conn:
|
||||
assert repr(conn) == f"<Connection [{server.url} idle]>"
|
||||
with conn.stream("GET", "/") as r:
|
||||
assert r.status_code == 200
|
||||
assert repr(conn) == f"<Connection [{server.url} active]>"
|
||||
r.read()
|
||||
assert repr(conn) == f"<Connection [{server.url} idle]>"
|
||||
|
||||
|
||||
# # with httpx.open_connection("https://www.example.com/") as conn:
|
||||
# # r = conn.request("GET", "/")
|
||||
|
||||
# # >>> pool = httpx.ConnectionPool()
|
||||
# # >>> pool
|
||||
# # <ConnectionPool [0 active]>
|
||||
|
||||
# # >>> with httpx.open_connection_pool() as pool:
|
||||
# # >>> res = pool.request("GET", "https://www.example.com")
|
||||
# # >>> res, pool
|
||||
# # <Response [200 OK]>, <ConnectionPool [1 idle]>
|
||||
|
||||
# # >>> with httpx.open_connection_pool() as pool:
|
||||
# # >>> with pool.stream("GET", "https://www.example.com") as res:
|
||||
# # >>> res, pool
|
||||
# # <Response [200 OK]>, <ConnectionPool [1 active]>
|
||||
|
||||
# # >>> with httpx.open_connection_pool() as pool:
|
||||
# # >>> req = httpx.Request("GET", "https://www.example.com")
|
||||
# # >>> with pool.send(req) as res:
|
||||
# # >>> res.body()
|
||||
# # >>> res, pool
|
||||
# # <Response [200 OK]>, <ConnectionPool [1 idle]>
|
||||
|
||||
# # >>> with httpx.open_connection_pool() as pool:
|
||||
# # >>> pool.close()
|
||||
# # <ConnectionPool [0 active]>
|
||||
|
||||
# # with httpx.open_connection("https://www.example.com/") as conn:
|
||||
# # with conn.upgrade("GET", "/feed", {"Upgrade": "WebSocket") as stream:
|
||||
# # ...
|
||||
|
||||
# # with httpx.open_connection("http://127.0.0.1:8080") as conn:
|
||||
# # with conn.upgrade("CONNECT", "www.encode.io:443") as stream:
|
||||
# # stream.start_tls(ctx, hostname="www.encode.io")
|
||||
# # ...
|
||||
|
||||
78
tests/test_quickstart.py
Normal file
78
tests/test_quickstart.py
Normal file
@ -0,0 +1,78 @@
|
||||
import json
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def echo(request):
|
||||
request.read()
|
||||
response = httpx.Response(200, content=httpx.JSON({
|
||||
'method': request.method,
|
||||
'query-params': dict(request.url.params.items()),
|
||||
'content-type': request.headers.get('Content-Type'),
|
||||
'json': json.loads(request.body) if request.body else None,
|
||||
}))
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
with httpx.serve_http(echo) as server:
|
||||
yield server
|
||||
|
||||
|
||||
def test_get(server):
|
||||
r = httpx.get(server.url)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'GET',
|
||||
'query-params': {},
|
||||
'content-type': None,
|
||||
'json': None,
|
||||
}
|
||||
|
||||
|
||||
def test_post(server):
|
||||
data = httpx.JSON({"data": 123})
|
||||
r = httpx.post(server.url, content=data)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'POST',
|
||||
'query-params': {},
|
||||
'content-type': 'application/json',
|
||||
'json': {"data": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_put(server):
|
||||
data = httpx.JSON({"data": 123})
|
||||
r = httpx.put(server.url, content=data)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'PUT',
|
||||
'query-params': {},
|
||||
'content-type': 'application/json',
|
||||
'json': {"data": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_patch(server):
|
||||
data = httpx.JSON({"data": 123})
|
||||
r = httpx.patch(server.url, content=data)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'PATCH',
|
||||
'query-params': {},
|
||||
'content-type': 'application/json',
|
||||
'json': {"data": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_delete(server):
|
||||
r = httpx.delete(server.url)
|
||||
assert r.status_code == 200
|
||||
assert json.loads(r.body) == {
|
||||
'method': 'DELETE',
|
||||
'query-params': {},
|
||||
'content-type': None,
|
||||
'json': None,
|
||||
}
|
||||
79
tests/test_request.py
Normal file
79
tests/test_request.py
Normal file
@ -0,0 +1,79 @@
|
||||
import httpx
|
||||
|
||||
|
||||
class ByteIterator:
|
||||
def __init__(self, buffer=b""):
|
||||
self._buffer = buffer
|
||||
|
||||
def next(self) -> bytes:
|
||||
buffer = self._buffer
|
||||
self._buffer = b''
|
||||
return buffer
|
||||
|
||||
|
||||
def test_request():
|
||||
r = httpx.Request("GET", "https://example.com")
|
||||
|
||||
assert repr(r) == "<Request [GET 'https://example.com']>"
|
||||
assert r.method == "GET"
|
||||
assert r.url == "https://example.com"
|
||||
assert r.headers == {
|
||||
"Host": "example.com"
|
||||
}
|
||||
assert r.read() == b""
|
||||
|
||||
def test_request_bytes():
|
||||
content = b"Hello, world"
|
||||
r = httpx.Request("POST", "https://example.com", content=content)
|
||||
|
||||
assert repr(r) == "<Request [POST 'https://example.com']>"
|
||||
assert r.method == "POST"
|
||||
assert r.url == "https://example.com"
|
||||
assert r.headers == {
|
||||
"Host": "example.com",
|
||||
"Content-Length": "12",
|
||||
}
|
||||
assert r.read() == b"Hello, world"
|
||||
|
||||
|
||||
def test_request_stream():
|
||||
i = ByteIterator(b"Hello, world")
|
||||
stream = httpx.HTTPStream(i.next, None)
|
||||
r = httpx.Request("POST", "https://example.com", content=stream)
|
||||
|
||||
assert repr(r) == "<Request [POST 'https://example.com']>"
|
||||
assert r.method == "POST"
|
||||
assert r.url == "https://example.com"
|
||||
assert r.headers == {
|
||||
"Host": "example.com",
|
||||
"Transfer-Encoding": "chunked",
|
||||
}
|
||||
assert r.read() == b"Hello, world"
|
||||
|
||||
|
||||
def test_request_json():
|
||||
data = httpx.JSON({"msg": "Hello, world"})
|
||||
r = httpx.Request("POST", "https://example.com", content=data)
|
||||
|
||||
assert repr(r) == "<Request [POST 'https://example.com']>"
|
||||
assert r.method == "POST"
|
||||
assert r.url == "https://example.com"
|
||||
assert r.headers == {
|
||||
"Host": "example.com",
|
||||
"Content-Length": "22",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
assert r.read() == b'{"msg":"Hello, world"}'
|
||||
|
||||
|
||||
def test_request_empty_post():
|
||||
r = httpx.Request("POST", "https://example.com")
|
||||
|
||||
assert repr(r) == "<Request [POST 'https://example.com']>"
|
||||
assert r.method == "POST"
|
||||
assert r.url == "https://example.com"
|
||||
assert r.headers == {
|
||||
"Host": "example.com",
|
||||
"Content-Length": "0",
|
||||
}
|
||||
assert r.read() == b''
|
||||
64
tests/test_response.py
Normal file
64
tests/test_response.py
Normal file
@ -0,0 +1,64 @@
|
||||
import httpx
|
||||
|
||||
|
||||
class ByteIterator:
|
||||
def __init__(self, buffer=b""):
|
||||
self._buffer = buffer
|
||||
|
||||
def next(self) -> bytes:
|
||||
buffer = self._buffer
|
||||
self._buffer = b''
|
||||
return buffer
|
||||
|
||||
|
||||
def test_response():
|
||||
r = httpx.Response(200)
|
||||
|
||||
assert repr(r) == "<Response [200 OK]>"
|
||||
assert r.status_code == 200
|
||||
assert r.headers == {'Content-Length': '0'}
|
||||
assert r.read() == b""
|
||||
|
||||
|
||||
def test_response_204():
|
||||
r = httpx.Response(204)
|
||||
|
||||
assert repr(r) == "<Response [204 No Content]>"
|
||||
assert r.status_code == 204
|
||||
assert r.headers == {}
|
||||
assert r.read() == b""
|
||||
|
||||
|
||||
def test_response_bytes():
|
||||
content = b"Hello, world"
|
||||
r = httpx.Response(200, content=content)
|
||||
|
||||
assert repr(r) == "<Response [200 OK]>"
|
||||
assert r.headers == {
|
||||
"Content-Length": "12",
|
||||
}
|
||||
assert r.read() == b"Hello, world"
|
||||
|
||||
|
||||
def test_response_stream():
|
||||
i = ByteIterator(b"Hello, world")
|
||||
stream = httpx.HTTPStream(i.next, None)
|
||||
r = httpx.Response(200, content=stream)
|
||||
|
||||
assert repr(r) == "<Response [200 OK]>"
|
||||
assert r.headers == {
|
||||
"Transfer-Encoding": "chunked",
|
||||
}
|
||||
assert r.read() == b"Hello, world"
|
||||
|
||||
|
||||
def test_response_json():
|
||||
data = httpx.JSON({"msg": "Hello, world"})
|
||||
r = httpx.Response(200, content=data)
|
||||
|
||||
assert repr(r) == "<Response [200 OK]>"
|
||||
assert r.headers == {
|
||||
"Content-Length": "22",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
assert r.read() == b'{"msg":"Hello, world"}'
|
||||
82
tests/test_streams.py
Normal file
82
tests/test_streams.py
Normal file
@ -0,0 +1,82 @@
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
|
||||
def test_stream():
|
||||
i = httpx.Stream()
|
||||
with pytest.raises(NotImplementedError):
|
||||
i.read()
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
i.close()
|
||||
|
||||
i.size == None
|
||||
|
||||
|
||||
def test_bytestream():
|
||||
data = b'abc'
|
||||
s = httpx.ByteStream(data)
|
||||
assert s.size == 3
|
||||
assert s.read() == b'abc'
|
||||
|
||||
s = httpx.ByteStream(data)
|
||||
assert s.read(1) == b'a'
|
||||
assert s.read(1) == b'b'
|
||||
assert s.read(1) == b'c'
|
||||
assert s.read(1) == b''
|
||||
|
||||
|
||||
def test_filestream(tmp_path):
|
||||
path = tmp_path / "example.txt"
|
||||
path.write_bytes(b"hello world")
|
||||
|
||||
with httpx.FileStream(path) as s:
|
||||
assert s.size == 11
|
||||
assert s.read() == b'hello world'
|
||||
|
||||
with httpx.FileStream(path) as s:
|
||||
assert s.read(5) == b'hello'
|
||||
assert s.read(5) == b' worl'
|
||||
assert s.read(5) == b'd'
|
||||
assert s.read(5) == b''
|
||||
|
||||
with httpx.FileStream(path) as s:
|
||||
assert s.read(5) == b'hello'
|
||||
|
||||
|
||||
|
||||
def test_multipartstream(tmp_path):
|
||||
path = tmp_path / 'example.txt'
|
||||
path.write_bytes(b'hello world' + b'x' * 50)
|
||||
|
||||
expected = b''.join([
|
||||
b'--boundary\r\n',
|
||||
b'Content-Disposition: form-data; name="email"\r\n',
|
||||
b'\r\n',
|
||||
b'heya@example.com\r\n',
|
||||
b'--boundary\r\n',
|
||||
b'Content-Disposition: form-data; name="upload"; filename="example.txt"\r\n',
|
||||
b'\r\n',
|
||||
b'hello world' + ( b'x' * 50) + b'\r\n',
|
||||
b'--boundary--\r\n',
|
||||
])
|
||||
|
||||
form = [('email', 'heya@example.com')]
|
||||
files = [('upload', str(path))]
|
||||
with httpx.MultiPartStream(form, files, boundary='boundary') as s:
|
||||
assert s.size is None
|
||||
assert s.read() == expected
|
||||
|
||||
with httpx.MultiPartStream(form, files, boundary='boundary') as s:
|
||||
assert s.read(50) == expected[:50]
|
||||
assert s.read(50) == expected[50:100]
|
||||
assert s.read(50) == expected[100:150]
|
||||
assert s.read(50) == expected[150:200]
|
||||
assert s.read(50) == expected[200:250]
|
||||
|
||||
with httpx.MultiPartStream(form, files, boundary='boundary') as s:
|
||||
assert s.read(50) == expected[:50]
|
||||
assert s.read(50) == expected[50:100]
|
||||
assert s.read(50) == expected[100:150]
|
||||
assert s.read(50) == expected[150:200]
|
||||
s.close() # test close during open file
|
||||
33
tests/test_urlencode.py
Normal file
33
tests/test_urlencode.py
Normal file
@ -0,0 +1,33 @@
|
||||
import httpx
|
||||
|
||||
|
||||
def test_urlencode():
|
||||
qs = "a=name%40example.com&a=456&b=7+8+9&c"
|
||||
d = httpx.urldecode(qs)
|
||||
assert d == {
|
||||
"a": ["name@example.com", "456"],
|
||||
"b": ["7 8 9"],
|
||||
"c": [""]
|
||||
}
|
||||
|
||||
|
||||
def test_urldecode():
|
||||
d = {
|
||||
"a": ["name@example.com", "456"],
|
||||
"b": ["7 8 9"],
|
||||
"c": [""]
|
||||
}
|
||||
qs = httpx.urlencode(d)
|
||||
assert qs == "a=name%40example.com&a=456&b=7+8+9&c="
|
||||
|
||||
|
||||
def test_urlencode_empty():
|
||||
qs = ""
|
||||
d = httpx.urldecode(qs)
|
||||
assert d == {}
|
||||
|
||||
|
||||
def test_urldecode_empty():
|
||||
d = {}
|
||||
qs = httpx.urlencode(d)
|
||||
assert qs == ""
|
||||
164
tests/test_urls.py
Normal file
164
tests/test_urls.py
Normal file
@ -0,0 +1,164 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def test_url():
|
||||
url = httpx.URL('https://www.example.com/')
|
||||
assert str(url) == "https://www.example.com/"
|
||||
|
||||
|
||||
def test_url_repr():
|
||||
url = httpx.URL('https://www.example.com/')
|
||||
assert repr(url) == "<URL 'https://www.example.com/'>"
|
||||
|
||||
|
||||
def test_url_params():
|
||||
url = httpx.URL('https://www.example.com/', params={"a": "b", "c": "d"})
|
||||
assert str(url) == "https://www.example.com/?a=b&c=d"
|
||||
|
||||
|
||||
def test_url_normalisation():
|
||||
url = httpx.URL('https://www.EXAMPLE.com:443/path/../main')
|
||||
assert str(url) == 'https://www.example.com/main'
|
||||
|
||||
|
||||
def test_url_relative():
|
||||
url = httpx.URL('/README.md')
|
||||
assert str(url) == '/README.md'
|
||||
|
||||
|
||||
def test_url_escaping():
|
||||
url = httpx.URL('https://example.com/path to here?search=🦋')
|
||||
assert str(url) == 'https://example.com/path%20to%20here?search=%F0%9F%A6%8B'
|
||||
|
||||
|
||||
def test_url_components():
|
||||
url = httpx.URL(scheme="https", host="example.com", path="/")
|
||||
assert str(url) == 'https://example.com/'
|
||||
|
||||
|
||||
# QueryParams
|
||||
|
||||
def test_queryparams():
|
||||
params = httpx.QueryParams({"color": "black", "size": "medium"})
|
||||
assert str(params) == 'color=black&size=medium'
|
||||
|
||||
|
||||
def test_queryparams_repr():
|
||||
params = httpx.QueryParams({"color": "black", "size": "medium"})
|
||||
assert repr(params) == "<QueryParams 'color=black&size=medium'>"
|
||||
|
||||
|
||||
def test_queryparams_list_of_values():
|
||||
params = httpx.QueryParams({"filter": ["60GHz", "75GHz", "100GHz"]})
|
||||
assert str(params) == 'filter=60GHz&filter=75GHz&filter=100GHz'
|
||||
|
||||
|
||||
def test_queryparams_from_str():
|
||||
params = httpx.QueryParams("color=black&size=medium")
|
||||
assert str(params) == 'color=black&size=medium'
|
||||
|
||||
|
||||
def test_queryparams_access():
|
||||
params = httpx.QueryParams("sort_by=published&author=natalie")
|
||||
assert params["sort_by"] == 'published'
|
||||
|
||||
|
||||
def test_queryparams_escaping():
|
||||
params = httpx.QueryParams({"email": "user@example.com", "search": "How HTTP works!"})
|
||||
assert str(params) == 'email=user%40example.com&search=How+HTTP+works%21'
|
||||
|
||||
|
||||
def test_queryparams_empty():
|
||||
q = httpx.QueryParams({"a": ""})
|
||||
assert str(q) == "a="
|
||||
|
||||
q = httpx.QueryParams("a=")
|
||||
assert str(q) == "a="
|
||||
|
||||
q = httpx.QueryParams("a")
|
||||
assert str(q) == "a="
|
||||
|
||||
|
||||
def test_queryparams_set():
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_set("a", "456")
|
||||
assert q == httpx.QueryParams("a=456")
|
||||
|
||||
|
||||
def test_queryparams_append():
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_append("a", "456")
|
||||
assert q == httpx.QueryParams("a=123&a=456")
|
||||
|
||||
|
||||
def test_queryparams_remove():
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_remove("a")
|
||||
assert q == httpx.QueryParams("")
|
||||
|
||||
|
||||
def test_queryparams_merge():
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.copy_update({"b": "456"})
|
||||
assert q == httpx.QueryParams("a=123&b=456")
|
||||
q = q.copy_update({"a": "000", "c": "789"})
|
||||
assert q == httpx.QueryParams("a=000&b=456&c=789")
|
||||
|
||||
|
||||
def test_queryparams_are_hashable():
|
||||
params = (
|
||||
httpx.QueryParams("a=123"),
|
||||
httpx.QueryParams({"a": "123"}),
|
||||
httpx.QueryParams("b=456"),
|
||||
httpx.QueryParams({"b": "456"}),
|
||||
)
|
||||
|
||||
assert len(set(params)) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
"a=123&a=456&b=789",
|
||||
{"a": ["123", "456"], "b": "789"},
|
||||
{"a": ("123", "456"), "b": "789"},
|
||||
[("a", "123"), ("a", "456"), ("b", "789")],
|
||||
(("a", "123"), ("a", "456"), ("b", "789")),
|
||||
],
|
||||
)
|
||||
def test_queryparams_misc(source):
|
||||
q = httpx.QueryParams(source)
|
||||
assert "a" in q
|
||||
assert "A" not in q
|
||||
assert "c" not in q
|
||||
assert q["a"] == "123"
|
||||
assert q.get("a") == "123"
|
||||
assert q.get("nope", default=None) is None
|
||||
assert q.get_list("a") == ["123", "456"]
|
||||
assert bool(q)
|
||||
|
||||
assert list(q.keys()) == ["a", "b"]
|
||||
assert list(q.values()) == ["123", "789"]
|
||||
assert list(q.items()) == [("a", "123"), ("b", "789")]
|
||||
assert len(q) == 2
|
||||
assert list(q) == ["a", "b"]
|
||||
assert dict(q) == {"a": "123", "b": "789"}
|
||||
assert str(q) == "a=123&a=456&b=789"
|
||||
assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams(
|
||||
[("a", "123"), ("b", "456")]
|
||||
)
|
||||
assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams(
|
||||
"a=123&b=456"
|
||||
)
|
||||
assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams(
|
||||
{"b": "456", "a": "123"}
|
||||
)
|
||||
assert httpx.QueryParams() == httpx.QueryParams({})
|
||||
assert httpx.QueryParams([("a", "123"), ("a", "456")]) == httpx.QueryParams(
|
||||
"a=123&a=456"
|
||||
)
|
||||
assert httpx.QueryParams({"a": "123", "b": "456"}) != "invalid"
|
||||
|
||||
q = httpx.QueryParams([("a", "123"), ("a", "456")])
|
||||
assert httpx.QueryParams(q) == q
|
||||
Loading…
Reference in New Issue
Block a user