mm-http 0.2.5__tar.gz → 0.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.
- mm_http-0.3.1/CLAUDE.md +24 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/PKG-INFO +4 -5
- {mm_http-0.2.5 → mm_http-0.3.1}/README.md +32 -20
- {mm_http-0.2.5 → mm_http-0.3.1}/pyproject.toml +13 -13
- mm_http-0.3.1/src/mm_http/__init__.py +7 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/src/mm_http/request.py +47 -17
- mm_http-0.3.1/src/mm_http/request_sync.py +68 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/src/mm_http/response.py +26 -19
- mm_http-0.3.1/tests/conftest.py +97 -0
- mm_http-0.3.1/tests/helpers/__init__.py +1 -0
- mm_http-0.3.1/tests/helpers/proxy.py +71 -0
- mm_http-0.3.1/tests/mm_http/__init__.py +0 -0
- mm_http-0.3.1/tests/mm_http/test_request.py +267 -0
- mm_http-0.3.1/tests/mm_http/test_request_sync.py +267 -0
- mm_http-0.3.1/tests/mm_http/test_response.py +270 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/uv.lock +57 -304
- mm_http-0.2.5/ADR.md +0 -3
- mm_http-0.2.5/CLAUDE.md +0 -19
- mm_http-0.2.5/src/mm_http/__init__.py +0 -5
- mm_http-0.2.5/src/mm_http/request_sync.py +0 -71
- mm_http-0.2.5/tests/conftest.py +0 -22
- mm_http-0.2.5/tests/test_request.py +0 -113
- mm_http-0.2.5/tests/test_request_sync.py +0 -113
- mm_http-0.2.5/tests/test_response.py +0 -163
- {mm_http-0.2.5 → mm_http-0.3.1}/.claude/settings.local.json +0 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/.env.example +0 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/.gitignore +0 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/.pre-commit-config.yaml +0 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/justfile +0 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/src/mm_http/py.typed +0 -0
- {mm_http-0.2.5 → mm_http-0.3.1}/tests/__init__.py +0 -0
mm_http-0.3.1/CLAUDE.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# AI Agent Start Guide
|
|
2
|
+
|
|
3
|
+
## Critical: Language
|
|
4
|
+
RESPOND IN ENGLISH. Always. No exceptions.
|
|
5
|
+
User's language does NOT determine your response language.
|
|
6
|
+
Only switch if user EXPLICITLY requests it (e.g., "respond in {language}").
|
|
7
|
+
Language switching applies ONLY to chat. All code, comments, commit messages, and files must ALWAYS be in English — no exceptions.
|
|
8
|
+
|
|
9
|
+
## Mandatory Rules (external)
|
|
10
|
+
These files are REQUIRED. Read them fully and follow all rules.
|
|
11
|
+
- `~/.claude/shared-rules/general.md`
|
|
12
|
+
- `~/.claude/shared-rules/python.md`
|
|
13
|
+
|
|
14
|
+
## Project Reading (context)
|
|
15
|
+
These files are REQUIRED for project understanding.
|
|
16
|
+
- `README.md`
|
|
17
|
+
|
|
18
|
+
## Preflight (mandatory)
|
|
19
|
+
Before your first response:
|
|
20
|
+
1. Read all files listed above.
|
|
21
|
+
2. Do not answer until all are read.
|
|
22
|
+
3. In your first reply, list every file you have read from this document.
|
|
23
|
+
|
|
24
|
+
Failure to follow this protocol is considered an error.
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mm-http
|
|
3
|
-
Version: 0.
|
|
4
|
-
Requires-Python: >=3.
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Requires-Python: >=3.14
|
|
5
5
|
Requires-Dist: aiohttp-socks~=0.11.0
|
|
6
6
|
Requires-Dist: aiohttp~=3.13.3
|
|
7
|
-
Requires-Dist: mm-result~=0.
|
|
7
|
+
Requires-Dist: mm-result~=0.2.0
|
|
8
8
|
Requires-Dist: pydantic~=2.12.5
|
|
9
|
-
Requires-Dist: pydash~=8.0.
|
|
9
|
+
Requires-Dist: pydash~=8.0.6
|
|
10
10
|
Requires-Dist: requests[socks]~=2.32.5
|
|
11
|
-
Requires-Dist: urllib3~=2.6.3
|
|
@@ -6,7 +6,7 @@ A simple and convenient HTTP client library for Python with both synchronous and
|
|
|
6
6
|
|
|
7
7
|
- **Simple API** for one-off HTTP requests
|
|
8
8
|
- **Sync and Async** support with identical interfaces
|
|
9
|
-
- **JSON path navigation** with dot notation (`response.
|
|
9
|
+
- **JSON path navigation** with dot notation (`response.json_body("user.profile.name")`)
|
|
10
10
|
- **Proxy support** (HTTP and SOCKS5)
|
|
11
11
|
- **Unified error handling**
|
|
12
12
|
- **Type-safe** with full type annotations
|
|
@@ -21,7 +21,8 @@ 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
|
-
|
|
24
|
+
result = response.json_body("name") # Navigate JSON with dot notation
|
|
25
|
+
user_name = result.value if result.is_ok() else None
|
|
25
26
|
|
|
26
27
|
# POST with JSON data
|
|
27
28
|
response = await http_request(
|
|
@@ -45,7 +46,8 @@ from mm_http import http_request_sync
|
|
|
45
46
|
|
|
46
47
|
# Same API, but synchronous
|
|
47
48
|
response = http_request_sync("https://api.github.com/users/octocat")
|
|
48
|
-
|
|
49
|
+
result = response.json_body("name")
|
|
50
|
+
user_name = result.value if result.is_ok() else None
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
## API Reference
|
|
@@ -63,10 +65,12 @@ user_name = response.parse_json("name")
|
|
|
63
65
|
- `data: dict[str, Any] | None = None` - Form data
|
|
64
66
|
- `json: dict[str, Any] | None = None` - JSON data
|
|
65
67
|
- `headers: dict[str, Any] | None = None` - HTTP headers
|
|
66
|
-
- `cookies:
|
|
68
|
+
- `cookies: dict[str, str] | None = None` - Cookies
|
|
67
69
|
- `user_agent: str | None = None` - User-Agent header
|
|
68
70
|
- `proxy: str | None = None` - Proxy URL (supports http://, https://, socks4://, socks5://)
|
|
69
71
|
- `timeout: float | None = 10.0` - Request timeout in seconds
|
|
72
|
+
- `verify_ssl: bool = True` - Enable/disable SSL certificate verification
|
|
73
|
+
- `follow_redirects: bool = True` - Enable/disable following redirects
|
|
70
74
|
|
|
71
75
|
### HttpResponse
|
|
72
76
|
|
|
@@ -75,10 +79,10 @@ class HttpResponse(BaseModel):
|
|
|
75
79
|
status_code: int | None
|
|
76
80
|
body: str | None
|
|
77
81
|
headers: dict[str, str] | None
|
|
78
|
-
transport_error:
|
|
82
|
+
transport_error: TransportError | None
|
|
79
83
|
|
|
80
84
|
# JSON parsing
|
|
81
|
-
def
|
|
85
|
+
def json_body(self, path: str | None = None) -> Result[Any]
|
|
82
86
|
|
|
83
87
|
# Header access
|
|
84
88
|
def get_header(self, name: str) -> str | None
|
|
@@ -95,15 +99,15 @@ class HttpResponse(BaseModel):
|
|
|
95
99
|
# Pydantic methods
|
|
96
100
|
def model_dump(self, mode: str = "python") -> dict[str, Any]
|
|
97
101
|
|
|
98
|
-
class
|
|
99
|
-
type:
|
|
102
|
+
class TransportError(BaseModel):
|
|
103
|
+
type: TransportErrorType
|
|
100
104
|
message: str
|
|
101
105
|
```
|
|
102
106
|
|
|
103
107
|
### Error Types
|
|
104
108
|
|
|
105
109
|
```python
|
|
106
|
-
class
|
|
110
|
+
class TransportErrorType(str, Enum):
|
|
107
111
|
TIMEOUT = "timeout"
|
|
108
112
|
PROXY = "proxy"
|
|
109
113
|
INVALID_URL = "invalid_url"
|
|
@@ -119,14 +123,20 @@ class TransportError(str, Enum):
|
|
|
119
123
|
response = await http_request("https://api.github.com/users/octocat")
|
|
120
124
|
|
|
121
125
|
# Instead of: json.loads(response.body)["plan"]["name"]
|
|
122
|
-
|
|
126
|
+
result = response.json_body("plan.name")
|
|
127
|
+
if result.is_ok():
|
|
128
|
+
plan_name = result.value
|
|
123
129
|
|
|
124
|
-
#
|
|
125
|
-
followers = response.
|
|
126
|
-
nonexistent = response.
|
|
130
|
+
# Navigate with dot notation
|
|
131
|
+
followers = response.json_body("followers_count")
|
|
132
|
+
nonexistent = response.json_body("does.not.exist") # Result with error
|
|
127
133
|
|
|
128
|
-
#
|
|
129
|
-
|
|
134
|
+
# Get full JSON
|
|
135
|
+
result = response.json_body()
|
|
136
|
+
if result.is_ok():
|
|
137
|
+
data = result.value
|
|
138
|
+
else:
|
|
139
|
+
print(f"Error: {result.error}") # "body is None", "JSON decode error", or "path not found: ..."
|
|
130
140
|
```
|
|
131
141
|
|
|
132
142
|
### Error Handling
|
|
@@ -136,7 +146,7 @@ response = await http_request("https://example.com", timeout=5.0)
|
|
|
136
146
|
|
|
137
147
|
# Simple check
|
|
138
148
|
if response.is_success():
|
|
139
|
-
|
|
149
|
+
result = response.json_body()
|
|
140
150
|
|
|
141
151
|
# Detailed error handling
|
|
142
152
|
if response.is_err():
|
|
@@ -188,17 +198,19 @@ async def get_user_id() -> Result[int]:
|
|
|
188
198
|
response = await http_request("https://api.example.com/user")
|
|
189
199
|
|
|
190
200
|
if response.is_err():
|
|
191
|
-
return response.to_result_err() # Returns "HTTP 404" or
|
|
201
|
+
return response.to_result_err() # Returns "HTTP 404" or TransportErrorType.TIMEOUT
|
|
192
202
|
|
|
193
|
-
|
|
194
|
-
|
|
203
|
+
result = response.json_body("id")
|
|
204
|
+
if result.is_err():
|
|
205
|
+
return response.to_result_err("invalid_json")
|
|
206
|
+
return response.to_result_ok(result.value)
|
|
195
207
|
|
|
196
208
|
# Usage
|
|
197
209
|
result = await get_user_id()
|
|
198
210
|
if result.is_ok():
|
|
199
211
|
print(f"User ID: {result.value}")
|
|
200
212
|
else:
|
|
201
|
-
print(f"Error: {result.error}") # "HTTP 404" or
|
|
213
|
+
print(f"Error: {result.error}") # "HTTP 404" or TransportErrorType.TIMEOUT
|
|
202
214
|
print(f"HTTP details: {result.extra}") # Contains full HTTP response data
|
|
203
215
|
```
|
|
204
216
|
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mm-http"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = ""
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
6
|
dependencies = [
|
|
7
|
-
"mm-result~=0.
|
|
7
|
+
"mm-result~=0.2.0",
|
|
8
8
|
"requests[socks]~=2.32.5",
|
|
9
9
|
"aiohttp~=3.13.3",
|
|
10
10
|
"aiohttp-socks~=0.11.0",
|
|
11
|
-
"pydash~=8.0.
|
|
11
|
+
"pydash~=8.0.6",
|
|
12
12
|
"pydantic~=2.12.5",
|
|
13
|
-
"urllib3~=2.6.3", # CVE-2026-21441
|
|
14
13
|
]
|
|
15
14
|
|
|
16
15
|
[build-system]
|
|
@@ -23,25 +22,25 @@ dev = [
|
|
|
23
22
|
"pytest-asyncio~=1.3.0",
|
|
24
23
|
"pytest-xdist~=3.8.0",
|
|
25
24
|
"pytest-httpserver~=1.1.3",
|
|
26
|
-
"ruff~=0.
|
|
25
|
+
"ruff~=0.15.0",
|
|
27
26
|
"mypy~=1.19.1",
|
|
28
27
|
"pip-audit~=2.10.0",
|
|
29
|
-
"bandit~=1.9.
|
|
28
|
+
"bandit~=1.9.3",
|
|
30
29
|
"pre-commit~=4.5.1",
|
|
31
30
|
"types-requests~=2.32.4.20260107",
|
|
32
31
|
"python-dotenv~=1.2.1",
|
|
33
|
-
"ty~=0.0.
|
|
32
|
+
"ty~=0.0.14",
|
|
34
33
|
]
|
|
35
34
|
|
|
36
35
|
[tool.mypy]
|
|
37
|
-
python_version = "3.
|
|
36
|
+
python_version = "3.14"
|
|
38
37
|
warn_no_return = false
|
|
39
38
|
strict = true
|
|
40
39
|
exclude = ["^tests/", "^tmp/"]
|
|
41
40
|
|
|
42
41
|
[tool.ruff]
|
|
43
42
|
line-length = 130
|
|
44
|
-
target-version = "
|
|
43
|
+
target-version = "py314"
|
|
45
44
|
[tool.ruff.lint]
|
|
46
45
|
select = ["ALL"]
|
|
47
46
|
ignore = [
|
|
@@ -49,7 +48,6 @@ ignore = [
|
|
|
49
48
|
"A005", # flake8-builtins: stdlib-module-shadowing
|
|
50
49
|
"ERA001", # eradicate: commented-out-code
|
|
51
50
|
"PT", # flake8-pytest-style
|
|
52
|
-
"D", # pydocstyle
|
|
53
51
|
"FIX", # flake8-fixme
|
|
54
52
|
"PLR0911", # pylint: too-many-return-statements
|
|
55
53
|
"PLR0912", # pylint: too-many-branches
|
|
@@ -68,17 +66,19 @@ ignore = [
|
|
|
68
66
|
"COM812", # it's used in ruff formatter
|
|
69
67
|
"ASYNC109",
|
|
70
68
|
"G004",
|
|
69
|
+
"D203", # pydocstyle: one-blank-line-before-class (conflicts with D211)
|
|
70
|
+
"D213", # pydocstyle: multi-line-summary-second-line (conflicts with D212)
|
|
71
71
|
]
|
|
72
72
|
[tool.ruff.lint.pep8-naming]
|
|
73
73
|
classmethod-decorators = ["field_validator"]
|
|
74
74
|
[tool.ruff.lint.per-file-ignores]
|
|
75
|
-
"tests/*.py" = ["ANN", "S"]
|
|
75
|
+
"tests/*.py" = ["ANN", "S", "D"]
|
|
76
76
|
[tool.ruff.format]
|
|
77
77
|
quote-style = "double"
|
|
78
78
|
indent-style = "space"
|
|
79
79
|
|
|
80
80
|
[tool.ty.environment]
|
|
81
|
-
python-version = "3.
|
|
81
|
+
python-version = "3.14"
|
|
82
82
|
[[tool.ty.overrides]]
|
|
83
83
|
include = ["tests/**"]
|
|
84
84
|
[tool.ty.overrides.rules]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""HTTP client library with sync and async support."""
|
|
2
|
+
|
|
3
|
+
from .request import http_request as http_request
|
|
4
|
+
from .request_sync import http_request_sync as http_request_sync
|
|
5
|
+
from .response import HttpResponse as HttpResponse
|
|
6
|
+
from .response import TransportError as TransportError
|
|
7
|
+
from .response import TransportErrorType as TransportErrorType
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Async HTTP request implementation using aiohttp."""
|
|
2
|
+
|
|
1
3
|
from typing import Any
|
|
2
4
|
|
|
3
5
|
import aiohttp
|
|
@@ -10,11 +12,10 @@ from aiohttp import (
|
|
|
10
12
|
ServerConnectionError,
|
|
11
13
|
ServerDisconnectedError,
|
|
12
14
|
)
|
|
13
|
-
from aiohttp.typedefs import LooseCookies
|
|
14
15
|
from aiohttp_socks import ProxyConnectionError, ProxyConnector
|
|
15
16
|
from multidict import CIMultiDictProxy
|
|
16
17
|
|
|
17
|
-
from .response import HttpResponse,
|
|
18
|
+
from .response import HttpResponse, TransportErrorType
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
async def http_request(
|
|
@@ -25,14 +26,14 @@ async def http_request(
|
|
|
25
26
|
data: dict[str, Any] | None = None,
|
|
26
27
|
json: dict[str, Any] | None = None,
|
|
27
28
|
headers: dict[str, Any] | None = None,
|
|
28
|
-
cookies:
|
|
29
|
+
cookies: dict[str, str] | None = None,
|
|
29
30
|
user_agent: str | None = None,
|
|
30
31
|
proxy: str | None = None,
|
|
31
32
|
timeout: float | None = 10.0,
|
|
33
|
+
verify_ssl: bool = True,
|
|
34
|
+
follow_redirects: bool = True,
|
|
32
35
|
) -> HttpResponse:
|
|
33
|
-
"""
|
|
34
|
-
Send an HTTP request and return the response.
|
|
35
|
-
"""
|
|
36
|
+
"""Send an HTTP request and return the response."""
|
|
36
37
|
timeout_ = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
|
37
38
|
if user_agent:
|
|
38
39
|
if not headers:
|
|
@@ -51,6 +52,8 @@ async def http_request(
|
|
|
51
52
|
cookies=cookies,
|
|
52
53
|
proxy=proxy,
|
|
53
54
|
timeout=timeout_,
|
|
55
|
+
verify_ssl=verify_ssl,
|
|
56
|
+
follow_redirects=follow_redirects,
|
|
54
57
|
)
|
|
55
58
|
return await _request_with_http_or_none_proxy(
|
|
56
59
|
url,
|
|
@@ -62,13 +65,15 @@ async def http_request(
|
|
|
62
65
|
cookies=cookies,
|
|
63
66
|
proxy=proxy,
|
|
64
67
|
timeout=timeout_,
|
|
68
|
+
verify_ssl=verify_ssl,
|
|
69
|
+
follow_redirects=follow_redirects,
|
|
65
70
|
)
|
|
66
71
|
except TimeoutError as err:
|
|
67
|
-
return HttpResponse(
|
|
72
|
+
return HttpResponse.from_transport_error(TransportErrorType.TIMEOUT, str(err))
|
|
68
73
|
except (aiohttp.ClientProxyConnectionError, ProxyConnectionError, ClientHttpProxyError) as err:
|
|
69
|
-
return HttpResponse(
|
|
70
|
-
except InvalidUrlClientError as
|
|
71
|
-
return HttpResponse(
|
|
74
|
+
return HttpResponse.from_transport_error(TransportErrorType.PROXY, str(err))
|
|
75
|
+
except InvalidUrlClientError as err:
|
|
76
|
+
return HttpResponse.from_transport_error(TransportErrorType.INVALID_URL, str(err))
|
|
72
77
|
except (
|
|
73
78
|
ClientConnectorError,
|
|
74
79
|
ServerConnectionError,
|
|
@@ -76,9 +81,9 @@ async def http_request(
|
|
|
76
81
|
ClientSSLError,
|
|
77
82
|
ClientConnectionError,
|
|
78
83
|
) as err:
|
|
79
|
-
return HttpResponse(
|
|
84
|
+
return HttpResponse.from_transport_error(TransportErrorType.CONNECTION, str(err))
|
|
80
85
|
except Exception as err:
|
|
81
|
-
return HttpResponse(
|
|
86
|
+
return HttpResponse.from_transport_error(TransportErrorType.ERROR, str(err))
|
|
82
87
|
|
|
83
88
|
|
|
84
89
|
async def _request_with_http_or_none_proxy(
|
|
@@ -89,12 +94,25 @@ async def _request_with_http_or_none_proxy(
|
|
|
89
94
|
data: dict[str, Any] | None = None,
|
|
90
95
|
json: dict[str, Any] | None = None,
|
|
91
96
|
headers: dict[str, Any] | None = None,
|
|
92
|
-
cookies:
|
|
97
|
+
cookies: dict[str, str] | None = None,
|
|
93
98
|
proxy: str | None = None,
|
|
94
99
|
timeout: aiohttp.ClientTimeout | None,
|
|
100
|
+
verify_ssl: bool,
|
|
101
|
+
follow_redirects: bool,
|
|
95
102
|
) -> HttpResponse:
|
|
103
|
+
"""Execute request with HTTP proxy or no proxy."""
|
|
96
104
|
async with aiohttp.request(
|
|
97
|
-
method,
|
|
105
|
+
method,
|
|
106
|
+
url,
|
|
107
|
+
params=params,
|
|
108
|
+
data=data,
|
|
109
|
+
json=json,
|
|
110
|
+
headers=headers,
|
|
111
|
+
cookies=cookies,
|
|
112
|
+
proxy=proxy,
|
|
113
|
+
timeout=timeout,
|
|
114
|
+
ssl=verify_ssl,
|
|
115
|
+
allow_redirects=follow_redirects,
|
|
98
116
|
) as res:
|
|
99
117
|
return HttpResponse(
|
|
100
118
|
status_code=res.status,
|
|
@@ -112,14 +130,25 @@ async def _request_with_socks_proxy(
|
|
|
112
130
|
data: dict[str, Any] | None = None,
|
|
113
131
|
json: dict[str, Any] | None = None,
|
|
114
132
|
headers: dict[str, Any] | None = None,
|
|
115
|
-
cookies:
|
|
133
|
+
cookies: dict[str, str] | None = None,
|
|
116
134
|
timeout: aiohttp.ClientTimeout | None,
|
|
135
|
+
verify_ssl: bool,
|
|
136
|
+
follow_redirects: bool,
|
|
117
137
|
) -> HttpResponse:
|
|
118
|
-
|
|
138
|
+
"""Execute request through SOCKS proxy."""
|
|
139
|
+
connector = ProxyConnector.from_url(proxy, ssl=verify_ssl)
|
|
119
140
|
async with (
|
|
120
141
|
aiohttp.ClientSession(connector=connector) as session,
|
|
121
142
|
session.request(
|
|
122
|
-
method,
|
|
143
|
+
method,
|
|
144
|
+
url,
|
|
145
|
+
params=params,
|
|
146
|
+
data=data,
|
|
147
|
+
json=json,
|
|
148
|
+
headers=headers,
|
|
149
|
+
cookies=cookies,
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
allow_redirects=follow_redirects,
|
|
123
152
|
) as res,
|
|
124
153
|
):
|
|
125
154
|
return HttpResponse(
|
|
@@ -130,6 +159,7 @@ async def _request_with_socks_proxy(
|
|
|
130
159
|
|
|
131
160
|
|
|
132
161
|
def headers_dict(headers: CIMultiDictProxy[str]) -> dict[str, str]:
|
|
162
|
+
"""Convert multidict headers to dict, joining duplicate keys with comma."""
|
|
133
163
|
result: dict[str, str] = {}
|
|
134
164
|
for key in headers:
|
|
135
165
|
values = headers.getall(key)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Sync HTTP request implementation using requests library."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from requests.exceptions import ConnectionError as RequestsConnectionError
|
|
7
|
+
from requests.exceptions import InvalidSchema, MissingSchema, ProxyError, SSLError
|
|
8
|
+
|
|
9
|
+
from .response import HttpResponse, TransportErrorType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def http_request_sync(
|
|
13
|
+
url: str,
|
|
14
|
+
*,
|
|
15
|
+
method: str = "GET",
|
|
16
|
+
params: dict[str, Any] | None = None,
|
|
17
|
+
data: dict[str, Any] | None = None,
|
|
18
|
+
json: dict[str, Any] | None = None,
|
|
19
|
+
headers: dict[str, Any] | None = None,
|
|
20
|
+
cookies: dict[str, str] | None = None,
|
|
21
|
+
user_agent: str | None = None,
|
|
22
|
+
proxy: str | None = None,
|
|
23
|
+
timeout: float | None = 10.0,
|
|
24
|
+
verify_ssl: bool = True,
|
|
25
|
+
follow_redirects: bool = True,
|
|
26
|
+
) -> HttpResponse:
|
|
27
|
+
"""Send a synchronous HTTP request and return the response."""
|
|
28
|
+
if user_agent:
|
|
29
|
+
if headers is None:
|
|
30
|
+
headers = {}
|
|
31
|
+
headers["User-Agent"] = user_agent
|
|
32
|
+
|
|
33
|
+
proxies: dict[str, str] | None = None
|
|
34
|
+
if proxy:
|
|
35
|
+
proxies = {
|
|
36
|
+
"http": proxy,
|
|
37
|
+
"https": proxy,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
res = requests.request(
|
|
42
|
+
method=method,
|
|
43
|
+
url=url,
|
|
44
|
+
params=params,
|
|
45
|
+
data=data,
|
|
46
|
+
json=json,
|
|
47
|
+
headers=headers,
|
|
48
|
+
cookies=cookies,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
proxies=proxies,
|
|
51
|
+
verify=verify_ssl,
|
|
52
|
+
allow_redirects=follow_redirects,
|
|
53
|
+
)
|
|
54
|
+
return HttpResponse(
|
|
55
|
+
status_code=res.status_code,
|
|
56
|
+
body=res.text,
|
|
57
|
+
headers=dict(res.headers),
|
|
58
|
+
)
|
|
59
|
+
except requests.Timeout as err:
|
|
60
|
+
return HttpResponse.from_transport_error(TransportErrorType.TIMEOUT, str(err))
|
|
61
|
+
except ProxyError as err:
|
|
62
|
+
return HttpResponse.from_transport_error(TransportErrorType.PROXY, str(err))
|
|
63
|
+
except (InvalidSchema, MissingSchema) as err:
|
|
64
|
+
return HttpResponse.from_transport_error(TransportErrorType.INVALID_URL, str(err))
|
|
65
|
+
except (RequestsConnectionError, SSLError) as err:
|
|
66
|
+
return HttpResponse.from_transport_error(TransportErrorType.CONNECTION, str(err))
|
|
67
|
+
except Exception as err:
|
|
68
|
+
return HttpResponse.from_transport_error(TransportErrorType.ERROR, str(err))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"""HTTP response models and error types."""
|
|
2
2
|
|
|
3
3
|
import enum
|
|
4
4
|
import json
|
|
@@ -10,7 +10,9 @@ from pydantic import BaseModel, model_validator
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@enum.unique
|
|
13
|
-
class
|
|
13
|
+
class TransportErrorType(enum.StrEnum):
|
|
14
|
+
"""Transport-level error types."""
|
|
15
|
+
|
|
14
16
|
TIMEOUT = "timeout"
|
|
15
17
|
PROXY = "proxy"
|
|
16
18
|
INVALID_URL = "invalid_url"
|
|
@@ -18,10 +20,10 @@ class TransportError(str, enum.Enum):
|
|
|
18
20
|
ERROR = "error"
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
class
|
|
23
|
+
class TransportError(BaseModel):
|
|
22
24
|
"""Transport error with type and message."""
|
|
23
25
|
|
|
24
|
-
type:
|
|
26
|
+
type: TransportErrorType
|
|
25
27
|
message: str
|
|
26
28
|
|
|
27
29
|
|
|
@@ -31,7 +33,7 @@ class HttpResponse(BaseModel):
|
|
|
31
33
|
status_code: int | None = None
|
|
32
34
|
body: str | None = None
|
|
33
35
|
headers: dict[str, str] | None = None
|
|
34
|
-
transport_error:
|
|
36
|
+
transport_error: TransportError | None = None
|
|
35
37
|
|
|
36
38
|
@model_validator(mode="after")
|
|
37
39
|
def validate_mutually_exclusive_states(self) -> HttpResponse:
|
|
@@ -49,20 +51,25 @@ class HttpResponse(BaseModel):
|
|
|
49
51
|
|
|
50
52
|
return self
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return None
|
|
57
|
-
raise ValueError("Body is None")
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_transport_error(cls, error_type: TransportErrorType, message: str) -> HttpResponse:
|
|
56
|
+
"""Create HttpResponse from transport error."""
|
|
57
|
+
return cls(transport_error=TransportError(type=error_type, message=message))
|
|
58
58
|
|
|
59
|
+
def json_body(self, path: str | None = None) -> Result[Any]:
|
|
60
|
+
"""Parse body as JSON with explicit error handling."""
|
|
61
|
+
if self.body is None:
|
|
62
|
+
return Result.err("body is None")
|
|
59
63
|
try:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
data = json.loads(self.body)
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
return Result.err(("JSON decode error", e))
|
|
67
|
+
|
|
68
|
+
if path:
|
|
69
|
+
if not pydash.has(data, path):
|
|
70
|
+
return Result.err(f"path not found: {path}")
|
|
71
|
+
return Result.ok(pydash.get(data, path))
|
|
72
|
+
return Result.ok(data)
|
|
66
73
|
|
|
67
74
|
def get_header(self, name: str) -> str | None:
|
|
68
75
|
"""Get header value (case-insensitive)."""
|
|
@@ -101,11 +108,11 @@ class HttpResponse(BaseModel):
|
|
|
101
108
|
result_error = f"HTTP {self.status_code}"
|
|
102
109
|
else:
|
|
103
110
|
result_error = "error"
|
|
104
|
-
return Result.err(result_error,
|
|
111
|
+
return Result.err(result_error, context=self.model_dump(mode="json"))
|
|
105
112
|
|
|
106
113
|
def to_result_ok[T](self, value: T) -> Result[T]:
|
|
107
114
|
"""Create success Result[T] from HttpResponse with given value."""
|
|
108
|
-
return Result.ok(value,
|
|
115
|
+
return Result.ok(value, context=self.model_dump(mode="json"))
|
|
109
116
|
|
|
110
117
|
@property
|
|
111
118
|
def content_type(self) -> str | None:
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
from pytest_httpserver import HTTPServer
|
|
10
|
+
from werkzeug import Request, Response
|
|
11
|
+
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def proxy_http() -> str:
|
|
17
|
+
proxy = os.getenv("PROXY_HTTP")
|
|
18
|
+
if not proxy:
|
|
19
|
+
raise ValueError("PROXY_HTTP environment variable must be set")
|
|
20
|
+
return proxy
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def proxy_socks5() -> str:
|
|
25
|
+
proxy = os.getenv("PROXY_SOCKS5")
|
|
26
|
+
if not proxy:
|
|
27
|
+
raise ValueError("PROXY_SOCKS5 environment variable must be set")
|
|
28
|
+
return proxy
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def json_response_data() -> dict[str, Any]:
|
|
33
|
+
"""Sample nested JSON for testing."""
|
|
34
|
+
return {
|
|
35
|
+
"user": {"id": 123, "profile": {"name": "John Doe", "email": "john@example.com"}},
|
|
36
|
+
"items": [{"id": 1, "value": "a"}, {"id": 2, "value": "b"}],
|
|
37
|
+
"nullable_field": None,
|
|
38
|
+
"count": 42,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def setup_json_endpoint(httpserver: HTTPServer, json_response_data: dict[str, Any]) -> Callable[[], str]:
|
|
44
|
+
"""Configure JSON endpoint that returns sample data."""
|
|
45
|
+
|
|
46
|
+
def setup() -> str:
|
|
47
|
+
httpserver.expect_request("/json").respond_with_json(json_response_data)
|
|
48
|
+
return httpserver.url_for("/json")
|
|
49
|
+
|
|
50
|
+
return setup
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def setup_text_endpoint(httpserver: HTTPServer) -> Callable[[str, int], str]:
|
|
55
|
+
"""Configure text endpoint with custom content and status."""
|
|
56
|
+
|
|
57
|
+
def setup(content: str = "Hello, World!", status: int = 200) -> str:
|
|
58
|
+
httpserver.expect_request("/text").respond_with_data(content, status=status, content_type="text/plain")
|
|
59
|
+
return httpserver.url_for("/text")
|
|
60
|
+
|
|
61
|
+
return setup
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def setup_echo_endpoint(httpserver: HTTPServer) -> Callable[[], str]:
|
|
66
|
+
"""Configure echo endpoint that returns request details."""
|
|
67
|
+
|
|
68
|
+
def handler(request: Request) -> Response:
|
|
69
|
+
echo_data = {
|
|
70
|
+
"method": request.method,
|
|
71
|
+
"path": request.path,
|
|
72
|
+
"query_string": request.query_string.decode("utf-8"),
|
|
73
|
+
"headers": dict(request.headers),
|
|
74
|
+
"body": request.get_data(as_text=True),
|
|
75
|
+
}
|
|
76
|
+
return Response(json.dumps(echo_data), content_type="application/json")
|
|
77
|
+
|
|
78
|
+
def setup() -> str:
|
|
79
|
+
httpserver.expect_request("/echo").respond_with_handler(handler)
|
|
80
|
+
return httpserver.url_for("/echo")
|
|
81
|
+
|
|
82
|
+
return setup
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.fixture
|
|
86
|
+
def setup_timeout_endpoint(httpserver: HTTPServer) -> Callable[[float], str]:
|
|
87
|
+
"""Configure delayed response endpoint for timeout tests."""
|
|
88
|
+
|
|
89
|
+
def setup(delay: float = 2.0) -> str:
|
|
90
|
+
def handler(_request: Request) -> Response:
|
|
91
|
+
time.sleep(delay)
|
|
92
|
+
return Response("OK", content_type="text/plain")
|
|
93
|
+
|
|
94
|
+
httpserver.expect_request("/timeout").respond_with_handler(handler)
|
|
95
|
+
return httpserver.url_for("/timeout")
|
|
96
|
+
|
|
97
|
+
return setup
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test helpers package."""
|