gunspec 0.1.0__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.
gunspec/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """GunSpec.io Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gunspec._client import AsyncGunSpec, GunSpec
6
+ from gunspec._core._errors import (
7
+ APIError,
8
+ AuthenticationError,
9
+ BadRequestError,
10
+ ConnectError,
11
+ GunSpecError,
12
+ InternalServerError,
13
+ NotFoundError,
14
+ PermissionDeniedError,
15
+ RateLimitError,
16
+ RequestTimeoutError,
17
+ )
18
+ from gunspec._core._http_client import (
19
+ APIResponse,
20
+ HttpClientConfig,
21
+ PaginatedResponse,
22
+ PaginationMeta,
23
+ RateLimitInfo,
24
+ )
25
+ from gunspec._core._pagination import AsyncPage, SyncPage
26
+ from gunspec._core._retry import RetryConfig
27
+ from gunspec._version import __version__
28
+
29
+ __all__ = [
30
+ # Client
31
+ "GunSpec",
32
+ "AsyncGunSpec",
33
+ # Version
34
+ "__version__",
35
+ # Config
36
+ "HttpClientConfig",
37
+ "RetryConfig",
38
+ # Response types
39
+ "APIResponse",
40
+ "PaginatedResponse",
41
+ "PaginationMeta",
42
+ "RateLimitInfo",
43
+ # Pagination
44
+ "SyncPage",
45
+ "AsyncPage",
46
+ # Errors
47
+ "GunSpecError",
48
+ "APIError",
49
+ "AuthenticationError",
50
+ "PermissionDeniedError",
51
+ "NotFoundError",
52
+ "BadRequestError",
53
+ "RateLimitError",
54
+ "InternalServerError",
55
+ "ConnectError",
56
+ "RequestTimeoutError",
57
+ ]
gunspec/_client.py ADDED
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ._core._http_client import (
6
+ AsyncHttpClient,
7
+ HttpClientConfig,
8
+ SyncHttpClient,
9
+ )
10
+ from ._core._retry import RetryConfig
11
+ from ._resources import (
12
+ Ammunition,
13
+ AsyncAmmunition,
14
+ AsyncCalibers,
15
+ AsyncCategories,
16
+ AsyncConflicts,
17
+ AsyncCountries,
18
+ AsyncDataQuality,
19
+ AsyncFavorites,
20
+ AsyncFirearms,
21
+ AsyncGame,
22
+ AsyncGameStats_,
23
+ AsyncManufacturers,
24
+ AsyncReports,
25
+ AsyncStats,
26
+ AsyncSupport,
27
+ AsyncUsage,
28
+ AsyncWebhooks,
29
+ Calibers,
30
+ Categories,
31
+ Conflicts,
32
+ Countries,
33
+ DataQuality,
34
+ Favorites,
35
+ Firearms,
36
+ Game,
37
+ GameStats_,
38
+ Manufacturers,
39
+ Reports,
40
+ Stats,
41
+ Support,
42
+ Usage,
43
+ Webhooks,
44
+ )
45
+ from ._version import __version__
46
+
47
+
48
+ class GunSpec:
49
+ """Synchronous GunSpec.io API client.
50
+
51
+ Example::
52
+
53
+ from gunspec import GunSpec
54
+
55
+ client = GunSpec(api_key="gs_...")
56
+
57
+ # List firearms
58
+ result = client.firearms.list({"category": "pistol"})
59
+ for firearm in result.data:
60
+ print(firearm)
61
+
62
+ # Context manager for clean shutdown
63
+ with GunSpec() as client:
64
+ data = client.firearms.get("glock-g17")
65
+ """
66
+
67
+ firearms: Firearms
68
+ manufacturers: Manufacturers
69
+ calibers: Calibers
70
+ categories: Categories
71
+ stats: Stats
72
+ game: Game
73
+ game_stats: GameStats_
74
+ ammunition: Ammunition
75
+ countries: Countries
76
+ conflicts: Conflicts
77
+ data_quality: DataQuality
78
+ favorites: Favorites
79
+ reports: Reports
80
+ support: Support
81
+ webhooks: Webhooks
82
+ usage: Usage
83
+
84
+ def __init__(
85
+ self,
86
+ *,
87
+ api_key: Optional[str] = None,
88
+ base_url: Optional[str] = None,
89
+ timeout: Optional[float] = None,
90
+ retry: Optional[RetryConfig] = None,
91
+ default_headers: Optional[dict[str, str]] = None,
92
+ ) -> None:
93
+ config = HttpClientConfig(
94
+ base_url=base_url or "https://api.gunspec.io",
95
+ timeout=timeout or 30.0,
96
+ api_key=api_key,
97
+ retry=retry or RetryConfig(),
98
+ headers={
99
+ **(default_headers or {}),
100
+ "User-Agent": f"gunspec-sdk/python/{__version__}",
101
+ "X-SDK-Version": __version__,
102
+ "X-SDK-Language": "python",
103
+ },
104
+ )
105
+ self._client = SyncHttpClient(config)
106
+
107
+ self.firearms = Firearms(self._client)
108
+ self.manufacturers = Manufacturers(self._client)
109
+ self.calibers = Calibers(self._client)
110
+ self.categories = Categories(self._client)
111
+ self.stats = Stats(self._client)
112
+ self.game = Game(self._client)
113
+ self.game_stats = GameStats_(self._client)
114
+ self.ammunition = Ammunition(self._client)
115
+ self.countries = Countries(self._client)
116
+ self.conflicts = Conflicts(self._client)
117
+ self.data_quality = DataQuality(self._client)
118
+ self.favorites = Favorites(self._client)
119
+ self.reports = Reports(self._client)
120
+ self.support = Support(self._client)
121
+ self.webhooks = Webhooks(self._client)
122
+ self.usage = Usage(self._client)
123
+
124
+ def close(self) -> None:
125
+ """Close the underlying HTTP connection pool."""
126
+ self._client.close()
127
+
128
+ def __enter__(self) -> GunSpec:
129
+ return self
130
+
131
+ def __exit__(self, *_: object) -> None:
132
+ self.close()
133
+
134
+
135
+ class AsyncGunSpec:
136
+ """Asynchronous GunSpec.io API client.
137
+
138
+ Example::
139
+
140
+ from gunspec import AsyncGunSpec
141
+
142
+ async with AsyncGunSpec(api_key="gs_...") as client:
143
+ result = await client.firearms.list({"category": "rifle"})
144
+ for firearm in result.data:
145
+ print(firearm)
146
+ """
147
+
148
+ firearms: AsyncFirearms
149
+ manufacturers: AsyncManufacturers
150
+ calibers: AsyncCalibers
151
+ categories: AsyncCategories
152
+ stats: AsyncStats
153
+ game: AsyncGame
154
+ game_stats: AsyncGameStats_
155
+ ammunition: AsyncAmmunition
156
+ countries: AsyncCountries
157
+ conflicts: AsyncConflicts
158
+ data_quality: AsyncDataQuality
159
+ favorites: AsyncFavorites
160
+ reports: AsyncReports
161
+ support: AsyncSupport
162
+ webhooks: AsyncWebhooks
163
+ usage: AsyncUsage
164
+
165
+ def __init__(
166
+ self,
167
+ *,
168
+ api_key: Optional[str] = None,
169
+ base_url: Optional[str] = None,
170
+ timeout: Optional[float] = None,
171
+ retry: Optional[RetryConfig] = None,
172
+ default_headers: Optional[dict[str, str]] = None,
173
+ ) -> None:
174
+ config = HttpClientConfig(
175
+ base_url=base_url or "https://api.gunspec.io",
176
+ timeout=timeout or 30.0,
177
+ api_key=api_key,
178
+ retry=retry or RetryConfig(),
179
+ headers={
180
+ **(default_headers or {}),
181
+ "User-Agent": f"gunspec-sdk/python/{__version__}",
182
+ "X-SDK-Version": __version__,
183
+ "X-SDK-Language": "python",
184
+ },
185
+ )
186
+ self._client = AsyncHttpClient(config)
187
+
188
+ self.firearms = AsyncFirearms(self._client)
189
+ self.manufacturers = AsyncManufacturers(self._client)
190
+ self.calibers = AsyncCalibers(self._client)
191
+ self.categories = AsyncCategories(self._client)
192
+ self.stats = AsyncStats(self._client)
193
+ self.game = AsyncGame(self._client)
194
+ self.game_stats = AsyncGameStats_(self._client)
195
+ self.ammunition = AsyncAmmunition(self._client)
196
+ self.countries = AsyncCountries(self._client)
197
+ self.conflicts = AsyncConflicts(self._client)
198
+ self.data_quality = AsyncDataQuality(self._client)
199
+ self.favorites = AsyncFavorites(self._client)
200
+ self.reports = AsyncReports(self._client)
201
+ self.support = AsyncSupport(self._client)
202
+ self.webhooks = AsyncWebhooks(self._client)
203
+ self.usage = AsyncUsage(self._client)
204
+
205
+ async def aclose(self) -> None:
206
+ """Close the underlying HTTP connection pool."""
207
+ await self._client.aclose()
208
+
209
+ async def __aenter__(self) -> AsyncGunSpec:
210
+ return self
211
+
212
+ async def __aexit__(self, *_: object) -> None:
213
+ await self.aclose()
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from ._auth import build_auth_headers, resolve_api_key
4
+ from ._errors import (
5
+ APIError,
6
+ AuthenticationError,
7
+ BadRequestError,
8
+ ConnectError,
9
+ GunSpecError,
10
+ InternalServerError,
11
+ NotFoundError,
12
+ PermissionDeniedError,
13
+ RateLimitError,
14
+ RequestTimeoutError,
15
+ create_api_error,
16
+ )
17
+ from ._http_client import (
18
+ DEFAULT_BASE_URL,
19
+ DEFAULT_TIMEOUT,
20
+ SDK_USER_AGENT,
21
+ APIResponse,
22
+ AsyncHttpClient,
23
+ HttpClientConfig,
24
+ PaginatedResponse,
25
+ PaginationMeta,
26
+ RateLimitInfo,
27
+ SyncHttpClient,
28
+ serialize_query,
29
+ )
30
+ from ._pagination import AsyncPage, SyncPage
31
+ from ._retry import RetryConfig, async_with_retry, compute_delay, is_retryable, with_retry
32
+
33
+ __all__ = [
34
+ # Errors
35
+ "GunSpecError",
36
+ "APIError",
37
+ "AuthenticationError",
38
+ "PermissionDeniedError",
39
+ "NotFoundError",
40
+ "BadRequestError",
41
+ "RateLimitError",
42
+ "InternalServerError",
43
+ "ConnectError",
44
+ "RequestTimeoutError",
45
+ "create_api_error",
46
+ # Auth
47
+ "resolve_api_key",
48
+ "build_auth_headers",
49
+ # HTTP client
50
+ "SyncHttpClient",
51
+ "AsyncHttpClient",
52
+ "HttpClientConfig",
53
+ "APIResponse",
54
+ "PaginatedResponse",
55
+ "PaginationMeta",
56
+ "RateLimitInfo",
57
+ "serialize_query",
58
+ "DEFAULT_BASE_URL",
59
+ "DEFAULT_TIMEOUT",
60
+ "SDK_USER_AGENT",
61
+ # Pagination
62
+ "SyncPage",
63
+ "AsyncPage",
64
+ # Retry
65
+ "RetryConfig",
66
+ "is_retryable",
67
+ "compute_delay",
68
+ "with_retry",
69
+ "async_with_retry",
70
+ ]
gunspec/_core/_auth.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Dict, Optional
5
+
6
+
7
+ def resolve_api_key(api_key: Optional[str] = None) -> Optional[str]:
8
+ """Resolve the API key from the explicit parameter or GUNSPEC_API_KEY env var.
9
+
10
+ Resolution order:
11
+ 1. Explicit ``api_key`` parameter.
12
+ 2. ``GUNSPEC_API_KEY`` environment variable.
13
+ 3. ``None`` (anonymous / unauthenticated).
14
+ """
15
+ if api_key is not None and api_key != "":
16
+ return api_key
17
+ return os.environ.get("GUNSPEC_API_KEY") or None
18
+
19
+
20
+ def build_auth_headers(api_key: Optional[str] = None) -> Dict[str, str]:
21
+ """Build the authentication headers for a request.
22
+
23
+ Returns ``{"X-API-Key": key}`` when a key is available, or an empty dict
24
+ for anonymous mode.
25
+ """
26
+ if api_key is not None and api_key != "":
27
+ return {"X-API-Key": api_key}
28
+ return {}
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class GunSpecError(Exception):
9
+ """Base error class for every error raised by the GunSpec SDK."""
10
+
11
+
12
+ class APIError(GunSpecError):
13
+ """An error returned by the GunSpec API with an HTTP status code."""
14
+
15
+ status: int
16
+ code: str
17
+ message: str
18
+ request_id: str
19
+ headers: Dict[str, str]
20
+
21
+ def __init__(
22
+ self,
23
+ status: int,
24
+ code: str,
25
+ message: str,
26
+ request_id: str,
27
+ headers: Dict[str, str],
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.code = code
32
+ self.message = message
33
+ self.request_id = request_id
34
+ self.headers = headers
35
+
36
+
37
+ class AuthenticationError(APIError):
38
+ """Raised when the API returns 401 Unauthorized."""
39
+
40
+ def __init__(
41
+ self,
42
+ code: str,
43
+ message: str,
44
+ request_id: str,
45
+ headers: Dict[str, str],
46
+ ) -> None:
47
+ super().__init__(401, code, message, request_id, headers)
48
+
49
+
50
+ class PermissionDeniedError(APIError):
51
+ """Raised when the API returns 403 Forbidden."""
52
+
53
+ def __init__(
54
+ self,
55
+ code: str,
56
+ message: str,
57
+ request_id: str,
58
+ headers: Dict[str, str],
59
+ ) -> None:
60
+ super().__init__(403, code, message, request_id, headers)
61
+
62
+
63
+ class NotFoundError(APIError):
64
+ """Raised when the API returns 404 Not Found."""
65
+
66
+ def __init__(
67
+ self,
68
+ code: str,
69
+ message: str,
70
+ request_id: str,
71
+ headers: Dict[str, str],
72
+ ) -> None:
73
+ super().__init__(404, code, message, request_id, headers)
74
+
75
+
76
+ class BadRequestError(APIError):
77
+ """Raised when the API returns 400 Bad Request."""
78
+
79
+ def __init__(
80
+ self,
81
+ code: str,
82
+ message: str,
83
+ request_id: str,
84
+ headers: Dict[str, str],
85
+ ) -> None:
86
+ super().__init__(400, code, message, request_id, headers)
87
+
88
+
89
+ class RateLimitError(APIError):
90
+ """Raised when the API returns 429 Too Many Requests."""
91
+
92
+ retry_after: Optional[float]
93
+
94
+ def __init__(
95
+ self,
96
+ code: str,
97
+ message: str,
98
+ request_id: str,
99
+ headers: Dict[str, str],
100
+ retry_after: Optional[float],
101
+ ) -> None:
102
+ super().__init__(429, code, message, request_id, headers)
103
+ self.retry_after = retry_after
104
+
105
+
106
+ class InternalServerError(APIError):
107
+ """Raised when the API returns 500 Internal Server Error."""
108
+
109
+ def __init__(
110
+ self,
111
+ code: str,
112
+ message: str,
113
+ request_id: str,
114
+ headers: Dict[str, str],
115
+ ) -> None:
116
+ super().__init__(500, code, message, request_id, headers)
117
+
118
+
119
+ class ConnectError(GunSpecError):
120
+ """Raised on network-level failures (DNS, connection reset, etc.)."""
121
+
122
+ cause: Optional[BaseException]
123
+
124
+ def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
125
+ super().__init__(message)
126
+ self.cause = cause
127
+
128
+
129
+ class RequestTimeoutError(GunSpecError):
130
+ """Raised when a request exceeds the configured timeout."""
131
+
132
+ timeout_s: float
133
+
134
+ def __init__(self, timeout_s: float) -> None:
135
+ super().__init__(f"Request timed out after {timeout_s}s")
136
+ self.timeout_s = timeout_s
137
+
138
+
139
+ def _parse_retry_after(headers: Dict[str, str]) -> Optional[float]:
140
+ """Parse the Retry-After header value into seconds."""
141
+ raw = headers.get("Retry-After") or headers.get("retry-after")
142
+ if raw is None:
143
+ return None
144
+
145
+ # Try delta-seconds first.
146
+ try:
147
+ seconds = float(raw)
148
+ if not math.isnan(seconds) and seconds >= 0:
149
+ return seconds
150
+ except ValueError:
151
+ pass
152
+
153
+ # Fall back to HTTP-date.
154
+ try:
155
+ dt = datetime.strptime(raw, "%a, %d %b %Y %H:%M:%S %Z")
156
+ dt = dt.replace(tzinfo=timezone.utc)
157
+ delta = (dt - datetime.now(timezone.utc)).total_seconds()
158
+ return max(delta, 0.0)
159
+ except ValueError:
160
+ pass
161
+
162
+ return None
163
+
164
+
165
+ def create_api_error(
166
+ status: int,
167
+ body: Optional[Dict[str, Any]],
168
+ request_id: str,
169
+ headers: Dict[str, str],
170
+ ) -> APIError:
171
+ """Create the appropriate APIError subclass for the given HTTP status."""
172
+ error_data = body.get("error", {}) if body else {}
173
+ code: str = error_data.get("code", f"HTTP_{status}")
174
+ message: str = error_data.get("message", f"Request failed with status {status}")
175
+
176
+ if status == 400:
177
+ return BadRequestError(code, message, request_id, headers)
178
+ if status == 401:
179
+ return AuthenticationError(code, message, request_id, headers)
180
+ if status == 403:
181
+ return PermissionDeniedError(code, message, request_id, headers)
182
+ if status == 404:
183
+ return NotFoundError(code, message, request_id, headers)
184
+ if status == 429:
185
+ return RateLimitError(
186
+ code, message, request_id, headers, _parse_retry_after(headers)
187
+ )
188
+ if status == 500:
189
+ return InternalServerError(code, message, request_id, headers)
190
+ return APIError(status, code, message, request_id, headers)