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.
- opendota_sdk-0.1.0a1/LICENSE +21 -0
- opendota_sdk-0.1.0a1/PKG-INFO +37 -0
- opendota_sdk-0.1.0a1/README.md +13 -0
- opendota_sdk-0.1.0a1/pyproject.toml +45 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/__init__.py +25 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/_config.py +81 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/_errors.py +89 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/client.py +284 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/enums.py +24 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/http/__init__.py +1 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/http/_auth.py +52 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/http/_retry.py +72 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/http/_transport.py +479 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/models.py +42 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/py.typed +0 -0
- opendota_sdk-0.1.0a1/src/opendota_sdk/responses.py +13 -0
|
@@ -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
|
+
[](https://opensource.org/licenses/MIT)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
[](https://github.com/astral-sh/uv)
|
|
32
|
+
[](https://github.com/astral-sh/ruff)
|
|
33
|
+
[](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
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.python.org/downloads/)
|
|
7
|
+
[](https://github.com/astral-sh/uv)
|
|
8
|
+
[](https://github.com/astral-sh/ruff)
|
|
9
|
+
[](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
|