petchr 1.0.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.
petchr-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eritrouib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
petchr-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: petchr
3
+ Version: 1.0.0
4
+ Summary: A zero-dependency HTTP client for Python with retry, timeout, and rate-limiting
5
+ Author: eritrouib
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/eritrouib/petchr-py
8
+ Project-URL: Repository, https://github.com/eritrouib/petchr-py
9
+ Project-URL: Issues, https://github.com/eritrouib/petchr-py/issues
10
+ Keywords: http,client,retry,timeout,rate-limit,requests,httpx
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # petchr
28
+
29
+ A zero-dependency HTTP client for Python with retry, timeout, and rate-limiting built in.
30
+
31
+ ```bash
32
+ pip install petchr
33
+ ```
34
+
35
+ > **Requires Python 3.9+**
36
+
37
+ ---
38
+
39
+ ## Why petchr?
40
+
41
+ `requests` is great but has no built-in retry or rate-limiting. `httpx` is modern but requires extra packages for resilience. `petchr` wraps Python's built-in `urllib` with everything you need — zero dependencies.
42
+
43
+ | Feature | urllib | requests | **petchr** |
44
+ |---|---|---|---|
45
+ | Zero dependencies | ✅ | ❌ | ✅ |
46
+ | Auto-retry w/ backoff | ❌ | ❌ | ✅ |
47
+ | Timeout | ✅ | ✅ | ✅ |
48
+ | Rate limiting | ❌ | ❌ | ✅ |
49
+ | JSON body shorthand | ❌ | ✅ | ✅ |
50
+ | Query params object | ❌ | ✅ | ✅ |
51
+ | Shared instance config | ❌ | ✅ | ✅ |
52
+
53
+ ---
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from petchr import petch
59
+
60
+ resp = petch("https://api.example.com/users/1")
61
+ print(resp.data) # parsed JSON
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Instance API
67
+
68
+ ```python
69
+ from petchr import Petchr
70
+
71
+ api = Petchr(
72
+ base_url="https://api.example.com",
73
+ headers={"Authorization": f"Bearer {token}"},
74
+ timeout=10.0,
75
+ retry=3,
76
+ )
77
+
78
+ user = api.get("/users/1")
79
+ post = api.post("/posts", json={"title": "Hello"})
80
+ api.delete("/posts/123")
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Retry
86
+
87
+ ```python
88
+ resp = petch("https://api.example.com/data",
89
+ retry=5,
90
+ retry_delay=1.0, # initial delay in seconds
91
+ retry_backoff=2.0, # exponential multiplier
92
+ retry_max_delay=30.0, # cap
93
+ retry_on={429, 503}, # status codes to retry
94
+ on_retry=lambda attempt, err, resp: print(f"Retry {attempt}"),
95
+ )
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Timeout
101
+
102
+ ```python
103
+ from petchr import PetchrTimeoutError
104
+
105
+ try:
106
+ resp = petch("https://slow-api.example.com", timeout=5.0)
107
+ except PetchrTimeoutError as e:
108
+ print(f"Timed out after {e.timeout}s")
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Rate Limiting
114
+
115
+ ```python
116
+ from petchr import Petchr, RateLimiter
117
+
118
+ api = Petchr(
119
+ base_url="https://api.example.com",
120
+ rate_limiter=RateLimiter(max_requests=10, window_seconds=1.0),
121
+ )
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Error Handling
127
+
128
+ ```python
129
+ from petchr import petch, PetchrError, PetchrTimeoutError, PetchrRateLimitError
130
+
131
+ try:
132
+ resp = petch("https://api.example.com/users/1")
133
+ except PetchrTimeoutError as e:
134
+ print(f"Timed out after {e.timeout}s")
135
+ except PetchrRateLimitError as e:
136
+ print(f"Rate limited. Retry after {e.retry_after}s")
137
+ except PetchrError as e:
138
+ print(f"HTTP {e.status_code}: {e}")
139
+ ```
140
+
141
+ ---
142
+
143
+ ## License
144
+
145
+ MIT
petchr-1.0.0/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # petchr
2
+
3
+ A zero-dependency HTTP client for Python with retry, timeout, and rate-limiting built in.
4
+
5
+ ```bash
6
+ pip install petchr
7
+ ```
8
+
9
+ > **Requires Python 3.9+**
10
+
11
+ ---
12
+
13
+ ## Why petchr?
14
+
15
+ `requests` is great but has no built-in retry or rate-limiting. `httpx` is modern but requires extra packages for resilience. `petchr` wraps Python's built-in `urllib` with everything you need — zero dependencies.
16
+
17
+ | Feature | urllib | requests | **petchr** |
18
+ |---|---|---|---|
19
+ | Zero dependencies | ✅ | ❌ | ✅ |
20
+ | Auto-retry w/ backoff | ❌ | ❌ | ✅ |
21
+ | Timeout | ✅ | ✅ | ✅ |
22
+ | Rate limiting | ❌ | ❌ | ✅ |
23
+ | JSON body shorthand | ❌ | ✅ | ✅ |
24
+ | Query params object | ❌ | ✅ | ✅ |
25
+ | Shared instance config | ❌ | ✅ | ✅ |
26
+
27
+ ---
28
+
29
+ ## Quick Start
30
+
31
+ ```python
32
+ from petchr import petch
33
+
34
+ resp = petch("https://api.example.com/users/1")
35
+ print(resp.data) # parsed JSON
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Instance API
41
+
42
+ ```python
43
+ from petchr import Petchr
44
+
45
+ api = Petchr(
46
+ base_url="https://api.example.com",
47
+ headers={"Authorization": f"Bearer {token}"},
48
+ timeout=10.0,
49
+ retry=3,
50
+ )
51
+
52
+ user = api.get("/users/1")
53
+ post = api.post("/posts", json={"title": "Hello"})
54
+ api.delete("/posts/123")
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Retry
60
+
61
+ ```python
62
+ resp = petch("https://api.example.com/data",
63
+ retry=5,
64
+ retry_delay=1.0, # initial delay in seconds
65
+ retry_backoff=2.0, # exponential multiplier
66
+ retry_max_delay=30.0, # cap
67
+ retry_on={429, 503}, # status codes to retry
68
+ on_retry=lambda attempt, err, resp: print(f"Retry {attempt}"),
69
+ )
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Timeout
75
+
76
+ ```python
77
+ from petchr import PetchrTimeoutError
78
+
79
+ try:
80
+ resp = petch("https://slow-api.example.com", timeout=5.0)
81
+ except PetchrTimeoutError as e:
82
+ print(f"Timed out after {e.timeout}s")
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Rate Limiting
88
+
89
+ ```python
90
+ from petchr import Petchr, RateLimiter
91
+
92
+ api = Petchr(
93
+ base_url="https://api.example.com",
94
+ rate_limiter=RateLimiter(max_requests=10, window_seconds=1.0),
95
+ )
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Error Handling
101
+
102
+ ```python
103
+ from petchr import petch, PetchrError, PetchrTimeoutError, PetchrRateLimitError
104
+
105
+ try:
106
+ resp = petch("https://api.example.com/users/1")
107
+ except PetchrTimeoutError as e:
108
+ print(f"Timed out after {e.timeout}s")
109
+ except PetchrRateLimitError as e:
110
+ print(f"Rate limited. Retry after {e.retry_after}s")
111
+ except PetchrError as e:
112
+ print(f"HTTP {e.status_code}: {e}")
113
+ ```
114
+
115
+ ---
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "petchr"
7
+ version = "1.0.0"
8
+ description = "A zero-dependency HTTP client for Python with retry, timeout, and rate-limiting"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "eritrouib" }]
12
+ requires-python = ">=3.9"
13
+ keywords = ["http", "client", "retry", "timeout", "rate-limit", "requests", "httpx"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Internet :: WWW/HTTP",
25
+ "Topic :: Software Development :: Libraries",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/eritrouib/petchr-py"
30
+ Repository = "https://github.com/eritrouib/petchr-py"
31
+ Issues = "https://github.com/eritrouib/petchr-py/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
petchr-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ from .client import Petchr, PetchrResponse, petch
2
+ from .exceptions import PetchrError, PetchrRateLimitError, PetchrTimeoutError
3
+ from .rate_limiter import RateLimiter
4
+
5
+ __all__ = [
6
+ "petch",
7
+ "Petchr",
8
+ "PetchrResponse",
9
+ "PetchrError",
10
+ "PetchrTimeoutError",
11
+ "PetchrRateLimitError",
12
+ "RateLimiter",
13
+ ]
14
+
15
+ __version__ = "1.0.0"
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ import random
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from typing import Any, Callable
10
+
11
+ from .exceptions import PetchrError, PetchrTimeoutError
12
+ from .rate_limiter import RateLimiter
13
+
14
+ DEFAULT_RETRY_ON = {429, 502, 503, 504}
15
+
16
+
17
+ def _backoff(attempt: int, delay: float, backoff: float, max_delay: float) -> float:
18
+ raw = delay * (backoff ** (attempt - 1))
19
+ jitter = raw * 0.2 * (random.random() * 2 - 1)
20
+ return min(raw + jitter, max_delay)
21
+
22
+
23
+ def petch(
24
+ url: str,
25
+ *,
26
+ method: str = "GET",
27
+ base_url: str | None = None,
28
+ params: dict[str, Any] | None = None,
29
+ json: Any = None,
30
+ data: bytes | None = None,
31
+ headers: dict[str, str] | None = None,
32
+ timeout: float = 30.0,
33
+ # Retry
34
+ retry: int = 3,
35
+ retry_delay: float = 0.5,
36
+ retry_backoff: float = 2.0,
37
+ retry_max_delay: float = 10.0,
38
+ retry_on: set[int] | None = None,
39
+ should_retry: Callable[[int, int], bool] | None = None,
40
+ # Rate limiting
41
+ rate_limiter: RateLimiter | None = None,
42
+ # Hooks
43
+ on_request: Callable[[str, dict], None] | None = None,
44
+ on_response: Callable[[urllib.request.Request, Any], None] | None = None,
45
+ on_retry: Callable[[int, Exception | None, Any], None] | None = None,
46
+ ) -> "PetchrResponse":
47
+ """Make an HTTP request with retry, timeout, and rate-limiting."""
48
+
49
+ if rate_limiter:
50
+ rate_limiter.wait()
51
+
52
+ # Build URL
53
+ if base_url:
54
+ url = urllib.parse.urljoin(base_url, url)
55
+ if params:
56
+ filtered = {k: str(v) for k, v in params.items() if v is not None}
57
+ url = f"{url}?{urllib.parse.urlencode(filtered)}"
58
+
59
+ # Build headers
60
+ req_headers = headers.copy() if headers else {}
61
+ body = data
62
+
63
+ if json is not None:
64
+ body = _json.dumps(json).encode()
65
+ req_headers.setdefault("Content-Type", "application/json")
66
+
67
+ _retry_on = retry_on if retry_on is not None else DEFAULT_RETRY_ON
68
+ max_attempts = retry + 1
69
+
70
+ last_error: Exception | None = None
71
+
72
+ for attempt in range(1, max_attempts + 1):
73
+ if on_request:
74
+ on_request(url, {"method": method, "headers": req_headers})
75
+
76
+ req = urllib.request.Request(url, data=body, headers=req_headers, method=method)
77
+
78
+ try:
79
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
80
+ raw = resp.read()
81
+ content_type = resp.headers.get_content_type()
82
+
83
+ if "json" in content_type:
84
+ response_data = _json.loads(raw)
85
+ else:
86
+ response_data = raw.decode(resp.headers.get_content_charset("utf-8"))
87
+
88
+ result = PetchrResponse(
89
+ data=response_data,
90
+ status_code=resp.status,
91
+ headers=dict(resp.headers),
92
+ url=resp.url,
93
+ )
94
+
95
+ if on_response:
96
+ on_response(req, result)
97
+
98
+ return result
99
+
100
+ except urllib.error.HTTPError as e:
101
+ status = e.code
102
+
103
+ if status in _retry_on and attempt < max_attempts:
104
+ do_retry = should_retry(status, attempt) if should_retry else True
105
+ if do_retry:
106
+ last_error = PetchrError(
107
+ f"Request failed with status {status}",
108
+ status_code=status,
109
+ attempt=attempt,
110
+ )
111
+ if on_retry:
112
+ on_retry(attempt, last_error, None)
113
+ time.sleep(_backoff(attempt, retry_delay, retry_backoff, retry_max_delay))
114
+ continue
115
+
116
+ raise PetchrError(
117
+ f"Request failed with status {status}",
118
+ status_code=status,
119
+ attempt=attempt,
120
+ ) from e
121
+
122
+ except TimeoutError as e:
123
+ raise PetchrTimeoutError(timeout) from e
124
+
125
+ except urllib.error.URLError as e:
126
+ if "timed out" in str(e.reason).lower():
127
+ raise PetchrTimeoutError(timeout) from e
128
+
129
+ if attempt < max_attempts:
130
+ last_error = PetchrError(str(e), attempt=attempt)
131
+ if on_retry:
132
+ on_retry(attempt, last_error, None)
133
+ time.sleep(_backoff(attempt, retry_delay, retry_backoff, retry_max_delay))
134
+ continue
135
+
136
+ raise PetchrError(str(e), attempt=attempt) from e
137
+
138
+ raise last_error or PetchrError("Request failed after all retry attempts")
139
+
140
+
141
+ class PetchrResponse:
142
+ """Parsed HTTP response."""
143
+
144
+ def __init__(self, data: Any, status_code: int, headers: dict, url: str):
145
+ self.data = data
146
+ self.status_code = status_code
147
+ self.headers = headers
148
+ self.url = url
149
+
150
+ def __repr__(self):
151
+ return f"<PetchrResponse [{self.status_code}]>"
152
+
153
+
154
+ class Petchr:
155
+ """
156
+ A configured petchr client instance.
157
+
158
+ Example:
159
+ api = Petchr(base_url="https://api.example.com", headers={"Authorization": "Bearer token"})
160
+ response = api.get("/users/1")
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ base_url: str | None = None,
166
+ headers: dict[str, str] | None = None,
167
+ timeout: float = 30.0,
168
+ retry: int = 3,
169
+ retry_delay: float = 0.5,
170
+ retry_backoff: float = 2.0,
171
+ retry_max_delay: float = 10.0,
172
+ retry_on: set[int] | None = None,
173
+ rate_limiter: RateLimiter | None = None,
174
+ on_request: Callable | None = None,
175
+ on_response: Callable | None = None,
176
+ on_retry: Callable | None = None,
177
+ ):
178
+ self.base_url = base_url
179
+ self.headers = headers or {}
180
+ self.timeout = timeout
181
+ self.retry = retry
182
+ self.retry_delay = retry_delay
183
+ self.retry_backoff = retry_backoff
184
+ self.retry_max_delay = retry_max_delay
185
+ self.retry_on = retry_on
186
+ self.rate_limiter = rate_limiter
187
+ self.on_request = on_request
188
+ self.on_response = on_response
189
+ self.on_retry = on_retry
190
+
191
+ def request(self, method: str, url: str, **kwargs) -> PetchrResponse:
192
+ merged_headers = {**self.headers, **kwargs.pop("headers", {})}
193
+ return petch(
194
+ url,
195
+ method=method,
196
+ base_url=kwargs.pop("base_url", self.base_url),
197
+ headers=merged_headers,
198
+ timeout=kwargs.pop("timeout", self.timeout),
199
+ retry=kwargs.pop("retry", self.retry),
200
+ retry_delay=kwargs.pop("retry_delay", self.retry_delay),
201
+ retry_backoff=kwargs.pop("retry_backoff", self.retry_backoff),
202
+ retry_max_delay=kwargs.pop("retry_max_delay", self.retry_max_delay),
203
+ retry_on=kwargs.pop("retry_on", self.retry_on),
204
+ rate_limiter=kwargs.pop("rate_limiter", self.rate_limiter),
205
+ on_request=kwargs.pop("on_request", self.on_request),
206
+ on_response=kwargs.pop("on_response", self.on_response),
207
+ on_retry=kwargs.pop("on_retry", self.on_retry),
208
+ **kwargs,
209
+ )
210
+
211
+ def get(self, url: str, **kwargs) -> PetchrResponse:
212
+ return self.request("GET", url, **kwargs)
213
+
214
+ def post(self, url: str, **kwargs) -> PetchrResponse:
215
+ return self.request("POST", url, **kwargs)
216
+
217
+ def put(self, url: str, **kwargs) -> PetchrResponse:
218
+ return self.request("PUT", url, **kwargs)
219
+
220
+ def patch(self, url: str, **kwargs) -> PetchrResponse:
221
+ return self.request("PATCH", url, **kwargs)
222
+
223
+ def delete(self, url: str, **kwargs) -> PetchrResponse:
224
+ return self.request("DELETE", url, **kwargs)
@@ -0,0 +1,25 @@
1
+ class PetchrError(Exception):
2
+ """Base exception for petchr."""
3
+
4
+ def __init__(self, message: str, status_code=None, response=None, attempt=None):
5
+ super().__init__(message)
6
+ self.status_code = status_code
7
+ self.response = response
8
+ self.attempt = attempt
9
+
10
+
11
+ class PetchrTimeoutError(PetchrError):
12
+ """Raised when a request times out."""
13
+
14
+ def __init__(self, timeout: float):
15
+ super().__init__(f"Request timed out after {timeout}s")
16
+ self.timeout = timeout
17
+
18
+
19
+ class PetchrRateLimitError(PetchrError):
20
+ """Raised when client-side rate limit is exceeded."""
21
+
22
+ def __init__(self, retry_after: float):
23
+ super().__init__(f"Rate limit exceeded. Retry after {retry_after:.2f}s")
24
+ self.retry_after = retry_after
25
+ self.status_code = 429
@@ -0,0 +1,36 @@
1
+ import time
2
+ from .exceptions import PetchrRateLimitError
3
+
4
+
5
+ class RateLimiter:
6
+ """Sliding window rate limiter."""
7
+
8
+ def __init__(self, max_requests: int, window_seconds: float = 1.0):
9
+ self.max_requests = max_requests
10
+ self.window_seconds = window_seconds
11
+ self._requests: list[float] = []
12
+
13
+ def _clean(self):
14
+ now = time.monotonic()
15
+ self._requests = [t for t in self._requests if now - t < self.window_seconds]
16
+
17
+ def throttle(self):
18
+ """Raise PetchrRateLimitError if rate limit exceeded."""
19
+ self._clean()
20
+ if len(self._requests) >= self.max_requests:
21
+ oldest = self._requests[0]
22
+ retry_after = self.window_seconds - (time.monotonic() - oldest)
23
+ raise PetchrRateLimitError(retry_after)
24
+ self._requests.append(time.monotonic())
25
+
26
+ def wait(self):
27
+ """Block until a request slot is available."""
28
+ while True:
29
+ self._clean()
30
+ if len(self._requests) < self.max_requests:
31
+ self._requests.append(time.monotonic())
32
+ return
33
+ oldest = self._requests[0]
34
+ wait_time = self.window_seconds - (time.monotonic() - oldest)
35
+ if wait_time > 0:
36
+ time.sleep(wait_time)
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: petchr
3
+ Version: 1.0.0
4
+ Summary: A zero-dependency HTTP client for Python with retry, timeout, and rate-limiting
5
+ Author: eritrouib
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/eritrouib/petchr-py
8
+ Project-URL: Repository, https://github.com/eritrouib/petchr-py
9
+ Project-URL: Issues, https://github.com/eritrouib/petchr-py/issues
10
+ Keywords: http,client,retry,timeout,rate-limit,requests,httpx
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # petchr
28
+
29
+ A zero-dependency HTTP client for Python with retry, timeout, and rate-limiting built in.
30
+
31
+ ```bash
32
+ pip install petchr
33
+ ```
34
+
35
+ > **Requires Python 3.9+**
36
+
37
+ ---
38
+
39
+ ## Why petchr?
40
+
41
+ `requests` is great but has no built-in retry or rate-limiting. `httpx` is modern but requires extra packages for resilience. `petchr` wraps Python's built-in `urllib` with everything you need — zero dependencies.
42
+
43
+ | Feature | urllib | requests | **petchr** |
44
+ |---|---|---|---|
45
+ | Zero dependencies | ✅ | ❌ | ✅ |
46
+ | Auto-retry w/ backoff | ❌ | ❌ | ✅ |
47
+ | Timeout | ✅ | ✅ | ✅ |
48
+ | Rate limiting | ❌ | ❌ | ✅ |
49
+ | JSON body shorthand | ❌ | ✅ | ✅ |
50
+ | Query params object | ❌ | ✅ | ✅ |
51
+ | Shared instance config | ❌ | ✅ | ✅ |
52
+
53
+ ---
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from petchr import petch
59
+
60
+ resp = petch("https://api.example.com/users/1")
61
+ print(resp.data) # parsed JSON
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Instance API
67
+
68
+ ```python
69
+ from petchr import Petchr
70
+
71
+ api = Petchr(
72
+ base_url="https://api.example.com",
73
+ headers={"Authorization": f"Bearer {token}"},
74
+ timeout=10.0,
75
+ retry=3,
76
+ )
77
+
78
+ user = api.get("/users/1")
79
+ post = api.post("/posts", json={"title": "Hello"})
80
+ api.delete("/posts/123")
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Retry
86
+
87
+ ```python
88
+ resp = petch("https://api.example.com/data",
89
+ retry=5,
90
+ retry_delay=1.0, # initial delay in seconds
91
+ retry_backoff=2.0, # exponential multiplier
92
+ retry_max_delay=30.0, # cap
93
+ retry_on={429, 503}, # status codes to retry
94
+ on_retry=lambda attempt, err, resp: print(f"Retry {attempt}"),
95
+ )
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Timeout
101
+
102
+ ```python
103
+ from petchr import PetchrTimeoutError
104
+
105
+ try:
106
+ resp = petch("https://slow-api.example.com", timeout=5.0)
107
+ except PetchrTimeoutError as e:
108
+ print(f"Timed out after {e.timeout}s")
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Rate Limiting
114
+
115
+ ```python
116
+ from petchr import Petchr, RateLimiter
117
+
118
+ api = Petchr(
119
+ base_url="https://api.example.com",
120
+ rate_limiter=RateLimiter(max_requests=10, window_seconds=1.0),
121
+ )
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Error Handling
127
+
128
+ ```python
129
+ from petchr import petch, PetchrError, PetchrTimeoutError, PetchrRateLimitError
130
+
131
+ try:
132
+ resp = petch("https://api.example.com/users/1")
133
+ except PetchrTimeoutError as e:
134
+ print(f"Timed out after {e.timeout}s")
135
+ except PetchrRateLimitError as e:
136
+ print(f"Rate limited. Retry after {e.retry_after}s")
137
+ except PetchrError as e:
138
+ print(f"HTTP {e.status_code}: {e}")
139
+ ```
140
+
141
+ ---
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/petchr/__init__.py
5
+ src/petchr/client.py
6
+ src/petchr/exceptions.py
7
+ src/petchr/rate_limiter.py
8
+ src/petchr.egg-info/PKG-INFO
9
+ src/petchr.egg-info/SOURCES.txt
10
+ src/petchr.egg-info/dependency_links.txt
11
+ src/petchr.egg-info/top_level.txt
12
+ tests/test_petchr.py
@@ -0,0 +1 @@
1
+ petchr
@@ -0,0 +1,171 @@
1
+ import json
2
+ import urllib.error
3
+ from io import BytesIO
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from petchr import Petchr, PetchrError, PetchrTimeoutError, RateLimiter, petch
9
+
10
+
11
+ def make_response(body, status=200, content_type="application/json"):
12
+ resp = MagicMock()
13
+ resp.status = status
14
+ resp.url = "https://example.com/api"
15
+ resp.read.return_value = json.dumps(body).encode() if isinstance(body, dict) else body.encode()
16
+ resp.headers.get_content_type.return_value = content_type
17
+ resp.headers.get_content_charset.return_value = "utf-8"
18
+ resp.headers.__iter__ = MagicMock(return_value=iter([]))
19
+ resp.__enter__ = lambda s: s
20
+ resp.__exit__ = MagicMock(return_value=False)
21
+ return resp
22
+
23
+
24
+ class TestPetch:
25
+ @patch("urllib.request.urlopen")
26
+ def test_basic_get(self, mock_urlopen):
27
+ mock_urlopen.return_value = make_response({"hello": "world"})
28
+ resp = petch("https://example.com/api", retry=0)
29
+ assert resp.data == {"hello": "world"}
30
+ assert resp.status_code == 200
31
+
32
+ @patch("urllib.request.urlopen")
33
+ def test_json_body_sets_content_type(self, mock_urlopen):
34
+ mock_urlopen.return_value = make_response({"ok": True})
35
+ petch("https://example.com/api", method="POST", json={"name": "test"}, retry=0)
36
+ req = mock_urlopen.call_args[0][0]
37
+ assert req.get_header("Content-type") == "application/json"
38
+ assert json.loads(req.data) == {"name": "test"}
39
+
40
+ @patch("urllib.request.urlopen")
41
+ def test_params_appended_to_url(self, mock_urlopen):
42
+ mock_urlopen.return_value = make_response({})
43
+ petch("https://example.com/api", params={"page": 1, "q": "hello", "empty": None}, retry=0)
44
+ url = mock_urlopen.call_args[0][0].full_url
45
+ assert "page=1" in url
46
+ assert "q=hello" in url
47
+ assert "empty" not in url
48
+
49
+ @patch("urllib.request.urlopen")
50
+ def test_base_url(self, mock_urlopen):
51
+ mock_urlopen.return_value = make_response({})
52
+ petch("/users", base_url="https://api.example.com", retry=0)
53
+ url = mock_urlopen.call_args[0][0].full_url
54
+ assert url.startswith("https://api.example.com/users")
55
+
56
+ @patch("urllib.request.urlopen")
57
+ def test_text_response(self, mock_urlopen):
58
+ resp = make_response("hello plain", content_type="text/plain")
59
+ mock_urlopen.return_value = resp
60
+ result = petch("https://example.com/text", retry=0)
61
+ assert result.data == "hello plain"
62
+
63
+ @patch("urllib.request.urlopen")
64
+ def test_raises_on_http_error(self, mock_urlopen):
65
+ mock_urlopen.side_effect = urllib.error.HTTPError(
66
+ url="https://example.com", code=404, msg="Not Found", hdrs=None, fp=None
67
+ )
68
+ with pytest.raises(PetchrError) as exc:
69
+ petch("https://example.com/api", retry=0)
70
+ assert exc.value.status_code == 404
71
+
72
+
73
+ class TestRetry:
74
+ @patch("petchr.client.time.sleep")
75
+ @patch("urllib.request.urlopen")
76
+ def test_retries_on_503(self, mock_urlopen, mock_sleep):
77
+ mock_urlopen.side_effect = [
78
+ urllib.error.HTTPError(url="", code=503, msg="", hdrs=None, fp=None),
79
+ urllib.error.HTTPError(url="", code=503, msg="", hdrs=None, fp=None),
80
+ make_response({"ok": True}),
81
+ ]
82
+ resp = petch("https://example.com/api", retry=3, retry_delay=0.01)
83
+ assert resp.data == {"ok": True}
84
+ assert mock_urlopen.call_count == 3
85
+
86
+ @patch("petchr.client.time.sleep")
87
+ @patch("urllib.request.urlopen")
88
+ def test_raises_after_exhausting_retries(self, mock_urlopen, mock_sleep):
89
+ mock_urlopen.side_effect = urllib.error.HTTPError(
90
+ url="", code=503, msg="", hdrs=None, fp=None
91
+ )
92
+ with pytest.raises(PetchrError):
93
+ petch("https://example.com/api", retry=2, retry_delay=0.01)
94
+ assert mock_urlopen.call_count == 3
95
+
96
+ @patch("petchr.client.time.sleep")
97
+ @patch("urllib.request.urlopen")
98
+ def test_on_retry_called(self, mock_urlopen, mock_sleep):
99
+ mock_urlopen.side_effect = [
100
+ urllib.error.HTTPError(url="", code=503, msg="", hdrs=None, fp=None),
101
+ make_response({"ok": True}),
102
+ ]
103
+ on_retry = MagicMock()
104
+ petch("https://example.com/api", retry=2, retry_delay=0.01, on_retry=on_retry)
105
+ assert on_retry.call_count == 1
106
+
107
+ @patch("urllib.request.urlopen")
108
+ def test_no_retry_on_404(self, mock_urlopen):
109
+ mock_urlopen.side_effect = urllib.error.HTTPError(
110
+ url="", code=404, msg="", hdrs=None, fp=None
111
+ )
112
+ with pytest.raises(PetchrError) as exc:
113
+ petch("https://example.com/api", retry=3)
114
+ assert mock_urlopen.call_count == 1
115
+ assert exc.value.status_code == 404
116
+
117
+
118
+ class TestTimeout:
119
+ @patch("urllib.request.urlopen")
120
+ def test_raises_timeout_error(self, mock_urlopen):
121
+ mock_urlopen.side_effect = urllib.error.URLError("timed out")
122
+ with pytest.raises(PetchrTimeoutError):
123
+ petch("https://example.com/api", timeout=1, retry=0)
124
+
125
+
126
+ class TestPetchrClient:
127
+ @patch("urllib.request.urlopen")
128
+ def test_instance_with_defaults(self, mock_urlopen):
129
+ mock_urlopen.return_value = make_response({"id": 1})
130
+ api = Petchr(
131
+ base_url="https://api.example.com",
132
+ headers={"Authorization": "Bearer token"},
133
+ retry=0,
134
+ )
135
+ api.get("/users/1")
136
+ req = mock_urlopen.call_args[0][0]
137
+ assert "api.example.com/users/1" in req.full_url
138
+ assert req.get_header("Authorization") == "Bearer token"
139
+
140
+ @patch("urllib.request.urlopen")
141
+ def test_merges_headers(self, mock_urlopen):
142
+ mock_urlopen.return_value = make_response({})
143
+ api = Petchr(headers={"Authorization": "Bearer token"}, retry=0)
144
+ api.get("https://example.com/api", headers={"X-Custom": "yes"})
145
+ req = mock_urlopen.call_args[0][0]
146
+ assert req.get_header("Authorization") == "Bearer token"
147
+ assert req.get_header("X-custom") == "yes"
148
+
149
+ @patch("urllib.request.urlopen")
150
+ def test_convenience_methods(self, mock_urlopen):
151
+ for method in ["get", "post", "put", "patch", "delete"]:
152
+ mock_urlopen.return_value = make_response({})
153
+ api = Petchr(retry=0)
154
+ getattr(api, method)("https://example.com/api")
155
+ req = mock_urlopen.call_args[0][0]
156
+ assert req.method == method.upper()
157
+
158
+
159
+ class TestRateLimiter:
160
+ def test_allows_requests_within_limit(self):
161
+ limiter = RateLimiter(max_requests=5, window_seconds=1.0)
162
+ for _ in range(5):
163
+ limiter.throttle()
164
+
165
+ def test_raises_when_limit_exceeded(self):
166
+ from petchr import PetchrRateLimitError
167
+ limiter = RateLimiter(max_requests=2, window_seconds=1.0)
168
+ limiter.throttle()
169
+ limiter.throttle()
170
+ with pytest.raises(PetchrRateLimitError):
171
+ limiter.throttle()