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 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
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
32
+ ![License](https://img.shields.io/badge/license-MIT-green)
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.
@@ -0,0 +1,36 @@
1
+ # HakiAPI
2
+
3
+ > A modern, typed, and extensible Python framework for building resilient API clients.
4
+
5
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
6
+ ![License](https://img.shields.io/badge/license-MIT-green)
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)