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.
Files changed (40) hide show
  1. uhttp_client-2.3.1/.github/workflows/publish.yml +38 -0
  2. uhttp_client-2.3.1/.github/workflows/tests.yml +87 -0
  3. uhttp_client-2.3.1/.gitignore +10 -0
  4. {uhttp_client-2.2.1/uhttp_client.egg-info → uhttp_client-2.3.1}/PKG-INFO +335 -21
  5. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/README.md +334 -20
  6. uhttp_client-2.3.1/examples/client_async.py +143 -0
  7. uhttp_client-2.3.1/examples/client_basic.py +117 -0
  8. uhttp_client-2.3.1/examples/client_https.py +74 -0
  9. uhttp_client-2.3.1/examples/client_stream.py +126 -0
  10. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/pyproject.toml +5 -2
  11. uhttp_client-2.3.1/tests/__init__.py +0 -0
  12. uhttp_client-2.3.1/tests/test_100_continue.py +245 -0
  13. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_async.py +3 -1
  14. uhttp_client-2.3.1/tests/test_body_framing.py +135 -0
  15. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_errors.py +4 -5
  16. uhttp_client-2.3.1/tests/test_event_mode.py +450 -0
  17. uhttp_client-2.3.1/tests/test_http_compliance.py +278 -0
  18. uhttp_client-2.3.1/tests/test_integration.py +536 -0
  19. uhttp_client-2.3.1/tests/test_mpy_integration.py +418 -0
  20. uhttp_client-2.3.1/tests/test_nonblocking_connect.py +414 -0
  21. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_unit.py +128 -0
  22. uhttp_client-2.3.1/uhttp/client.py +1806 -0
  23. {uhttp_client-2.2.1 → uhttp_client-2.3.1/uhttp_client.egg-info}/PKG-INFO +335 -21
  24. uhttp_client-2.3.1/uhttp_client.egg-info/SOURCES.txt +35 -0
  25. uhttp_client-2.3.1/uhttp_client.egg-info/scm_file_list.json +32 -0
  26. uhttp_client-2.3.1/uhttp_client.egg-info/scm_version.json +8 -0
  27. uhttp_client-2.2.1/tests/test_integration.py +0 -260
  28. uhttp_client-2.2.1/uhttp/client.py +0 -851
  29. uhttp_client-2.2.1/uhttp_client.egg-info/SOURCES.txt +0 -19
  30. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/LICENSE +0 -0
  31. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/setup.cfg +0 -0
  32. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_auth.py +0 -0
  33. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_basic.py +0 -0
  34. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_cli.py +0 -0
  35. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_cookies.py +0 -0
  36. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/tests/test_keepalive.py +0 -0
  37. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp/cli.py +0 -0
  38. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp_client.egg-info/dependency_links.txt +0 -0
  39. {uhttp_client-2.2.1 → uhttp_client-2.3.1}/uhttp_client.egg-info/entry_points.txt +0 -0
  40. {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
@@ -0,0 +1,10 @@
1
+ *.egg-info
2
+ __pycache__
3
+ *.pyc
4
+ .*
5
+ !.gitignore
6
+ !.github
7
+ !.mpyproject
8
+ *.pem
9
+ *.md
10
+ !README*.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-client
3
- Version: 2.2.1
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
- - Select-based async (no async/await, no threading)
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
- Default mode is async. Use with external select loop:
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
- # Wait for all
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 response size (default: 1MB)
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 socket is connected
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). Returns `HttpResponse` when complete.
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. Returns `HttpResponse` when complete, `None` otherwise.
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
- - First processes any ready data, then checks request timeout.
361
- - Raises `HttpTimeoutError` if request timeout has expired and no complete response.
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
- Two types of timeouts:
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 (Ubuntu + Windows, Python 3.10 + 3.14).
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