opendota-sdk 0.1.0a1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juan Manuel Bermudez Cabrera
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.
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: opendota-sdk
3
+ Version: 0.1.0a1
4
+ Summary: SDK for the OpenDota API, providing enhanced capabilities for Dota 2 data.
5
+ Author: Juan Manuel Bermudez Cabrera
6
+ Author-email: Juan Manuel Bermudez Cabrera <juanmorestep@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: niquests>=3.9,<4.0
18
+ Requires-Dist: tenacity>=9.0,<10.0
19
+ Requires-Python: >=3.10
20
+ Project-URL: Homepage, https://github.com/jbnone/opendota-sdk
21
+ Project-URL: Issues, https://github.com/jbnone/opendota-sdk/issues
22
+ Project-URL: Repository, https://github.com/jbnone/opendota-sdk
23
+ Description-Content-Type: text/markdown
24
+
25
+ <div align="center">
26
+
27
+ <h1>Open Dota SDK</h1>
28
+
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
30
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
31
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
32
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
33
+ [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)
34
+
35
+ </div>
36
+
37
+ Build better Dota 2 tools with the OpenDota SDK, providing enhanced access to [OpenDota API](https://docs.opendota.com/) data.
@@ -0,0 +1,13 @@
1
+ <div align="center">
2
+
3
+ <h1>Open Dota SDK</h1>
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
7
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
8
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek)
10
+
11
+ </div>
12
+
13
+ Build better Dota 2 tools with the OpenDota SDK, providing enhanced access to [OpenDota API](https://docs.opendota.com/) data.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "opendota-sdk"
3
+ version = "0.1.0-alpha.1"
4
+ description = "SDK for the OpenDota API, providing enhanced capabilities for Dota 2 data."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ authors = [
10
+ { name = "Juan Manuel Bermudez Cabrera", email = "juanmorestep@gmail.com" }
11
+ ]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+
24
+ dependencies = [
25
+ "niquests>=3.9,<4.0",
26
+ "tenacity>=9.0,<10.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/jbnone/opendota-sdk"
31
+ Issues = "https://github.com/jbnone/opendota-sdk/issues"
32
+ Repository = "https://github.com/jbnone/opendota-sdk"
33
+
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.6,<0.12.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "pytest==9.0.3",
42
+ "pytest-asyncio==1.3.0",
43
+ "ruff==0.15.11",
44
+ "ty==0.0.31",
45
+ ]
@@ -0,0 +1,25 @@
1
+ """OpenDota SDK - A modern, type-safe Python SDK for the OpenDota API."""
2
+
3
+ from opendota_sdk._config import OpenDotaClientConfig, config_from_env, default_config
4
+ from opendota_sdk._errors import (
5
+ HTTPStatusError,
6
+ OpenDotaError,
7
+ RateLimitError,
8
+ ResponseDecodeError,
9
+ TransportError,
10
+ )
11
+ from opendota_sdk.client import OpenDotaAsyncClient, OpenDotaClient
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = [
15
+ "OpenDotaClient",
16
+ "OpenDotaAsyncClient",
17
+ "OpenDotaClientConfig",
18
+ "OpenDotaError",
19
+ "HTTPStatusError",
20
+ "RateLimitError",
21
+ "TransportError",
22
+ "ResponseDecodeError",
23
+ "default_config",
24
+ "config_from_env",
25
+ ]
@@ -0,0 +1,81 @@
1
+ """Configuration for OpenDota SDK transport and client behavior."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class OpenDotaClientConfig:
9
+ """Configuration for OpenDota API client.
10
+
11
+ Attributes:
12
+ api_key: Optional API key for authentication. If not provided, checks
13
+ OPENDOTA_API_KEY environment variable.
14
+ base_url: Base URL for the OpenDota API.
15
+ timeout: Request timeout in seconds.
16
+ max_retries: Maximum number of retries for failed requests.
17
+ backoff_factor: Exponential backoff factor for retries.
18
+ retry_on_status: HTTP status codes that trigger automatic retries.
19
+ extra_headers: Extra headers to include in all requests.
20
+ verify_ssl: Whether to verify SSL certificates.
21
+ trust_env: Whether to trust environment settings for HTTP proxies, etc.
22
+ """
23
+
24
+ api_key: str | None = None
25
+ base_url: str = "https://api.opendota.com/api"
26
+ timeout: float = 10.0
27
+ max_retries: int = 3
28
+ backoff_factor: float = 0.5
29
+ retry_on_status: list[int] = field(
30
+ default_factory=lambda: [429, 500, 502, 503, 504]
31
+ )
32
+ extra_headers: dict[str, str] = field(default_factory=dict)
33
+ verify_ssl: bool = True
34
+ trust_env: bool = True
35
+
36
+ def merge_other(self, other: "OpenDotaClientConfig") -> "OpenDotaClientConfig":
37
+ """Merge another config into this one, with the other config taking precedence."""
38
+ return OpenDotaClientConfig(
39
+ api_key=other.api_key or self.api_key,
40
+ base_url=other.base_url or self.base_url,
41
+ timeout=other.timeout or self.timeout,
42
+ max_retries=other.max_retries or self.max_retries,
43
+ backoff_factor=other.backoff_factor or self.backoff_factor,
44
+ retry_on_status=other.retry_on_status or self.retry_on_status,
45
+ extra_headers={**self.extra_headers, **other.extra_headers},
46
+ verify_ssl=other.verify_ssl
47
+ if other.verify_ssl is not None
48
+ else self.verify_ssl,
49
+ trust_env=other.trust_env
50
+ if other.trust_env is not None
51
+ else self.trust_env,
52
+ )
53
+
54
+
55
+ def default_config() -> OpenDotaClientConfig:
56
+ """Create a default OpenDota client configuration.
57
+
58
+ Returns:
59
+ An OpenDotaClientConfig with sensible defaults for the OpenDota API.
60
+ """
61
+ return OpenDotaClientConfig()
62
+
63
+
64
+ def config_from_env() -> OpenDotaClientConfig:
65
+ """Create configuration from environment variables.
66
+
67
+ Reads:
68
+ OPENDOTA_API_KEY: API key for authentication.
69
+ OPENDOTA_BASE_URL: Base URL for the API (default: https://api.opendota.com/api).
70
+ OPENDOTA_TIMEOUT: Request timeout in seconds (default: 10.0).
71
+ OPENDOTA_MAX_RETRIES: Maximum number of retries (default: 3).
72
+
73
+ Returns:
74
+ An OpenDotaClientConfig populated from environment variables.
75
+ """
76
+ return OpenDotaClientConfig(
77
+ api_key=os.getenv("OPENDOTA_API_KEY"),
78
+ base_url=os.getenv("OPENDOTA_BASE_URL", "https://api.opendota.com/api"),
79
+ timeout=float(os.getenv("OPENDOTA_TIMEOUT", "10.0")),
80
+ max_retries=int(os.getenv("OPENDOTA_MAX_RETRIES", "3")),
81
+ )
@@ -0,0 +1,89 @@
1
+ """OpenDota SDK exception hierarchy and error types."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class OpenDotaError(Exception):
7
+ """Base exception for all OpenDota SDK errors."""
8
+
9
+ pass
10
+
11
+
12
+ class TransportError(OpenDotaError):
13
+ """Raised when a transport-level error occurs (connection, timeout, etc.)."""
14
+
15
+ pass
16
+
17
+
18
+ class HTTPStatusError(OpenDotaError):
19
+ """Raised when an HTTP response indicates an error status code.
20
+
21
+ Attributes:
22
+ status_code: The HTTP status code returned by the server.
23
+ method: The HTTP method used in the request.
24
+ url: The URL that was requested.
25
+ response_text: The response body as text.
26
+ headers: The response headers.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ status_code: int | None,
32
+ method: str,
33
+ url: str | None,
34
+ response_text: str | None = None,
35
+ headers: dict[str, Any] | None = None,
36
+ ) -> None:
37
+ """Initialize HTTPStatusError.
38
+
39
+ Args:
40
+ status_code: The HTTP status code returned by the server.
41
+ method: The HTTP method used in the request.
42
+ url: The URL that was requested.
43
+ response_text: The response body as text (optional).
44
+ headers: The response headers (optional).
45
+ """
46
+ self.status_code = status_code
47
+ self.method = method
48
+ self.url = url
49
+ self.response_text = response_text
50
+ self.headers = headers or {}
51
+ message = f"{method} {url}: {status_code}"
52
+ if response_text:
53
+ truncated = (
54
+ response_text[:100] + "..."
55
+ if len(response_text) > 100
56
+ else response_text
57
+ )
58
+ message += f"\n{truncated}"
59
+ super().__init__(message)
60
+
61
+
62
+ class RateLimitError(OpenDotaError):
63
+ """Raised when the API rate limit is exceeded (HTTP 429).
64
+
65
+ Attributes:
66
+ retry_after: The number of seconds to wait before retrying, if provided.
67
+ message: A descriptive error message.
68
+ """
69
+
70
+ def __init__(self, retry_after: int | None = None, message: str = "") -> None:
71
+ """Initialize RateLimitError.
72
+
73
+ Args:
74
+ retry_after: The number of seconds to wait before retrying (optional).
75
+ message: A descriptive error message (optional).
76
+ """
77
+ self.retry_after = retry_after
78
+ if not message:
79
+ if retry_after:
80
+ message = f"Rate limited. Retry after {retry_after} seconds."
81
+ else:
82
+ message = "Rate limited."
83
+ super().__init__(message)
84
+
85
+
86
+ class ResponseDecodeError(OpenDotaError):
87
+ """Raised when response body cannot be decoded (e.g., invalid JSON)."""
88
+
89
+ pass
@@ -0,0 +1,284 @@
1
+ """Public client interface for the OpenDota API."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from opendota_sdk._config import OpenDotaClientConfig, config_from_env, default_config
7
+ from opendota_sdk.http._auth import AuthHandler
8
+ from opendota_sdk.http._retry import RetryPolicy
9
+ from opendota_sdk.http._transport import AsyncHTTPTransport, SyncHTTPTransport
10
+ from opendota_sdk.responses import HeroesResponse
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ClientLogicMixin:
16
+ """Mixin for client logic that can be shared between sync and async clients."""
17
+
18
+ def make_heroes_response(
19
+ self,
20
+ *,
21
+ heroes_api: list[dict[str, Any]],
22
+ heroes_constants: dict[str, dict[str, Any]],
23
+ ) -> HeroesResponse:
24
+ """Updates hero data from the constants with the data from /heroes and return a HeroesResponse."""
25
+ heroes: list[dict[str, Any]] = []
26
+ for hero in heroes_api:
27
+ hero_id = str(hero["id"])
28
+ if hero_id in heroes_constants:
29
+ merged_hero = {**heroes_constants[hero_id], **hero}
30
+ heroes.append(merged_hero)
31
+ else:
32
+ logger.warning(
33
+ f"Hero ID {hero_id} from /heroes not found in /constants/heroes, using API data only"
34
+ )
35
+ heroes.append(hero)
36
+
37
+ return HeroesResponse(heroes)
38
+
39
+
40
+ class OpenDotaClient(ClientLogicMixin):
41
+ """Synchronous client for the OpenDota API.
42
+
43
+ Provides high-level, ergonomic access to OpenDota API endpoints with
44
+ automatic retry handling, rate-limit management, and API key authentication.
45
+
46
+ Example:
47
+ ```python
48
+ from opendota_sdk import OpenDotaClient
49
+
50
+ # Zero-config usage
51
+ client = OpenDotaClient()
52
+ heroes = client.get_heroes()
53
+
54
+ # With API key
55
+ client = OpenDotaClient(api_key="my-key")
56
+ heroes = client.get_heroes()
57
+
58
+ # Context manager for resource management
59
+ with OpenDotaClient() as client:
60
+ heroes = client.get_heroes()
61
+ ```
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ api_key: str | None = None,
67
+ timeout: float = 10.0,
68
+ max_retries: int = 3,
69
+ base_url: str | None = None,
70
+ config: OpenDotaClientConfig | None = None,
71
+ ) -> None:
72
+ """Initialize the OpenDota client.
73
+
74
+ Args:
75
+ api_key: Optional API key for authentication. If not provided, checks
76
+ OPENDOTA_API_KEY environment variable.
77
+ timeout: Request timeout in seconds (default: 10.0).
78
+ max_retries: Maximum number of retries for failed requests (default: 3).
79
+ base_url: Optional base URL for the API (default: https://api.opendota.com/api).
80
+ config: Optional OpenDotaClientConfig for advanced customization.
81
+ If provided, overrides all other arguments.
82
+ """
83
+ config_from_args = OpenDotaClientConfig(
84
+ api_key=api_key,
85
+ timeout=timeout,
86
+ max_retries=max_retries,
87
+ )
88
+ if base_url:
89
+ config_from_args.base_url = base_url
90
+
91
+ if config is None:
92
+ defaults = default_config()
93
+ self._config = defaults.merge_other(config_from_env()).merge_other(
94
+ config_from_args
95
+ )
96
+ else:
97
+ self._config = config
98
+
99
+ self._auth_handler = AuthHandler(api_key=self._config.api_key)
100
+ self._retry_policy = RetryPolicy(
101
+ max_retries=self._config.max_retries,
102
+ backoff_factor=self._config.backoff_factor,
103
+ retry_on_status=self._config.retry_on_status,
104
+ )
105
+ self._transport = SyncHTTPTransport(
106
+ config=self._config,
107
+ auth_handler=self._auth_handler,
108
+ retry_policy=self._retry_policy,
109
+ )
110
+
111
+ def _get(
112
+ self,
113
+ path: str,
114
+ *,
115
+ params: dict[str, Any] | None = None,
116
+ json_body: Any | None = None,
117
+ **kwargs: Any,
118
+ ) -> Any:
119
+ """Make an internal HTTP request and return decoded JSON.
120
+
121
+ Args:
122
+ method: HTTP method (GET, POST, etc.).
123
+ path: API path (e.g., "/heroStats").
124
+ params: Query parameters.
125
+ json_body: JSON request body.
126
+ **kwargs: Additional arguments passed to transport.
127
+
128
+ Returns:
129
+ The decoded JSON response body.
130
+ """
131
+ return self._transport.request_json(
132
+ method="GET",
133
+ path=path,
134
+ params=params,
135
+ json_body=json_body,
136
+ **kwargs,
137
+ )
138
+
139
+ def get_heroes(self) -> HeroesResponse:
140
+ """Retrieve info about all Dota 2 heroes.
141
+
142
+ Returns:
143
+ A HeroesResponse containing a list of hero data.
144
+
145
+ Raises:
146
+ OpenDotaError: For any API-related errors, including HTTP errors, rate limits, and decoding issues.
147
+ """
148
+ heroes_api: list[dict[str, Any]] = self._get("/heroes")
149
+ heroes_constants: dict[str, dict[str, Any]] = self._get("/constants/heroes")
150
+ return self.make_heroes_response(
151
+ heroes_api=heroes_api, heroes_constants=heroes_constants
152
+ )
153
+
154
+ def close(self) -> None:
155
+ """Close the client and release resources."""
156
+ self._transport.close()
157
+
158
+ def __enter__(self) -> "OpenDotaClient":
159
+ """Enter context manager."""
160
+ return self
161
+
162
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
163
+ """Exit context manager and close the client."""
164
+ self.close()
165
+
166
+
167
+ class OpenDotaAsyncClient(ClientLogicMixin):
168
+ """Asynchronous client for the OpenDota API.
169
+
170
+ Provides high-level, ergonomic access to OpenDota API endpoints with
171
+ automatic retry handling, rate-limit management, and API key authentication.
172
+ All operations are async and must be awaited.
173
+
174
+ Example:
175
+ ```python
176
+ import asyncio
177
+ from opendota_sdk import OpenDotaAsyncClient
178
+
179
+ async def main():
180
+ # Zero-config usage
181
+ async with OpenDotaAsyncClient() as client:
182
+ heroes = await client.get_heroes()
183
+ print(f"Loaded {len(heroes)} heroes")
184
+
185
+ asyncio.run(main())
186
+ ```
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ api_key: str | None = None,
192
+ timeout: float = 10.0,
193
+ max_retries: int = 3,
194
+ base_url: str | None = None,
195
+ config: OpenDotaClientConfig | None = None,
196
+ ) -> None:
197
+ """Initialize the async OpenDota client.
198
+
199
+ Args:
200
+ api_key: Optional API key for authentication. If not provided, checks
201
+ OPENDOTA_API_KEY environment variable.
202
+ timeout: Request timeout in seconds (default: 10.0).
203
+ max_retries: Maximum number of retries for failed requests (default: 3).
204
+ base_url: Optional base URL for the API (default: https://api.opendota.com/api).
205
+ config: Optional OpenDotaClientConfig for advanced customization.
206
+ If provided, overrides all other arguments.
207
+ """
208
+ if config is None:
209
+ config = OpenDotaClientConfig(
210
+ api_key=api_key,
211
+ timeout=timeout,
212
+ max_retries=max_retries,
213
+ base_url=base_url or "https://api.opendota.com/api",
214
+ )
215
+
216
+ self._config = config
217
+ self._auth_handler = AuthHandler(api_key=config.api_key)
218
+ self._retry_policy = RetryPolicy(
219
+ max_retries=config.max_retries,
220
+ backoff_factor=config.backoff_factor,
221
+ retry_on_status=config.retry_on_status,
222
+ )
223
+ self._transport = AsyncHTTPTransport(
224
+ config=config,
225
+ auth_handler=self._auth_handler,
226
+ retry_policy=self._retry_policy,
227
+ )
228
+
229
+ async def _get(
230
+ self,
231
+ path: str,
232
+ *,
233
+ params: dict[str, Any] | None = None,
234
+ json_body: Any | None = None,
235
+ **kwargs: Any,
236
+ ) -> Any:
237
+ """Make an internal HTTP request and return decoded JSON.
238
+
239
+ Args:
240
+ method: HTTP method (GET, POST, etc.).
241
+ path: API path (e.g., "/heroStats").
242
+ params: Query parameters.
243
+ json_body: JSON request body.
244
+ **kwargs: Additional arguments passed to transport.
245
+
246
+ Returns:
247
+ The decoded JSON response body.
248
+ """
249
+ return await self._transport.request_json(
250
+ method="GET",
251
+ path=path,
252
+ params=params,
253
+ json_body=json_body,
254
+ **kwargs,
255
+ )
256
+
257
+ async def get_heroes(self) -> HeroesResponse:
258
+ """Retrieve info about all Dota 2 heroes.
259
+
260
+ Returns:
261
+ A HeroesResponse containing a list of hero data.
262
+
263
+ Raises:
264
+ OpenDotaError: For any API-related errors, including HTTP errors, rate limits, and decoding issues.
265
+ """
266
+ heroes_api: list[dict[str, Any]] = await self._get("/heroes")
267
+ heroes_constants: dict[str, dict[str, Any]] = await self._get(
268
+ "/constants/heroes"
269
+ )
270
+ return self.make_heroes_response(
271
+ heroes_api=heroes_api, heroes_constants=heroes_constants
272
+ )
273
+
274
+ async def close(self) -> None:
275
+ """Close the client and release resources."""
276
+ await self._transport.close()
277
+
278
+ async def __aenter__(self) -> "OpenDotaAsyncClient":
279
+ """Async context manager entry."""
280
+ return self
281
+
282
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
283
+ """Async context manager exit and close the client."""
284
+ await self.close()
@@ -0,0 +1,24 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class HeroRole(StrEnum):
5
+ CARRY = "Carry"
6
+ SUPPORT = "Support"
7
+ NUKER = "Nuker"
8
+ DISABLER = "Disabler"
9
+ JUNGLER = "Jungler"
10
+ DURABLE = "Durable"
11
+ ESCAPE = "Escape"
12
+ Pusher = "Pusher"
13
+
14
+
15
+ class HeroAttackType(StrEnum):
16
+ MELEE = "Melee"
17
+ RANGED = "Ranged"
18
+
19
+
20
+ class HeroPrimaryAttr(StrEnum):
21
+ STR = "str"
22
+ AGI = "agi"
23
+ INT = "int"
24
+ ALL = "all"
@@ -0,0 +1 @@
1
+ """Internal HTTP transport layer for OpenDota SDK."""
@@ -0,0 +1,52 @@
1
+ """Authentication handler for API key injection."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+
7
+ class AuthHandler:
8
+ """Handles API key authentication by injecting credentials into requests.
9
+
10
+ Supports injection via HTTP headers (default) or query parameters.
11
+
12
+ Attributes:
13
+ api_key: The API key for authentication.
14
+ header_name: The header name for API key injection (e.g., "X-API-Key").
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ api_key: str | None = None,
20
+ header_name: str = "X-API-Key",
21
+ ) -> None:
22
+ """Initialize the authentication handler.
23
+
24
+ Args:
25
+ api_key: Optional API key. If not provided, reads from OPENDOTA_API_KEY
26
+ environment variable.
27
+ header_name: The HTTP header name for API key injection.
28
+ """
29
+ self.api_key = api_key or os.getenv("OPENDOTA_API_KEY")
30
+ self.header_name = header_name
31
+
32
+ def apply_to_headers(self, headers: dict[str, Any]) -> dict[str, Any]:
33
+ """Inject API key into request headers.
34
+
35
+ Args:
36
+ headers: The headers dictionary to modify.
37
+
38
+ Returns:
39
+ The modified headers dictionary with API key injected (if available).
40
+ """
41
+ if self.api_key:
42
+ headers = dict(headers)
43
+ headers[self.header_name] = self.api_key
44
+ return headers
45
+
46
+ def has_auth(self) -> bool:
47
+ """Check if an API key is configured.
48
+
49
+ Returns:
50
+ True if an API key is set, False otherwise.
51
+ """
52
+ return bool(self.api_key)
@@ -0,0 +1,72 @@
1
+ """Retry policy and decorator builder for HTTP requests."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from tenacity import (
7
+ Retrying,
8
+ retry_if_exception_type,
9
+ retry_if_result,
10
+ stop_after_attempt,
11
+ wait_exponential,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class RetryPolicy:
17
+ """Policy for retrying failed HTTP requests.
18
+
19
+ Attributes:
20
+ max_retries: Maximum number of attempts (total, including initial).
21
+ backoff_factor: Multiplier for exponential backoff wait time.
22
+ retry_on_status: HTTP status codes that should trigger a retry.
23
+ retry_on_timeout: Whether to retry on timeout exceptions.
24
+ """
25
+
26
+ max_retries: int = 3
27
+ backoff_factor: float = 0.5
28
+ retry_on_status: list[int] = field(
29
+ default_factory=lambda: [429, 500, 502, 503, 504]
30
+ )
31
+ retry_on_timeout: bool = True
32
+
33
+
34
+ def build_retry_decorator(policy: RetryPolicy) -> Retrying:
35
+ """Build a tenacity Retrying object from a retry policy.
36
+
37
+ The returned object can be used as a decorator or context manager to retry
38
+ a callable with exponential backoff. Retries are triggered when:
39
+ - The response status code is in retry_on_status
40
+ - A timeout exception occurs (if retry_on_timeout is True)
41
+
42
+ Args:
43
+ policy: The retry policy configuration.
44
+
45
+ Returns:
46
+ A tenacity.Retrying object configured according to the policy.
47
+ """
48
+
49
+ def should_retry(result: Any) -> bool:
50
+ """Check if response should be retried based on status code."""
51
+ if hasattr(result, "status_code"):
52
+ return result.status_code in policy.retry_on_status
53
+ return False
54
+
55
+ retry_decorator = Retrying(
56
+ stop=stop_after_attempt(policy.max_retries),
57
+ wait=wait_exponential(multiplier=policy.backoff_factor, min=1, max=60),
58
+ retry=retry_if_result(should_retry),
59
+ reraise=True,
60
+ )
61
+
62
+ if policy.retry_on_timeout:
63
+ retry_decorator = Retrying(
64
+ stop=stop_after_attempt(policy.max_retries),
65
+ wait=wait_exponential(multiplier=policy.backoff_factor, min=1, max=60),
66
+ retry=(
67
+ retry_if_result(should_retry) | retry_if_exception_type(TimeoutError)
68
+ ),
69
+ reraise=True,
70
+ )
71
+
72
+ return retry_decorator
@@ -0,0 +1,479 @@
1
+ """HTTP transport layer for OpenDota API requests."""
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import urljoin
6
+
7
+ import niquests
8
+
9
+ from opendota_sdk._config import OpenDotaClientConfig
10
+ from opendota_sdk._errors import (
11
+ HTTPStatusError,
12
+ RateLimitError,
13
+ ResponseDecodeError,
14
+ TransportError,
15
+ )
16
+
17
+ from ._auth import AuthHandler
18
+ from ._retry import RetryPolicy, build_retry_decorator
19
+
20
+
21
+ _DEFAULT_HEADERS = {"Accept": "application/json"}
22
+
23
+
24
+ class HTTPTransportBase:
25
+ """Base class for HTTP transports.
26
+
27
+ Defines the interface and common functionality for both sync and async transports.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ config: OpenDotaClientConfig,
33
+ auth_handler: AuthHandler,
34
+ retry_policy: RetryPolicy,
35
+ ) -> None:
36
+ """Initialize the transport.
37
+
38
+ Args:
39
+ config: The client configuration.
40
+ auth_handler: Authentication handler for API key injection.
41
+ retry_policy: Policy for retrying failed requests.
42
+ """
43
+ self.config = config
44
+ self.auth_handler = auth_handler
45
+ self.retry_policy = retry_policy
46
+ self._retry_decorator = build_retry_decorator(retry_policy)
47
+
48
+ def build_url(self, path: str) -> str:
49
+ """Build full URL from base URL and path.
50
+
51
+ Args:
52
+ path: The API path (e.g., "/heroStats").
53
+
54
+ Returns:
55
+ The full URL.
56
+ """
57
+ base_url = self.config.base_url
58
+ if base_url:
59
+ # Ensure base URL ends with a slash for proper urljoin behavior
60
+ sanitized_base_url = base_url if base_url.endswith("/") else base_url + "/"
61
+ else:
62
+ sanitized_base_url = ""
63
+
64
+ return urljoin(sanitized_base_url, path.lstrip("/"))
65
+
66
+ def build_headers(
67
+ self, request_headers: dict[str, str] | None = None
68
+ ) -> dict[str, str]:
69
+ """Build request headers by merging defaults, config, and extra headers.
70
+
71
+ Args:
72
+ extra_headers: Additional headers to include in the request.
73
+
74
+ Returns:
75
+ The merged headers dictionary.
76
+ """
77
+ merged_headers = _DEFAULT_HEADERS.copy()
78
+ merged_headers.update(self.config.extra_headers or {})
79
+ if request_headers:
80
+ merged_headers.update(request_headers)
81
+
82
+ return self.auth_handler.apply_to_headers(merged_headers)
83
+
84
+ def handle_response(
85
+ self, response: niquests.Response, method: str
86
+ ) -> niquests.Response:
87
+ """Validate response status and raise errors if needed.
88
+
89
+ Args:
90
+ response: The HTTP response.
91
+ method: The HTTP method used.
92
+
93
+ Returns:
94
+ The response if valid.
95
+
96
+ Raises:
97
+ RateLimitError: If rate limited (429).
98
+ HTTPStatusError: For other error status codes.
99
+ """
100
+ if response.status_code == 429:
101
+ retry_after = None
102
+ if "Retry-After" in response.headers:
103
+ try:
104
+ retry_after = int(response.headers["Retry-After"])
105
+ except (ValueError, TypeError):
106
+ pass
107
+ raise RateLimitError(
108
+ retry_after=retry_after,
109
+ message=f"Rate limited on {method} {response.url}",
110
+ )
111
+
112
+ if not response.ok:
113
+ raise HTTPStatusError(
114
+ status_code=response.status_code,
115
+ method=method,
116
+ url=response.url,
117
+ response_text=response.text,
118
+ headers=dict(response.headers),
119
+ )
120
+
121
+ return response
122
+
123
+
124
+ class SyncHTTPTransport(HTTPTransportBase):
125
+ """Synchronous HTTP transport using niquests.
126
+
127
+ Handles request execution, retries, rate limiting, and API key authentication
128
+ for synchronous operations.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ config: OpenDotaClientConfig,
134
+ auth_handler: AuthHandler,
135
+ retry_policy: RetryPolicy,
136
+ ) -> None:
137
+ """Initialize the sync transport.
138
+
139
+ Args:
140
+ config: The client configuration.
141
+ auth_handler: Authentication handler for API key injection.
142
+ retry_policy: Policy for retrying failed requests.
143
+ """
144
+ super().__init__(config, auth_handler, retry_policy)
145
+ self._session = niquests.Session()
146
+
147
+ def request(
148
+ self,
149
+ method: str,
150
+ path: str,
151
+ params: dict[str, Any] | None = None,
152
+ json_body: Any | None = None,
153
+ data: Any | None = None,
154
+ headers: dict[str, str] | None = None,
155
+ timeout: float | None = None,
156
+ ) -> niquests.Response:
157
+ """Make an HTTP request.
158
+
159
+ Args:
160
+ method: HTTP method.
161
+ path: API path.
162
+ params: Query parameters.
163
+ json_body: JSON body for POST/PUT.
164
+ data: Form data.
165
+ headers: Additional headers.
166
+ timeout: Request timeout in seconds.
167
+
168
+ Returns:
169
+ The HTTP response.
170
+
171
+ Raises:
172
+ HTTPStatusError: For error status codes.
173
+ TransportError: For connection/timeout errors.
174
+ RateLimitError: For rate limit errors.
175
+ """
176
+
177
+ def _do_request() -> niquests.Response:
178
+ try:
179
+ response = self._session.request(
180
+ method=method,
181
+ url=self.build_url(path),
182
+ params=params or None,
183
+ json=json_body,
184
+ data=data,
185
+ headers=self.build_headers(headers),
186
+ timeout=timeout or self.config.timeout,
187
+ verify=self.config.verify_ssl,
188
+ )
189
+ return self.handle_response(response, method)
190
+ except (
191
+ niquests.RequestException,
192
+ niquests.ConnectionError,
193
+ niquests.Timeout,
194
+ ) as exc:
195
+ raise TransportError(f"Request failed: {exc}") from exc
196
+ except RateLimitError:
197
+ raise
198
+ except HTTPStatusError:
199
+ raise
200
+
201
+ try:
202
+ return self._retry_decorator(_do_request)
203
+ except Exception as exc:
204
+ if isinstance(exc, (RateLimitError, HTTPStatusError, TransportError)):
205
+ raise
206
+ raise TransportError(f"Unexpected error: {exc}") from exc
207
+
208
+ def request_json(
209
+ self,
210
+ method: str,
211
+ path: str,
212
+ params: dict[str, Any] | None = None,
213
+ json_body: Any | None = None,
214
+ data: Any | None = None,
215
+ headers: dict[str, str] | None = None,
216
+ timeout: float | None = None,
217
+ ) -> Any:
218
+ """Make a request and return decoded JSON.
219
+
220
+ Args:
221
+ method: HTTP method.
222
+ path: API path.
223
+ params: Query parameters.
224
+ json_body: JSON body.
225
+ data: Form data.
226
+ headers: Additional headers.
227
+ timeout: Request timeout.
228
+
229
+ Returns:
230
+ The decoded JSON response body.
231
+
232
+ Raises:
233
+ HTTPStatusError: For error status codes.
234
+ TransportError: For connection errors.
235
+ ResponseDecodeError: If JSON decoding fails.
236
+ """
237
+ response = self.request(
238
+ method=method,
239
+ path=path,
240
+ params=params,
241
+ json_body=json_body,
242
+ data=data,
243
+ headers=headers,
244
+ timeout=timeout,
245
+ )
246
+ try:
247
+ return response.json()
248
+ except json.JSONDecodeError as exc:
249
+ raise ResponseDecodeError(
250
+ f"Failed to decode JSON from {response.url}: {exc}"
251
+ ) from exc
252
+
253
+ def request_bytes(
254
+ self,
255
+ method: str,
256
+ path: str,
257
+ params: dict[str, Any] | None = None,
258
+ json_body: Any | None = None,
259
+ data: Any | None = None,
260
+ headers: dict[str, str] | None = None,
261
+ timeout: float | None = None,
262
+ ) -> bytes:
263
+ """Make a request and return raw bytes.
264
+
265
+ Args:
266
+ method: HTTP method.
267
+ path: API path.
268
+ params: Query parameters.
269
+ json_body: JSON body.
270
+ data: Form data.
271
+ headers: Additional headers.
272
+ timeout: Request timeout.
273
+
274
+ Returns:
275
+ The raw response body as bytes.
276
+ """
277
+ response = self.request(
278
+ method=method,
279
+ path=path,
280
+ params=params,
281
+ json_body=json_body,
282
+ data=data,
283
+ headers=headers,
284
+ timeout=timeout,
285
+ )
286
+ return response.content or b""
287
+
288
+ def close(self) -> None:
289
+ """Close the session and clean up resources."""
290
+ self._session.close()
291
+
292
+ def __enter__(self) -> "SyncHTTPTransport":
293
+ """Enter context manager."""
294
+ return self
295
+
296
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
297
+ """Exit context manager and close the session."""
298
+ self.close()
299
+
300
+
301
+ class AsyncHTTPTransport(HTTPTransportBase):
302
+ """Asynchronous HTTP transport using niquests.
303
+
304
+ Handles request execution, retries, rate limiting, and API key authentication
305
+ for asynchronous operations.
306
+
307
+ This is an internal transport implementation. It is not part of the public API
308
+ and should not be used directly by end users.
309
+ """
310
+
311
+ def __init__(
312
+ self,
313
+ config: OpenDotaClientConfig,
314
+ auth_handler: AuthHandler,
315
+ retry_policy: RetryPolicy,
316
+ ) -> None:
317
+ """Initialize the async transport.
318
+
319
+ Args:
320
+ config: The client configuration.
321
+ auth_handler: Authentication handler for API key injection.
322
+ retry_policy: Policy for retrying failed requests.
323
+ """
324
+ super().__init__(config, auth_handler, retry_policy)
325
+ self._session = niquests.AsyncSession()
326
+
327
+ async def request(
328
+ self,
329
+ method: str,
330
+ path: str,
331
+ params: dict[str, Any] | None = None,
332
+ json_body: Any | None = None,
333
+ data: Any | None = None,
334
+ headers: dict[str, str] | None = None,
335
+ timeout: float | None = None,
336
+ ) -> niquests.Response:
337
+ """Make an HTTP request.
338
+
339
+ Args:
340
+ method: HTTP method.
341
+ path: API path.
342
+ params: Query parameters.
343
+ json_body: JSON body for POST/PUT.
344
+ data: Form data.
345
+ headers: Additional headers.
346
+ timeout: Request timeout in seconds.
347
+
348
+ Returns:
349
+ The HTTP response.
350
+
351
+ Raises:
352
+ HTTPStatusError: For error status codes.
353
+ TransportError: For connection/timeout errors.
354
+ RateLimitError: For rate limit errors.
355
+ """
356
+
357
+ async def _do_request() -> niquests.Response:
358
+ try:
359
+ response = await self._session.request(
360
+ method=method,
361
+ url=self.build_url(path),
362
+ params=params or None,
363
+ json=json_body,
364
+ data=data,
365
+ headers=self.build_headers(headers),
366
+ timeout=timeout or self.config.timeout,
367
+ verify=self.config.verify_ssl,
368
+ )
369
+ return self.handle_response(response, method)
370
+ except (
371
+ niquests.RequestException,
372
+ niquests.ConnectionError,
373
+ niquests.Timeout,
374
+ ) as exc:
375
+ raise TransportError(f"Request failed: {exc}") from exc
376
+ except RateLimitError:
377
+ raise
378
+ except HTTPStatusError:
379
+ raise
380
+
381
+ try:
382
+ return await self._retry_decorator(_do_request)
383
+ except Exception as exc:
384
+ if isinstance(exc, (RateLimitError, HTTPStatusError, TransportError)):
385
+ raise
386
+ raise TransportError(f"Unexpected error: {exc}") from exc
387
+
388
+ async def request_json(
389
+ self,
390
+ method: str,
391
+ path: str,
392
+ params: dict[str, Any] | None = None,
393
+ json_body: Any | None = None,
394
+ data: Any | None = None,
395
+ headers: dict[str, str] | None = None,
396
+ timeout: float | None = None,
397
+ ) -> Any:
398
+ """Make a request and return decoded JSON.
399
+
400
+ Args:
401
+ method: HTTP method.
402
+ path: API path.
403
+ params: Query parameters.
404
+ json_body: JSON body.
405
+ data: Form data.
406
+ headers: Additional headers.
407
+ timeout: Request timeout.
408
+
409
+ Returns:
410
+ The decoded JSON response body.
411
+
412
+ Raises:
413
+ HTTPStatusError: For error status codes.
414
+ TransportError: For connection errors.
415
+ ResponseDecodeError: If JSON decoding fails.
416
+ """
417
+ response = await self.request(
418
+ method=method,
419
+ path=path,
420
+ params=params,
421
+ json_body=json_body,
422
+ data=data,
423
+ headers=headers,
424
+ timeout=timeout,
425
+ )
426
+ try:
427
+ return response.json()
428
+ except json.JSONDecodeError as exc:
429
+ raise ResponseDecodeError(
430
+ f"Failed to decode JSON from {response.url}: {exc}"
431
+ ) from exc
432
+
433
+ async def request_bytes(
434
+ self,
435
+ method: str,
436
+ path: str,
437
+ params: dict[str, Any] | None = None,
438
+ json_body: Any | None = None,
439
+ data: Any | None = None,
440
+ headers: dict[str, str] | None = None,
441
+ timeout: float | None = None,
442
+ ) -> bytes:
443
+ """Make a request and return raw bytes.
444
+
445
+ Args:
446
+ method: HTTP method.
447
+ path: API path.
448
+ params: Query parameters.
449
+ json_body: JSON body.
450
+ data: Form data.
451
+ headers: Additional headers.
452
+ timeout: Request timeout.
453
+
454
+ Returns:
455
+ The raw response body as bytes.
456
+ """
457
+ response = await self.request(
458
+ method=method,
459
+ path=path,
460
+ params=params,
461
+ json_body=json_body,
462
+ data=data,
463
+ headers=headers,
464
+ timeout=timeout,
465
+ )
466
+ return response.content or b""
467
+
468
+ async def close(self) -> None:
469
+ """Close the session and clean up resources."""
470
+ if self._session is not None:
471
+ await self._session.close()
472
+
473
+ async def __aenter__(self) -> "AsyncHTTPTransport":
474
+ """Async context manager entry."""
475
+ return self
476
+
477
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
478
+ """Async context manager exit and close the session."""
479
+ await self.close()
@@ -0,0 +1,42 @@
1
+ from opendota_sdk.enums import HeroPrimaryAttr, HeroAttackType, HeroRole
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Hero:
7
+ # Values coming from the API
8
+ id: int
9
+ name: str
10
+ localized_name: str
11
+ primary_attr: HeroPrimaryAttr
12
+ attack_type: HeroAttackType
13
+ roles: list[HeroRole]
14
+ legs: int
15
+
16
+ # Values coming from dotaconstants
17
+ img: str
18
+ icon: str
19
+ base_health: int
20
+ base_health_regen: float
21
+ base_mana: int
22
+ base_mana_regen: float
23
+ base_armor: float
24
+ base_mr: int
25
+ base_attack_min: int
26
+ base_attack_max: int
27
+ base_attack_time: int
28
+ base_str: int
29
+ base_agi: int
30
+ base_int: int
31
+ str_gain: float
32
+ agi_gain: float
33
+ int_gain: float
34
+ attack_point: float
35
+ attack_range: int
36
+ projectile_speed: int
37
+ attack_rate: float
38
+ move_speed: int
39
+ turn_rate: float | None
40
+ cm_enabled: bool
41
+ day_vision: int
42
+ night_vision: int
File without changes
@@ -0,0 +1,13 @@
1
+ from opendota_sdk.models import Hero
2
+ from typing import Any
3
+
4
+
5
+ class HeroesResponse:
6
+ def __init__(self, data: list[dict[str, Any]]) -> None:
7
+ self._data = data
8
+
9
+ def as_typed(self) -> list[Hero]:
10
+ return [Hero(**hero) for hero in self._data]
11
+
12
+ def as_raw(self) -> list[dict[str, Any]]:
13
+ return self._data