uhttp-client 2.3.0__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/.github/workflows/publish.yml +0 -6
- uhttp_client-3.0.0/.github/workflows/tests.yml +87 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/PKG-INFO +183 -122
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/README.md +182 -121
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_async.py +42 -54
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_stream.py +11 -17
- uhttp_client-3.0.0/tests/helpers.py +230 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_async.py +35 -49
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_body_framing.py +3 -35
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_event_mode.py +1 -65
- uhttp_client-3.0.0/tests/test_http_compliance.py +278 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_mpy_integration.py +12 -0
- uhttp_client-3.0.0/tests/test_mpy_resources.py +234 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_nonblocking_connect.py +38 -67
- uhttp_client-3.0.0/tests/test_selectors_v3.py +613 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_unit.py +3 -2
- uhttp_client-3.0.0/tests/test_v3_fixes.py +253 -0
- uhttp_client-3.0.0/tests/test_v3_review.py +341 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp/cli.py +2 -10
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp/client.py +649 -280
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/PKG-INFO +183 -122
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/SOURCES.txt +8 -1
- uhttp_client-3.0.0/uhttp_client.egg-info/scm_file_list.json +37 -0
- uhttp_client-3.0.0/uhttp_client.egg-info/scm_version.json +8 -0
- uhttp_client-2.3.0/.github/workflows/micropython.yml +0 -39
- uhttp_client-2.3.0/.github/workflows/tests.yml +0 -33
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/.gitignore +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/LICENSE +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_basic.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_https.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/pyproject.toml +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/setup.cfg +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/__init__.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_100_continue.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_auth.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_basic.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_cli.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_cookies.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_errors.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_integration.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_keepalive.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/dependency_links.txt +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/entry_points.txt +0 -0
- {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/top_level.txt +0 -0
|
@@ -25,12 +25,6 @@ jobs:
|
|
|
25
25
|
- name: Install package
|
|
26
26
|
run: pip install .
|
|
27
27
|
|
|
28
|
-
- name: Install test dependencies
|
|
29
|
-
run: pip install uhttp-server
|
|
30
|
-
|
|
31
|
-
- name: Run unit tests
|
|
32
|
-
run: python -m unittest discover -v tests/
|
|
33
|
-
|
|
34
28
|
- name: Build package
|
|
35
29
|
run: python -m build
|
|
36
30
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main, v2]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main, v2]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test-ubuntu:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
max-parallel: 1
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.14"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
|
|
26
|
+
- name: Install package
|
|
27
|
+
run: pip install .
|
|
28
|
+
|
|
29
|
+
- name: Install test dependencies
|
|
30
|
+
run: pip install uhttp-server
|
|
31
|
+
|
|
32
|
+
- name: Run unit tests
|
|
33
|
+
run: python -m unittest discover -v tests/
|
|
34
|
+
|
|
35
|
+
test-windows:
|
|
36
|
+
runs-on: windows-latest
|
|
37
|
+
strategy:
|
|
38
|
+
max-parallel: 1
|
|
39
|
+
matrix:
|
|
40
|
+
python-version: ["3.10", "3.14"]
|
|
41
|
+
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
|
|
45
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
46
|
+
uses: actions/setup-python@v5
|
|
47
|
+
with:
|
|
48
|
+
python-version: ${{ matrix.python-version }}
|
|
49
|
+
|
|
50
|
+
- name: Install package
|
|
51
|
+
run: pip install .
|
|
52
|
+
|
|
53
|
+
- name: Install test dependencies
|
|
54
|
+
run: pip install uhttp-server
|
|
55
|
+
|
|
56
|
+
- name: Run unit tests
|
|
57
|
+
run: python -m unittest discover -v tests/
|
|
58
|
+
|
|
59
|
+
micropython:
|
|
60
|
+
if: github.event_name == 'push'
|
|
61
|
+
needs: [test-ubuntu, test-windows]
|
|
62
|
+
runs-on: [self-hosted, Linux]
|
|
63
|
+
# One device, two maintained branches - queue instead of colliding, and
|
|
64
|
+
# never cancel: interrupting mpytool mid-operation wedges the REPL.
|
|
65
|
+
concurrency:
|
|
66
|
+
group: micropython-${{ matrix.device }}
|
|
67
|
+
cancel-in-progress: false
|
|
68
|
+
strategy:
|
|
69
|
+
fail-fast: false
|
|
70
|
+
matrix:
|
|
71
|
+
device: [ESP32]
|
|
72
|
+
|
|
73
|
+
steps:
|
|
74
|
+
- uses: actions/checkout@v4
|
|
75
|
+
|
|
76
|
+
- name: Install packages
|
|
77
|
+
run: |
|
|
78
|
+
$HOME/actions-runner/.venv/bin/pip install -e .
|
|
79
|
+
$HOME/actions-runner/.venv/bin/pip install 'mpytool>=2.4.0'
|
|
80
|
+
|
|
81
|
+
- name: MicroPython tests (${{ matrix.device }})
|
|
82
|
+
run: |
|
|
83
|
+
export PATH="$HOME/actions-runner/.venv/bin:$PATH"
|
|
84
|
+
export MPY_TEST_PORT="$(cat $HOME/actions-runner/.config/mpytool/${{ matrix.device }})"
|
|
85
|
+
export MPY_WIFI_SSID="$(jq -r .ssid $HOME/actions-runner/.config/uhttp/wifi.json)"
|
|
86
|
+
export MPY_WIFI_PASSWORD="$(jq -r .password $HOME/actions-runner/.config/uhttp/wifi.json)"
|
|
87
|
+
python -m unittest tests.test_mpy_integration -v
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: uhttp-client
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Micro HTTP client for Python and MicroPython
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -21,7 +21,7 @@ Dynamic: license-file
|
|
|
21
21
|
## Features
|
|
22
22
|
|
|
23
23
|
- MicroPython and CPython compatible
|
|
24
|
-
- Fully non-blocking: TCP connect, SSL handshake, and HTTP I/O via
|
|
24
|
+
- Fully non-blocking: TCP connect, SSL handshake, and HTTP I/O via `selectors`
|
|
25
25
|
- Keep-alive connections with automatic reuse
|
|
26
26
|
- Fluent API: `response = client.get('/path').wait()`
|
|
27
27
|
- URL parsing with automatic SSL detection
|
|
@@ -164,10 +164,11 @@ client.close()
|
|
|
164
164
|
|
|
165
165
|
## Async (non-blocking) mode
|
|
166
166
|
|
|
167
|
-
Everything is non-blocking by default — TCP connect, SSL handshake, and HTTP I/O all happen through `
|
|
167
|
+
Everything is non-blocking by default — TCP connect, SSL handshake, and HTTP I/O all happen through a `selectors.BaseSelector`. This is critical for embedded devices on slow networks (4G modems, ESP32 PPP) where each phase can take seconds.
|
|
168
|
+
|
|
169
|
+
The client registers its socket in a selector and is driven either by its own `wait()`, or by a shared-selector loop dispatching through `key.data.handle_event()`.
|
|
168
170
|
|
|
169
171
|
```python
|
|
170
|
-
import select
|
|
171
172
|
import uhttp.client
|
|
172
173
|
|
|
173
174
|
client = uhttp.client.HttpClient('http://httpbin.org')
|
|
@@ -175,112 +176,109 @@ client = uhttp.client.HttpClient('http://httpbin.org')
|
|
|
175
176
|
# Start request (non-blocking, including connect)
|
|
176
177
|
client.get('/delay/2')
|
|
177
178
|
|
|
178
|
-
# Manual
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
response = client.process_events(r, w)
|
|
187
|
-
if response:
|
|
188
|
-
print(response.status)
|
|
189
|
-
break
|
|
179
|
+
# Manual selector loop - handles connect, send, and receive
|
|
180
|
+
done = False
|
|
181
|
+
while not done:
|
|
182
|
+
for key, mask in client.selector.select(10.0):
|
|
183
|
+
if key.data.handle_event(key.fileobj, mask) is not None:
|
|
184
|
+
print(client.response.status)
|
|
185
|
+
done = True
|
|
186
|
+
client.maintenance() # enforces the deadline; no event can trigger it
|
|
190
187
|
|
|
191
188
|
client.close()
|
|
192
189
|
```
|
|
193
190
|
|
|
191
|
+
`handle_event()` returns the client itself when a result is ready (not the
|
|
192
|
+
value — `EVENT_RESPONSE` is `0` and would be falsy); read it from
|
|
193
|
+
`client.response` or `client.event`.
|
|
194
|
+
|
|
195
|
+
**Breaking change in v3:** `read_sockets`, `write_sockets` and
|
|
196
|
+
`process_events()` are gone. `select.select()` is no longer used.
|
|
197
|
+
|
|
194
198
|
### State machine
|
|
195
199
|
|
|
196
|
-
After `client.get('/path')`, the client progresses through states automatically via `
|
|
200
|
+
After `client.get('/path')`, the client progresses through states automatically via `handle_event()`:
|
|
197
201
|
|
|
198
|
-
| State | Description |
|
|
202
|
+
| State | Description | selector interest |
|
|
199
203
|
|---|---|---|
|
|
200
|
-
| `STATE_CONNECTING` | TCP connect in progress |
|
|
201
|
-
| `STATE_SSL_HANDSHAKE` | SSL handshake in progress |
|
|
202
|
-
| `STATE_SENDING` | Sending request data |
|
|
203
|
-
| `STATE_RECEIVING_HEADERS` | Waiting for response headers |
|
|
204
|
-
| `STATE_RECEIVING_BODY` | Receiving response body |
|
|
204
|
+
| `STATE_CONNECTING` | TCP connect in progress | `EVENT_WRITE` |
|
|
205
|
+
| `STATE_SSL_HANDSHAKE` | SSL handshake in progress | `EVENT_READ`/`EVENT_WRITE` |
|
|
206
|
+
| `STATE_SENDING` | Sending request data | `EVENT_WRITE` |
|
|
207
|
+
| `STATE_RECEIVING_HEADERS` | Waiting for response headers | `EVENT_READ` |
|
|
208
|
+
| `STATE_RECEIVING_BODY` | Receiving response body | `EVENT_READ` |
|
|
205
209
|
| `STATE_COMPLETE` | Response ready | — |
|
|
206
210
|
|
|
207
211
|
The `state` property exposes the current state. The `is_connected` property returns `True` only after connect and handshake are complete.
|
|
208
212
|
|
|
209
213
|
### Parallel requests
|
|
210
214
|
|
|
211
|
-
|
|
215
|
+
Pass one selector to every client and a single loop drives them all. Connect, handshake, and data transfer happen concurrently:
|
|
212
216
|
|
|
213
217
|
```python
|
|
214
|
-
import
|
|
218
|
+
import selectors
|
|
215
219
|
import uhttp.client
|
|
216
220
|
|
|
221
|
+
selector = selectors.DefaultSelector()
|
|
217
222
|
clients = [
|
|
218
|
-
uhttp.client.HttpClient('http://httpbin.org')
|
|
219
|
-
|
|
220
|
-
uhttp.client.HttpClient('http://httpbin.org'),
|
|
223
|
+
uhttp.client.HttpClient('http://httpbin.org', selector=selector)
|
|
224
|
+
for _ in range(3)
|
|
221
225
|
]
|
|
222
226
|
|
|
223
227
|
# Start all requests (non-blocking connects begin immediately)
|
|
224
228
|
for i, client in enumerate(clients):
|
|
225
229
|
client.get('/delay/1', query={'n': i})
|
|
226
230
|
|
|
227
|
-
# Single
|
|
231
|
+
# Single selector loop handles all clients
|
|
228
232
|
results = {}
|
|
229
233
|
while len(results) < len(clients):
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
for i, client in enumerate(clients):
|
|
239
|
-
if i not in results:
|
|
240
|
-
resp = client.process_events(r, w)
|
|
241
|
-
if resp:
|
|
242
|
-
results[i] = resp
|
|
234
|
+
for key, mask in selector.select(10.0):
|
|
235
|
+
ready = key.data.handle_event(key.fileobj, mask)
|
|
236
|
+
if ready is not None:
|
|
237
|
+
results[clients.index(ready)] = ready.response
|
|
238
|
+
for client in clients:
|
|
239
|
+
while client.next(): # drain results buffered from one recv
|
|
240
|
+
results[clients.index(client)] = client.response
|
|
241
|
+
client.maintenance()
|
|
243
242
|
|
|
244
243
|
for client in clients:
|
|
245
244
|
client.close()
|
|
245
|
+
selector.close()
|
|
246
246
|
```
|
|
247
247
|
|
|
248
248
|
### Combined with HttpServer
|
|
249
249
|
|
|
250
|
-
Server and client in the same
|
|
250
|
+
Server and client in the same selector loop — true single-threaded concurrency. Both register in the same selector, and `key.data.handle_event()` dispatches to whichever owns the ready socket:
|
|
251
251
|
|
|
252
252
|
```python
|
|
253
|
-
import
|
|
253
|
+
import selectors
|
|
254
254
|
import uhttp.server
|
|
255
255
|
import uhttp.client
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
selector = selectors.DefaultSelector()
|
|
258
|
+
server = uhttp.server.HttpServer(port=8080, selector=selector)
|
|
259
|
+
backend = uhttp.client.HttpClient('http://api.example.com', selector=selector)
|
|
259
260
|
|
|
261
|
+
incoming = None
|
|
260
262
|
while True:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
#
|
|
273
|
-
response = backend.process_events(r, w)
|
|
274
|
-
if response:
|
|
275
|
-
incoming.respond(data=response.data)
|
|
263
|
+
for key, mask in selector.select(1.0):
|
|
264
|
+
ready = key.data.handle_event(key.fileobj, mask)
|
|
265
|
+
if ready is None:
|
|
266
|
+
continue
|
|
267
|
+
if isinstance(ready, uhttp.server.HttpConnection):
|
|
268
|
+
incoming = ready # request in
|
|
269
|
+
backend.get('/data', query=ready.query)
|
|
270
|
+
elif ready is backend and incoming:
|
|
271
|
+
incoming.respond(data=backend.response.data) # response out
|
|
272
|
+
incoming = None
|
|
273
|
+
server.maintenance()
|
|
274
|
+
backend.maintenance() # a hung backend has no event to time out on
|
|
276
275
|
```
|
|
277
276
|
|
|
278
277
|
### HTTPS with non-blocking handshake
|
|
279
278
|
|
|
280
|
-
SSL handshake is also non-blocking. The client tracks whether `do_handshake()` needs to read or write, and
|
|
279
|
+
SSL handshake is also non-blocking. The client tracks whether `do_handshake()` needs to read or write, and arms only that direction to prevent the selector from spinning:
|
|
281
280
|
|
|
282
281
|
```python
|
|
283
|
-
import select
|
|
284
282
|
import ssl
|
|
285
283
|
import uhttp.client
|
|
286
284
|
|
|
@@ -288,19 +286,16 @@ ctx = ssl.create_default_context()
|
|
|
288
286
|
client = uhttp.client.HttpClient(
|
|
289
287
|
'api.example.com', port=443, ssl_context=ctx)
|
|
290
288
|
|
|
291
|
-
# Connect + SSL handshake + request all happen via
|
|
289
|
+
# Connect + SSL handshake + request all happen via the selector
|
|
292
290
|
client.get('/data')
|
|
293
291
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if response:
|
|
302
|
-
print(response.json())
|
|
303
|
-
break
|
|
292
|
+
done = False
|
|
293
|
+
while not done:
|
|
294
|
+
for key, mask in client.selector.select(10.0):
|
|
295
|
+
if key.data.handle_event(key.fileobj, mask) is not None:
|
|
296
|
+
print(client.response.json())
|
|
297
|
+
done = True
|
|
298
|
+
client.maintenance()
|
|
304
299
|
|
|
305
300
|
client.close()
|
|
306
301
|
```
|
|
@@ -308,7 +303,7 @@ client.close()
|
|
|
308
303
|
### Multiple HTTPS clients in parallel
|
|
309
304
|
|
|
310
305
|
```python
|
|
311
|
-
import
|
|
306
|
+
import selectors
|
|
312
307
|
import uhttp.client
|
|
313
308
|
|
|
314
309
|
urls = [
|
|
@@ -317,28 +312,26 @@ urls = [
|
|
|
317
312
|
'https://api3.example.com/data',
|
|
318
313
|
]
|
|
319
314
|
|
|
320
|
-
|
|
315
|
+
selector = selectors.DefaultSelector()
|
|
316
|
+
clients = [
|
|
317
|
+
uhttp.client.HttpClient(url, selector=selector) for url in urls]
|
|
321
318
|
for c in clients:
|
|
322
319
|
c.get('/') # All start non-blocking connects + SSL handshakes
|
|
323
320
|
|
|
324
321
|
responses = [None] * len(clients)
|
|
325
322
|
while not all(responses):
|
|
326
|
-
|
|
327
|
-
|
|
323
|
+
for key, mask in selector.select(10.0):
|
|
324
|
+
ready = key.data.handle_event(key.fileobj, mask)
|
|
325
|
+
if ready is not None:
|
|
326
|
+
responses[clients.index(ready)] = ready.response
|
|
328
327
|
for c in clients:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
r, w, _ = select.select(read_socks, write_socks, [], 10.0)
|
|
333
|
-
|
|
334
|
-
for i, c in enumerate(clients):
|
|
335
|
-
if responses[i] is None:
|
|
336
|
-
resp = c.process_events(r, w)
|
|
337
|
-
if resp:
|
|
338
|
-
responses[i] = resp
|
|
328
|
+
while c.next(): # drain results buffered from one recv
|
|
329
|
+
responses[clients.index(c)] = c.response
|
|
330
|
+
c.maintenance()
|
|
339
331
|
|
|
340
332
|
for c in clients:
|
|
341
333
|
c.close()
|
|
334
|
+
selector.close()
|
|
342
335
|
```
|
|
343
336
|
|
|
344
337
|
|
|
@@ -346,9 +339,10 @@ for c in clients:
|
|
|
346
339
|
|
|
347
340
|
For large or open-ended responses (downloads, NDJSON, MJPEG, SSE) the client
|
|
348
341
|
offers an **event mode** that mirrors uhttp-server's `HttpConnection` API.
|
|
349
|
-
With `event_mode=True`, `wait()`
|
|
350
|
-
|
|
351
|
-
|
|
342
|
+
With `event_mode=True`, `wait()` returns `EVENT_*` constants instead of an
|
|
343
|
+
`HttpResponse`, and you choose how the body is delivered after the headers
|
|
344
|
+
arrive. (`handle_event()` returns `self`/`None` in both modes — read the value
|
|
345
|
+
from `client.event`.)
|
|
352
346
|
|
|
353
347
|
### Events
|
|
354
348
|
|
|
@@ -360,7 +354,7 @@ delivered after the headers arrive.
|
|
|
360
354
|
| `EVENT_COMPLETE` | Body fully received |
|
|
361
355
|
| `EVENT_ERROR` | Connection or decode error → message in `client.error` (no exception) |
|
|
362
356
|
|
|
363
|
-
Names and numeric values match uhttp-server, so the same
|
|
357
|
+
Names and numeric values match uhttp-server, so the same selector loop can drive
|
|
364
358
|
both a server and a client.
|
|
365
359
|
|
|
366
360
|
### Body delivery (choose after `EVENT_HEADERS`)
|
|
@@ -378,7 +372,6 @@ new event type.
|
|
|
378
372
|
### NDJSON streaming
|
|
379
373
|
|
|
380
374
|
```python
|
|
381
|
-
import select
|
|
382
375
|
from uhttp.client import (
|
|
383
376
|
HttpClient, EVENT_HEADERS, EVENT_DATA, EVENT_COMPLETE, EVENT_ERROR)
|
|
384
377
|
|
|
@@ -386,8 +379,7 @@ client = HttpClient('http://api.example.com', event_mode=True)
|
|
|
386
379
|
client.get('/events.ndjson', stream=True) # stream until close if unframed
|
|
387
380
|
|
|
388
381
|
while True:
|
|
389
|
-
|
|
390
|
-
event = client.process_events(r, w)
|
|
382
|
+
event = client.wait(30) # drains next() first, then selects
|
|
391
383
|
|
|
392
384
|
if event == EVENT_HEADERS:
|
|
393
385
|
client.accept_ndjson()
|
|
@@ -414,8 +406,7 @@ client = HttpClient('http://example.com', event_mode=True)
|
|
|
414
406
|
client.get('/firmware.bin')
|
|
415
407
|
|
|
416
408
|
while True:
|
|
417
|
-
|
|
418
|
-
event = client.process_events(r, w)
|
|
409
|
+
event = client.wait(10)
|
|
419
410
|
if event == EVENT_HEADERS:
|
|
420
411
|
client.accept_body_to_file('/sd/firmware.bin')
|
|
421
412
|
elif event == EVENT_COMPLETE:
|
|
@@ -500,8 +491,12 @@ Parameters:
|
|
|
500
491
|
- `connect_timeout` - Connection timeout in seconds (default: 10)
|
|
501
492
|
- `timeout` - Response timeout in seconds (default: 30)
|
|
502
493
|
- `max_response_length` - Maximum buffered body size (default: 1MB)
|
|
503
|
-
- `event_mode` - If `True`, `wait()
|
|
504
|
-
|
|
494
|
+
- `event_mode` - If `True`, `wait()` returns `EVENT_*` constants instead of
|
|
495
|
+
`HttpResponse` (see [Streaming & Event Mode](#streaming--event-mode))
|
|
496
|
+
- `selector` - A `selectors.BaseSelector` to register the socket in (default: a
|
|
497
|
+
`DefaultSelector` the client owns and closes). Pass the same instance to
|
|
498
|
+
several clients / servers to drive them from one loop — then drive it with
|
|
499
|
+
`handle_event()` + `maintenance()`, because `wait()` needs an owned selector.
|
|
505
500
|
|
|
506
501
|
#### Properties
|
|
507
502
|
|
|
@@ -512,8 +507,9 @@ Parameters:
|
|
|
512
507
|
- `state` - Current state (STATE_IDLE, STATE_CONNECTING, STATE_SSL_HANDSHAKE, STATE_SENDING, etc.)
|
|
513
508
|
- `auth` - Authentication credentials tuple (username, password) or None
|
|
514
509
|
- `cookies` - Cookies dict (persistent across requests)
|
|
515
|
-
- `
|
|
516
|
-
|
|
510
|
+
- `selector` - The `selectors.BaseSelector` the client registers its socket in.
|
|
511
|
+
Read events from it and dispatch via `key.data.handle_event()` for a
|
|
512
|
+
shared-selector loop across several clients / servers / your own sockets.
|
|
517
513
|
|
|
518
514
|
Event-mode properties (available once headers are received):
|
|
519
515
|
|
|
@@ -560,22 +556,53 @@ Start HTTP request (async). Returns `self` for chaining.
|
|
|
560
556
|
|
|
561
557
|
Wait for response (blocking).
|
|
562
558
|
|
|
559
|
+
Single-client convenience backed by the client's own selector: it drains
|
|
560
|
+
`next()` first, then selects and dispatches.
|
|
561
|
+
|
|
562
|
+
**Requires an owned selector.** A blocking wait cannot service the other
|
|
563
|
+
owners' ready keys of a shared selector, and level-triggered readiness would
|
|
564
|
+
hand them back on every call — a busy spin. With an injected selector `wait()`
|
|
565
|
+
raises `HttpClientError`; drive that loop yourself with `handle_event()` and
|
|
566
|
+
`maintenance()`.
|
|
567
|
+
|
|
563
568
|
- Classic mode: returns `HttpResponse` when complete; raises `HttpTimeoutError`
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
timeout expires with nothing new.
|
|
569
|
+
(and closes the connection) when the request or wait timeout expires.
|
|
570
|
+
- Event mode: returns the next `EVENT_*` constant, or `None` when the timeout
|
|
571
|
+
expires with nothing new (the connection stays open — call again).
|
|
568
572
|
- `timeout` - Max time to spend in wait() call. If `None`, uses request timeout.
|
|
569
573
|
|
|
570
|
-
**`
|
|
574
|
+
**`handle_event(fileobj, mask)`**
|
|
575
|
+
|
|
576
|
+
Owner dispatch for a selector event (the client is stored as `key.data`).
|
|
577
|
+
Drives read/write for the given readiness `mask` and returns the client itself
|
|
578
|
+
when a result is ready, else `None`. Read the result from `response` (classic
|
|
579
|
+
mode) or `event` (event mode) — it returns `self` rather than the value
|
|
580
|
+
because `EVENT_RESPONSE` is `0` and would be falsy.
|
|
581
|
+
|
|
582
|
+
In classic mode connection errors raise; in event mode they surface as
|
|
583
|
+
`EVENT_ERROR` with the message in `error`.
|
|
584
|
+
|
|
585
|
+
**`next()`**
|
|
571
586
|
|
|
572
|
-
Process
|
|
587
|
+
Process a result already buffered locally, returning `True` while another one
|
|
588
|
+
is ready. One `recv()` can carry several NDJSON records or body chunks, and an
|
|
589
|
+
SSL socket can hold decrypted bytes the selector will never report — both are
|
|
590
|
+
invisible to the selector, so drain with `next()` before blocking again.
|
|
591
|
+
`wait()` does this for you.
|
|
573
592
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
593
|
+
**`maintenance()`**
|
|
594
|
+
|
|
595
|
+
Enforce the request deadline and expire an idle kept-alive connection. A
|
|
596
|
+
shared-selector loop only calls `handle_event()` for *ready* keys, so a hung
|
|
597
|
+
peer would otherwise leave the request pending forever — call this once per
|
|
598
|
+
loop iteration (`wait()` does it for you). It reports rather than raises, in
|
|
599
|
+
both modes: the client is returned with the reason in `error` (event mode also
|
|
600
|
+
sets `event` to `EVENT_ERROR`), else `None`. One hung peer must not abort a
|
|
601
|
+
loop that serves other owners — only `wait()` raises `HttpTimeoutError`.
|
|
602
|
+
|
|
603
|
+
It also applies the server's `Keep-Alive` hint to an idle connection, though
|
|
604
|
+
the hint is honoured on reuse as well, so a plain `get().wait()` caller does
|
|
605
|
+
not have to schedule `maintenance()` for that alone.
|
|
579
606
|
|
|
580
607
|
#### Event-mode body methods
|
|
581
608
|
|
|
@@ -738,6 +765,30 @@ for i in range(10):
|
|
|
738
765
|
client.close()
|
|
739
766
|
```
|
|
740
767
|
|
|
768
|
+
### How it actually works
|
|
769
|
+
|
|
770
|
+
HTTP/1.1 keep-alive sends **nothing** over the wire while idle — it is only an
|
|
771
|
+
agreement not to close the socket after the response. (The thing that does send
|
|
772
|
+
idle probes is TCP `SO_KEEPALIVE`, a different layer handled by the kernel.)
|
|
773
|
+
Either side may close at any time without announcing it, so the client handles
|
|
774
|
+
it in three ways:
|
|
775
|
+
|
|
776
|
+
1. **The idle socket stays armed for reading.** On an idle HTTP/1.1 connection
|
|
777
|
+
the server must not send anything, so readability means the peer closed —
|
|
778
|
+
the client drops the socket right away instead of discovering it on the next
|
|
779
|
+
request. Needs a running loop (`wait()` or a shared selector).
|
|
780
|
+
2. **The `Keep-Alive: timeout=5, max=100` hint is honoured.** If the server
|
|
781
|
+
advertises its idle limit, `maintenance()` closes slightly before it (90% of
|
|
782
|
+
the advertised timeout), so a new request never races the server's close.
|
|
783
|
+
3. **One transparent replay.** If a *reused* connection dies before any
|
|
784
|
+
response byte arrives, the client reconnects and resends — once, and only
|
|
785
|
+
for idempotent methods (GET/HEAD/PUT/DELETE/OPTIONS/TRACE). A non-idempotent
|
|
786
|
+
request may already have been processed by the server, so it is reported
|
|
787
|
+
instead.
|
|
788
|
+
|
|
789
|
+
Together these mean an idle connection that the server recycles is normally
|
|
790
|
+
invisible to your code.
|
|
791
|
+
|
|
741
792
|
|
|
742
793
|
## Timeouts
|
|
743
794
|
|
|
@@ -777,21 +828,31 @@ Both `connect_timeout` and `timeout` are checked during connect/handshake phases
|
|
|
777
828
|
|
|
778
829
|
### Wait timeout
|
|
779
830
|
|
|
780
|
-
Time to spend in `wait()` call.
|
|
781
|
-
|
|
831
|
+
Time to spend in a single `wait()` call.
|
|
832
|
+
|
|
833
|
+
In **classic mode** an expired wait raises `HttpTimeoutError` and closes the
|
|
834
|
+
connection — it is not a poll. To interleave with other work, use event mode,
|
|
835
|
+
where `wait()` returns `None` on expiry and the request stays alive:
|
|
782
836
|
|
|
783
837
|
```python
|
|
784
838
|
import uhttp.client
|
|
839
|
+
from uhttp.client import EVENT_RESPONSE, EVENT_ERROR
|
|
785
840
|
|
|
786
|
-
client = uhttp.client.HttpClient(
|
|
841
|
+
client = uhttp.client.HttpClient(
|
|
842
|
+
'https://example.com', timeout=60, event_mode=True)
|
|
787
843
|
client.get('/slow')
|
|
788
844
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
845
|
+
while True:
|
|
846
|
+
event = client.wait(timeout=5) # None once per idle 5s slice
|
|
847
|
+
if event is None:
|
|
848
|
+
print("Still waiting, doing other work...")
|
|
849
|
+
continue
|
|
850
|
+
if event == EVENT_RESPONSE:
|
|
851
|
+
print(client.response.status)
|
|
852
|
+
break
|
|
853
|
+
if event == EVENT_ERROR:
|
|
854
|
+
print(client.error)
|
|
855
|
+
break
|
|
795
856
|
```
|
|
796
857
|
|
|
797
858
|
|
|
@@ -832,7 +893,7 @@ MAX_RESPONSE_LENGTH = 1MB
|
|
|
832
893
|
See [examples/](../examples/) directory:
|
|
833
894
|
- `client_basic.py` - Basic blocking examples
|
|
834
895
|
- `client_https.py` - HTTPS examples
|
|
835
|
-
- `client_async.py` - Async
|
|
896
|
+
- `client_async.py` - Async selector loop examples (incl. shared selector)
|
|
836
897
|
- `client_stream.py` - Event-mode streaming (download-to-file, chunks, NDJSON)
|
|
837
898
|
- `client_with_server.py` - Combined server + client examples
|
|
838
899
|
|