uhttp-client 2.2.1__tar.gz → 2.3.1__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.1/.github/workflows/publish.yml +38 -0
- uhttp_client-2.3.1/.github/workflows/tests.yml +87 -0
- uhttp_client-2.3.1/.gitignore +10 -0
- {uhttp_client-2.2.1/uhttp_client.egg-info → uhttp_client-2.3.1}/PKG-INFO +335 -21
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/README.md +334 -20
- uhttp_client-2.3.1/examples/client_async.py +143 -0
- uhttp_client-2.3.1/examples/client_basic.py +117 -0
- uhttp_client-2.3.1/examples/client_https.py +74 -0
- uhttp_client-2.3.1/examples/client_stream.py +126 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/pyproject.toml +5 -2
- uhttp_client-2.3.1/tests/__init__.py +0 -0
- uhttp_client-2.3.1/tests/test_100_continue.py +245 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_async.py +3 -1
- uhttp_client-2.3.1/tests/test_body_framing.py +135 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_errors.py +4 -5
- uhttp_client-2.3.1/tests/test_event_mode.py +450 -0
- uhttp_client-2.3.1/tests/test_http_compliance.py +278 -0
- uhttp_client-2.3.1/tests/test_integration.py +536 -0
- uhttp_client-2.3.1/tests/test_mpy_integration.py +418 -0
- uhttp_client-2.3.1/tests/test_nonblocking_connect.py +414 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_unit.py +128 -0
- uhttp_client-2.3.1/uhttp/client.py +1806 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1/uhttp_client.egg-info}/PKG-INFO +335 -21
- uhttp_client-2.3.1/uhttp_client.egg-info/SOURCES.txt +35 -0
- uhttp_client-2.3.1/uhttp_client.egg-info/scm_file_list.json +32 -0
- uhttp_client-2.3.1/uhttp_client.egg-info/scm_version.json +8 -0
- uhttp_client-2.2.1/tests/test_integration.py +0 -260
- uhttp_client-2.2.1/uhttp/client.py +0 -851
- uhttp_client-2.2.1/uhttp_client.egg-info/SOURCES.txt +0 -19
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/LICENSE +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/setup.cfg +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_auth.py +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_basic.py +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_cli.py +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_cookies.py +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_keepalive.py +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp/cli.py +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp_client.egg-info/dependency_links.txt +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp_client.egg-info/entry_points.txt +0 -0
- {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp_client.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.14"
|
|
21
|
+
|
|
22
|
+
- name: Install build dependencies
|
|
23
|
+
run: pip install build
|
|
24
|
+
|
|
25
|
+
- name: Install package
|
|
26
|
+
run: pip install .
|
|
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
|
+
- name: Build package
|
|
35
|
+
run: python -m build
|
|
36
|
+
|
|
37
|
+
- name: Publish to PyPI
|
|
38
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -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: 2.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: Micro HTTP client for Python and MicroPython
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -21,13 +21,17 @@ Dynamic: license-file
|
|
|
21
21
|
## Features
|
|
22
22
|
|
|
23
23
|
- MicroPython and CPython compatible
|
|
24
|
-
-
|
|
24
|
+
- Fully non-blocking: TCP connect, SSL handshake, and HTTP I/O via select
|
|
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
|
|
28
28
|
- Base path support for API versioning
|
|
29
29
|
- JSON support (auto-encode request, lazy decode response)
|
|
30
30
|
- Binary data support
|
|
31
|
+
- Chunked transfer encoding (and `Content-Length`) response decoding
|
|
32
|
+
- Streaming event mode (`event_mode=True`) mirroring uhttp-server: `EVENT_*` +
|
|
33
|
+
`accept_body*()` — stream to memory, file, or NDJSON records
|
|
34
|
+
- Read-until-close responses (`stream=True`) for MJPEG / SSE
|
|
31
35
|
- Cookies persistence
|
|
32
36
|
- HTTP Basic and Digest authentication
|
|
33
37
|
- SSL/TLS support for HTTPS
|
|
@@ -160,7 +164,7 @@ client.close()
|
|
|
160
164
|
|
|
161
165
|
## Async (non-blocking) mode
|
|
162
166
|
|
|
163
|
-
|
|
167
|
+
Everything is non-blocking by default — TCP connect, SSL handshake, and HTTP I/O all happen through `select()`. This is critical for embedded devices on slow networks (4G modems, ESP32 PPP) where each phase can take seconds.
|
|
164
168
|
|
|
165
169
|
```python
|
|
166
170
|
import select
|
|
@@ -168,10 +172,10 @@ import uhttp.client
|
|
|
168
172
|
|
|
169
173
|
client = uhttp.client.HttpClient('http://httpbin.org')
|
|
170
174
|
|
|
171
|
-
# Start request (non-blocking)
|
|
175
|
+
# Start request (non-blocking, including connect)
|
|
172
176
|
client.get('/delay/2')
|
|
173
177
|
|
|
174
|
-
# Manual select loop
|
|
178
|
+
# Manual select loop - handles connect, send, and receive
|
|
175
179
|
while True:
|
|
176
180
|
r, w, _ = select.select(
|
|
177
181
|
client.read_sockets,
|
|
@@ -187,8 +191,25 @@ while True:
|
|
|
187
191
|
client.close()
|
|
188
192
|
```
|
|
189
193
|
|
|
194
|
+
### State machine
|
|
195
|
+
|
|
196
|
+
After `client.get('/path')`, the client progresses through states automatically via `process_events()`:
|
|
197
|
+
|
|
198
|
+
| State | Description | select watches |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| `STATE_CONNECTING` | TCP connect in progress | write |
|
|
201
|
+
| `STATE_SSL_HANDSHAKE` | SSL handshake in progress | read or write |
|
|
202
|
+
| `STATE_SENDING` | Sending request data | write |
|
|
203
|
+
| `STATE_RECEIVING_HEADERS` | Waiting for response headers | read |
|
|
204
|
+
| `STATE_RECEIVING_BODY` | Receiving response body | read |
|
|
205
|
+
| `STATE_COMPLETE` | Response ready | — |
|
|
206
|
+
|
|
207
|
+
The `state` property exposes the current state. The `is_connected` property returns `True` only after connect and handshake are complete.
|
|
208
|
+
|
|
190
209
|
### Parallel requests
|
|
191
210
|
|
|
211
|
+
All clients share one select loop. Connect, handshake, and data transfer happen concurrently:
|
|
212
|
+
|
|
192
213
|
```python
|
|
193
214
|
import select
|
|
194
215
|
import uhttp.client
|
|
@@ -199,11 +220,11 @@ clients = [
|
|
|
199
220
|
uhttp.client.HttpClient('http://httpbin.org'),
|
|
200
221
|
]
|
|
201
222
|
|
|
202
|
-
# Start all requests
|
|
223
|
+
# Start all requests (non-blocking connects begin immediately)
|
|
203
224
|
for i, client in enumerate(clients):
|
|
204
225
|
client.get('/delay/1', query={'n': i})
|
|
205
226
|
|
|
206
|
-
#
|
|
227
|
+
# Single select loop handles all clients
|
|
207
228
|
results = {}
|
|
208
229
|
while len(results) < len(clients):
|
|
209
230
|
read_socks = []
|
|
@@ -226,6 +247,8 @@ for client in clients:
|
|
|
226
247
|
|
|
227
248
|
### Combined with HttpServer
|
|
228
249
|
|
|
250
|
+
Server and client in the same select loop — true single-threaded concurrency:
|
|
251
|
+
|
|
229
252
|
```python
|
|
230
253
|
import select
|
|
231
254
|
import uhttp.server
|
|
@@ -252,6 +275,178 @@ while True:
|
|
|
252
275
|
incoming.respond(data=response.data)
|
|
253
276
|
```
|
|
254
277
|
|
|
278
|
+
### HTTPS with non-blocking handshake
|
|
279
|
+
|
|
280
|
+
SSL handshake is also non-blocking. The client tracks whether `do_handshake()` needs to read or write, and exposes the socket only in the correct direction to prevent `select()` from spinning:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
import select
|
|
284
|
+
import ssl
|
|
285
|
+
import uhttp.client
|
|
286
|
+
|
|
287
|
+
ctx = ssl.create_default_context()
|
|
288
|
+
client = uhttp.client.HttpClient(
|
|
289
|
+
'api.example.com', port=443, ssl_context=ctx)
|
|
290
|
+
|
|
291
|
+
# Connect + SSL handshake + request all happen via select
|
|
292
|
+
client.get('/data')
|
|
293
|
+
|
|
294
|
+
while True:
|
|
295
|
+
r, w, _ = select.select(
|
|
296
|
+
client.read_sockets,
|
|
297
|
+
client.write_sockets,
|
|
298
|
+
[], 10.0
|
|
299
|
+
)
|
|
300
|
+
response = client.process_events(r, w)
|
|
301
|
+
if response:
|
|
302
|
+
print(response.json())
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
client.close()
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Multiple HTTPS clients in parallel
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
import select
|
|
312
|
+
import uhttp.client
|
|
313
|
+
|
|
314
|
+
urls = [
|
|
315
|
+
'https://api1.example.com/data',
|
|
316
|
+
'https://api2.example.com/data',
|
|
317
|
+
'https://api3.example.com/data',
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
clients = [uhttp.client.HttpClient(url) for url in urls]
|
|
321
|
+
for c in clients:
|
|
322
|
+
c.get('/') # All start non-blocking connects + SSL handshakes
|
|
323
|
+
|
|
324
|
+
responses = [None] * len(clients)
|
|
325
|
+
while not all(responses):
|
|
326
|
+
read_socks = []
|
|
327
|
+
write_socks = []
|
|
328
|
+
for c in clients:
|
|
329
|
+
read_socks.extend(c.read_sockets)
|
|
330
|
+
write_socks.extend(c.write_sockets)
|
|
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
|
|
339
|
+
|
|
340
|
+
for c in clients:
|
|
341
|
+
c.close()
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
## Streaming & Event Mode
|
|
346
|
+
|
|
347
|
+
For large or open-ended responses (downloads, NDJSON, MJPEG, SSE) the client
|
|
348
|
+
offers an **event mode** that mirrors uhttp-server's `HttpConnection` API.
|
|
349
|
+
With `event_mode=True`, `wait()` / `process_events()` return `EVENT_*`
|
|
350
|
+
constants instead of an `HttpResponse`, and you choose how the body is
|
|
351
|
+
delivered after the headers arrive.
|
|
352
|
+
|
|
353
|
+
### Events
|
|
354
|
+
|
|
355
|
+
| Event | Meaning |
|
|
356
|
+
|---|---|
|
|
357
|
+
| `EVENT_RESPONSE` | Complete response (headers + body) in one step — small/buffered |
|
|
358
|
+
| `EVENT_HEADERS` | Headers ready → call an `accept_body*()` variant |
|
|
359
|
+
| `EVENT_DATA` | One decoded chunk/record ready → `read_buffer()` / `read_record()` |
|
|
360
|
+
| `EVENT_COMPLETE` | Body fully received |
|
|
361
|
+
| `EVENT_ERROR` | Connection or decode error → message in `client.error` (no exception) |
|
|
362
|
+
|
|
363
|
+
Names and numeric values match uhttp-server, so the same select loop can drive
|
|
364
|
+
both a server and a client.
|
|
365
|
+
|
|
366
|
+
### Body delivery (choose after `EVENT_HEADERS`)
|
|
367
|
+
|
|
368
|
+
- `accept_body()` — buffer the whole body → `EVENT_COMPLETE`; read it as a full
|
|
369
|
+
`HttpResponse` via `client.response` (so `.json()` / `.data` are reused)
|
|
370
|
+
- `accept_body_streaming()` — `EVENT_DATA` per chunk; `read_buffer()` → bytes
|
|
371
|
+
- `accept_body_to_file(path)` — stream the body to disk (low RAM, no `EVENT_DATA`)
|
|
372
|
+
- `accept_ndjson()` — `EVENT_DATA` per record; `read_record()` → decoded object
|
|
373
|
+
|
|
374
|
+
The *event* tells you the phase; the *decoder* (which `accept_*()` you call)
|
|
375
|
+
tells you the shape of what you read — so new formats add an `accept_*()`, not a
|
|
376
|
+
new event type.
|
|
377
|
+
|
|
378
|
+
### NDJSON streaming
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
import select
|
|
382
|
+
from uhttp.client import (
|
|
383
|
+
HttpClient, EVENT_HEADERS, EVENT_DATA, EVENT_COMPLETE, EVENT_ERROR)
|
|
384
|
+
|
|
385
|
+
client = HttpClient('http://api.example.com', event_mode=True)
|
|
386
|
+
client.get('/events.ndjson', stream=True) # stream until close if unframed
|
|
387
|
+
|
|
388
|
+
while True:
|
|
389
|
+
r, w, _ = select.select(client.read_sockets, client.write_sockets, [], 30)
|
|
390
|
+
event = client.process_events(r, w)
|
|
391
|
+
|
|
392
|
+
if event == EVENT_HEADERS:
|
|
393
|
+
client.accept_ndjson()
|
|
394
|
+
elif event == EVENT_DATA:
|
|
395
|
+
record = client.read_record() # already a decoded object
|
|
396
|
+
handle(record)
|
|
397
|
+
elif event == EVENT_COMPLETE:
|
|
398
|
+
break
|
|
399
|
+
elif event == EVENT_ERROR:
|
|
400
|
+
print(client.error)
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
client.close()
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
A line that fails to JSON-decode is reported as `EVENT_ERROR` (with the message
|
|
407
|
+
in `client.error`) only **after** the good records before it have been
|
|
408
|
+
delivered. The client never closes the connection on its own — you decide.
|
|
409
|
+
|
|
410
|
+
### Download to file (low RAM)
|
|
411
|
+
|
|
412
|
+
```python
|
|
413
|
+
client = HttpClient('http://example.com', event_mode=True)
|
|
414
|
+
client.get('/firmware.bin')
|
|
415
|
+
|
|
416
|
+
while True:
|
|
417
|
+
r, w, _ = select.select(client.read_sockets, client.write_sockets, [], 10)
|
|
418
|
+
event = client.process_events(r, w)
|
|
419
|
+
if event == EVENT_HEADERS:
|
|
420
|
+
client.accept_body_to_file('/sd/firmware.bin')
|
|
421
|
+
elif event == EVENT_COMPLETE:
|
|
422
|
+
print('written', client.bytes_received, 'bytes')
|
|
423
|
+
break
|
|
424
|
+
elif event == EVENT_ERROR:
|
|
425
|
+
print(client.error)
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
client.close()
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Read until close (MJPEG / SSE)
|
|
432
|
+
|
|
433
|
+
`stream=True` selects a close-delimited body reader when the response has
|
|
434
|
+
neither `Content-Length` nor chunked encoding — the body is read until the
|
|
435
|
+
server closes the connection. (Such a connection cannot be kept alive.)
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
client = HttpClient('http://cam.local', event_mode=True)
|
|
439
|
+
client.get('/stream.mjpeg', stream=True)
|
|
440
|
+
# ... EVENT_HEADERS → accept_body_streaming() → EVENT_DATA loop ...
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Blocking mode still works
|
|
444
|
+
|
|
445
|
+
Small responses don't need any of this — in event mode they arrive as a single
|
|
446
|
+
`EVENT_RESPONSE` with the full body in `client.response`. And with the default
|
|
447
|
+
`event_mode=False`, `wait()` returns an `HttpResponse` exactly as before;
|
|
448
|
+
chunked decoding works transparently there too.
|
|
449
|
+
|
|
255
450
|
|
|
256
451
|
## API
|
|
257
452
|
|
|
@@ -280,7 +475,7 @@ uhttp.client.parse_url('example.com')
|
|
|
280
475
|
|
|
281
476
|
### Class `HttpClient`
|
|
282
477
|
|
|
283
|
-
**`uhttp.client.HttpClient(url_or_host, port=None, ssl_context=None, auth=None, connect_timeout=10, timeout=30, max_response_length=1MB)`**
|
|
478
|
+
**`uhttp.client.HttpClient(url_or_host, port=None, ssl_context=None, auth=None, connect_timeout=10, timeout=30, max_response_length=1MB, event_mode=False)`**
|
|
284
479
|
|
|
285
480
|
Can be initialized with URL or host/port:
|
|
286
481
|
|
|
@@ -304,23 +499,37 @@ Parameters:
|
|
|
304
499
|
- `auth` - Optional (username, password) tuple for HTTP authentication
|
|
305
500
|
- `connect_timeout` - Connection timeout in seconds (default: 10)
|
|
306
501
|
- `timeout` - Response timeout in seconds (default: 30)
|
|
307
|
-
- `max_response_length` - Maximum
|
|
502
|
+
- `max_response_length` - Maximum buffered body size (default: 1MB)
|
|
503
|
+
- `event_mode` - If `True`, `wait()`/`process_events()` return `EVENT_*`
|
|
504
|
+
constants instead of `HttpResponse` (see [Streaming & Event Mode](#streaming--event-mode))
|
|
308
505
|
|
|
309
506
|
#### Properties
|
|
310
507
|
|
|
311
508
|
- `host` - Server hostname
|
|
312
509
|
- `port` - Server port
|
|
313
510
|
- `base_path` - Base path from URL (prepended to all request paths)
|
|
314
|
-
- `is_connected` - True if
|
|
315
|
-
- `state` - Current state (STATE_IDLE, STATE_SENDING, etc.)
|
|
511
|
+
- `is_connected` - True if TCP (and SSL) connection is fully established
|
|
512
|
+
- `state` - Current state (STATE_IDLE, STATE_CONNECTING, STATE_SSL_HANDSHAKE, STATE_SENDING, etc.)
|
|
316
513
|
- `auth` - Authentication credentials tuple (username, password) or None
|
|
317
514
|
- `cookies` - Cookies dict (persistent across requests)
|
|
318
515
|
- `read_sockets` - Sockets to monitor for reading (for select)
|
|
319
516
|
- `write_sockets` - Sockets to monitor for writing (for select)
|
|
320
517
|
|
|
518
|
+
Event-mode properties (available once headers are received):
|
|
519
|
+
|
|
520
|
+
- `event` - Last `EVENT_*` constant returned
|
|
521
|
+
- `error` - Error message when the last event was `EVENT_ERROR`
|
|
522
|
+
- `status` - Response status code (int)
|
|
523
|
+
- `status_message` - Response status message (str)
|
|
524
|
+
- `headers` - Response headers dict (keys lowercase)
|
|
525
|
+
- `content_type` - Response Content-Type
|
|
526
|
+
- `content_length` - Response Content-Length, or `None` if unknown
|
|
527
|
+
- `bytes_received` - Decoded body bytes received so far
|
|
528
|
+
- `response` - Completed `HttpResponse` (after `EVENT_RESPONSE` or buffered `EVENT_COMPLETE`)
|
|
529
|
+
|
|
321
530
|
#### Methods
|
|
322
531
|
|
|
323
|
-
**`request(method, path, headers=None, data=None, query=None, json=None, auth=None, timeout=None)`**
|
|
532
|
+
**`request(method, path, headers=None, data=None, query=None, json=None, auth=None, timeout=None, expect_continue=False, stream=False)`**
|
|
324
533
|
|
|
325
534
|
Start HTTP request (async). Returns `self` for chaining.
|
|
326
535
|
|
|
@@ -332,6 +541,8 @@ Start HTTP request (async). Returns `self` for chaining.
|
|
|
332
541
|
- `json` - Shortcut for data with JSON encoding
|
|
333
542
|
- `auth` - Optional (username, password) tuple, overrides client's default auth
|
|
334
543
|
- `timeout` - Optional timeout in seconds, overrides client's default timeout
|
|
544
|
+
- `expect_continue` - Send `Expect: 100-continue` header and wait for server confirmation before sending body (default: False)
|
|
545
|
+
- `stream` - Read a response without `Content-Length`/chunked framing until the server closes the connection (MJPEG, SSE); default: False
|
|
335
546
|
|
|
336
547
|
**`get(path, **kwargs)`** - Send GET request
|
|
337
548
|
|
|
@@ -347,18 +558,45 @@ Start HTTP request (async). Returns `self` for chaining.
|
|
|
347
558
|
|
|
348
559
|
**`wait(timeout=None)`**
|
|
349
560
|
|
|
350
|
-
Wait for response (blocking).
|
|
561
|
+
Wait for response (blocking).
|
|
351
562
|
|
|
563
|
+
- Classic mode: returns `HttpResponse` when complete; raises `HttpTimeoutError`
|
|
564
|
+
if the request timeout expires; returns `None` if the wait timeout expires
|
|
565
|
+
(connection stays open, can call again).
|
|
566
|
+
- Event mode: returns the next `EVENT_*` constant, or `None` when the wait
|
|
567
|
+
timeout expires with nothing new.
|
|
352
568
|
- `timeout` - Max time to spend in wait() call. If `None`, uses request timeout.
|
|
353
|
-
- Returns `None` if wait timeout expires (connection stays open, can call again).
|
|
354
|
-
- Raises `HttpTimeoutError` if request timeout expires (connection closed).
|
|
355
569
|
|
|
356
570
|
**`process_events(read_sockets, write_sockets)`**
|
|
357
571
|
|
|
358
|
-
Process select events
|
|
572
|
+
Process select events from an external select loop.
|
|
573
|
+
|
|
574
|
+
- Classic mode: returns `HttpResponse` when complete (`None` otherwise); raises
|
|
575
|
+
on errors.
|
|
576
|
+
- Event mode: returns an `EVENT_*` constant (`None` when nothing new yet);
|
|
577
|
+
connection/decode errors surface as `EVENT_ERROR` with the message in
|
|
578
|
+
`client.error`.
|
|
359
579
|
|
|
360
|
-
-
|
|
361
|
-
|
|
580
|
+
#### Event-mode body methods
|
|
581
|
+
|
|
582
|
+
Call one of these after `EVENT_HEADERS` to choose how the body is delivered
|
|
583
|
+
(see [Streaming & Event Mode](#streaming--event-mode)):
|
|
584
|
+
|
|
585
|
+
**`accept_body()`** - Buffer the whole body, then emit `EVENT_COMPLETE`; read
|
|
586
|
+
via the `response` property.
|
|
587
|
+
|
|
588
|
+
**`accept_body_streaming()`** - Emit `EVENT_DATA` per decoded chunk; read bytes
|
|
589
|
+
via `read_buffer()`.
|
|
590
|
+
|
|
591
|
+
**`accept_body_to_file(path)`** - Stream the decoded body to a file, then emit
|
|
592
|
+
`EVENT_COMPLETE`.
|
|
593
|
+
|
|
594
|
+
**`accept_ndjson()`** - Decode newline-delimited JSON; emit `EVENT_DATA` per
|
|
595
|
+
record; read decoded objects via `read_record()`.
|
|
596
|
+
|
|
597
|
+
**`read_buffer()`** - Return decoded body bytes buffered so far, or `None`.
|
|
598
|
+
|
|
599
|
+
**`read_record()`** - Return the next decoded NDJSON record, or `None`.
|
|
362
600
|
|
|
363
601
|
**`close()`**
|
|
364
602
|
|
|
@@ -431,6 +669,34 @@ Supported digest features:
|
|
|
431
669
|
- Nonce counting for multiple requests
|
|
432
670
|
|
|
433
671
|
|
|
672
|
+
## Expect: 100-continue
|
|
673
|
+
|
|
674
|
+
For large uploads, use `expect_continue=True` to wait for server confirmation before sending the body. This saves bandwidth when the server rejects the request (e.g., 413 Too Large, 401 Unauthorized):
|
|
675
|
+
|
|
676
|
+
```python
|
|
677
|
+
import uhttp.client
|
|
678
|
+
|
|
679
|
+
client = uhttp.client.HttpClient('https://api.example.com')
|
|
680
|
+
|
|
681
|
+
# Large file upload with expect_continue
|
|
682
|
+
large_data = b'x' * 10_000_000 # 10 MB
|
|
683
|
+
response = client.post('/upload', data=large_data, expect_continue=True).wait()
|
|
684
|
+
|
|
685
|
+
if response.status == 413:
|
|
686
|
+
print("Server rejected - body was NOT sent (bandwidth saved)")
|
|
687
|
+
else:
|
|
688
|
+
print(f"Upload complete: {response.status}")
|
|
689
|
+
|
|
690
|
+
client.close()
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
How it works:
|
|
694
|
+
1. Client sends headers with `Expect: 100-continue`
|
|
695
|
+
2. Waits for server response
|
|
696
|
+
3. If server sends `100 Continue` → sends body → waits for final response
|
|
697
|
+
4. If server sends other status (413, 401, etc.) → returns that response (body not sent)
|
|
698
|
+
|
|
699
|
+
|
|
434
700
|
## Cookies
|
|
435
701
|
|
|
436
702
|
Cookies are automatically:
|
|
@@ -475,11 +741,26 @@ client.close()
|
|
|
475
741
|
|
|
476
742
|
## Timeouts
|
|
477
743
|
|
|
478
|
-
|
|
744
|
+
Three types of timeouts:
|
|
745
|
+
|
|
746
|
+
### Connect timeout
|
|
747
|
+
|
|
748
|
+
Time allowed for TCP connect + SSL handshake. Set via `connect_timeout` parameter (default: 10s).
|
|
749
|
+
When expired during connect/handshake phase, raises `HttpTimeoutError`.
|
|
750
|
+
|
|
751
|
+
```python
|
|
752
|
+
import uhttp.client
|
|
753
|
+
|
|
754
|
+
# Short connect timeout for fast-fail on unreachable hosts
|
|
755
|
+
client = uhttp.client.HttpClient('https://example.com', connect_timeout=3)
|
|
756
|
+
|
|
757
|
+
# Long connect timeout for slow 4G/satellite links
|
|
758
|
+
client = uhttp.client.HttpClient('https://example.com', connect_timeout=30)
|
|
759
|
+
```
|
|
479
760
|
|
|
480
761
|
### Request timeout
|
|
481
762
|
|
|
482
|
-
Total time allowed for the request. Set via `timeout` parameter on client or per-request.
|
|
763
|
+
Total time allowed for the entire request (including connect). Set via `timeout` parameter on client or per-request.
|
|
483
764
|
When expired, raises `HttpTimeoutError` and closes connection.
|
|
484
765
|
|
|
485
766
|
```python
|
|
@@ -492,6 +773,8 @@ client = uhttp.client.HttpClient('https://example.com', timeout=30)
|
|
|
492
773
|
response = client.get('/slow', timeout=60).wait()
|
|
493
774
|
```
|
|
494
775
|
|
|
776
|
+
Both `connect_timeout` and `timeout` are checked during connect/handshake phases — whichever expires first triggers `HttpTimeoutError`.
|
|
777
|
+
|
|
495
778
|
### Wait timeout
|
|
496
779
|
|
|
497
780
|
Time to spend in `wait()` call. When expired, returns `None` but keeps connection open.
|
|
@@ -550,6 +833,7 @@ See [examples/](../examples/) directory:
|
|
|
550
833
|
- `client_basic.py` - Basic blocking examples
|
|
551
834
|
- `client_https.py` - HTTPS examples
|
|
552
835
|
- `client_async.py` - Async select loop examples
|
|
836
|
+
- `client_stream.py` - Event-mode streaming (download-to-file, chunks, NDJSON)
|
|
553
837
|
- `client_with_server.py` - Combined server + client examples
|
|
554
838
|
|
|
555
839
|
Run examples from project root:
|
|
@@ -661,6 +945,36 @@ client = uhttp.client.HttpClient('http://[::1]:8080')
|
|
|
661
945
|
|
|
662
946
|
For running tests from meta-repo, see [uhttp README](https://github.com/pavelrevak/uhttp#testing).
|
|
663
947
|
|
|
948
|
+
### MicroPython integration tests
|
|
949
|
+
|
|
950
|
+
Tests run on real ESP32 hardware via [mpytool](https://github.com/pavelrevak/mpytool).
|
|
951
|
+
|
|
952
|
+
**Configuration:**
|
|
953
|
+
|
|
954
|
+
1. WiFi credentials in `~/.config/uhttp/wifi.json`:
|
|
955
|
+
```json
|
|
956
|
+
{"ssid": "MyWiFi", "password": "secret"}
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
2. Serial port via environment variable or mpytool config:
|
|
960
|
+
```bash
|
|
961
|
+
# Environment variable
|
|
962
|
+
export MPY_TEST_PORT=/dev/ttyUSB0
|
|
963
|
+
|
|
964
|
+
# Or mpytool config
|
|
965
|
+
echo "/dev/ttyUSB0" > ~/.config/mpytool/ESP32
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
**Run tests:**
|
|
969
|
+
|
|
970
|
+
```bash
|
|
971
|
+
MPY_TEST_PORT=/dev/ttyUSB0 ../.venv/bin/python -m unittest tests.test_mpy_integration -v
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
**Note:** MicroPython requires explicit `ssl_context` for HTTPS connections.
|
|
975
|
+
|
|
664
976
|
### CI
|
|
665
977
|
|
|
666
|
-
Tests run automatically on push/PR via GitHub Actions
|
|
978
|
+
Tests run automatically on push/PR via GitHub Actions:
|
|
979
|
+
- Unit tests: Ubuntu + Windows, Python 3.10 + 3.14
|
|
980
|
+
- MicroPython tests: Self-hosted runner with ESP32
|