Skip to content

Commit c8b5508

Browse files
committed
uhttp: clitool - simple http client
1 parent 284f167 commit c8b5508

5 files changed

Lines changed: 800 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "uhttp-client"
7-
version = "2.2.0"
7+
version = "2.2.1"
88
description = "Micro HTTP client for Python and MicroPython"
99
readme = "README.md"
1010
authors = [
@@ -20,8 +20,11 @@ classifiers = [
2020
]
2121

2222
[project.urls]
23-
Homepage = "https://github.com/pavelrevak/uhttp"
24-
Repository = "https://github.com/pavelrevak/uhttp"
23+
Homepage = "https://github.com/pavelrevak/uhttp-client"
24+
Repository = "https://github.com/pavelrevak/uhttp-client"
25+
26+
[project.scripts]
27+
uhttp = "uhttp.cli:main"
2528

2629
[tool.setuptools.packages.find]
2730
include = ["uhttp*"]

tests/test_cli.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
#!/usr/bin/env python3
2+
"""Tests for CLI module"""
3+
4+
import unittest
5+
import sys
6+
import io
7+
from unittest.mock import patch, MagicMock
8+
9+
from uhttp.cli import parse_headers, format_size, HTTP_METHODS, main
10+
11+
12+
class TestParseHeaders(unittest.TestCase):
13+
"""Test header parsing"""
14+
15+
def test_empty(self):
16+
"""Test empty header list"""
17+
self.assertEqual(parse_headers(None), {})
18+
self.assertEqual(parse_headers([]), {})
19+
20+
def test_single_header(self):
21+
"""Test single header"""
22+
result = parse_headers(['Content-Type: application/json'])
23+
self.assertEqual(result, {'Content-Type': 'application/json'})
24+
25+
def test_multiple_headers(self):
26+
"""Test multiple headers"""
27+
result = parse_headers([
28+
'Content-Type: application/json',
29+
'Authorization: Bearer token123'
30+
])
31+
self.assertEqual(result, {
32+
'Content-Type': 'application/json',
33+
'Authorization': 'Bearer token123'
34+
})
35+
36+
def test_header_with_extra_colons(self):
37+
"""Test header value containing colons"""
38+
result = parse_headers(['X-Custom: value:with:colons'])
39+
self.assertEqual(result, {'X-Custom': 'value:with:colons'})
40+
41+
def test_header_whitespace_stripped(self):
42+
"""Test whitespace is stripped"""
43+
result = parse_headers([' Key : Value '])
44+
self.assertEqual(result, {'Key': 'Value'})
45+
46+
def test_header_without_colon_ignored(self):
47+
"""Test header without colon is ignored"""
48+
result = parse_headers(['InvalidHeader', 'Valid: Header'])
49+
self.assertEqual(result, {'Valid': 'Header'})
50+
51+
52+
class TestFormatSize(unittest.TestCase):
53+
"""Test size formatting"""
54+
55+
def test_bytes(self):
56+
"""Test bytes formatting"""
57+
self.assertEqual(format_size(0), '0 B')
58+
self.assertEqual(format_size(100), '100 B')
59+
self.assertEqual(format_size(1023), '1023 B')
60+
61+
def test_kilobytes(self):
62+
"""Test kilobytes formatting"""
63+
self.assertEqual(format_size(1024), '1.0 KB')
64+
self.assertEqual(format_size(1536), '1.5 KB')
65+
self.assertEqual(format_size(10240), '10.0 KB')
66+
67+
def test_megabytes(self):
68+
"""Test megabytes formatting"""
69+
self.assertEqual(format_size(1024 * 1024), '1.0 MB')
70+
self.assertEqual(format_size(1024 * 1024 * 5), '5.0 MB')
71+
self.assertEqual(format_size(1024 * 1024 + 512 * 1024), '1.5 MB')
72+
73+
74+
class TestHTTPMethods(unittest.TestCase):
75+
"""Test HTTP methods constant"""
76+
77+
def test_all_methods_present(self):
78+
"""Test all standard methods are present"""
79+
self.assertIn('GET', HTTP_METHODS)
80+
self.assertIn('POST', HTTP_METHODS)
81+
self.assertIn('PUT', HTTP_METHODS)
82+
self.assertIn('DELETE', HTTP_METHODS)
83+
self.assertIn('PATCH', HTTP_METHODS)
84+
self.assertIn('HEAD', HTTP_METHODS)
85+
self.assertIn('OPTIONS', HTTP_METHODS)
86+
87+
88+
class TestCLIArgumentParsing(unittest.TestCase):
89+
"""Test CLI argument parsing"""
90+
91+
def run_cli(self, args, mock_client=None):
92+
"""Helper to run CLI with mocked client"""
93+
if mock_client is None:
94+
mock_client = MagicMock()
95+
mock_response = MagicMock()
96+
mock_response.status = 200
97+
mock_response.status_message = 'OK'
98+
mock_response.headers = {}
99+
mock_response.data = b'{"result": "ok"}'
100+
mock_client.request.return_value.wait.return_value = mock_response
101+
102+
with patch('uhttp.cli._client.HttpClient', return_value=mock_client):
103+
with patch('uhttp.cli._client.parse_url') as mock_parse:
104+
mock_parse.return_value = ('example.com', 80, '/path', False, None)
105+
with patch('sys.argv', ['uhttp'] + args):
106+
with patch('sys.stdout', new_callable=io.StringIO):
107+
with patch('sys.stderr', new_callable=io.StringIO):
108+
try:
109+
main()
110+
except SystemExit as e:
111+
if e.code not in (0, None):
112+
raise
113+
return mock_client
114+
115+
def test_url_only_get(self):
116+
"""Test URL only defaults to GET"""
117+
mock = self.run_cli(['http://example.com/path'])
118+
mock.request.assert_called_once()
119+
call_args = mock.request.call_args
120+
self.assertEqual(call_args[0][0], 'GET')
121+
122+
def test_explicit_get(self):
123+
"""Test explicit GET method"""
124+
mock = self.run_cli(['GET', 'http://example.com/path'])
125+
mock.request.assert_called_once()
126+
call_args = mock.request.call_args
127+
self.assertEqual(call_args[0][0], 'GET')
128+
129+
def test_explicit_post(self):
130+
"""Test explicit POST method"""
131+
mock = self.run_cli(['POST', 'http://example.com/path'])
132+
mock.request.assert_called_once()
133+
call_args = mock.request.call_args
134+
self.assertEqual(call_args[0][0], 'POST')
135+
136+
def test_explicit_put(self):
137+
"""Test explicit PUT method"""
138+
mock = self.run_cli(['PUT', 'http://example.com/path', '-d', 'data'])
139+
mock.request.assert_called_once()
140+
call_args = mock.request.call_args
141+
self.assertEqual(call_args[0][0], 'PUT')
142+
143+
def test_explicit_delete(self):
144+
"""Test explicit DELETE method"""
145+
mock = self.run_cli(['DELETE', 'http://example.com/path'])
146+
mock.request.assert_called_once()
147+
call_args = mock.request.call_args
148+
self.assertEqual(call_args[0][0], 'DELETE')
149+
150+
def test_explicit_patch(self):
151+
"""Test explicit PATCH method"""
152+
mock = self.run_cli(['PATCH', 'http://example.com/path', '-d', 'data'])
153+
mock.request.assert_called_once()
154+
call_args = mock.request.call_args
155+
self.assertEqual(call_args[0][0], 'PATCH')
156+
157+
def test_data_implies_post(self):
158+
"""Test -d data implies POST method"""
159+
mock = self.run_cli(['http://example.com/path', '-d', 'key=value'])
160+
mock.request.assert_called_once()
161+
call_args = mock.request.call_args
162+
self.assertEqual(call_args[0][0], 'POST')
163+
164+
def test_json_implies_post(self):
165+
"""Test -j json implies POST method"""
166+
mock = self.run_cli(['http://example.com/path', '-j', '{"key": "value"}'])
167+
mock.request.assert_called_once()
168+
call_args = mock.request.call_args
169+
self.assertEqual(call_args[0][0], 'POST')
170+
171+
def test_explicit_method_overrides_data(self):
172+
"""Test explicit method is used even with data"""
173+
mock = self.run_cli(['PUT', 'http://example.com/path', '-d', 'data'])
174+
mock.request.assert_called_once()
175+
call_args = mock.request.call_args
176+
self.assertEqual(call_args[0][0], 'PUT')
177+
178+
def test_lowercase_method(self):
179+
"""Test lowercase method is uppercased"""
180+
mock = self.run_cli(['get', 'http://example.com/path'])
181+
mock.request.assert_called_once()
182+
call_args = mock.request.call_args
183+
self.assertEqual(call_args[0][0], 'GET')
184+
185+
def test_unknown_method_error(self):
186+
"""Test unknown method raises error"""
187+
with self.assertRaises(SystemExit):
188+
with patch('sys.stderr', new_callable=io.StringIO):
189+
self.run_cli(['UNKNOWN', 'http://example.com/path'])
190+
191+
def test_url_without_protocol(self):
192+
"""Test URL without protocol gets http:// added"""
193+
with patch('uhttp.cli._client.HttpClient') as mock_class:
194+
mock_client = MagicMock()
195+
mock_response = MagicMock()
196+
mock_response.status = 200
197+
mock_response.headers = {}
198+
mock_response.data = b'{}'
199+
mock_client.request.return_value.wait.return_value = mock_response
200+
mock_class.return_value = mock_client
201+
202+
with patch('uhttp.cli._client.parse_url') as mock_parse:
203+
mock_parse.return_value = ('example.com', 80, '/', False, None)
204+
with patch('sys.argv', ['uhttp', 'example.com']):
205+
with patch('sys.stdout', new_callable=io.StringIO):
206+
main()
207+
# parse_url should receive URL with protocol
208+
mock_parse.assert_called_with('http://example.com')
209+
210+
def test_custom_headers(self):
211+
"""Test custom headers are passed"""
212+
mock = self.run_cli([
213+
'http://example.com/path',
214+
'-H', 'X-Custom: value1',
215+
'-H', 'Authorization: Bearer token'
216+
])
217+
mock.request.assert_called_once()
218+
call_args = mock.request.call_args
219+
headers = call_args[1].get('headers', {})
220+
self.assertEqual(headers.get('X-Custom'), 'value1')
221+
self.assertEqual(headers.get('Authorization'), 'Bearer token')
222+
223+
224+
class TestCLIErrors(unittest.TestCase):
225+
"""Test CLI error handling"""
226+
227+
def test_no_arguments(self):
228+
"""Test no arguments shows error"""
229+
with patch('sys.argv', ['uhttp']):
230+
with patch('sys.stderr', new_callable=io.StringIO):
231+
with self.assertRaises(SystemExit) as ctx:
232+
main()
233+
self.assertNotEqual(ctx.exception.code, 0)
234+
235+
def test_invalid_json(self):
236+
"""Test invalid JSON shows error"""
237+
with patch('sys.argv', ['uhttp', 'http://example.com', '-j', 'not json']):
238+
with patch('sys.stderr', new_callable=io.StringIO):
239+
with self.assertRaises(SystemExit) as ctx:
240+
main()
241+
self.assertEqual(ctx.exception.code, 1)
242+
243+
def test_too_many_arguments(self):
244+
"""Test too many positional arguments shows error"""
245+
with patch('sys.argv', ['uhttp', 'GET', 'http://example.com', 'extra']):
246+
with patch('sys.stderr', new_callable=io.StringIO):
247+
with self.assertRaises(SystemExit) as ctx:
248+
main()
249+
self.assertNotEqual(ctx.exception.code, 0)
250+
251+
252+
if __name__ == '__main__':
253+
unittest.main()

0 commit comments

Comments
 (0)