tha-req-runner 0.1.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.
- tha_req_runner-0.1.0/.github/workflows/ci.yml +21 -0
- tha_req_runner-0.1.0/.github/workflows/publish.yml +21 -0
- tha_req_runner-0.1.0/.gitignore +8 -0
- tha_req_runner-0.1.0/PKG-INFO +130 -0
- tha_req_runner-0.1.0/README.md +112 -0
- tha_req_runner-0.1.0/pyproject.toml +31 -0
- tha_req_runner-0.1.0/src/tha_req_runner/__init__.py +7 -0
- tha_req_runner-0.1.0/src/tha_req_runner/errors.py +2 -0
- tha_req_runner-0.1.0/src/tha_req_runner/py.typed +0 -0
- tha_req_runner-0.1.0/src/tha_req_runner/runner.py +73 -0
- tha_req_runner-0.1.0/tests/conftest.py +8 -0
- tha_req_runner-0.1.0/tests/test_runner.py +158 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: pytest tests/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- run: pip install hatchling build
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tha-req-runner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Tabular Helper API library that wraps requests with thread-safe session reuse, automatic retries, and a normalized response dict.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: api,http,requests,retry,session
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: requests
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# tha-req-runner
|
|
20
|
+
|
|
21
|
+
[](https://github.com/tha-guy-nate/tha-req-runner/actions/workflows/ci.yml)
|
|
22
|
+
|
|
23
|
+
A small Python library that provides a thread-safe `requests.Session` with automatic retries and a normalized response parser. Intended as the HTTP transport layer for other `tha-*` runners.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install tha-req-runner
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from tha_req_runner import ThaReq
|
|
35
|
+
|
|
36
|
+
req = ThaReq()
|
|
37
|
+
session = req.get_session()
|
|
38
|
+
|
|
39
|
+
# safe_call wraps the try/except for you
|
|
40
|
+
result = req.safe_call(session.get, "https://api.example.com/students", params={"limit": 100})
|
|
41
|
+
# {"status": 200, "data": [...], "message": None, "raw_response": <Response>}
|
|
42
|
+
|
|
43
|
+
# network errors return the same shape — no try/except needed
|
|
44
|
+
result = req.safe_call(session.get, "https://unreachable.example.com")
|
|
45
|
+
# {"status": None, "data": None, "message": "Connection refused", "raw_response": None}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Response dict
|
|
49
|
+
|
|
50
|
+
Every method returns the same shape whether the call succeeded or raised:
|
|
51
|
+
|
|
52
|
+
| Key | Type | Description |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `status` | `int \| None` | HTTP status code, or `None` on network error |
|
|
55
|
+
| `data` | `object` | Parsed JSON body, or `None` if not JSON |
|
|
56
|
+
| `message` | `str \| None` | Exception message on error, otherwise `None` |
|
|
57
|
+
| `raw_response` | `Response \| None` | The raw `requests.Response` object |
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
### `ThaReq`
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
ThaReq()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `req.get_session()`
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
req.get_session(
|
|
71
|
+
*,
|
|
72
|
+
status_forcelist: tuple[int, ...] = (500, 502, 503, 504),
|
|
73
|
+
allowed_methods: Collection[str] | None = None,
|
|
74
|
+
) -> requests.Session
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Returns a `requests.Session` configured with automatic retries (`total=3`, `backoff_factor=0.5`). Config is applied only on the **first call per thread** — subsequent calls on the same thread return the cached session regardless of args. Two `ThaReq` instances never share a session.
|
|
78
|
+
|
|
79
|
+
`allowed_methods=None` uses urllib3's safe-method default, which **excludes POST**. To retry POST (e.g. token endpoints):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
session = req.get_session(
|
|
83
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
84
|
+
allowed_methods=frozenset(["GET", "POST"]),
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `ThaReq.parse_response()`
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
ThaReq.parse_response(result: requests.Response | Exception) -> dict[str, Any]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Normalizes a `requests.Response` or a caught `Exception` into a consistent dict. Also callable as `req.parse_response(result)` without instantiation.
|
|
95
|
+
|
|
96
|
+
### `req.safe_call()`
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
req.safe_call(fn, *args, **kwargs) -> dict[str, Any]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Calls `fn(*args, **kwargs)`, catches any exception, and returns a normalized response dict. Equivalent to:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
try:
|
|
106
|
+
result = req.parse_response(fn(*args, **kwargs))
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
result = req.parse_response(exc)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
result = req.safe_call(session.get, url, params={"limit": 100})
|
|
113
|
+
result = req.safe_call(session.post, token_url, data={"grant_type": "client_credentials"})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Session and retries
|
|
117
|
+
|
|
118
|
+
- **Thread-safe**: each thread gets its own session via `threading.local` on the instance
|
|
119
|
+
- **Retry defaults**: `total=3`, `backoff_factor=0.5` (delays: 0.5s → 1s → 2s)
|
|
120
|
+
- **Retry statuses**: `500`, `502`, `503`, `504` by default
|
|
121
|
+
- **POST not retried by default** — pass `allowed_methods` explicitly to enable it
|
|
122
|
+
- Sessions are reused across calls on the same thread
|
|
123
|
+
|
|
124
|
+
## Used by
|
|
125
|
+
|
|
126
|
+
- `tha-edfi-runner` — uses `ThaReq` as its HTTP transport layer
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# tha-req-runner
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tha-guy-nate/tha-req-runner/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A small Python library that provides a thread-safe `requests.Session` with automatic retries and a normalized response parser. Intended as the HTTP transport layer for other `tha-*` runners.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install tha-req-runner
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from tha_req_runner import ThaReq
|
|
17
|
+
|
|
18
|
+
req = ThaReq()
|
|
19
|
+
session = req.get_session()
|
|
20
|
+
|
|
21
|
+
# safe_call wraps the try/except for you
|
|
22
|
+
result = req.safe_call(session.get, "https://api.example.com/students", params={"limit": 100})
|
|
23
|
+
# {"status": 200, "data": [...], "message": None, "raw_response": <Response>}
|
|
24
|
+
|
|
25
|
+
# network errors return the same shape — no try/except needed
|
|
26
|
+
result = req.safe_call(session.get, "https://unreachable.example.com")
|
|
27
|
+
# {"status": None, "data": None, "message": "Connection refused", "raw_response": None}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Response dict
|
|
31
|
+
|
|
32
|
+
Every method returns the same shape whether the call succeeded or raised:
|
|
33
|
+
|
|
34
|
+
| Key | Type | Description |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `status` | `int \| None` | HTTP status code, or `None` on network error |
|
|
37
|
+
| `data` | `object` | Parsed JSON body, or `None` if not JSON |
|
|
38
|
+
| `message` | `str \| None` | Exception message on error, otherwise `None` |
|
|
39
|
+
| `raw_response` | `Response \| None` | The raw `requests.Response` object |
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
### `ThaReq`
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
ThaReq()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `req.get_session()`
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
req.get_session(
|
|
53
|
+
*,
|
|
54
|
+
status_forcelist: tuple[int, ...] = (500, 502, 503, 504),
|
|
55
|
+
allowed_methods: Collection[str] | None = None,
|
|
56
|
+
) -> requests.Session
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Returns a `requests.Session` configured with automatic retries (`total=3`, `backoff_factor=0.5`). Config is applied only on the **first call per thread** — subsequent calls on the same thread return the cached session regardless of args. Two `ThaReq` instances never share a session.
|
|
60
|
+
|
|
61
|
+
`allowed_methods=None` uses urllib3's safe-method default, which **excludes POST**. To retry POST (e.g. token endpoints):
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
session = req.get_session(
|
|
65
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
66
|
+
allowed_methods=frozenset(["GET", "POST"]),
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `ThaReq.parse_response()`
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
ThaReq.parse_response(result: requests.Response | Exception) -> dict[str, Any]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Normalizes a `requests.Response` or a caught `Exception` into a consistent dict. Also callable as `req.parse_response(result)` without instantiation.
|
|
77
|
+
|
|
78
|
+
### `req.safe_call()`
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
req.safe_call(fn, *args, **kwargs) -> dict[str, Any]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Calls `fn(*args, **kwargs)`, catches any exception, and returns a normalized response dict. Equivalent to:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
try:
|
|
88
|
+
result = req.parse_response(fn(*args, **kwargs))
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
result = req.parse_response(exc)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
result = req.safe_call(session.get, url, params={"limit": 100})
|
|
95
|
+
result = req.safe_call(session.post, token_url, data={"grant_type": "client_credentials"})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Session and retries
|
|
99
|
+
|
|
100
|
+
- **Thread-safe**: each thread gets its own session via `threading.local` on the instance
|
|
101
|
+
- **Retry defaults**: `total=3`, `backoff_factor=0.5` (delays: 0.5s → 1s → 2s)
|
|
102
|
+
- **Retry statuses**: `500`, `502`, `503`, `504` by default
|
|
103
|
+
- **POST not retried by default** — pass `allowed_methods` explicitly to enable it
|
|
104
|
+
- Sessions are reused across calls on the same thread
|
|
105
|
+
|
|
106
|
+
## Used by
|
|
107
|
+
|
|
108
|
+
- `tha-edfi-runner` — uses `ThaReq` as its HTTP transport layer
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tha-req-runner"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Tabular Helper API library that wraps requests with thread-safe session reuse, automatic retries, and a normalized response dict."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
keywords = ["http", "requests", "api", "retry", "session"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Typing :: Typed",
|
|
14
|
+
]
|
|
15
|
+
dependencies = ["requests"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest", "ruff", "mypy"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/tha_req_runner"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 100
|
|
29
|
+
|
|
30
|
+
[tool.mypy]
|
|
31
|
+
strict = true
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Collection
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from requests.adapters import HTTPAdapter
|
|
9
|
+
from urllib3.util.retry import Retry
|
|
10
|
+
|
|
11
|
+
_DEFAULT_RETRIES = 3
|
|
12
|
+
_DEFAULT_BACKOFF = 0.5
|
|
13
|
+
_DEFAULT_STATUS_FORCELIST = (500, 502, 503, 504)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ThaReq:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._local = threading.local()
|
|
19
|
+
|
|
20
|
+
def get_session(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
status_forcelist: tuple[int, ...] = _DEFAULT_STATUS_FORCELIST,
|
|
24
|
+
allowed_methods: Collection[str] | None = None, # None → urllib3 safe-method default; POST excluded
|
|
25
|
+
) -> requests.Session:
|
|
26
|
+
# config applies only on first call per thread; subsequent calls return the cached session
|
|
27
|
+
if not hasattr(self._local, "session"):
|
|
28
|
+
session = requests.Session()
|
|
29
|
+
retry = Retry(
|
|
30
|
+
total=_DEFAULT_RETRIES,
|
|
31
|
+
backoff_factor=_DEFAULT_BACKOFF,
|
|
32
|
+
status_forcelist=status_forcelist,
|
|
33
|
+
allowed_methods=allowed_methods,
|
|
34
|
+
)
|
|
35
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
36
|
+
session.mount("https://", adapter)
|
|
37
|
+
session.mount("http://", adapter)
|
|
38
|
+
self._local.session = session
|
|
39
|
+
return self._local.session # type: ignore[no-any-return]
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def parse_response(result: requests.Response | Exception) -> dict[str, Any]:
|
|
43
|
+
if isinstance(result, Exception):
|
|
44
|
+
raw = getattr(result, "response", None)
|
|
45
|
+
if not isinstance(raw, requests.Response):
|
|
46
|
+
raw = None
|
|
47
|
+
return {
|
|
48
|
+
"status": raw.status_code if raw is not None else None,
|
|
49
|
+
"data": None,
|
|
50
|
+
"message": str(result),
|
|
51
|
+
"raw_response": raw,
|
|
52
|
+
}
|
|
53
|
+
try:
|
|
54
|
+
data: Any = result.json()
|
|
55
|
+
except Exception:
|
|
56
|
+
data = None
|
|
57
|
+
return {
|
|
58
|
+
"status": result.status_code,
|
|
59
|
+
"data": data,
|
|
60
|
+
"message": None,
|
|
61
|
+
"raw_response": result,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def safe_call(
|
|
65
|
+
self,
|
|
66
|
+
fn: Callable[..., requests.Response],
|
|
67
|
+
*args: Any,
|
|
68
|
+
**kwargs: Any,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
try:
|
|
71
|
+
return self.parse_response(fn(*args, **kwargs))
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
return self.parse_response(exc)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from tha_req_runner import ThaReq
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# --- helpers ---
|
|
11
|
+
|
|
12
|
+
def _mock_resp(status_code: int = 200, json_data: object = None) -> MagicMock:
|
|
13
|
+
resp = MagicMock(spec=requests.Response)
|
|
14
|
+
resp.status_code = status_code
|
|
15
|
+
if json_data is not None:
|
|
16
|
+
resp.json.return_value = json_data
|
|
17
|
+
else:
|
|
18
|
+
resp.json.side_effect = ValueError("no json")
|
|
19
|
+
return resp
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- get_session ---
|
|
23
|
+
|
|
24
|
+
def test_get_session_returns_session(req: ThaReq) -> None:
|
|
25
|
+
assert isinstance(req.get_session(), requests.Session)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_get_session_same_instance_per_thread(req: ThaReq) -> None:
|
|
29
|
+
assert req.get_session() is req.get_session()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_get_session_thread_local(req: ThaReq) -> None:
|
|
33
|
+
sessions: list[requests.Session] = []
|
|
34
|
+
|
|
35
|
+
def capture() -> None:
|
|
36
|
+
sessions.append(req.get_session())
|
|
37
|
+
|
|
38
|
+
t1 = threading.Thread(target=capture)
|
|
39
|
+
t2 = threading.Thread(target=capture)
|
|
40
|
+
t1.start()
|
|
41
|
+
t2.start()
|
|
42
|
+
t1.join()
|
|
43
|
+
t2.join()
|
|
44
|
+
|
|
45
|
+
assert len(sessions) == 2
|
|
46
|
+
assert sessions[0] is not sessions[1]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_get_session_instances_independent() -> None:
|
|
50
|
+
assert ThaReq().get_session() is not ThaReq().get_session()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_get_session_default_status_forcelist(req: ThaReq) -> None:
|
|
54
|
+
adapter = req.get_session().get_adapter("https://example.com")
|
|
55
|
+
for code in (500, 502, 503, 504):
|
|
56
|
+
assert code in adapter.max_retries.status_forcelist
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_get_session_custom_status_forcelist() -> None:
|
|
60
|
+
req = ThaReq()
|
|
61
|
+
adapter = req.get_session(status_forcelist=(429, 503)).get_adapter("https://example.com")
|
|
62
|
+
assert 429 in adapter.max_retries.status_forcelist
|
|
63
|
+
assert 503 in adapter.max_retries.status_forcelist
|
|
64
|
+
assert 500 not in adapter.max_retries.status_forcelist
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_get_session_allowed_methods_frozenset() -> None:
|
|
68
|
+
req = ThaReq()
|
|
69
|
+
adapter = req.get_session(allowed_methods=frozenset(["GET", "POST"])).get_adapter("https://example.com")
|
|
70
|
+
assert "POST" in adapter.max_retries.allowed_methods
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_get_session_allowed_methods_list() -> None:
|
|
74
|
+
req = ThaReq()
|
|
75
|
+
adapter = req.get_session(allowed_methods=["GET", "POST"]).get_adapter("https://example.com")
|
|
76
|
+
assert "POST" in adapter.max_retries.allowed_methods
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_get_session_mounts_http_and_https(req: ThaReq) -> None:
|
|
80
|
+
session = req.get_session()
|
|
81
|
+
assert session.get_adapter("https://example.com") is not None
|
|
82
|
+
assert session.get_adapter("http://example.com") is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- parse_response ---
|
|
86
|
+
|
|
87
|
+
def test_parse_success_json() -> None:
|
|
88
|
+
resp = _mock_resp(200, {"id": 1})
|
|
89
|
+
result = ThaReq.parse_response(resp)
|
|
90
|
+
assert result["status"] == 200
|
|
91
|
+
assert result["data"] == {"id": 1}
|
|
92
|
+
assert result["message"] is None
|
|
93
|
+
assert result["raw_response"] is resp
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_parse_success_non_json() -> None:
|
|
97
|
+
resp = _mock_resp(200)
|
|
98
|
+
result = ThaReq.parse_response(resp)
|
|
99
|
+
assert result["status"] == 200
|
|
100
|
+
assert result["data"] is None
|
|
101
|
+
assert result["message"] is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_parse_exception_no_response() -> None:
|
|
105
|
+
exc = ConnectionError("timed out")
|
|
106
|
+
result = ThaReq.parse_response(exc)
|
|
107
|
+
assert result["status"] is None
|
|
108
|
+
assert result["data"] is None
|
|
109
|
+
assert "timed out" in result["message"]
|
|
110
|
+
assert result["raw_response"] is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_parse_exception_with_response() -> None:
|
|
114
|
+
raw = _mock_resp(401)
|
|
115
|
+
exc = requests.HTTPError("401 Unauthorized", response=raw)
|
|
116
|
+
result = ThaReq.parse_response(exc)
|
|
117
|
+
assert result["status"] == 401
|
|
118
|
+
assert result["raw_response"] is raw
|
|
119
|
+
assert result["data"] is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_parse_callable_as_static(req: ThaReq) -> None:
|
|
123
|
+
result = req.parse_response(_mock_resp(204))
|
|
124
|
+
assert result["status"] == 204
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- safe_call ---
|
|
128
|
+
|
|
129
|
+
def test_safe_call_success(req: ThaReq) -> None:
|
|
130
|
+
resp = _mock_resp(200, {"id": 1})
|
|
131
|
+
result = req.safe_call(lambda: resp)
|
|
132
|
+
assert result["status"] == 200
|
|
133
|
+
assert result["data"] == {"id": 1}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_safe_call_exception(req: ThaReq) -> None:
|
|
137
|
+
def boom() -> requests.Response:
|
|
138
|
+
raise ConnectionError("refused")
|
|
139
|
+
|
|
140
|
+
result = req.safe_call(boom)
|
|
141
|
+
assert result["status"] is None
|
|
142
|
+
assert "refused" in result["message"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_safe_call_passes_args_and_kwargs(req: ThaReq) -> None:
|
|
146
|
+
calls: list[tuple[object, ...]] = []
|
|
147
|
+
|
|
148
|
+
def fn(url: str, **kwargs: object) -> requests.Response:
|
|
149
|
+
calls.append((url, kwargs))
|
|
150
|
+
return _mock_resp(200, {})
|
|
151
|
+
|
|
152
|
+
req.safe_call(fn, "https://example.com", headers={"X-Key": "v"})
|
|
153
|
+
assert calls[0] == ("https://example.com", {"headers": {"X-Key": "v"}})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_safe_call_stores_nothing_on_instance(req: ThaReq) -> None:
|
|
157
|
+
req.safe_call(lambda: _mock_resp(200, {}))
|
|
158
|
+
assert not hasattr(req, "result")
|