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.
Files changed (31) hide show
  1. mm_http-0.3.1/CLAUDE.md +24 -0
  2. {mm_http-0.2.5 → mm_http-0.3.1}/PKG-INFO +4 -5
  3. {mm_http-0.2.5 → mm_http-0.3.1}/README.md +32 -20
  4. {mm_http-0.2.5 → mm_http-0.3.1}/pyproject.toml +13 -13
  5. mm_http-0.3.1/src/mm_http/__init__.py +7 -0
  6. {mm_http-0.2.5 → mm_http-0.3.1}/src/mm_http/request.py +47 -17
  7. mm_http-0.3.1/src/mm_http/request_sync.py +68 -0
  8. {mm_http-0.2.5 → mm_http-0.3.1}/src/mm_http/response.py +26 -19
  9. mm_http-0.3.1/tests/conftest.py +97 -0
  10. mm_http-0.3.1/tests/helpers/__init__.py +1 -0
  11. mm_http-0.3.1/tests/helpers/proxy.py +71 -0
  12. mm_http-0.3.1/tests/mm_http/__init__.py +0 -0
  13. mm_http-0.3.1/tests/mm_http/test_request.py +267 -0
  14. mm_http-0.3.1/tests/mm_http/test_request_sync.py +267 -0
  15. mm_http-0.3.1/tests/mm_http/test_response.py +270 -0
  16. {mm_http-0.2.5 → mm_http-0.3.1}/uv.lock +57 -304
  17. mm_http-0.2.5/ADR.md +0 -3
  18. mm_http-0.2.5/CLAUDE.md +0 -19
  19. mm_http-0.2.5/src/mm_http/__init__.py +0 -5
  20. mm_http-0.2.5/src/mm_http/request_sync.py +0 -71
  21. mm_http-0.2.5/tests/conftest.py +0 -22
  22. mm_http-0.2.5/tests/test_request.py +0 -113
  23. mm_http-0.2.5/tests/test_request_sync.py +0 -113
  24. mm_http-0.2.5/tests/test_response.py +0 -163
  25. {mm_http-0.2.5 → mm_http-0.3.1}/.claude/settings.local.json +0 -0
  26. {mm_http-0.2.5 → mm_http-0.3.1}/.env.example +0 -0
  27. {mm_http-0.2.5 → mm_http-0.3.1}/.gitignore +0 -0
  28. {mm_http-0.2.5 → mm_http-0.3.1}/.pre-commit-config.yaml +0 -0
  29. {mm_http-0.2.5 → mm_http-0.3.1}/justfile +0 -0
  30. {mm_http-0.2.5 → mm_http-0.3.1}/src/mm_http/py.typed +0 -0
  31. {mm_http-0.2.5 → mm_http-0.3.1}/tests/__init__.py +0 -0
@@ -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.2.5
4
- Requires-Python: >=3.13
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.1.3
7
+ Requires-Dist: mm-result~=0.2.0
8
8
  Requires-Dist: pydantic~=2.12.5
9
- Requires-Dist: pydash~=8.0.5
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.parse_json_body("user.profile.name")`)
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
- user_name = response.parse_json("name") # Navigate JSON with dot notation
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
- user_name = response.parse_json("name")
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: LooseCookies | None = None` - 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: TransportErrorDetail | None
82
+ transport_error: TransportError | None
79
83
 
80
84
  # JSON parsing
81
- def parse_json(self, path: str | None = None, none_on_error: bool = False) -> Any
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 TransportErrorDetail(BaseModel):
99
- type: TransportError
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 TransportError(str, Enum):
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
- plan_name = response.parse_json("plan.name")
126
+ result = response.json_body("plan.name")
127
+ if result.is_ok():
128
+ plan_name = result.value
123
129
 
124
- # Safe navigation - returns None if path doesn't exist
125
- followers = response.parse_json("followers_count")
126
- nonexistent = response.parse_json("does.not.exist") # Returns None
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
- # Or get full JSON
129
- data = response.parse_json()
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
- data = response.parse_json()
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 TransportError.TIMEOUT
201
+ return response.to_result_err() # Returns "HTTP 404" or TransportErrorType.TIMEOUT
192
202
 
193
- user_id = response.parse_json("id")
194
- return response.to_result_ok(user_id)
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 TransportError.TIMEOUT
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.2.5"
3
+ version = "0.3.1"
4
4
  description = ""
5
- requires-python = ">=3.13"
5
+ requires-python = ">=3.14"
6
6
  dependencies = [
7
- "mm-result~=0.1.3",
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.5",
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.14.13",
25
+ "ruff~=0.15.0",
27
26
  "mypy~=1.19.1",
28
27
  "pip-audit~=2.10.0",
29
- "bandit~=1.9.2",
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.12",
32
+ "ty~=0.0.14",
34
33
  ]
35
34
 
36
35
  [tool.mypy]
37
- python_version = "3.13"
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 = "py313"
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.13"
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, TransportError, TransportErrorDetail
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: LooseCookies | None = None,
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(transport_error=TransportErrorDetail(type=TransportError.TIMEOUT, message=str(err)))
72
+ return HttpResponse.from_transport_error(TransportErrorType.TIMEOUT, str(err))
68
73
  except (aiohttp.ClientProxyConnectionError, ProxyConnectionError, ClientHttpProxyError) as err:
69
- return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.PROXY, message=str(err)))
70
- except InvalidUrlClientError as e:
71
- return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.INVALID_URL, message=str(e)))
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(transport_error=TransportErrorDetail(type=TransportError.CONNECTION, message=str(err)))
84
+ return HttpResponse.from_transport_error(TransportErrorType.CONNECTION, str(err))
80
85
  except Exception as err:
81
- return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.ERROR, message=str(err)))
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: LooseCookies | None = None,
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, url, params=params, data=data, json=json, headers=headers, cookies=cookies, proxy=proxy, timeout=timeout
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: LooseCookies | None = None,
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
- connector = ProxyConnector.from_url(proxy)
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, url, params=params, data=data, json=json, headers=headers, cookies=cookies, timeout=timeout
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
- from __future__ import annotations
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 TransportError(str, enum.Enum):
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 TransportErrorDetail(BaseModel):
23
+ class TransportError(BaseModel):
22
24
  """Transport error with type and message."""
23
25
 
24
- type: TransportError
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: TransportErrorDetail | None = None
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
- 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")
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
- 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
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, extra=self.model_dump(mode="json"))
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, extra=self.model_dump(mode="json"))
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."""