hakiapi 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.
- hakiapi-1.0.0/LICENSE +21 -0
- hakiapi-1.0.0/PKG-INFO +62 -0
- hakiapi-1.0.0/README.md +36 -0
- hakiapi-1.0.0/hakiapi/__init__.py +0 -0
- hakiapi-1.0.0/hakiapi/clients/__init__.py +0 -0
- hakiapi-1.0.0/hakiapi/clients/github.py +74 -0
- hakiapi-1.0.0/hakiapi/core/auth.py +123 -0
- hakiapi-1.0.0/hakiapi/core/base_client.py +133 -0
- hakiapi-1.0.0/hakiapi/core/exceptions.py +70 -0
- hakiapi-1.0.0/hakiapi/core/paginator.py +93 -0
- hakiapi-1.0.0/hakiapi/core/retry.py +28 -0
- hakiapi-1.0.0/hakiapi.egg-info/PKG-INFO +62 -0
- hakiapi-1.0.0/hakiapi.egg-info/SOURCES.txt +22 -0
- hakiapi-1.0.0/hakiapi.egg-info/dependency_links.txt +1 -0
- hakiapi-1.0.0/hakiapi.egg-info/requires.txt +7 -0
- hakiapi-1.0.0/hakiapi.egg-info/top_level.txt +1 -0
- hakiapi-1.0.0/pyproject.toml +50 -0
- hakiapi-1.0.0/setup.cfg +4 -0
- hakiapi-1.0.0/tests/test_auth.py +353 -0
- hakiapi-1.0.0/tests/test_base_client.py +399 -0
- hakiapi-1.0.0/tests/test_exceptions.py +159 -0
- hakiapi-1.0.0/tests/test_github.py +266 -0
- hakiapi-1.0.0/tests/test_paginator.py +516 -0
- hakiapi-1.0.0/tests/test_retry.py +150 -0
hakiapi-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gugilla-Aakash
|
|
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.
|
hakiapi-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hakiapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A modern Python framework for building clean, typed, and extensible API clients.
|
|
5
|
+
Author-email: Gugilla Aakash <gugillaaakash6@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Gugilla-Aakash/hakiapi
|
|
8
|
+
Project-URL: Source Code, https://github.com/Gugilla-Aakash/hakiapi
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Gugilla-Aakash/hakiapi/issues
|
|
10
|
+
Keywords: api-client,retry,pagination,rest-api,http
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: requests>=2.32.0
|
|
20
|
+
Requires-Dist: urllib3>=1.26.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: responses; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# HakiAPI
|
|
28
|
+
|
|
29
|
+
> A modern, typed, and extensible Python framework for building resilient API clients.
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 🚀 Vision
|
|
37
|
+
|
|
38
|
+
HakiAPI aims to make building Python SDKs simple, consistent, and developer-friendly by providing reusable components for:
|
|
39
|
+
|
|
40
|
+
- Authentication
|
|
41
|
+
- Automatic retries
|
|
42
|
+
- Request handling
|
|
43
|
+
- Pagination
|
|
44
|
+
- Error management
|
|
45
|
+
- Typed API clients
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## ✨ Current Features
|
|
50
|
+
|
|
51
|
+
- ✅ Custom exception hierarchy
|
|
52
|
+
- ✅ Configurable retry adapter
|
|
53
|
+
- ✅ Authentication
|
|
54
|
+
- ✅ Base API client
|
|
55
|
+
- ✅ Pagination
|
|
56
|
+
- 🚧 Built-in API clients (in progress)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 📄 License
|
|
61
|
+
|
|
62
|
+
Released under the MIT License.
|
hakiapi-1.0.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# HakiAPI
|
|
2
|
+
|
|
3
|
+
> A modern, typed, and extensible Python framework for building resilient API clients.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 🚀 Vision
|
|
11
|
+
|
|
12
|
+
HakiAPI aims to make building Python SDKs simple, consistent, and developer-friendly by providing reusable components for:
|
|
13
|
+
|
|
14
|
+
- Authentication
|
|
15
|
+
- Automatic retries
|
|
16
|
+
- Request handling
|
|
17
|
+
- Pagination
|
|
18
|
+
- Error management
|
|
19
|
+
- Typed API clients
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## ✨ Current Features
|
|
24
|
+
|
|
25
|
+
- ✅ Custom exception hierarchy
|
|
26
|
+
- ✅ Configurable retry adapter
|
|
27
|
+
- ✅ Authentication
|
|
28
|
+
- ✅ Base API client
|
|
29
|
+
- ✅ Pagination
|
|
30
|
+
- 🚧 Built-in API clients (in progress)
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 📄 License
|
|
35
|
+
|
|
36
|
+
Released under the MIT License.
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import Any, Iterator
|
|
2
|
+
|
|
3
|
+
from hakiapi.core.base_client import BaseAPIClient
|
|
4
|
+
from hakiapi.core.auth import BearerTokenAuth
|
|
5
|
+
from hakiapi.core.paginator import paginate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitHubClient(BaseAPIClient):
|
|
9
|
+
def __init__(self, token: str | None = None, **kwargs: Any) -> None:
|
|
10
|
+
auth_obj = BearerTokenAuth(token) if token else None
|
|
11
|
+
|
|
12
|
+
super().__init__(
|
|
13
|
+
base_url="https://api.github.com",
|
|
14
|
+
auth=auth_obj,
|
|
15
|
+
**kwargs,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
self.session.headers.update(
|
|
19
|
+
{
|
|
20
|
+
"Accept": "application/vnd.github+json",
|
|
21
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
22
|
+
"User-Agent": "HakiAPI/0.1.0",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def get_user(self, username: str, **kwargs: Any) -> dict[str, Any]:
|
|
27
|
+
"""Fetch a GitHub user's profile."""
|
|
28
|
+
return self.get(f"users/{username}", **kwargs)
|
|
29
|
+
|
|
30
|
+
def get_user_repos(self, username: str, **kwargs: Any) -> list[dict[str, Any]]:
|
|
31
|
+
"""Fetch a single page of public repositories of a GitHub user."""
|
|
32
|
+
return self.get(f"users/{username}/repos", **kwargs)
|
|
33
|
+
|
|
34
|
+
def get_all_user_repos(
|
|
35
|
+
self, username: str, **kwargs: Any
|
|
36
|
+
) -> Iterator[dict[str, Any]]:
|
|
37
|
+
"""Fetch ALL public repositories using automatic pagination."""
|
|
38
|
+
return paginate(self, f"users/{username}/repos", **kwargs)
|
|
39
|
+
|
|
40
|
+
def get_repo_languages(
|
|
41
|
+
self, owner: str, repo: str, **kwargs: Any
|
|
42
|
+
) -> dict[str, int]:
|
|
43
|
+
"""Fetch the exact byte breakdown of all languages used in a specific repository."""
|
|
44
|
+
return self.get(f"repos/{owner}/{repo}/languages", **kwargs)
|
|
45
|
+
|
|
46
|
+
def get_aggregate_user_languages(
|
|
47
|
+
self, username: str, **kwargs: Any
|
|
48
|
+
) -> dict[str, int]:
|
|
49
|
+
"""
|
|
50
|
+
Iterate through all of a user's repositories and get a repository-independent
|
|
51
|
+
map of all languages used, down to the smallest byte.
|
|
52
|
+
"""
|
|
53
|
+
aggregate_languages: dict[str, int] = {}
|
|
54
|
+
|
|
55
|
+
# Pull all repos lazily via our paginator engine
|
|
56
|
+
repos = self.get_all_user_repos(username, **kwargs)
|
|
57
|
+
|
|
58
|
+
for repo in repos:
|
|
59
|
+
repo_name = repo.get("name")
|
|
60
|
+
if not repo_name:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Query the specific byte-breakdown for this exact repo pointer
|
|
65
|
+
languages = self.get_repo_languages(username, repo_name)
|
|
66
|
+
for lang, bytes_count in languages.items():
|
|
67
|
+
aggregate_languages[lang] = (
|
|
68
|
+
aggregate_languages.get(lang, 0) + bytes_count
|
|
69
|
+
)
|
|
70
|
+
except Exception:
|
|
71
|
+
# Shield the iteration sequence if a single repo is deleted or inaccessible
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
return aggregate_languages
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
5
|
+
from requests.auth import AuthBase
|
|
6
|
+
from requests import PreparedRequest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BearerTokenAuth(AuthBase):
|
|
10
|
+
"""Authenticate using a Bearer token."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, token: str) -> None:
|
|
13
|
+
self.token = token
|
|
14
|
+
|
|
15
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
16
|
+
r.headers["Authorization"] = f"Bearer {self.token}"
|
|
17
|
+
return r
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HeaderApiKeyAuth(AuthBase):
|
|
21
|
+
"""Authenticate using a custom API key header."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, header_name: str, api_key: str) -> None:
|
|
24
|
+
self.header_name = header_name
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
|
|
27
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
28
|
+
r.headers[self.header_name] = self.api_key
|
|
29
|
+
return r
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class QueryApiKeyAuth(AuthBase):
|
|
33
|
+
"""Authenticate by appending an API key as a query parameter."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, param_name: str, api_key: str) -> None:
|
|
36
|
+
self.param_name = param_name
|
|
37
|
+
self.api_key = api_key
|
|
38
|
+
|
|
39
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
40
|
+
if not r.url:
|
|
41
|
+
return r
|
|
42
|
+
|
|
43
|
+
parts = urlsplit(r.url)
|
|
44
|
+
|
|
45
|
+
# Maintain as list of tuples to avoid dropping duplicate keys
|
|
46
|
+
query_params = parse_qsl(parts.query, keep_blank_values=True)
|
|
47
|
+
query_params.append((self.param_name, self.api_key))
|
|
48
|
+
|
|
49
|
+
r.url = urlunsplit(
|
|
50
|
+
(
|
|
51
|
+
parts.scheme,
|
|
52
|
+
parts.netloc,
|
|
53
|
+
parts.path,
|
|
54
|
+
urlencode(query_params),
|
|
55
|
+
parts.fragment,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return r
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HmacAuth(AuthBase):
|
|
63
|
+
"""
|
|
64
|
+
Authenticate requests using HMAC-SHA256 signatures.
|
|
65
|
+
|
|
66
|
+
The signature is generated from the HTTP method,
|
|
67
|
+
request path, timestamp, and request body.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
api_key: str,
|
|
73
|
+
secret_key: str,
|
|
74
|
+
api_key_header: str = "X-API-Key",
|
|
75
|
+
signature_header: str = "X-Signature",
|
|
76
|
+
timestamp_header: str = "X-Timestamp",
|
|
77
|
+
) -> None:
|
|
78
|
+
self.api_key = api_key
|
|
79
|
+
self.secret_key = secret_key.encode()
|
|
80
|
+
self.api_key_header = api_key_header
|
|
81
|
+
self.signature_header = signature_header
|
|
82
|
+
self.timestamp_header = timestamp_header
|
|
83
|
+
|
|
84
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
85
|
+
timestamp = str(int(time.time()))
|
|
86
|
+
|
|
87
|
+
body = r.body
|
|
88
|
+
|
|
89
|
+
if body is None:
|
|
90
|
+
body_bytes = b""
|
|
91
|
+
elif isinstance(body, bytes):
|
|
92
|
+
body_bytes = body
|
|
93
|
+
elif isinstance(body, str):
|
|
94
|
+
body_bytes = body.encode()
|
|
95
|
+
else:
|
|
96
|
+
raise TypeError("Streaming request bodies are not supported.")
|
|
97
|
+
|
|
98
|
+
if not r.method:
|
|
99
|
+
raise ValueError("Cannot sign a request with no HTTP method set.")
|
|
100
|
+
|
|
101
|
+
method = r.method.upper()
|
|
102
|
+
path_url = getattr(r, "path_url", "/")
|
|
103
|
+
|
|
104
|
+
message = b"\n".join(
|
|
105
|
+
[
|
|
106
|
+
method.encode(),
|
|
107
|
+
path_url.encode(),
|
|
108
|
+
timestamp.encode(),
|
|
109
|
+
body_bytes,
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
signature = hmac.new(
|
|
114
|
+
self.secret_key,
|
|
115
|
+
message,
|
|
116
|
+
hashlib.sha256,
|
|
117
|
+
).hexdigest()
|
|
118
|
+
|
|
119
|
+
r.headers[self.api_key_header] = self.api_key
|
|
120
|
+
r.headers[self.timestamp_header] = timestamp
|
|
121
|
+
r.headers[self.signature_header] = signature
|
|
122
|
+
|
|
123
|
+
return r
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
|
+
from requests.auth import AuthBase
|
|
4
|
+
|
|
5
|
+
from .retry import create_retry_adapter
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
HakiAPIError,
|
|
8
|
+
ClientError,
|
|
9
|
+
ServerError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
RequestTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T", bound="BaseAPIClient")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseAPIClient:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
auth: AuthBase | tuple[str, str] | None = None,
|
|
23
|
+
timeout: float = 10.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.base_url = base_url.rstrip("/")
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
|
|
30
|
+
if auth is not None:
|
|
31
|
+
self.session.auth = auth
|
|
32
|
+
|
|
33
|
+
adapter = create_retry_adapter()
|
|
34
|
+
self.session.mount("http://", adapter)
|
|
35
|
+
self.session.mount("https://", adapter)
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
self.session.close()
|
|
39
|
+
|
|
40
|
+
def __enter__(self: T) -> T:
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
44
|
+
self.close()
|
|
45
|
+
|
|
46
|
+
def _request(
|
|
47
|
+
self, method: str, endpoint: str, raw_response: bool = False, **kwargs: Any
|
|
48
|
+
) -> Any:
|
|
49
|
+
full_url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
50
|
+
|
|
51
|
+
# Safely extract timeout to pass to the exception engine if needed
|
|
52
|
+
request_timeout = kwargs.pop("timeout", self.timeout)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
response = self.session.request(
|
|
56
|
+
method=method,
|
|
57
|
+
url=full_url,
|
|
58
|
+
timeout=request_timeout,
|
|
59
|
+
**kwargs,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
except requests.exceptions.Timeout as e:
|
|
63
|
+
raise RequestTimeoutError(
|
|
64
|
+
message="Request timed out.",
|
|
65
|
+
timeout_duration=float(request_timeout) if request_timeout else None,
|
|
66
|
+
) from e
|
|
67
|
+
|
|
68
|
+
except requests.exceptions.RequestException as e:
|
|
69
|
+
raise HakiAPIError(message=str(e)) from e
|
|
70
|
+
|
|
71
|
+
# Rate limiting
|
|
72
|
+
if response.status_code == 429:
|
|
73
|
+
retry_after_str = response.headers.get("Retry-After")
|
|
74
|
+
retry_after = None
|
|
75
|
+
if retry_after_str:
|
|
76
|
+
try:
|
|
77
|
+
retry_after = float(retry_after_str)
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass # Ignore HTTP date formats; fallback to None
|
|
80
|
+
|
|
81
|
+
raise RateLimitError(
|
|
82
|
+
message="Rate limit exceeded.",
|
|
83
|
+
status_code=response.status_code,
|
|
84
|
+
retry_after=retry_after,
|
|
85
|
+
response=response,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Authentication
|
|
89
|
+
if response.status_code in (401, 403):
|
|
90
|
+
raise AuthenticationError(
|
|
91
|
+
message="Authentication failed.",
|
|
92
|
+
status_code=response.status_code,
|
|
93
|
+
response=response,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Client errors
|
|
97
|
+
if 400 <= response.status_code < 500:
|
|
98
|
+
raise ClientError(
|
|
99
|
+
message=f"HTTP {response.status_code} Client Error",
|
|
100
|
+
status_code=response.status_code,
|
|
101
|
+
response=response,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Server errors
|
|
105
|
+
if response.status_code >= 500:
|
|
106
|
+
raise ServerError(
|
|
107
|
+
message=f"HTTP {response.status_code} Server Error",
|
|
108
|
+
status_code=response.status_code,
|
|
109
|
+
response=response,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if raw_response:
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
return response.json()
|
|
117
|
+
except ValueError:
|
|
118
|
+
return response.text
|
|
119
|
+
|
|
120
|
+
def get(self, endpoint: str, **kwargs: Any) -> Any:
|
|
121
|
+
return self._request("GET", endpoint, **kwargs)
|
|
122
|
+
|
|
123
|
+
def post(self, endpoint: str, **kwargs: Any) -> Any:
|
|
124
|
+
return self._request("POST", endpoint, **kwargs)
|
|
125
|
+
|
|
126
|
+
def put(self, endpoint: str, **kwargs: Any) -> Any:
|
|
127
|
+
return self._request("PUT", endpoint, **kwargs)
|
|
128
|
+
|
|
129
|
+
def delete(self, endpoint: str, **kwargs: Any) -> Any:
|
|
130
|
+
return self._request("DELETE", endpoint, **kwargs)
|
|
131
|
+
|
|
132
|
+
def patch(self, endpoint: str, **kwargs: Any) -> Any:
|
|
133
|
+
return self._request("PATCH", endpoint, **kwargs)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HakiAPIError(Exception):
|
|
5
|
+
"""Base exception for all Haki API errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self, message: str, status_code: int | None = None, response: Any | None = None
|
|
9
|
+
) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.response = response
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
if self.status_code:
|
|
17
|
+
return f"[{self.status_code}] {self.message}"
|
|
18
|
+
return self.message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClientError(HakiAPIError):
|
|
22
|
+
"""Raised when the API returns a 4xx status code."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServerError(HakiAPIError):
|
|
28
|
+
"""Raised when the API returns a 5xx status code."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitError(ClientError):
|
|
34
|
+
"""Raised for HTTP 429 Too Many Requests."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
retry_after: int | float | None = None,
|
|
40
|
+
status_code: int | None = 429,
|
|
41
|
+
response: Any | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message, status_code=status_code, response=response)
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthenticationError(ClientError):
|
|
48
|
+
"""Raised for HTTP 401 Unauthorized and 403 Forbidden."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
auth_method: str | None = None,
|
|
54
|
+
status_code: int | None = None,
|
|
55
|
+
response: Any | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(message, status_code=status_code, response=response)
|
|
58
|
+
self.auth_method = auth_method
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RequestTimeoutError(HakiAPIError):
|
|
62
|
+
"""
|
|
63
|
+
Raised when a request times out at the network level.
|
|
64
|
+
Inherits directly from base, as timeouts lack HTTP status codes.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, message: str, timeout_duration: float | None = None) -> None:
|
|
68
|
+
# Status code and response are explicitly None for network timeouts
|
|
69
|
+
super().__init__(message, status_code=None, response=None)
|
|
70
|
+
self.timeout_duration = timeout_duration
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from typing import Any, Iterator
|
|
2
|
+
from urllib.parse import parse_qsl, urlparse
|
|
3
|
+
|
|
4
|
+
from .base_client import BaseAPIClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def paginate(
|
|
8
|
+
client: BaseAPIClient, endpoint: str, max_pages: int | None = None, **kwargs: Any
|
|
9
|
+
) -> Iterator[Any]:
|
|
10
|
+
"""
|
|
11
|
+
Fetches all pages from APIs that use different pagination methods,
|
|
12
|
+
figuring out which one to use based on the response.
|
|
13
|
+
|
|
14
|
+
Supported pagination styles:
|
|
15
|
+
|
|
16
|
+
1. Link header pagination (used by APIs like GitHub)
|
|
17
|
+
- Looks for a `next` link in the HTTP `Link` header.
|
|
18
|
+
- Each response is expected to be a JSON list.
|
|
19
|
+
|
|
20
|
+
2. Token-based pagination (used by APIs like Twitter/X API v2)
|
|
21
|
+
- Reads `meta.next_token` from the response body.
|
|
22
|
+
- Requests the next page by sending the same endpoint with the
|
|
23
|
+
updated `pagination_token` query parameter.
|
|
24
|
+
- Responses are expected to look like:
|
|
25
|
+
{"data": [...], "meta": {"next_token": "..."}}
|
|
26
|
+
|
|
27
|
+
If a `next` link is available, it takes priority. Otherwise, the
|
|
28
|
+
paginator checks for a `next_token`. If neither is found, there are
|
|
29
|
+
no more pages to fetch.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Extract initial params and ensure they are a list of tuples
|
|
33
|
+
# to prevent dropping duplicate keys.
|
|
34
|
+
raw_params = kwargs.pop("params", None) or {}
|
|
35
|
+
if isinstance(raw_params, dict):
|
|
36
|
+
params = list(raw_params.items())
|
|
37
|
+
else:
|
|
38
|
+
params = list(raw_params)
|
|
39
|
+
|
|
40
|
+
pages_fetched = 0
|
|
41
|
+
|
|
42
|
+
while endpoint:
|
|
43
|
+
# Safety valve: Prevent infinite loops caused by API routing bugs
|
|
44
|
+
if max_pages is not None and pages_fetched >= max_pages:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
response = client._request(
|
|
48
|
+
"GET",
|
|
49
|
+
endpoint,
|
|
50
|
+
raw_response=True,
|
|
51
|
+
params=params or None,
|
|
52
|
+
**kwargs,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
pages_fetched += 1
|
|
56
|
+
data = response.json()
|
|
57
|
+
|
|
58
|
+
if isinstance(data, list):
|
|
59
|
+
items = data
|
|
60
|
+
elif isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
61
|
+
items = data["data"]
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"Paginator expected a list response, or a dict with a "
|
|
65
|
+
"'data' list (e.g. {'data': [...]})."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for item in items:
|
|
69
|
+
yield item
|
|
70
|
+
|
|
71
|
+
# RFC 5988 Link header (GitHub-style)
|
|
72
|
+
if "next" in response.links:
|
|
73
|
+
next_url = response.links["next"]["url"]
|
|
74
|
+
parsed = urlparse(next_url)
|
|
75
|
+
|
|
76
|
+
# Strictly extract only the path to prevent query string duplication
|
|
77
|
+
# in requests, and completely bypass brittle base_url prefix matching.
|
|
78
|
+
endpoint = parsed.path.lstrip("/")
|
|
79
|
+
params = parse_qsl(parsed.query) if parsed.query else []
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# cursor/token pagination (Twitter-style)
|
|
83
|
+
next_token = (
|
|
84
|
+
data.get("meta", {}).get("next_token") if isinstance(data, dict) else None
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if next_token:
|
|
88
|
+
# Filters out the old pagination_token tuple, then append the new one
|
|
89
|
+
params = [(k, v) for k, v in params if k != "pagination_token"]
|
|
90
|
+
params.append(("pagination_token", next_token))
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
break
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Collection
|
|
2
|
+
|
|
3
|
+
from requests.adapters import HTTPAdapter
|
|
4
|
+
from urllib3.util import Retry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_retry_adapter(
|
|
8
|
+
total_retries: int = 3,
|
|
9
|
+
backoff_factor: float = 1.0,
|
|
10
|
+
status_forcelist: Collection[int] | None = None,
|
|
11
|
+
allowed_methods: Collection[str] | None = None,
|
|
12
|
+
) -> HTTPAdapter:
|
|
13
|
+
"""
|
|
14
|
+
Creates an HTTPAdapter configured with exponential backoff and retry behavior.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
if status_forcelist is None:
|
|
18
|
+
status_forcelist = [429, 500, 502, 503, 504]
|
|
19
|
+
|
|
20
|
+
retry_strategy = Retry(
|
|
21
|
+
total=total_retries,
|
|
22
|
+
backoff_factor=backoff_factor,
|
|
23
|
+
status_forcelist=status_forcelist,
|
|
24
|
+
allowed_methods=allowed_methods, # Explicitly expose method filtering
|
|
25
|
+
raise_on_status=False, # Defer error handling to HakiAPI exceptions
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return HTTPAdapter(max_retries=retry_strategy)
|