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.
@@ -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,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ *.egg
@@ -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
+ [![CI](https://github.com/tha-guy-nate/tha-req-runner/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/tha-guy-nate/tha-req-runner/actions/workflows/ci.yml/badge.svg)](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
@@ -0,0 +1,7 @@
1
+ """tha-req-runner: thread-safe HTTP requests with automatic retries and normalized responses."""
2
+
3
+ from .errors import ReqError
4
+ from .runner import ThaReq
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["ReqError", "ThaReq"]
@@ -0,0 +1,2 @@
1
+ class ReqError(Exception):
2
+ pass
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,8 @@
1
+ import pytest
2
+
3
+ from tha_req_runner import ThaReq
4
+
5
+
6
+ @pytest.fixture
7
+ def req() -> ThaReq:
8
+ return ThaReq()
@@ -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")