mm-http 0.1.4__tar.gz → 0.2.0__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.
- mm_http-0.2.0/.claude/settings.local.json +13 -0
- mm_http-0.2.0/CLAUDE.md +13 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/PKG-INFO +4 -4
- {mm_http-0.1.4 → mm_http-0.2.0}/README.md +46 -21
- {mm_http-0.1.4 → mm_http-0.2.0}/pyproject.toml +11 -12
- mm_http-0.2.0/src/mm_http/__init__.py +5 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/src/mm_http/http_request.py +33 -21
- {mm_http-0.1.4 → mm_http-0.2.0}/src/mm_http/http_request_sync.py +16 -8
- mm_http-0.2.0/src/mm_http/response.py +104 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/tests/test_http_request.py +32 -14
- {mm_http-0.1.4 → mm_http-0.2.0}/tests/test_http_request_sync.py +32 -14
- mm_http-0.2.0/tests/test_http_response.py +137 -0
- mm_http-0.2.0/uv.lock +1315 -0
- mm_http-0.1.4/dict.dic +0 -0
- mm_http-0.1.4/src/mm_http/__init__.py +0 -5
- mm_http-0.1.4/src/mm_http/response.py +0 -90
- mm_http-0.1.4/tests/test_http_response.py +0 -108
- mm_http-0.1.4/uv.lock +0 -1016
- {mm_http-0.1.4 → mm_http-0.2.0}/.gitignore +0 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/.pre-commit-config.yaml +0 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/justfile +0 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/src/mm_http/py.typed +0 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/tests/__init__.py +0 -0
- {mm_http-0.1.4 → mm_http-0.2.0}/tests/conftest.py +0 -0
mm_http-0.2.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Claude Guidelines
|
|
2
|
+
|
|
3
|
+
## Critical Guidelines
|
|
4
|
+
|
|
5
|
+
1. **Always communicate in English** - Regardless of the language the user speaks, always respond in English. All code, comments, and documentation must be in English.
|
|
6
|
+
|
|
7
|
+
2. **Minimal documentation** - Only add comments/documentation when it simplifies understanding and isn't obvious from the code itself. Keep it strictly relevant and concise.
|
|
8
|
+
|
|
9
|
+
3. **Critical thinking** - Always critically evaluate user ideas. Users can make mistakes. Think first about whether the user's idea is good before implementing.
|
|
10
|
+
|
|
11
|
+
4. **Lint after changes** - After making code changes, always run `just lint` to verify code quality and fix any linter issues.
|
|
12
|
+
|
|
13
|
+
5. **No disabling linter rules** - Never use special disabling comments (like `# noqa`, `# type: ignore`, `# ruff: noqa`, etc.) to turn off linter rules without explicit permission. If you believe a rule should be disabled, ask first.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mm-http
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Requires-Python: >=3.13
|
|
5
5
|
Requires-Dist: aiohttp-socks~=0.10.1
|
|
6
|
-
Requires-Dist: aiohttp~=3.
|
|
7
|
-
Requires-Dist: mm-result~=0.1.
|
|
6
|
+
Requires-Dist: aiohttp~=3.13.0
|
|
7
|
+
Requires-Dist: mm-result~=0.1.2
|
|
8
|
+
Requires-Dist: pydantic~=2.12.0
|
|
8
9
|
Requires-Dist: pydash~=8.0.5
|
|
9
10
|
Requires-Dist: requests[socks]~=2.32.5
|
|
10
|
-
Requires-Dist: urllib3~=2.5.0
|
|
@@ -21,7 +21,7 @@ from mm_http import http_request
|
|
|
21
21
|
|
|
22
22
|
# Simple GET request
|
|
23
23
|
response = await http_request("https://api.github.com/users/octocat")
|
|
24
|
-
user_name = response.
|
|
24
|
+
user_name = response.parse_json("name") # Navigate JSON with dot notation
|
|
25
25
|
|
|
26
26
|
# POST with JSON data
|
|
27
27
|
response = await http_request(
|
|
@@ -45,7 +45,7 @@ from mm_http import http_request_sync
|
|
|
45
45
|
|
|
46
46
|
# Same API, but synchronous
|
|
47
47
|
response = http_request_sync("https://api.github.com/users/octocat")
|
|
48
|
-
user_name = response.
|
|
48
|
+
user_name = response.parse_json("name")
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
## API Reference
|
|
@@ -60,9 +60,9 @@ user_name = response.parse_json_body("name")
|
|
|
60
60
|
- `url: str` - Request URL
|
|
61
61
|
- `method: str = "GET"` - HTTP method
|
|
62
62
|
- `params: dict[str, Any] | None = None` - URL query parameters
|
|
63
|
-
- `data: dict[str,
|
|
64
|
-
- `json: dict[str,
|
|
65
|
-
- `headers: dict[str,
|
|
63
|
+
- `data: dict[str, Any] | None = None` - Form data
|
|
64
|
+
- `json: dict[str, Any] | None = None` - JSON data
|
|
65
|
+
- `headers: dict[str, Any] | None = None` - HTTP headers
|
|
66
66
|
- `cookies: LooseCookies | None = None` - Cookies
|
|
67
67
|
- `user_agent: str | None = None` - User-Agent header
|
|
68
68
|
- `proxy: str | None = None` - Proxy URL (supports http://, https://, socks4://, socks5://)
|
|
@@ -71,25 +71,39 @@ user_name = response.parse_json_body("name")
|
|
|
71
71
|
### HttpResponse
|
|
72
72
|
|
|
73
73
|
```python
|
|
74
|
-
|
|
75
|
-
class HttpResponse:
|
|
74
|
+
class HttpResponse(BaseModel):
|
|
76
75
|
status_code: int | None
|
|
77
|
-
error: HttpError | None
|
|
78
|
-
error_message: str | None
|
|
79
76
|
body: str | None
|
|
80
77
|
headers: dict[str, str] | None
|
|
78
|
+
transport_error: TransportErrorDetail | None
|
|
81
79
|
|
|
82
|
-
|
|
83
|
-
def
|
|
84
|
-
|
|
80
|
+
# JSON parsing
|
|
81
|
+
def parse_json(self, path: str | None = None, none_on_error: bool = False) -> Any
|
|
82
|
+
|
|
83
|
+
# Header access
|
|
84
|
+
def get_header(self, name: str) -> str | None
|
|
85
|
+
@property content_type(self) -> str | None
|
|
86
|
+
|
|
87
|
+
# Status checks
|
|
88
|
+
def is_success(self) -> bool # 2xx status
|
|
89
|
+
def is_err(self) -> bool # Has transport error or status >= 400
|
|
90
|
+
|
|
91
|
+
# Result conversion
|
|
85
92
|
def to_result_ok[T](self, value: T) -> Result[T]
|
|
86
93
|
def to_result_err[T](self, error: str | Exception | None = None) -> Result[T]
|
|
94
|
+
|
|
95
|
+
# Pydantic methods
|
|
96
|
+
def model_dump(self, mode: str = "python") -> dict[str, Any]
|
|
97
|
+
|
|
98
|
+
class TransportErrorDetail(BaseModel):
|
|
99
|
+
type: TransportError
|
|
100
|
+
message: str
|
|
87
101
|
```
|
|
88
102
|
|
|
89
103
|
### Error Types
|
|
90
104
|
|
|
91
105
|
```python
|
|
92
|
-
class
|
|
106
|
+
class TransportError(str, Enum):
|
|
93
107
|
TIMEOUT = "timeout"
|
|
94
108
|
PROXY = "proxy"
|
|
95
109
|
INVALID_URL = "invalid_url"
|
|
@@ -105,11 +119,14 @@ class HttpError(str, Enum):
|
|
|
105
119
|
response = await http_request("https://api.github.com/users/octocat")
|
|
106
120
|
|
|
107
121
|
# Instead of: json.loads(response.body)["plan"]["name"]
|
|
108
|
-
plan_name = response.
|
|
122
|
+
plan_name = response.parse_json("plan.name")
|
|
109
123
|
|
|
110
124
|
# Safe navigation - returns None if path doesn't exist
|
|
111
|
-
followers = response.
|
|
112
|
-
nonexistent = response.
|
|
125
|
+
followers = response.parse_json("followers_count")
|
|
126
|
+
nonexistent = response.parse_json("does.not.exist") # Returns None
|
|
127
|
+
|
|
128
|
+
# Or get full JSON
|
|
129
|
+
data = response.parse_json()
|
|
113
130
|
```
|
|
114
131
|
|
|
115
132
|
### Error Handling
|
|
@@ -117,8 +134,16 @@ nonexistent = response.parse_json_body("does.not.exist") # Returns None
|
|
|
117
134
|
```python
|
|
118
135
|
response = await http_request("https://example.com", timeout=5.0)
|
|
119
136
|
|
|
137
|
+
# Simple check
|
|
138
|
+
if response.is_success():
|
|
139
|
+
data = response.parse_json()
|
|
140
|
+
|
|
141
|
+
# Detailed error handling
|
|
120
142
|
if response.is_err():
|
|
121
|
-
|
|
143
|
+
if response.transport_error:
|
|
144
|
+
print(f"Transport error: {response.transport_error.type} - {response.transport_error.message}")
|
|
145
|
+
elif response.status_code >= 400:
|
|
146
|
+
print(f"HTTP error: {response.status_code}")
|
|
122
147
|
else:
|
|
123
148
|
print(f"Success: {response.status_code}")
|
|
124
149
|
```
|
|
@@ -163,17 +188,17 @@ async def get_user_id() -> Result[int]:
|
|
|
163
188
|
response = await http_request("https://api.example.com/user")
|
|
164
189
|
|
|
165
190
|
if response.is_err():
|
|
166
|
-
return response.to_result_err() #
|
|
191
|
+
return response.to_result_err() # Returns "HTTP 404" or TransportError.TIMEOUT
|
|
167
192
|
|
|
168
|
-
user_id = response.
|
|
169
|
-
return response.to_result_ok(user_id)
|
|
193
|
+
user_id = response.parse_json("id")
|
|
194
|
+
return response.to_result_ok(user_id)
|
|
170
195
|
|
|
171
196
|
# Usage
|
|
172
197
|
result = await get_user_id()
|
|
173
198
|
if result.is_ok():
|
|
174
199
|
print(f"User ID: {result.value}")
|
|
175
200
|
else:
|
|
176
|
-
print(f"Error: {result.error}")
|
|
201
|
+
print(f"Error: {result.error}") # "HTTP 404" or TransportError.TIMEOUT
|
|
177
202
|
print(f"HTTP details: {result.extra}") # Contains full HTTP response data
|
|
178
203
|
```
|
|
179
204
|
|
|
@@ -1,34 +1,33 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mm-http"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = ""
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
dependencies = [
|
|
7
|
-
"mm-result~=0.1.
|
|
7
|
+
"mm-result~=0.1.2",
|
|
8
8
|
"requests[socks]~=2.32.5",
|
|
9
|
-
"aiohttp~=3.
|
|
9
|
+
"aiohttp~=3.13.0",
|
|
10
10
|
"aiohttp-socks~=0.10.1",
|
|
11
11
|
"pydash~=8.0.5",
|
|
12
|
-
"
|
|
13
|
-
|
|
12
|
+
"pydantic~=2.12.0",
|
|
14
13
|
]
|
|
15
14
|
|
|
16
15
|
[build-system]
|
|
17
16
|
requires = ["hatchling"]
|
|
18
17
|
build-backend = "hatchling.build"
|
|
19
18
|
|
|
20
|
-
[
|
|
21
|
-
dev
|
|
22
|
-
"pytest~=8.4.
|
|
23
|
-
"pytest-asyncio~=1.
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest~=8.4.2",
|
|
22
|
+
"pytest-asyncio~=1.2.0",
|
|
24
23
|
"pytest-xdist~=3.8.0",
|
|
25
24
|
"pytest-httpserver~=1.1.3",
|
|
26
|
-
"ruff~=0.
|
|
27
|
-
"mypy~=1.
|
|
25
|
+
"ruff~=0.14.0",
|
|
26
|
+
"mypy~=1.18.2",
|
|
28
27
|
"pip-audit~=2.9.0",
|
|
29
28
|
"bandit~=1.8.6",
|
|
30
29
|
"pre-commit~=4.3.0",
|
|
31
|
-
"types-requests~=2.32.4.
|
|
30
|
+
"types-requests~=2.32.4.20250913",
|
|
32
31
|
"python-dotenv~=1.1.1",
|
|
33
32
|
]
|
|
34
33
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from .http_request import http_request
|
|
2
|
+
from .http_request_sync import http_request_sync
|
|
3
|
+
from .response import HttpResponse, TransportError, TransportErrorDetail
|
|
4
|
+
|
|
5
|
+
__all__ = ["HttpResponse", "TransportError", "TransportErrorDetail", "http_request", "http_request_sync"]
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
3
|
import aiohttp
|
|
4
|
-
from aiohttp import
|
|
4
|
+
from aiohttp import (
|
|
5
|
+
ClientConnectionError,
|
|
6
|
+
ClientConnectorError,
|
|
7
|
+
ClientHttpProxyError,
|
|
8
|
+
ClientSSLError,
|
|
9
|
+
InvalidUrlClientError,
|
|
10
|
+
ServerConnectionError,
|
|
11
|
+
ServerDisconnectedError,
|
|
12
|
+
)
|
|
5
13
|
from aiohttp.typedefs import LooseCookies
|
|
6
14
|
from aiohttp_socks import ProxyConnectionError, ProxyConnector
|
|
7
15
|
from multidict import CIMultiDictProxy
|
|
8
16
|
|
|
9
|
-
from .response import
|
|
17
|
+
from .response import HttpResponse, TransportError, TransportErrorDetail
|
|
10
18
|
|
|
11
19
|
|
|
12
20
|
async def http_request(
|
|
@@ -14,9 +22,9 @@ async def http_request(
|
|
|
14
22
|
*,
|
|
15
23
|
method: str = "GET",
|
|
16
24
|
params: dict[str, Any] | None = None,
|
|
17
|
-
data: dict[str,
|
|
18
|
-
json: dict[str,
|
|
19
|
-
headers: dict[str,
|
|
25
|
+
data: dict[str, Any] | None = None,
|
|
26
|
+
json: dict[str, Any] | None = None,
|
|
27
|
+
headers: dict[str, Any] | None = None,
|
|
20
28
|
cookies: LooseCookies | None = None,
|
|
21
29
|
user_agent: str | None = None,
|
|
22
30
|
proxy: str | None = None,
|
|
@@ -56,13 +64,21 @@ async def http_request(
|
|
|
56
64
|
timeout=timeout_,
|
|
57
65
|
)
|
|
58
66
|
except TimeoutError as err:
|
|
59
|
-
return HttpResponse(
|
|
67
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.TIMEOUT, message=str(err)))
|
|
60
68
|
except (aiohttp.ClientProxyConnectionError, ProxyConnectionError, ClientHttpProxyError) as err:
|
|
61
|
-
return HttpResponse(
|
|
69
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.PROXY, message=str(err)))
|
|
62
70
|
except InvalidUrlClientError as e:
|
|
63
|
-
return HttpResponse(
|
|
71
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.INVALID_URL, message=str(e)))
|
|
72
|
+
except (
|
|
73
|
+
ClientConnectorError,
|
|
74
|
+
ServerConnectionError,
|
|
75
|
+
ServerDisconnectedError,
|
|
76
|
+
ClientSSLError,
|
|
77
|
+
ClientConnectionError,
|
|
78
|
+
) as err:
|
|
79
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.CONNECTION, message=str(err)))
|
|
64
80
|
except Exception as err:
|
|
65
|
-
return HttpResponse(
|
|
81
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.ERROR, message=str(err)))
|
|
66
82
|
|
|
67
83
|
|
|
68
84
|
async def _request_with_http_or_none_proxy(
|
|
@@ -70,9 +86,9 @@ async def _request_with_http_or_none_proxy(
|
|
|
70
86
|
*,
|
|
71
87
|
method: str = "GET",
|
|
72
88
|
params: dict[str, Any] | None = None,
|
|
73
|
-
data: dict[str,
|
|
74
|
-
json: dict[str,
|
|
75
|
-
headers: dict[str,
|
|
89
|
+
data: dict[str, Any] | None = None,
|
|
90
|
+
json: dict[str, Any] | None = None,
|
|
91
|
+
headers: dict[str, Any] | None = None,
|
|
76
92
|
cookies: LooseCookies | None = None,
|
|
77
93
|
proxy: str | None = None,
|
|
78
94
|
timeout: aiohttp.ClientTimeout | None,
|
|
@@ -82,9 +98,7 @@ async def _request_with_http_or_none_proxy(
|
|
|
82
98
|
) as res:
|
|
83
99
|
return HttpResponse(
|
|
84
100
|
status_code=res.status,
|
|
85
|
-
|
|
86
|
-
error_message=None,
|
|
87
|
-
body=(await res.read()).decode(),
|
|
101
|
+
body=await res.text(),
|
|
88
102
|
headers=headers_dict(res.headers),
|
|
89
103
|
)
|
|
90
104
|
|
|
@@ -95,9 +109,9 @@ async def _request_with_socks_proxy(
|
|
|
95
109
|
method: str = "GET",
|
|
96
110
|
proxy: str,
|
|
97
111
|
params: dict[str, Any] | None = None,
|
|
98
|
-
data: dict[str,
|
|
99
|
-
json: dict[str,
|
|
100
|
-
headers: dict[str,
|
|
112
|
+
data: dict[str, Any] | None = None,
|
|
113
|
+
json: dict[str, Any] | None = None,
|
|
114
|
+
headers: dict[str, Any] | None = None,
|
|
101
115
|
cookies: LooseCookies | None = None,
|
|
102
116
|
timeout: aiohttp.ClientTimeout | None,
|
|
103
117
|
) -> HttpResponse:
|
|
@@ -110,9 +124,7 @@ async def _request_with_socks_proxy(
|
|
|
110
124
|
):
|
|
111
125
|
return HttpResponse(
|
|
112
126
|
status_code=res.status,
|
|
113
|
-
|
|
114
|
-
error_message=None,
|
|
115
|
-
body=(await res.read()).decode(),
|
|
127
|
+
body=await res.text(),
|
|
116
128
|
headers=headers_dict(res.headers),
|
|
117
129
|
)
|
|
118
130
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
3
|
import requests
|
|
4
|
-
from requests.exceptions import
|
|
4
|
+
from requests.exceptions import (
|
|
5
|
+
ConnectionError as RequestsConnectionError,
|
|
6
|
+
)
|
|
7
|
+
from requests.exceptions import (
|
|
8
|
+
InvalidSchema,
|
|
9
|
+
MissingSchema,
|
|
10
|
+
ProxyError,
|
|
11
|
+
SSLError,
|
|
12
|
+
)
|
|
5
13
|
|
|
6
|
-
from .response import
|
|
14
|
+
from .response import HttpResponse, TransportError, TransportErrorDetail
|
|
7
15
|
|
|
8
16
|
|
|
9
17
|
def http_request_sync(
|
|
@@ -48,16 +56,16 @@ def http_request_sync(
|
|
|
48
56
|
)
|
|
49
57
|
return HttpResponse(
|
|
50
58
|
status_code=res.status_code,
|
|
51
|
-
error=None,
|
|
52
|
-
error_message=None,
|
|
53
59
|
body=res.text,
|
|
54
60
|
headers=dict(res.headers),
|
|
55
61
|
)
|
|
56
62
|
except requests.Timeout as e:
|
|
57
|
-
return HttpResponse(
|
|
63
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.TIMEOUT, message=str(e)))
|
|
58
64
|
except ProxyError as e:
|
|
59
|
-
return HttpResponse(
|
|
65
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.PROXY, message=str(e)))
|
|
60
66
|
except (InvalidSchema, MissingSchema) as e:
|
|
61
|
-
return HttpResponse(
|
|
67
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.INVALID_URL, message=str(e)))
|
|
68
|
+
except (RequestsConnectionError, SSLError) as e:
|
|
69
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.CONNECTION, message=str(e)))
|
|
62
70
|
except Exception as e:
|
|
63
|
-
return HttpResponse(
|
|
71
|
+
return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.ERROR, message=str(e)))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pydash
|
|
8
|
+
from mm_result import Result
|
|
9
|
+
from pydantic import BaseModel, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@enum.unique
|
|
13
|
+
class TransportError(str, enum.Enum):
|
|
14
|
+
TIMEOUT = "timeout"
|
|
15
|
+
PROXY = "proxy"
|
|
16
|
+
INVALID_URL = "invalid_url"
|
|
17
|
+
CONNECTION = "connection"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TransportErrorDetail(BaseModel):
|
|
22
|
+
"""Transport error with type and message."""
|
|
23
|
+
|
|
24
|
+
type: TransportError
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpResponse(BaseModel):
|
|
29
|
+
"""HTTP response with status, body, headers, and optional transport error."""
|
|
30
|
+
|
|
31
|
+
status_code: int | None = None
|
|
32
|
+
body: str | None = None
|
|
33
|
+
headers: dict[str, str] | None = None
|
|
34
|
+
transport_error: TransportErrorDetail | None = None
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def validate_mutually_exclusive_states(self) -> HttpResponse:
|
|
38
|
+
"""Validate that response has either HTTP data or transport error, but not both."""
|
|
39
|
+
has_http_response = self.status_code is not None
|
|
40
|
+
has_transport_error = self.transport_error is not None
|
|
41
|
+
|
|
42
|
+
if has_http_response and has_transport_error:
|
|
43
|
+
msg = "Cannot have both HTTP response and transport error"
|
|
44
|
+
raise ValueError(msg)
|
|
45
|
+
|
|
46
|
+
if not has_http_response and not has_transport_error:
|
|
47
|
+
msg = "Must have either HTTP response or transport error"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def parse_json(self, path: str | None = None, none_on_error: bool = False) -> Any: # noqa: ANN401
|
|
53
|
+
"""Parse JSON body and optionally extract value by path."""
|
|
54
|
+
if self.body is None:
|
|
55
|
+
if none_on_error:
|
|
56
|
+
return None
|
|
57
|
+
raise ValueError("Body is None")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
res = json.loads(self.body)
|
|
61
|
+
return pydash.get(res, path, None) if path else res
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
if none_on_error:
|
|
64
|
+
return None
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
def get_header(self, name: str) -> str | None:
|
|
68
|
+
"""Get header value (case-insensitive)."""
|
|
69
|
+
if self.headers is None:
|
|
70
|
+
return None
|
|
71
|
+
name_lower = name.lower()
|
|
72
|
+
for key, value in self.headers.items():
|
|
73
|
+
if key.lower() == name_lower:
|
|
74
|
+
return value
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def is_success(self) -> bool:
|
|
78
|
+
"""Check if response has 2xx status."""
|
|
79
|
+
return self.status_code is not None and 200 <= self.status_code < 300
|
|
80
|
+
|
|
81
|
+
def is_err(self) -> bool:
|
|
82
|
+
"""Check if response represents an error (has transport error or status >= 400)."""
|
|
83
|
+
return self.transport_error is not None or (self.status_code is not None and self.status_code >= 400)
|
|
84
|
+
|
|
85
|
+
def to_result_err[T](self, error: str | Exception | tuple[str, Exception] | None = None) -> Result[T]:
|
|
86
|
+
"""Create error Result[T] from HttpResponse with meaningful error message."""
|
|
87
|
+
if error is not None:
|
|
88
|
+
result_error = error
|
|
89
|
+
elif self.transport_error is not None:
|
|
90
|
+
result_error = self.transport_error.type
|
|
91
|
+
elif self.status_code is not None:
|
|
92
|
+
result_error = f"HTTP {self.status_code}"
|
|
93
|
+
else:
|
|
94
|
+
result_error = "error"
|
|
95
|
+
return Result.err(result_error, extra=self.model_dump(mode="json"))
|
|
96
|
+
|
|
97
|
+
def to_result_ok[T](self, value: T) -> Result[T]:
|
|
98
|
+
"""Create success Result[T] from HttpResponse with given value."""
|
|
99
|
+
return Result.ok(value, extra=self.model_dump(mode="json"))
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def content_type(self) -> str | None:
|
|
103
|
+
"""Get Content-Type header value (case-insensitive)."""
|
|
104
|
+
return self.get_header("content-type")
|
|
@@ -5,19 +5,19 @@ from urllib.parse import urlencode, urlparse
|
|
|
5
5
|
from pytest_httpserver import HTTPServer
|
|
6
6
|
from werkzeug import Request, Response
|
|
7
7
|
|
|
8
|
-
from mm_http import
|
|
8
|
+
from mm_http import TransportError, http_request
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
async def test_json_path(httpserver: HTTPServer):
|
|
12
12
|
httpserver.expect_request("/test").respond_with_json({"a": {"b": {"c": 123}}})
|
|
13
13
|
res = await http_request(httpserver.url_for("/test"))
|
|
14
|
-
assert res.
|
|
14
|
+
assert res.parse_json("a.b.c") == 123
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
async def test_body_as_json_path_not_exists(httpserver: HTTPServer):
|
|
18
18
|
httpserver.expect_request("/test").respond_with_json({"d": 1})
|
|
19
19
|
res = await http_request(httpserver.url_for("/test"))
|
|
20
|
-
assert res.
|
|
20
|
+
assert res.parse_json("a.b.c") is None
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
async def test_body_as_json_no_body(httpserver: HTTPServer):
|
|
@@ -26,7 +26,7 @@ async def test_body_as_json_no_body(httpserver: HTTPServer):
|
|
|
26
26
|
|
|
27
27
|
httpserver.expect_request("/test").respond_with_handler(handler)
|
|
28
28
|
res = await http_request(httpserver.url_for("/test"))
|
|
29
|
-
assert res.
|
|
29
|
+
assert res.parse_json("a.b.c", none_on_error=True) is None
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
async def test_custom_user_agent(httpserver: HTTPServer):
|
|
@@ -36,21 +36,21 @@ async def test_custom_user_agent(httpserver: HTTPServer):
|
|
|
36
36
|
httpserver.expect_request("/test").respond_with_handler(handler)
|
|
37
37
|
user_agent = "moon cat"
|
|
38
38
|
res = await http_request(httpserver.url_for("/test"), user_agent=user_agent)
|
|
39
|
-
assert res.
|
|
39
|
+
assert res.parse_json()["user-agent"] == user_agent
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
async def test_params(httpserver: HTTPServer):
|
|
43
43
|
data = {"a": 123, "b": "bla bla"}
|
|
44
44
|
httpserver.expect_request("/test", query_string="a=123&b=bla+bla").respond_with_json(data)
|
|
45
45
|
res = await http_request(httpserver.url_for("/test"), params=data)
|
|
46
|
-
assert res.
|
|
46
|
+
assert res.parse_json() == data
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
async def test_post_with_params(httpserver: HTTPServer):
|
|
50
50
|
data = {"a": 1}
|
|
51
51
|
httpserver.expect_request("/test", query_string=urlencode(data)).respond_with_json(data)
|
|
52
52
|
res = await http_request(httpserver.url_for("/test"), params=data)
|
|
53
|
-
assert res.
|
|
53
|
+
assert res.parse_json() == data
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
async def test_timeout(httpserver: HTTPServer):
|
|
@@ -60,26 +60,26 @@ async def test_timeout(httpserver: HTTPServer):
|
|
|
60
60
|
|
|
61
61
|
httpserver.expect_request("/test").respond_with_handler(handler)
|
|
62
62
|
res = await http_request(httpserver.url_for("/test"), timeout=1)
|
|
63
|
-
assert res.
|
|
63
|
+
assert res.transport_error.type == TransportError.TIMEOUT
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
async def test_proxy_http(proxy_http: str):
|
|
67
67
|
proxy = urlparse(proxy_http)
|
|
68
68
|
res = await http_request("https://api.ipify.org?format=json", proxy=proxy_http, timeout=5)
|
|
69
|
-
assert proxy.hostname in res.
|
|
69
|
+
assert proxy.hostname in res.parse_json()["ip"]
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
async def test_proxy_socks5(proxy_socks5):
|
|
73
73
|
proxy = urlparse(proxy_socks5)
|
|
74
74
|
res = await http_request("https://api.ipify.org?format=json", proxy=proxy_socks5, timeout=5)
|
|
75
|
-
assert proxy.hostname in res.
|
|
75
|
+
assert proxy.hostname in res.parse_json()["ip"]
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
async def test_http_request_invalid_url() -> None:
|
|
79
79
|
"""Test that http_request returns INVALID_URL error for malformed URLs."""
|
|
80
80
|
response = await http_request("not-a-valid-url")
|
|
81
|
-
assert response.
|
|
82
|
-
assert response.
|
|
81
|
+
assert response.transport_error.type == TransportError.INVALID_URL
|
|
82
|
+
assert response.transport_error.message is not None
|
|
83
83
|
assert response.status_code is None
|
|
84
84
|
assert response.body is None
|
|
85
85
|
assert response.headers is None
|
|
@@ -88,8 +88,26 @@ async def test_http_request_invalid_url() -> None:
|
|
|
88
88
|
async def test_http_request_invalid_url_with_proxy() -> None:
|
|
89
89
|
"""Test that http_request returns INVALID_URL error for malformed URLs even with proxy."""
|
|
90
90
|
response = await http_request("not-a-valid-url", proxy="http://proxy.example.com:8080")
|
|
91
|
-
assert response.
|
|
92
|
-
assert response.
|
|
91
|
+
assert response.transport_error.type == TransportError.INVALID_URL
|
|
92
|
+
assert response.transport_error.message is not None
|
|
93
93
|
assert response.status_code is None
|
|
94
94
|
assert response.body is None
|
|
95
95
|
assert response.headers is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_connection_error_refused() -> None:
|
|
99
|
+
"""Test CONNECTION error when port is not listening."""
|
|
100
|
+
response = await http_request("http://localhost:59999/test", timeout=2)
|
|
101
|
+
assert response.transport_error.type == TransportError.CONNECTION
|
|
102
|
+
assert response.transport_error.message is not None
|
|
103
|
+
assert response.status_code is None
|
|
104
|
+
assert response.body is None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_connection_error_dns() -> None:
|
|
108
|
+
"""Test CONNECTION error when DNS resolution fails."""
|
|
109
|
+
response = await http_request("http://this-host-does-not-exist-xyz123.invalid/", timeout=5)
|
|
110
|
+
assert response.transport_error.type == TransportError.CONNECTION
|
|
111
|
+
assert response.transport_error.message is not None
|
|
112
|
+
assert response.status_code is None
|
|
113
|
+
assert response.body is None
|