yeonjae-universal-http-api-client 1.0.1__py3-none-any.whl

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,35 @@
1
+ """
2
+ Universal HTTP API Client - 범용 HTTP API 클라이언트
3
+
4
+ 이 모듈은 GitHub, GitLab 등 다양한 플랫폼의 API를 호출하는 범용 클라이언트입니다.
5
+ 재사용성을 고려하여 설계되었으며, 인증, 재시도, 캐싱 등의 기능을 제공합니다.
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+
10
+ from .client import HTTPAPIClient, AsyncHTTPAPIClient
11
+ from .adapters import PlatformAPIAdapter, GitHubAdapter, GitLabAdapter
12
+ from .models import APIRequest, APIResponse, PlatformConfig, Platform, HTTPMethod
13
+ from .exceptions import APIError, RateLimitError, AuthenticationError, NetworkError, TimeoutError
14
+ from .utils import ModuleIOLogger, setup_logging
15
+
16
+ __all__ = [
17
+ "HTTPAPIClient",
18
+ "AsyncHTTPAPIClient",
19
+ "PlatformAPIAdapter",
20
+ "GitHubAdapter",
21
+ "GitLabAdapter",
22
+ "APIRequest",
23
+ "APIResponse",
24
+ "PlatformConfig",
25
+ "Platform",
26
+ "HTTPMethod",
27
+ "APIError",
28
+ "RateLimitError",
29
+ "AuthenticationError",
30
+ "NetworkError",
31
+ "TimeoutError",
32
+ "ModuleIOLogger",
33
+ "setup_logging",
34
+ "__version__",
35
+ ]
@@ -0,0 +1,314 @@
1
+ """
2
+ 플랫폼별 API 어댑터 구현
3
+ """
4
+
5
+ import hashlib
6
+ import time
7
+ from abc import ABC, abstractmethod
8
+ from typing import Dict, Any, Optional, Callable
9
+ from datetime import datetime
10
+
11
+ from .models import Platform, PlatformConfig, APIRequest, APIResponse, RateLimitInfo, PLATFORM_CONFIGS
12
+ from .exceptions import RateLimitError, PlatformNotSupportedError
13
+
14
+
15
+ class PlatformAPIAdapter(ABC):
16
+ """플랫폼 API 어댑터 기본 클래스"""
17
+
18
+ def __init__(self, config: PlatformConfig, auth_token: str):
19
+ self.config = config
20
+ self.auth_token = auth_token
21
+
22
+ @abstractmethod
23
+ def build_url(self, endpoint: str) -> str:
24
+ """API URL 구성"""
25
+ pass
26
+
27
+ @abstractmethod
28
+ def get_auth_headers(self) -> Dict[str, str]:
29
+ """인증 헤더 반환"""
30
+ pass
31
+
32
+ @abstractmethod
33
+ def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
34
+ """Rate limit 정보 파싱"""
35
+ pass
36
+
37
+ @abstractmethod
38
+ def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
39
+ """응답 데이터 파싱"""
40
+ pass
41
+
42
+ def get_default_headers(self) -> Dict[str, str]:
43
+ """기본 헤더 반환"""
44
+ headers = self.config.default_headers.copy()
45
+ headers.update(self.get_auth_headers())
46
+ return headers
47
+
48
+ def get_cache_key(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> str:
49
+ """캐시 키 생성"""
50
+ params_str = ""
51
+ if params:
52
+ # 파라미터를 정렬하여 일관된 해시 생성
53
+ sorted_params = sorted(params.items())
54
+ params_str = str(sorted_params)
55
+
56
+ cache_data = f"{self.config.name}:{endpoint}:{params_str}"
57
+ return hashlib.md5(cache_data.encode()).hexdigest()
58
+
59
+
60
+ class GitHubAdapter(PlatformAPIAdapter):
61
+ """GitHub API 어댑터"""
62
+
63
+ def build_url(self, endpoint: str) -> str:
64
+ """GitHub API URL 구성"""
65
+ # endpoint가 이미 전체 URL이면 그대로 반환
66
+ if endpoint.startswith('http'):
67
+ return endpoint
68
+
69
+ # 앞에 슬래시가 없으면 추가
70
+ if not endpoint.startswith('/'):
71
+ endpoint = f"/{endpoint}"
72
+
73
+ return f"{self.config.base_url}{endpoint}"
74
+
75
+ def get_auth_headers(self) -> Dict[str, str]:
76
+ """GitHub 인증 헤더"""
77
+ return self.config.get_auth_header(self.auth_token)
78
+
79
+ def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
80
+ """GitHub Rate limit 정보 파싱"""
81
+ try:
82
+ remaining = int(headers.get('X-RateLimit-Remaining', 0))
83
+ limit = int(headers.get('X-RateLimit-Limit', 5000))
84
+ reset_timestamp = int(headers.get('X-RateLimit-Reset', 0))
85
+ reset_time = datetime.fromtimestamp(reset_timestamp)
86
+
87
+ return RateLimitInfo(
88
+ remaining=remaining,
89
+ limit=limit,
90
+ reset_time=reset_time
91
+ )
92
+ except (ValueError, TypeError):
93
+ return None
94
+
95
+ def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
96
+ """GitHub 응답 데이터 파싱"""
97
+ if operation == "get_commit":
98
+ return self._parse_commit_response(response_data)
99
+ elif operation == "get_diff":
100
+ return self._parse_diff_response(response_data)
101
+ elif operation == "get_repository":
102
+ return self._parse_repository_response(response_data)
103
+ else:
104
+ return response_data
105
+
106
+ def _parse_commit_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
107
+ """커밋 응답 파싱"""
108
+ return {
109
+ "sha": data.get("sha"),
110
+ "message": data.get("commit", {}).get("message"),
111
+ "author": data.get("commit", {}).get("author", {}),
112
+ "committer": data.get("commit", {}).get("committer", {}),
113
+ "stats": data.get("stats", {}),
114
+ "files": data.get("files", []),
115
+ "parents": data.get("parents", []),
116
+ "url": data.get("html_url")
117
+ }
118
+
119
+ def _parse_diff_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
120
+ """Diff 응답 파싱"""
121
+ return {
122
+ "files": data.get("files", []),
123
+ "stats": data.get("stats", {}),
124
+ "total_additions": sum(f.get("additions", 0) for f in data.get("files", [])),
125
+ "total_deletions": sum(f.get("deletions", 0) for f in data.get("files", [])),
126
+ "total_changes": sum(f.get("changes", 0) for f in data.get("files", []))
127
+ }
128
+
129
+ def _parse_repository_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
130
+ """저장소 응답 파싱"""
131
+ return {
132
+ "id": data.get("id"),
133
+ "name": data.get("name"),
134
+ "full_name": data.get("full_name"),
135
+ "owner": data.get("owner", {}),
136
+ "private": data.get("private"),
137
+ "description": data.get("description"),
138
+ "language": data.get("language"),
139
+ "size": data.get("size"),
140
+ "stars": data.get("stargazers_count"),
141
+ "forks": data.get("forks_count"),
142
+ "created_at": data.get("created_at"),
143
+ "updated_at": data.get("updated_at"),
144
+ "url": data.get("html_url")
145
+ }
146
+
147
+
148
+ class GitLabAdapter(PlatformAPIAdapter):
149
+ """GitLab API 어댑터"""
150
+
151
+ def build_url(self, endpoint: str) -> str:
152
+ """GitLab API URL 구성"""
153
+ if endpoint.startswith('http'):
154
+ return endpoint
155
+
156
+ if not endpoint.startswith('/'):
157
+ endpoint = f"/{endpoint}"
158
+
159
+ return f"{self.config.base_url}{endpoint}"
160
+
161
+ def get_auth_headers(self) -> Dict[str, str]:
162
+ """GitLab 인증 헤더"""
163
+ return self.config.get_auth_header(self.auth_token)
164
+
165
+ def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
166
+ """GitLab Rate limit 정보 파싱"""
167
+ try:
168
+ remaining = int(headers.get('RateLimit-Remaining', 0))
169
+ limit = int(headers.get('RateLimit-Limit', 300))
170
+ reset_timestamp = int(headers.get('RateLimit-Reset', 0))
171
+ reset_time = datetime.fromtimestamp(reset_timestamp)
172
+
173
+ return RateLimitInfo(
174
+ remaining=remaining,
175
+ limit=limit,
176
+ reset_time=reset_time
177
+ )
178
+ except (ValueError, TypeError):
179
+ return None
180
+
181
+ def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
182
+ """GitLab 응답 데이터 파싱"""
183
+ if operation == "get_commit":
184
+ return self._parse_commit_response(response_data)
185
+ elif operation == "get_diff":
186
+ return self._parse_diff_response(response_data)
187
+ elif operation == "get_project":
188
+ return self._parse_project_response(response_data)
189
+ else:
190
+ return response_data
191
+
192
+ def _parse_commit_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
193
+ """커밋 응답 파싱"""
194
+ return {
195
+ "sha": data.get("id"),
196
+ "message": data.get("message"),
197
+ "author": {
198
+ "name": data.get("author_name"),
199
+ "email": data.get("author_email"),
200
+ "date": data.get("authored_date")
201
+ },
202
+ "committer": {
203
+ "name": data.get("committer_name"),
204
+ "email": data.get("committer_email"),
205
+ "date": data.get("committed_date")
206
+ },
207
+ "stats": data.get("stats", {}),
208
+ "parent_ids": data.get("parent_ids", []),
209
+ "url": data.get("web_url")
210
+ }
211
+
212
+ def _parse_diff_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
213
+ """Diff 응답 파싱"""
214
+ # GitLab의 diff 응답은 파일별로 분리되어 있음
215
+ files = []
216
+ total_additions = 0
217
+ total_deletions = 0
218
+
219
+ if isinstance(data, list):
220
+ for file_diff in data:
221
+ files.append(file_diff)
222
+ # GitLab diff에서 통계 정보 추출
223
+ diff_text = file_diff.get("diff", "")
224
+ additions = diff_text.count("\n+") if diff_text else 0
225
+ deletions = diff_text.count("\n-") if diff_text else 0
226
+ total_additions += additions
227
+ total_deletions += deletions
228
+
229
+ return {
230
+ "files": files,
231
+ "total_additions": total_additions,
232
+ "total_deletions": total_deletions,
233
+ "total_changes": total_additions + total_deletions
234
+ }
235
+
236
+ def _parse_project_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
237
+ """프로젝트 응답 파싱"""
238
+ return {
239
+ "id": data.get("id"),
240
+ "name": data.get("name"),
241
+ "full_name": data.get("path_with_namespace"),
242
+ "owner": data.get("owner", {}),
243
+ "private": data.get("visibility") == "private",
244
+ "description": data.get("description"),
245
+ "language": None, # GitLab API에서는 별도 조회 필요
246
+ "size": data.get("repository_size"),
247
+ "stars": data.get("star_count"),
248
+ "forks": data.get("forks_count"),
249
+ "created_at": data.get("created_at"),
250
+ "updated_at": data.get("last_activity_at"),
251
+ "url": data.get("web_url")
252
+ }
253
+
254
+
255
+ class BitbucketAdapter(PlatformAPIAdapter):
256
+ """Bitbucket API 어댑터"""
257
+
258
+ def build_url(self, endpoint: str) -> str:
259
+ """Bitbucket API URL 구성"""
260
+ if endpoint.startswith('http'):
261
+ return endpoint
262
+
263
+ if not endpoint.startswith('/'):
264
+ endpoint = f"/{endpoint}"
265
+
266
+ return f"{self.config.base_url}{endpoint}"
267
+
268
+ def get_auth_headers(self) -> Dict[str, str]:
269
+ """Bitbucket 인증 헤더"""
270
+ return self.config.get_auth_header(self.auth_token)
271
+
272
+ def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
273
+ """Bitbucket Rate limit 정보 파싱"""
274
+ # Bitbucket은 특별한 rate limit 헤더가 없음
275
+ return None
276
+
277
+ def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
278
+ """Bitbucket 응답 데이터 파싱"""
279
+ # Bitbucket API 응답 구조에 맞게 파싱
280
+ return response_data
281
+
282
+
283
+ class AdapterFactory:
284
+ """어댑터 팩토리"""
285
+
286
+ _adapters = {
287
+ Platform.GITHUB: GitHubAdapter,
288
+ Platform.GITLAB: GitLabAdapter,
289
+ Platform.BITBUCKET: BitbucketAdapter
290
+ }
291
+
292
+ @classmethod
293
+ def create_adapter(cls, platform: Platform, auth_token: str) -> PlatformAPIAdapter:
294
+ """플랫폼에 맞는 어댑터 생성"""
295
+ if platform not in cls._adapters:
296
+ supported = [p.value for p in cls._adapters.keys()]
297
+ raise PlatformNotSupportedError(platform.value, supported)
298
+
299
+ config = PLATFORM_CONFIGS.get(platform)
300
+ if not config:
301
+ raise PlatformNotSupportedError(platform.value)
302
+
303
+ adapter_class = cls._adapters[platform]
304
+ return adapter_class(config, auth_token)
305
+
306
+ @classmethod
307
+ def register_adapter(cls, platform: Platform, adapter_class: type):
308
+ """새로운 어댑터 등록"""
309
+ cls._adapters[platform] = adapter_class
310
+
311
+ @classmethod
312
+ def get_supported_platforms(cls) -> list:
313
+ """지원하는 플랫폼 목록 반환"""
314
+ return [platform.value for platform in cls._adapters.keys()]