uhttp-client 2.3.0__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 (39) hide show
  1. uhttp_client-2.3.1/.github/workflows/tests.yml +87 -0
  2. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/PKG-INFO +1 -1
  3. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_event_mode.py +50 -0
  4. uhttp_client-2.3.1/tests/test_http_compliance.py +278 -0
  5. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp/client.py +143 -45
  6. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/PKG-INFO +1 -1
  7. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/SOURCES.txt +3 -1
  8. uhttp_client-2.3.1/uhttp_client.egg-info/scm_file_list.json +32 -0
  9. uhttp_client-2.3.1/uhttp_client.egg-info/scm_version.json +8 -0
  10. uhttp_client-2.3.0/.github/workflows/micropython.yml +0 -39
  11. uhttp_client-2.3.0/.github/workflows/tests.yml +0 -33
  12. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/.github/workflows/publish.yml +0 -0
  13. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/.gitignore +0 -0
  14. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/LICENSE +0 -0
  15. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/README.md +0 -0
  16. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_async.py +0 -0
  17. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_basic.py +0 -0
  18. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_https.py +0 -0
  19. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_stream.py +0 -0
  20. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/pyproject.toml +0 -0
  21. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/setup.cfg +0 -0
  22. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/__init__.py +0 -0
  23. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_100_continue.py +0 -0
  24. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_async.py +0 -0
  25. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_auth.py +0 -0
  26. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_basic.py +0 -0
  27. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_body_framing.py +0 -0
  28. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_cli.py +0 -0
  29. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_cookies.py +0 -0
  30. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_errors.py +0 -0
  31. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_integration.py +0 -0
  32. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_keepalive.py +0 -0
  33. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_mpy_integration.py +0 -0
  34. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_nonblocking_connect.py +0 -0
  35. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_unit.py +0 -0
  36. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp/cli.py +0 -0
  37. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/dependency_links.txt +0 -0
  38. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/entry_points.txt +0 -0
  39. {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/top_level.txt +0 -0
@@ -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.0
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
@@ -5,6 +5,7 @@ accept_body*() body delivery, and EVENT_ERROR surfacing.
5
5
 
6
6
  Uses raw TCP servers so we can produce exact framing and timing.
7
7
  """
8
+ import errno
8
9
  import os
9
10
  import socket
10
11
  import tempfile
@@ -311,6 +312,55 @@ class TestEventModeNdjson(unittest.TestCase):
311
312
  server.stop()
312
313
 
313
314
 
315
+ class _WindowsSelect:
316
+ """select() that rejects all-empty fd sets, the way Windows does.
317
+
318
+ POSIX happily waits on nothing; Windows needs at least one non-empty
319
+ set and answers WSAEINVAL (WinError 10022) otherwise. The client
320
+ reaches that call at STATE_COMPLETE, where read_sockets and
321
+ write_sockets are both empty but decoded records may still be queued.
322
+ """
323
+
324
+ def __init__(self, real):
325
+ self._real = real
326
+
327
+ def __call__(self, readers, writers, errors, timeout=None):
328
+ if not readers and not writers and not errors:
329
+ raise OSError(errno.EINVAL, 'An invalid argument was supplied')
330
+ return self._real(readers, writers, errors, timeout)
331
+
332
+
333
+ class TestNothingToSelectOn(unittest.TestCase):
334
+ """Buffered output must survive having no socket left to watch."""
335
+
336
+ def test_trailing_record_survives_windows_empty_select(self):
337
+ # One recv carries the body and the EOF, so both records are decoded
338
+ # at once and the second is still queued after the first EVENT_DATA.
339
+ # By then the socket is out of read_sockets/write_sockets.
340
+ server = RawServer([
341
+ b'HTTP/1.1 200 OK\r\n'
342
+ b'Content-Type: application/x-ndjson\r\n'
343
+ b'Connection: close\r\n\r\n'
344
+ b'{"x": 1}\n{"y": 2}'], delay=0.0, close=True)
345
+ real_select = uhttp_client._select.select
346
+ uhttp_client._select.select = _WindowsSelect(real_select)
347
+ records = []
348
+ try:
349
+ client = uhttp_client.HttpClient(
350
+ '127.0.0.1', port=server.port, event_mode=True)
351
+ client.get('/', stream=True)
352
+ events = drive(
353
+ client,
354
+ on_headers=lambda c: c.accept_ndjson(),
355
+ on_data=lambda c: records.append(c.read_record()))
356
+ self.assertNotIn(EVENT_ERROR, events, client.error)
357
+ self.assertEqual(records, [{'x': 1}, {'y': 2}])
358
+ client.close()
359
+ finally:
360
+ uhttp_client._select.select = real_select
361
+ server.stop()
362
+
363
+
314
364
  class _ResetSocket:
315
365
  """Fake socket whose recv() raises a given OSError (e.g. ECONNRESET)."""
316
366
 
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python3
2
+ """HTTP/1.1 conformance regressions found reviewing the client.
3
+
4
+ Self-contained fixture on purpose: this file is meant to rebase cleanly onto
5
+ branches that reorganise the shared test helpers.
6
+ """
7
+ import errno
8
+ import socket
9
+ import threading
10
+ import unittest
11
+
12
+ from uhttp import client as uhttp_client
13
+
14
+
15
+ class RawServer:
16
+ """Answers one connection with a fixed byte string."""
17
+
18
+ def __init__(self, response, host='127.0.0.1'):
19
+ self._response = response
20
+ self._sock = socket.socket()
21
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22
+ self._sock.bind((host, 0))
23
+ self._sock.listen(1)
24
+ self.port = self._sock.getsockname()[1]
25
+ self._thread = threading.Thread(target=self._serve, daemon=True)
26
+ self._thread.start()
27
+
28
+ def _serve(self):
29
+ try:
30
+ conn, _ = self._sock.accept()
31
+ conn.recv(4096)
32
+ conn.sendall(self._response)
33
+ try:
34
+ conn.shutdown(socket.SHUT_WR)
35
+ conn.settimeout(2.0)
36
+ while conn.recv(4096):
37
+ pass
38
+ except OSError:
39
+ pass
40
+ conn.close()
41
+ except OSError:
42
+ pass
43
+ finally:
44
+ self.stop()
45
+
46
+ def stop(self):
47
+ try:
48
+ self._sock.close()
49
+ except OSError:
50
+ pass
51
+
52
+
53
+ class TestBodylessResponses(unittest.TestCase):
54
+ """(9) HEAD / 204 / 304 never carry a body, whatever Content-Length says.
55
+
56
+ RFC 7230 3.3.3: the framing of those responses is fixed by the status or
57
+ the request method, so a Content-Length header must not be believed.
58
+ """
59
+
60
+ def _request(self, method, raw_response):
61
+ server = RawServer(raw_response)
62
+ try:
63
+ client = uhttp_client.HttpClient('127.0.0.1', port=server.port)
64
+ try:
65
+ return client.request(method, '/').wait(timeout=1)
66
+ finally:
67
+ client.close()
68
+ finally:
69
+ server.stop()
70
+
71
+ def test_head_with_content_length(self):
72
+ response = self._request(
73
+ 'HEAD',
74
+ b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n'
75
+ b'Connection: close\r\n\r\n')
76
+ self.assertEqual(response.status, 200)
77
+ self.assertEqual(response.data, b'')
78
+
79
+ def test_204_with_content_length(self):
80
+ response = self._request(
81
+ 'GET',
82
+ b'HTTP/1.1 204 No Content\r\nContent-Length: 5\r\n'
83
+ b'Connection: close\r\n\r\n')
84
+ self.assertEqual(response.status, 204)
85
+ self.assertEqual(response.data, b'')
86
+
87
+ def test_304_with_content_length(self):
88
+ response = self._request(
89
+ 'GET',
90
+ b'HTTP/1.1 304 Not Modified\r\nContent-Length: 5\r\n'
91
+ b'Connection: close\r\n\r\n')
92
+ self.assertEqual(response.status, 304)
93
+ self.assertEqual(response.data, b'')
94
+
95
+
96
+ # An address family no port implements, so socket() raises before any
97
+ # connect: family -1 does NOT do that - CPython's socket.__init__ maps -1
98
+ # to AF_INET, and the candidate then failed only because connect() to an
99
+ # IPv6 literal happens to raise at once on POSIX. On Windows it does not,
100
+ # so the client adopted the socket and sat in STATE_CONNECTING until the
101
+ # test's own timeout.
102
+ BAD_FAMILY = 12345
103
+
104
+
105
+ class TestAddressFallback(unittest.TestCase):
106
+ """(10) getaddrinfo returns several addresses; all must be tried."""
107
+
108
+ def test_unusable_first_address_falls_back(self):
109
+ server = RawServer(
110
+ b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n'
111
+ b'Connection: close\r\n\r\nok')
112
+ original = uhttp_client._socket.getaddrinfo
113
+
114
+ def two_addresses(host, port, *args, **kwargs):
115
+ real = original('127.0.0.1', port, *args, **kwargs)
116
+ # A family socket() rejects outright, then the working address.
117
+ return [
118
+ (BAD_FAMILY, socket.SOCK_STREAM, 0, '', ('::1', port)),
119
+ ] + list(real)
120
+
121
+ uhttp_client._socket.getaddrinfo = two_addresses
122
+ try:
123
+ client = uhttp_client.HttpClient('localhost', port=server.port)
124
+ response = client.get('/').wait(timeout=2)
125
+ self.assertEqual(response.data, b'ok')
126
+ client.close()
127
+ finally:
128
+ uhttp_client._socket.getaddrinfo = original
129
+ server.stop()
130
+
131
+ def test_all_addresses_failing_raises_connection_error(self):
132
+ original = uhttp_client._socket.getaddrinfo
133
+
134
+ def only_bad(host, port, *args, **kwargs):
135
+ return [
136
+ (BAD_FAMILY, socket.SOCK_STREAM, 0, '', ('::1', port)),
137
+ (BAD_FAMILY, socket.SOCK_STREAM, 0, '', ('127.0.0.1', port)),
138
+ ]
139
+
140
+ uhttp_client._socket.getaddrinfo = only_bad
141
+ try:
142
+ client = uhttp_client.HttpClient('localhost', port=9)
143
+ with self.assertRaises(uhttp_client.HttpConnectionError):
144
+ client.get('/').wait(timeout=2)
145
+ client.close()
146
+ finally:
147
+ uhttp_client._socket.getaddrinfo = original
148
+
149
+
150
+ class TestMalformedContentLength(unittest.TestCase):
151
+ """(11) A bad Content-Length is a response error, not a raw ValueError."""
152
+
153
+ def test_non_numeric_content_length(self):
154
+ server = RawServer(
155
+ b'HTTP/1.1 200 OK\r\nContent-Length: abc\r\n'
156
+ b'Connection: close\r\n\r\n')
157
+ try:
158
+ client = uhttp_client.HttpClient('127.0.0.1', port=server.port)
159
+ with self.assertRaises(uhttp_client.HttpResponseError):
160
+ client.get('/').wait(timeout=1)
161
+ # The state machine must not be left mid-request.
162
+ self.assertEqual(client.state, uhttp_client.STATE_IDLE)
163
+ client.close()
164
+ finally:
165
+ server.stop()
166
+
167
+ def test_client_is_reusable_after_a_bad_content_length(self):
168
+ server = RawServer(
169
+ b'HTTP/1.1 200 OK\r\nContent-Length: 1x\r\n'
170
+ b'Connection: close\r\n\r\n')
171
+ try:
172
+ client = uhttp_client.HttpClient('127.0.0.1', port=server.port)
173
+ with self.assertRaises(uhttp_client.HttpResponseError):
174
+ client.get('/').wait(timeout=1)
175
+ # Used to raise "Request already in progress" forever.
176
+ try:
177
+ client.get('/again')
178
+ except uhttp_client.HttpConnectionError:
179
+ pass # server is gone; only the wedged state matters here
180
+ client.close()
181
+ finally:
182
+ server.stop()
183
+
184
+
185
+ class TestHeaderOverride(unittest.TestCase):
186
+ """(12) A caller-supplied header replaces the default, whatever its case."""
187
+
188
+ def _header_lines(self, request, name):
189
+ prefix = name.lower().encode('ascii') + b':'
190
+ return [line for line in request.split(b'\r\n')
191
+ if line.lower().startswith(prefix)]
192
+
193
+ def test_canonical_case_host_is_not_duplicated(self):
194
+ client = uhttp_client.HttpClient('example.com')
195
+ try:
196
+ request = client._build_request(
197
+ 'GET', '/', {'Host': 'vhost.example'})
198
+ lines = self._header_lines(request, 'Host')
199
+ self.assertEqual(len(lines), 1, b' | '.join(lines).decode())
200
+ self.assertEqual(lines[0], b'Host: vhost.example')
201
+ finally:
202
+ client.close()
203
+
204
+ def test_canonical_case_user_agent_is_not_duplicated(self):
205
+ client = uhttp_client.HttpClient('example.com')
206
+ try:
207
+ request = client._build_request(
208
+ 'GET', '/', {'User-Agent': 'mine/1.0'})
209
+ self.assertEqual(len(self._header_lines(request, 'User-Agent')), 1)
210
+ finally:
211
+ client.close()
212
+
213
+ def test_canonical_case_content_type_is_not_duplicated(self):
214
+ client = uhttp_client.HttpClient('example.com')
215
+ try:
216
+ request = client._build_request(
217
+ 'POST', '/', {'Content-Type': 'text/plain'}, data=b'x')
218
+ self.assertEqual(
219
+ len(self._header_lines(request, 'Content-Type')), 1)
220
+ finally:
221
+ client.close()
222
+
223
+
224
+ class TestIpv6Url(unittest.TestCase):
225
+ """(13) Bracketed IPv6 literals are valid URLs (RFC 3986 3.2.2)."""
226
+
227
+ def test_ipv6_with_port(self):
228
+ host, port, path, use_ssl, auth = uhttp_client.parse_url(
229
+ 'http://[::1]:8080/x')
230
+ self.assertEqual(host, '::1')
231
+ self.assertEqual(port, 8080)
232
+ self.assertEqual(path, '/x')
233
+
234
+ def test_ipv6_without_port(self):
235
+ host, port, path, use_ssl, auth = uhttp_client.parse_url(
236
+ 'http://[::1]/x')
237
+ self.assertEqual(host, '::1')
238
+ self.assertEqual(port, 80)
239
+
240
+ def test_ipv6_https_default_port(self):
241
+ host, port, path, use_ssl, auth = uhttp_client.parse_url(
242
+ 'https://[2001:db8::1]/')
243
+ self.assertEqual(host, '2001:db8::1')
244
+ self.assertEqual(port, 443)
245
+ self.assertTrue(use_ssl)
246
+
247
+ def test_ipv6_with_auth(self):
248
+ host, port, path, use_ssl, auth = uhttp_client.parse_url(
249
+ 'http://user:pass@[::1]:8080/x')
250
+ self.assertEqual(host, '::1')
251
+ self.assertEqual(port, 8080)
252
+ self.assertEqual(auth, ('user', 'pass'))
253
+
254
+ def test_ipv6_client_sends_bare_host_header(self):
255
+ client = uhttp_client.HttpClient('http://[::1]:8080/')
256
+ try:
257
+ self.assertEqual(client.host, '::1')
258
+ self.assertEqual(client.port, 8080)
259
+ finally:
260
+ client.close()
261
+
262
+
263
+ class TestPortableErrno(unittest.TestCase):
264
+ """(14) Every site must use the EWOULDBLOCK fallback constant."""
265
+
266
+ def test_no_site_reads_errno_ewouldblock_directly(self):
267
+ with open(uhttp_client.__file__) as handle:
268
+ source = handle.read()
269
+ below_fallback = source.split('EWOULDBLOCK = getattr', 1)[1]
270
+ offenders = [line.strip() for line in below_fallback.splitlines()
271
+ if 'errno.EWOULDBLOCK' in line]
272
+ self.assertEqual(
273
+ offenders, [],
274
+ "some MicroPython ports do not define errno.EWOULDBLOCK")
275
+
276
+
277
+ if __name__ == '__main__':
278
+ unittest.main()
@@ -20,6 +20,28 @@ MB = 2 ** 20
20
20
  # because some MicroPython ports do not define EWOULDBLOCK.
21
21
  EWOULDBLOCK = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
22
22
 
23
+
24
+ def _errnos(*names):
25
+ """Collect the errno values a port actually defines"""
26
+ values = []
27
+ for name in names:
28
+ value = getattr(errno, name, None)
29
+ if value is not None and value not in values:
30
+ values.append(value)
31
+ return tuple(values)
32
+
33
+
34
+ # "No progress yet" rather than a failure: the non-blocking family plus
35
+ # ENOENT, which MicroPython raises while an SSL handshake is in flight.
36
+ WOULD_BLOCK_ERRNOS = _errnos(
37
+ 'EAGAIN', 'EWOULDBLOCK', 'EINPROGRESS', 'EALREADY', 'ENOENT')
38
+
39
+
40
+ def _would_block(err):
41
+ """True when an OSError only means the operation has not progressed"""
42
+ return err.errno in WOULD_BLOCK_ERRNOS
43
+
44
+
23
45
  CONNECT_TIMEOUT = 10
24
46
  TIMEOUT = 30
25
47
 
@@ -282,6 +304,17 @@ class _NdjsonDecoder(_RecordDecoder):
282
304
  return []
283
305
 
284
306
 
307
+ def _has_header(headers, name):
308
+ """HTTP field names are case-insensitive, so 'Host' must hide 'host'"""
309
+ if name in headers:
310
+ return True
311
+ lowered = name.lower()
312
+ for key in headers:
313
+ if key.lower() == lowered:
314
+ return True
315
+ return False
316
+
317
+
285
318
  def _parse_header_line(line):
286
319
  try:
287
320
  line = line.decode('ascii')
@@ -326,16 +359,35 @@ def parse_url(url):
326
359
  else:
327
360
  auth = (auth_part, '')
328
361
 
329
- if ':' in host_port:
362
+ # A bracketed IPv6 literal (RFC 3986 3.2.2) is full of colons, so the
363
+ # port can only be what follows the closing bracket.
364
+ port = None
365
+ if host_port.startswith('['):
366
+ end = host_port.find(']')
367
+ if end == -1:
368
+ raise HttpClientError(f"Unterminated IPv6 address: {host_port}")
369
+ host = host_port[1:end]
370
+ rest = host_port[end + 1:]
371
+ if rest.startswith(':'):
372
+ port = _parse_port(rest[1:])
373
+ elif ':' in host_port:
330
374
  host, port_str = host_port.rsplit(':', 1)
331
- port = int(port_str)
375
+ port = _parse_port(port_str)
332
376
  else:
333
377
  host = host_port
378
+ if port is None:
334
379
  port = 443 if ssl else 80
335
380
 
336
381
  return host, port, path, ssl, auth
337
382
 
338
383
 
384
+ def _parse_port(value):
385
+ try:
386
+ return int(value)
387
+ except ValueError as err:
388
+ raise HttpClientError(f"Invalid port: {value}") from err
389
+
390
+
339
391
  def _encode_query(query):
340
392
  if not query:
341
393
  return ''
@@ -356,12 +408,12 @@ def _encode_request_data(data, headers):
356
408
  return None
357
409
  if isinstance(data, (dict, list, tuple)):
358
410
  data = _json.dumps(data).encode('ascii')
359
- if CONTENT_TYPE not in headers:
411
+ if not _has_header(headers, CONTENT_TYPE):
360
412
  headers[CONTENT_TYPE] = CONTENT_TYPE_JSON
361
413
  elif isinstance(data, str):
362
414
  data = data.encode('utf-8')
363
415
  elif isinstance(data, (bytes, bytearray, memoryview)):
364
- if CONTENT_TYPE not in headers:
416
+ if not _has_header(headers, CONTENT_TYPE):
365
417
  headers[CONTENT_TYPE] = CONTENT_TYPE_OCTET_STREAM
366
418
  else:
367
419
  raise HttpClientError(f"Unsupported data type: {type(data).__name__}")
@@ -738,16 +790,16 @@ class HttpClient:
738
790
 
739
791
  full_path = path + _encode_query(query)
740
792
 
741
- if HOST not in headers:
793
+ if not _has_header(headers, HOST):
742
794
  if self._port == 80 or (self._ssl_context and self._port == 443):
743
795
  headers[HOST] = self._host
744
796
  else:
745
797
  headers[HOST] = f"{self._host}:{self._port}"
746
798
 
747
- if USER_AGENT not in headers:
799
+ if not _has_header(headers, USER_AGENT):
748
800
  headers[USER_AGENT] = USER_AGENT_VALUE
749
801
 
750
- if encoded_data:
802
+ if encoded_data and not _has_header(headers, CONTENT_LENGTH):
751
803
  headers[CONTENT_LENGTH] = len(encoded_data)
752
804
 
753
805
  # Add Expect: 100-continue header if requested and there's data to send
@@ -761,7 +813,7 @@ class HttpClient:
761
813
 
762
814
  # Use request-specific auth if set, otherwise client's default
763
815
  auth = self._request_auth if self._request_auth is not None else self._auth
764
- if auth and AUTHORIZATION not in headers:
816
+ if auth and not _has_header(headers, AUTHORIZATION):
765
817
  if self._digest_params:
766
818
  # Digest auth
767
819
  self._digest_nc += 1
@@ -813,31 +865,44 @@ class HttpClient:
813
865
  try:
814
866
  addr_info = _socket.getaddrinfo(
815
867
  self._host, self._port, 0, _socket.SOCK_STREAM)
816
- if not addr_info:
817
- raise HttpConnectionError(
818
- f"Cannot resolve host: {self._host}")
819
- family, socktype, proto, _, addr = addr_info[0]
820
- sock = _socket.socket(family, socktype, proto)
821
- sock.setblocking(False)
868
+ except OSError as err:
869
+ raise HttpConnectionError(f"Connect failed: {err}") from err
870
+ if not addr_info:
871
+ raise HttpConnectionError(f"Cannot resolve host: {self._host}")
872
+
873
+ # getaddrinfo may return both IPv6 and IPv4 records; a dual-stack
874
+ # host whose AAAA is unreachable must still connect over IPv4.
875
+ last_error = None
876
+ for family, socktype, proto, _, addr in addr_info:
822
877
  try:
823
- sock.connect(addr)
824
- # Connect completed immediately (e.g. loopback)
825
- self._socket = sock
826
- self._connect_complete()
878
+ sock, in_progress = self._open_socket(
879
+ family, socktype, proto, addr)
827
880
  except OSError as err:
828
- if err.errno in (
829
- errno.EINPROGRESS, errno.EAGAIN,
830
- errno.EALREADY, errno.EWOULDBLOCK):
831
- self._socket = sock
832
- self._state = STATE_CONNECTING
833
- else:
834
- sock.close()
835
- raise HttpConnectionError(
836
- f"Connect failed: {err}") from err
837
- except HttpConnectionError:
838
- raise
881
+ last_error = err
882
+ continue
883
+ # Adopted: from here a failure belongs to this connection, not
884
+ # to the candidate list, so it must not try the next address.
885
+ self._socket = sock
886
+ if in_progress:
887
+ self._state = STATE_CONNECTING
888
+ else:
889
+ self._connect_complete()
890
+ return
891
+ raise HttpConnectionError(f"Connect failed: {last_error}")
892
+
893
+ @staticmethod
894
+ def _open_socket(family, socktype, proto, addr):
895
+ """Start a non-blocking connect; (socket, still_connecting)"""
896
+ sock = _socket.socket(family, socktype, proto)
897
+ try:
898
+ sock.setblocking(False)
899
+ sock.connect(addr)
839
900
  except OSError as err:
840
- raise HttpConnectionError(f"Connect failed: {err}") from err
901
+ if not _would_block(err):
902
+ sock.close()
903
+ raise
904
+ return sock, True
905
+ return sock, False # completed immediately (e.g. loopback)
841
906
 
842
907
  def _connect_complete(self):
843
908
  """TCP connection established, start SSL or proceed to sending"""
@@ -901,7 +966,7 @@ class HttpClient:
901
966
  # implicitly during first send/recv
902
967
  pass
903
968
  except OSError as err:
904
- if err.errno in (errno.EAGAIN, EWOULDBLOCK, errno.ENOENT):
969
+ if _would_block(err):
905
970
  self._ssl_want_read = True # MicroPython
906
971
  return
907
972
  self._close()
@@ -992,14 +1057,39 @@ class HttpClient:
992
1057
 
993
1058
  self._body_reader = self._make_body_reader()
994
1059
 
1060
+ def _is_bodyless_response(self):
1061
+ """RFC 7230 3.3.3: status or method fixes the framing as empty.
1062
+
1063
+ Responses to HEAD, and 1xx/204/304 responses, never carry a body -
1064
+ any Content-Length they advertise describes what a GET would have
1065
+ returned, and waiting for it would hang until the timeout.
1066
+ """
1067
+ status = self._response_status
1068
+ if status is not None and (status < 200 or status in (204, 304)):
1069
+ return True
1070
+ method = self._request_method
1071
+ return method is not None and method.upper() == 'HEAD'
1072
+
1073
+ def _content_length(self):
1074
+ """Parsed Content-Length, or None when absent"""
1075
+ value = self._response_headers.get(CONTENT_LENGTH)
1076
+ if value is None:
1077
+ return None
1078
+ value = value.strip()
1079
+ if not value.isdigit():
1080
+ raise HttpResponseError(f"Invalid Content-Length: {value}")
1081
+ return int(value)
1082
+
995
1083
  def _make_body_reader(self):
996
1084
  """Select a body framing strategy based on response headers."""
1085
+ if self._is_bodyless_response():
1086
+ return _LengthBodyReader(0)
997
1087
  te = self._response_headers.get(TRANSFER_ENCODING, '').lower()
998
1088
  if CHUNKED in te:
999
1089
  return _ChunkedBodyReader()
1000
- cl = self._response_headers.get(CONTENT_LENGTH)
1001
- if cl is not None:
1002
- return _LengthBodyReader(int(cl))
1090
+ length = self._content_length()
1091
+ if length is not None:
1092
+ return _LengthBodyReader(length)
1003
1093
  if self._request_stream:
1004
1094
  # Streaming request without framing: read until the server closes
1005
1095
  # the connection (MJPEG, SSE, close-delimited bodies).
@@ -1010,9 +1100,11 @@ class HttpClient:
1010
1100
 
1011
1101
  def _check_response_length(self):
1012
1102
  """Fast-fail when the advertised Content-Length is too large."""
1013
- cl = self._response_headers.get(CONTENT_LENGTH)
1014
- if cl is not None and int(cl) > self._max_response_length:
1015
- raise HttpResponseError(f"Response too large: {cl}")
1103
+ if self._is_bodyless_response():
1104
+ return
1105
+ length = self._content_length()
1106
+ if length is not None and length > self._max_response_length:
1107
+ raise HttpResponseError(f"Response too large: {length}")
1016
1108
 
1017
1109
  def _feed_body(self):
1018
1110
  """Feed buffered raw bytes through the body reader into self._body."""
@@ -1141,9 +1233,7 @@ class HttpClient:
1141
1233
  except (_ssl.SSLWantReadError, _ssl.SSLWantWriteError):
1142
1234
  return False
1143
1235
  except OSError as err:
1144
- if err.errno in (errno.EAGAIN, EWOULDBLOCK, errno.ENOENT):
1145
- # EAGAIN/EWOULDBLOCK: no data yet (non-blocking; the two
1146
- # differ on Windows). ENOENT: MicroPython SSL would-block.
1236
+ if _would_block(err):
1147
1237
  return False
1148
1238
  # A close-delimited body (EOF reader) ends when the connection
1149
1239
  # ends. The termination may surface as an empty read OR an error
@@ -1222,8 +1312,8 @@ class HttpClient:
1222
1312
  except (_ssl.SSLWantReadError, _ssl.SSLWantWriteError):
1223
1313
  break
1224
1314
  except OSError as err:
1225
- if err.errno in (errno.EAGAIN, EWOULDBLOCK):
1226
- break # send buffer full (EWOULDBLOCK differs on Windows)
1315
+ if _would_block(err):
1316
+ break # send buffer full
1227
1317
  raise HttpConnectionError(f"Send failed: {err}") from err
1228
1318
 
1229
1319
  if not self._send_buffer:
@@ -1690,11 +1780,19 @@ class HttpClient:
1690
1780
  else:
1691
1781
  select_timeout = timeout
1692
1782
 
1783
+ read_socks = self.read_sockets
1784
+ write_socks = self.write_sockets
1785
+ if not read_socks and not write_socks:
1786
+ # Nothing left to watch: at STATE_COMPLETE the socket is out of
1787
+ # both lists while decoded records may still be queued. Deliver
1788
+ # them - waiting on nothing cannot help, and Windows' select()
1789
+ # rejects all-empty sets outright (WinError 10022), which turned
1790
+ # a queued record into EVENT_ERROR.
1791
+ return self.process_events([], [])
1792
+
1693
1793
  try:
1694
1794
  r, w, x = _select.select(
1695
- self.read_sockets,
1696
- self.write_sockets,
1697
- self.write_sockets, select_timeout)
1795
+ read_socks, write_socks, write_socks, select_timeout)
1698
1796
  except (OSError, ValueError) as err:
1699
1797
  self._error = f"select failed: {err}"
1700
1798
  self._close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-client
3
- Version: 2.3.0
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
@@ -2,7 +2,6 @@
2
2
  LICENSE
3
3
  README.md
4
4
  pyproject.toml
5
- .github/workflows/micropython.yml
6
5
  .github/workflows/publish.yml
7
6
  .github/workflows/tests.yml
8
7
  examples/client_async.py
@@ -19,6 +18,7 @@ tests/test_cli.py
19
18
  tests/test_cookies.py
20
19
  tests/test_errors.py
21
20
  tests/test_event_mode.py
21
+ tests/test_http_compliance.py
22
22
  tests/test_integration.py
23
23
  tests/test_keepalive.py
24
24
  tests/test_mpy_integration.py
@@ -30,4 +30,6 @@ uhttp_client.egg-info/PKG-INFO
30
30
  uhttp_client.egg-info/SOURCES.txt
31
31
  uhttp_client.egg-info/dependency_links.txt
32
32
  uhttp_client.egg-info/entry_points.txt
33
+ uhttp_client.egg-info/scm_file_list.json
34
+ uhttp_client.egg-info/scm_version.json
33
35
  uhttp_client.egg-info/top_level.txt
@@ -0,0 +1,32 @@
1
+ {
2
+ "files": [
3
+ ".github/workflows/publish.yml",
4
+ ".github/workflows/tests.yml",
5
+ ".gitignore",
6
+ "LICENSE",
7
+ "README.md",
8
+ "examples/client_async.py",
9
+ "examples/client_basic.py",
10
+ "examples/client_https.py",
11
+ "examples/client_stream.py",
12
+ "pyproject.toml",
13
+ "tests/__init__.py",
14
+ "tests/test_100_continue.py",
15
+ "tests/test_async.py",
16
+ "tests/test_auth.py",
17
+ "tests/test_basic.py",
18
+ "tests/test_body_framing.py",
19
+ "tests/test_cli.py",
20
+ "tests/test_cookies.py",
21
+ "tests/test_errors.py",
22
+ "tests/test_event_mode.py",
23
+ "tests/test_http_compliance.py",
24
+ "tests/test_integration.py",
25
+ "tests/test_keepalive.py",
26
+ "tests/test_mpy_integration.py",
27
+ "tests/test_nonblocking_connect.py",
28
+ "tests/test_unit.py",
29
+ "uhttp/cli.py",
30
+ "uhttp/client.py"
31
+ ]
32
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.3.1",
3
+ "distance": 0,
4
+ "node": "g6a6a5f49de988d4a1c41cb2710d6b3d6aed05d26",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-15"
8
+ }
@@ -1,39 +0,0 @@
1
- name: MicroPython Integration
2
-
3
- on:
4
- workflow_dispatch:
5
- push:
6
- branches: [main]
7
-
8
- jobs:
9
- unit-tests:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
- - uses: actions/setup-python@v5
14
- with:
15
- python-version: '3.13'
16
- - run: pip install -e .
17
- - run: pip install uhttp-server
18
- - run: python -m unittest discover tests/ -v
19
-
20
- micropython:
21
- runs-on: [self-hosted, Linux]
22
- needs: unit-tests
23
- strategy:
24
- fail-fast: false
25
- matrix:
26
- device: [ESP32]
27
- steps:
28
- - uses: actions/checkout@v4
29
-
30
- - name: Install package
31
- run: $HOME/actions-runner/.venv/bin/pip install -e .
32
-
33
- - name: MicroPython tests (${{ matrix.device }})
34
- run: |
35
- export PATH="$HOME/actions-runner/.venv/bin:$PATH"
36
- export MPY_TEST_PORT="$(cat $HOME/actions-runner/.config/mpytool/${{ matrix.device }})"
37
- export MPY_WIFI_SSID="$(jq -r .ssid $HOME/actions-runner/.config/uhttp/wifi.json)"
38
- export MPY_WIFI_PASSWORD="$(jq -r .password $HOME/actions-runner/.config/uhttp/wifi.json)"
39
- python -m unittest tests.test_mpy_integration -v
@@ -1,33 +0,0 @@
1
- name: Tests
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- test:
11
- runs-on: ${{ matrix.os }}
12
- strategy:
13
- max-parallel: 1
14
- matrix:
15
- os: [ubuntu-latest, windows-latest]
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/
File without changes
File without changes
File without changes
File without changes
File without changes