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.
- uhttp_client-2.3.1/.github/workflows/tests.yml +87 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/PKG-INFO +1 -1
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_event_mode.py +50 -0
- uhttp_client-2.3.1/tests/test_http_compliance.py +278 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp/client.py +143 -45
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/PKG-INFO +1 -1
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/SOURCES.txt +3 -1
- 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.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-2.3.1}/.github/workflows/publish.yml +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/.gitignore +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/LICENSE +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/README.md +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_async.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_basic.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_https.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/examples/client_stream.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/pyproject.toml +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/setup.cfg +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/__init__.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_100_continue.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_async.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_auth.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_basic.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_body_framing.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_cli.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_cookies.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_errors.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_integration.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_keepalive.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_mpy_integration.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_nonblocking_connect.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/tests/test_unit.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp/cli.py +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/dependency_links.txt +0 -0
- {uhttp_client-2.3.0 → uhttp_client-2.3.1}/uhttp_client.egg-info/entry_points.txt +0 -0
- {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
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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.
|
|
824
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1001
|
-
if
|
|
1002
|
-
return _LengthBodyReader(
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
|
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
|
|
1226
|
-
break # send buffer full
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|