cocapi 4.0.1__tar.gz → 4.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cocapi-4.0.1 → cocapi-4.1.0}/.github/FUNDING.yml +1 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/.github/workflows/ci.yml +5 -5
- {cocapi-4.0.1 → cocapi-4.1.0}/.github/workflows/release.yml +5 -5
- {cocapi-4.0.1 → cocapi-4.1.0}/PKG-INFO +3 -2
- {cocapi-4.0.1 → cocapi-4.1.0}/README.md +1 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/__init__.py +46 -1
- cocapi-4.1.0/cocapi/army.py +89 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/async_client.py +41 -10
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/client.py +129 -6
- cocapi-4.1.0/cocapi/controls.py +66 -0
- cocapi-4.1.0/cocapi/errors.py +106 -0
- cocapi-4.1.0/cocapi/game_time.py +68 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/schemas.py +1 -0
- cocapi-4.1.0/cocapi/static_data.py +125 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/utils.py +8 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/changelog.md +10 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/caching.md +17 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/error-handling.md +33 -0
- cocapi-4.1.0/docs/guides/game-data.md +40 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/mkdocs.yml +1 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/pyproject.toml +1 -1
- cocapi-4.1.0/uv.lock +1937 -0
- cocapi-4.0.1/uv.lock +0 -1305
- {cocapi-4.0.1 → cocapi-4.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/.github/workflows/docs.yml +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/.gitignore +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/.pre-commit-config.yaml +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/CODE_OF_CONDUCT.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/LICENSE +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/SECURITY.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/api_methods.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/cache.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/cli.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/cocapi.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/config.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/__init__.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_diff.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_state.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_stream.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_types.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_war_fsm.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_watchers.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/key_manager.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/metrics.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/middleware.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/model_registry.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/models.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/py.typed +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/examples.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/getting-started/authentication.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/getting-started/installation.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/getting-started/quickstart.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/async.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/cli.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/configuration.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/events.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/metrics.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/middleware.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/pagination-batch.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/pydantic-models.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/docs/index.md +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/mypy.ini +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/setup.py +0 -0
- {cocapi-4.0.1 → cocapi-4.1.0}/sonar-project.properties +0 -0
|
@@ -23,16 +23,16 @@ jobs:
|
|
|
23
23
|
uses: astral-sh/setup-uv@v3
|
|
24
24
|
|
|
25
25
|
- name: Install dependencies
|
|
26
|
-
run: uv sync --dev --extra pydantic
|
|
26
|
+
run: uv sync --locked --no-build --dev --extra pydantic --extra cli
|
|
27
27
|
|
|
28
28
|
- name: Run ruff linting
|
|
29
|
-
run: uv run ruff check cocapi
|
|
29
|
+
run: uv run --locked --no-build ruff check cocapi
|
|
30
30
|
|
|
31
31
|
- name: Run ruff formatting check
|
|
32
|
-
run: uv run ruff format --check cocapi
|
|
32
|
+
run: uv run --locked --no-build ruff format --check cocapi
|
|
33
33
|
|
|
34
34
|
- name: Run mypy type checking
|
|
35
|
-
run: uv run mypy cocapi
|
|
35
|
+
run: uv run --locked --no-build mypy cocapi
|
|
36
36
|
|
|
37
37
|
- name: Run tests
|
|
38
|
-
run: uv run pytest tests/
|
|
38
|
+
run: uv run --locked --no-build pytest tests/ -v --tb=short
|
|
@@ -22,19 +22,19 @@ jobs:
|
|
|
22
22
|
uses: astral-sh/setup-uv@v3
|
|
23
23
|
|
|
24
24
|
- name: Install dependencies
|
|
25
|
-
run: uv sync --dev --extra pydantic
|
|
25
|
+
run: uv sync --locked --no-build --dev --extra pydantic --extra cli
|
|
26
26
|
|
|
27
27
|
- name: Run ruff linting
|
|
28
|
-
run: uv run ruff check cocapi
|
|
28
|
+
run: uv run --locked --no-build ruff check cocapi
|
|
29
29
|
|
|
30
30
|
- name: Run ruff formatting check
|
|
31
|
-
run: uv run ruff format --check cocapi
|
|
31
|
+
run: uv run --locked --no-build ruff format --check cocapi
|
|
32
32
|
|
|
33
33
|
- name: Run mypy type checking
|
|
34
|
-
run: uv run mypy cocapi
|
|
34
|
+
run: uv run --locked --no-build mypy cocapi
|
|
35
35
|
|
|
36
36
|
- name: Run tests
|
|
37
|
-
run: uv run pytest tests/
|
|
37
|
+
run: uv run --locked --no-build pytest tests/ -v --tb=short
|
|
38
38
|
|
|
39
39
|
check-version:
|
|
40
40
|
needs: ci
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: cocapi
|
|
3
|
-
Version: 4.0
|
|
3
|
+
Version: 4.1.0
|
|
4
4
|
Summary: Async & sync Python wrapper for the Clash of Clans API with auto key management
|
|
5
5
|
Project-URL: Homepage, https://github.com/tonybenoy/cocapi
|
|
6
6
|
Project-URL: Documentation, https://tonybenoy.github.io/cocapi/
|
|
@@ -67,6 +67,7 @@ A Python wrapper for the official [Clash of Clans API](https://developer.clashof
|
|
|
67
67
|
- **Retries** — exponential backoff for rate limits and server errors
|
|
68
68
|
- **Pagination & batch** — `paginate()` auto-follows cursors, `batch()` fetches many resources at once
|
|
69
69
|
- **Event polling** — real-time monitoring of clans, wars, and players with `async for` or callbacks
|
|
70
|
+
- **Game data helpers** — static game data lookups, army link parsing, CWL-aware current war, typed errors, per-call cache control
|
|
70
71
|
- **Pydantic models** — optional typed response objects with IDE autocompletion
|
|
71
72
|
- **Middleware** — plug in custom request/response processing
|
|
72
73
|
- **CLI** — `cocapi clan "#2PP"` from the terminal
|
|
@@ -28,6 +28,7 @@ A Python wrapper for the official [Clash of Clans API](https://developer.clashof
|
|
|
28
28
|
- **Retries** — exponential backoff for rate limits and server errors
|
|
29
29
|
- **Pagination & batch** — `paginate()` auto-follows cursors, `batch()` fetches many resources at once
|
|
30
30
|
- **Event polling** — real-time monitoring of clans, wars, and players with `async for` or callbacks
|
|
31
|
+
- **Game data helpers** — static game data lookups, army link parsing, CWL-aware current war, typed errors, per-call cache control
|
|
31
32
|
- **Pydantic models** — optional typed response objects with IDE autocompletion
|
|
32
33
|
- **Middleware** — plug in custom request/response processing
|
|
33
34
|
- **CLI** — `cocapi clan "#2PP"` from the terminal
|
|
@@ -40,6 +40,8 @@ Async Usage:
|
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
# Main API class - primary interface
|
|
43
|
+
# Army links, static data, event windows, error handling, per-call controls
|
|
44
|
+
from .army import Army, ArmyUnit, build_army_link, parse_army_link
|
|
43
45
|
from .async_client import AsyncCocApiCore, AsyncRateLimiter
|
|
44
46
|
|
|
45
47
|
# Advanced components for power users
|
|
@@ -48,6 +50,25 @@ from .client import CocApi
|
|
|
48
50
|
|
|
49
51
|
# Configuration classes
|
|
50
52
|
from .config import ApiConfig, CacheEntry, RequestMetric
|
|
53
|
+
from .controls import cache_control, without_rate_limit
|
|
54
|
+
from .errors import (
|
|
55
|
+
AccessDeniedError,
|
|
56
|
+
BadRequestError,
|
|
57
|
+
CocApiError,
|
|
58
|
+
MaintenanceError,
|
|
59
|
+
NotFoundError,
|
|
60
|
+
PrivateWarLogError,
|
|
61
|
+
RateLimitedError,
|
|
62
|
+
ServerError,
|
|
63
|
+
raise_for_error,
|
|
64
|
+
)
|
|
65
|
+
from .game_time import (
|
|
66
|
+
clan_games_window,
|
|
67
|
+
is_clan_games,
|
|
68
|
+
is_raid_weekend,
|
|
69
|
+
legend_day_window,
|
|
70
|
+
raid_weekend_window,
|
|
71
|
+
)
|
|
51
72
|
|
|
52
73
|
# Key management (credential-based auth)
|
|
53
74
|
from .key_manager import (
|
|
@@ -70,6 +91,7 @@ from .middleware import (
|
|
|
70
91
|
|
|
71
92
|
# Utility functions
|
|
72
93
|
from .models import create_dynamic_model, get_pydantic_info, validate_pydantic_available
|
|
94
|
+
from .static_data import StaticData
|
|
73
95
|
from .utils import build_url, clean_tag, validate_params
|
|
74
96
|
|
|
75
97
|
# Pydantic response models (optional - only available when pydantic is installed)
|
|
@@ -123,7 +145,7 @@ except (ImportError, SyntaxError):
|
|
|
123
145
|
_SCHEMAS_AVAILABLE = False
|
|
124
146
|
|
|
125
147
|
# Version info
|
|
126
|
-
__version__ = "4.0
|
|
148
|
+
__version__ = "4.1.0"
|
|
127
149
|
__author__ = "Tony Benoy"
|
|
128
150
|
__email__ = "me@tonybenoy.com"
|
|
129
151
|
|
|
@@ -144,6 +166,29 @@ __all__ = [
|
|
|
144
166
|
"AsyncKeyManager",
|
|
145
167
|
"KeyManagerError",
|
|
146
168
|
"InvalidCredentials",
|
|
169
|
+
# Army links / static data / event windows
|
|
170
|
+
"Army",
|
|
171
|
+
"ArmyUnit",
|
|
172
|
+
"parse_army_link",
|
|
173
|
+
"build_army_link",
|
|
174
|
+
"StaticData",
|
|
175
|
+
"clan_games_window",
|
|
176
|
+
"raid_weekend_window",
|
|
177
|
+
"legend_day_window",
|
|
178
|
+
"is_clan_games",
|
|
179
|
+
"is_raid_weekend",
|
|
180
|
+
# Errors and per-call controls
|
|
181
|
+
"CocApiError",
|
|
182
|
+
"AccessDeniedError",
|
|
183
|
+
"BadRequestError",
|
|
184
|
+
"MaintenanceError",
|
|
185
|
+
"NotFoundError",
|
|
186
|
+
"PrivateWarLogError",
|
|
187
|
+
"RateLimitedError",
|
|
188
|
+
"ServerError",
|
|
189
|
+
"raise_for_error",
|
|
190
|
+
"cache_control",
|
|
191
|
+
"without_rate_limit",
|
|
147
192
|
# Data structures
|
|
148
193
|
"CacheEntry",
|
|
149
194
|
"RequestMetric",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Army link parsing and building.
|
|
3
|
+
|
|
4
|
+
Army links look like
|
|
5
|
+
``https://link.clashofclans.com/en?action=CopyArmy&army=u10x0-2x3s1x9``
|
|
6
|
+
where ``u`` introduces troops and ``s`` spells as ``-``-separated ``{count}x{id}``
|
|
7
|
+
pairs. Any other sections (e.g. heroes, pets, equipment) are kept verbatim in
|
|
8
|
+
:attr:`Army.extra` so nothing is lost.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .static_data import StaticData
|
|
18
|
+
|
|
19
|
+
TROOP_ID_BASE = 4_000_000
|
|
20
|
+
SPELL_ID_BASE = 26_000_000
|
|
21
|
+
|
|
22
|
+
_SECTION = re.compile(r"([a-z])((?:\d+x\d+(?:-\d+x\d+)*)|[\d\-x]*)")
|
|
23
|
+
_PAIR = re.compile(r"(\d+)x(\d+)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ArmyUnit:
|
|
28
|
+
id: int # short id as used in the link
|
|
29
|
+
count: int
|
|
30
|
+
name: str | None = None # resolved when StaticData is supplied
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Army:
|
|
35
|
+
troops: list[ArmyUnit] = field(default_factory=list)
|
|
36
|
+
spells: list[ArmyUnit] = field(default_factory=list)
|
|
37
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def to_link(self, lang: str = "en") -> str:
|
|
40
|
+
return build_army_link(self, lang)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_army_link(link: str, static_data: "StaticData | None" = None) -> Army:
|
|
44
|
+
"""Parse an army link (or just its ``army=`` value) into an :class:`Army`."""
|
|
45
|
+
link = link.strip()
|
|
46
|
+
if "army=" in link:
|
|
47
|
+
query = urllib.parse.urlparse(link).query or link
|
|
48
|
+
values = urllib.parse.parse_qs(query).get("army")
|
|
49
|
+
if not values:
|
|
50
|
+
raise ValueError(f"No army parameter in link: {link!r}")
|
|
51
|
+
code = values[0]
|
|
52
|
+
else:
|
|
53
|
+
code = link
|
|
54
|
+
if not code or not code[0].isalpha() or not re.fullmatch(r"[a-z0-9\-]+", code):
|
|
55
|
+
raise ValueError(f"Invalid army code: {code!r}")
|
|
56
|
+
|
|
57
|
+
army = Army()
|
|
58
|
+
for letter, body in _SECTION.findall(code):
|
|
59
|
+
if letter in ("u", "s"):
|
|
60
|
+
units = [ArmyUnit(id=int(i), count=int(c)) for c, i in _PAIR.findall(body)]
|
|
61
|
+
if static_data is not None:
|
|
62
|
+
category, base = (
|
|
63
|
+
("troops", TROOP_ID_BASE)
|
|
64
|
+
if letter == "u"
|
|
65
|
+
else ("spells", SPELL_ID_BASE)
|
|
66
|
+
)
|
|
67
|
+
for u in units:
|
|
68
|
+
item = static_data.by_id(category, base + u.id)
|
|
69
|
+
u.name = item["name"] if item else None
|
|
70
|
+
(army.troops if letter == "u" else army.spells).extend(units)
|
|
71
|
+
else:
|
|
72
|
+
army.extra[letter] = body
|
|
73
|
+
return army
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_army_link(army: Army, lang: str = "en") -> str:
|
|
77
|
+
"""Build an army link from an :class:`Army`."""
|
|
78
|
+
|
|
79
|
+
def fmt(units: list[ArmyUnit]) -> str:
|
|
80
|
+
return "-".join(f"{u.count}x{u.id}" for u in units)
|
|
81
|
+
|
|
82
|
+
code = ""
|
|
83
|
+
if army.troops:
|
|
84
|
+
code += "u" + fmt(army.troops)
|
|
85
|
+
if army.spells:
|
|
86
|
+
code += "s" + fmt(army.spells)
|
|
87
|
+
for letter, body in army.extra.items():
|
|
88
|
+
code += letter + body
|
|
89
|
+
return f"https://link.clashofclans.com/{lang}?action=CopyArmy&army={code}"
|
|
@@ -11,10 +11,25 @@ import httpx
|
|
|
11
11
|
|
|
12
12
|
from .cache import CacheManager
|
|
13
13
|
from .config import ApiConfig
|
|
14
|
+
from .controls import (
|
|
15
|
+
cache_lookup_enabled,
|
|
16
|
+
cache_update_enabled,
|
|
17
|
+
rate_limit_skipped,
|
|
18
|
+
)
|
|
19
|
+
from .errors import classify_http_error
|
|
14
20
|
from .metrics import MetricsTracker
|
|
15
21
|
from .middleware import MiddlewareManager
|
|
16
22
|
from .models import create_dynamic_model
|
|
17
|
-
from .utils import build_url, is_successful_response, should_retry_error
|
|
23
|
+
from .utils import build_url, is_successful_response, safe_json, should_retry_error
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _http2_available() -> bool:
|
|
27
|
+
"""HTTP/2 needs the optional ``h2`` package; fall back to HTTP/1.1 without it."""
|
|
28
|
+
try:
|
|
29
|
+
import h2 # noqa: F401
|
|
30
|
+
except ImportError:
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
18
33
|
|
|
19
34
|
|
|
20
35
|
class AsyncRateLimiter:
|
|
@@ -105,7 +120,7 @@ class AsyncCocApiCore:
|
|
|
105
120
|
max_connections=self.config.max_connections,
|
|
106
121
|
max_keepalive_connections=self.config.max_keepalive_connections,
|
|
107
122
|
),
|
|
108
|
-
http2=
|
|
123
|
+
http2=_http2_available(),
|
|
109
124
|
)
|
|
110
125
|
self._should_close_client = True
|
|
111
126
|
|
|
@@ -192,7 +207,7 @@ class AsyncCocApiCore:
|
|
|
192
207
|
url = build_url(self.config.base_url, endpoint, params)
|
|
193
208
|
|
|
194
209
|
# Check cache first
|
|
195
|
-
if self.config.enable_caching:
|
|
210
|
+
if self.config.enable_caching and cache_lookup_enabled():
|
|
196
211
|
cached_response = self.cache.get(url, params)
|
|
197
212
|
if cached_response is not None:
|
|
198
213
|
cache_hit = True
|
|
@@ -214,7 +229,7 @@ class AsyncCocApiCore:
|
|
|
214
229
|
return cached_response
|
|
215
230
|
|
|
216
231
|
# Apply rate limiting
|
|
217
|
-
if self._rate_limiter:
|
|
232
|
+
if self._rate_limiter and not rate_limit_skipped():
|
|
218
233
|
await self._rate_limiter.acquire()
|
|
219
234
|
|
|
220
235
|
# Apply request middleware
|
|
@@ -257,7 +272,7 @@ class AsyncCocApiCore:
|
|
|
257
272
|
)
|
|
258
273
|
|
|
259
274
|
# Cache successful response
|
|
260
|
-
if self.config.enable_caching:
|
|
275
|
+
if self.config.enable_caching and cache_update_enabled():
|
|
261
276
|
self.cache.set(url, params, json_response)
|
|
262
277
|
|
|
263
278
|
# Record metrics
|
|
@@ -299,7 +314,10 @@ class AsyncCocApiCore:
|
|
|
299
314
|
|
|
300
315
|
# Handle HTTP errors
|
|
301
316
|
error_response = self._handle_http_error(
|
|
302
|
-
response.status_code,
|
|
317
|
+
response.status_code,
|
|
318
|
+
attempt,
|
|
319
|
+
safe_json(response),
|
|
320
|
+
endpoint,
|
|
303
321
|
)
|
|
304
322
|
|
|
305
323
|
if self.config.enable_metrics:
|
|
@@ -389,7 +407,7 @@ class AsyncCocApiCore:
|
|
|
389
407
|
start_time = time.time()
|
|
390
408
|
|
|
391
409
|
# Apply rate limiting
|
|
392
|
-
if self._rate_limiter:
|
|
410
|
+
if self._rate_limiter and not rate_limit_skipped():
|
|
393
411
|
await self._rate_limiter.acquire()
|
|
394
412
|
|
|
395
413
|
# Apply request middleware before building URL so middleware can modify params
|
|
@@ -472,7 +490,10 @@ class AsyncCocApiCore:
|
|
|
472
490
|
pass
|
|
473
491
|
|
|
474
492
|
error_response = self._handle_http_error(
|
|
475
|
-
response.status_code,
|
|
493
|
+
response.status_code,
|
|
494
|
+
attempt,
|
|
495
|
+
safe_json(response),
|
|
496
|
+
endpoint,
|
|
476
497
|
)
|
|
477
498
|
|
|
478
499
|
if self.config.enable_metrics:
|
|
@@ -537,7 +558,13 @@ class AsyncCocApiCore:
|
|
|
537
558
|
"error_type": "retry_exhausted",
|
|
538
559
|
}
|
|
539
560
|
|
|
540
|
-
def _handle_http_error(
|
|
561
|
+
def _handle_http_error(
|
|
562
|
+
self,
|
|
563
|
+
status: int,
|
|
564
|
+
attempt: int,
|
|
565
|
+
body: Any = None,
|
|
566
|
+
endpoint: str | None = None,
|
|
567
|
+
) -> dict[str, Any]:
|
|
541
568
|
"""Handle HTTP error responses"""
|
|
542
569
|
error_messages = {
|
|
543
570
|
400: "Bad request - check your parameters",
|
|
@@ -549,12 +576,16 @@ class AsyncCocApiCore:
|
|
|
549
576
|
if status >= 500:
|
|
550
577
|
error_messages[status] = "Server error - try again later"
|
|
551
578
|
|
|
552
|
-
|
|
579
|
+
error_kind, reason = classify_http_error(status, body, endpoint)
|
|
580
|
+
error_response: dict[str, Any] = {
|
|
553
581
|
"result": "error",
|
|
554
582
|
"message": error_messages.get(status, f"HTTP error {status}"),
|
|
583
|
+
"error_kind": error_kind,
|
|
555
584
|
"http_status": status,
|
|
556
585
|
"error_type": "http",
|
|
557
586
|
}
|
|
587
|
+
if reason:
|
|
588
|
+
error_response["reason"] = reason
|
|
558
589
|
|
|
559
590
|
# Determine if we should retry
|
|
560
591
|
should_retry = (
|
|
@@ -15,19 +15,68 @@ from .api_methods import ApiMethods
|
|
|
15
15
|
from .async_client import AsyncCocApiCore
|
|
16
16
|
from .cache import CacheManager
|
|
17
17
|
from .config import ApiConfig
|
|
18
|
+
from .controls import (
|
|
19
|
+
cache_lookup_enabled,
|
|
20
|
+
cache_update_enabled,
|
|
21
|
+
)
|
|
22
|
+
from .errors import classify_http_error
|
|
18
23
|
from .metrics import MetricsTracker
|
|
19
24
|
from .middleware import MiddlewareManager
|
|
20
25
|
from .models import create_dynamic_model
|
|
21
26
|
from .utils import (
|
|
22
27
|
build_url,
|
|
28
|
+
clean_tag,
|
|
23
29
|
extract_after_cursor,
|
|
24
30
|
extract_items,
|
|
25
31
|
is_successful_response,
|
|
32
|
+
safe_json,
|
|
26
33
|
should_retry_error,
|
|
27
34
|
validate_params,
|
|
28
35
|
)
|
|
29
36
|
|
|
30
37
|
|
|
38
|
+
def _needs_cwl_fallback(war: Any) -> bool:
|
|
39
|
+
if not isinstance(war, dict):
|
|
40
|
+
return False
|
|
41
|
+
if war.get("result") == "error":
|
|
42
|
+
return war.get("error_kind") == "private_war_log"
|
|
43
|
+
return war.get("state") == "notInWar"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cwl_candidate_tags(group: dict[str, Any]) -> list[str]:
|
|
47
|
+
"""War tags from the most recent rounds that have started."""
|
|
48
|
+
rounds = [
|
|
49
|
+
[t for t in r.get("warTags", []) if t and t != "#0"]
|
|
50
|
+
for r in group.get("rounds", [])
|
|
51
|
+
]
|
|
52
|
+
started = [r for r in rounds if r]
|
|
53
|
+
# Only the last two started rounds can be prep/in-war/just-ended
|
|
54
|
+
return [t for r in started[-2:] for t in r]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pick_cwl_war(
|
|
58
|
+
wars: list[tuple[str, dict[str, Any]]], clan_tag: str
|
|
59
|
+
) -> dict[str, Any] | None:
|
|
60
|
+
"""Pick the clan's most relevant CWL war (in war > preparation > ended)."""
|
|
61
|
+
want = "#" + clean_tag(clan_tag).upper()
|
|
62
|
+
mine = [
|
|
63
|
+
(tag, w)
|
|
64
|
+
for tag, w in wars
|
|
65
|
+
if want in (w.get("clan", {}).get("tag"), w.get("opponent", {}).get("tag"))
|
|
66
|
+
]
|
|
67
|
+
if not mine:
|
|
68
|
+
return None
|
|
69
|
+
priority = {"inWar": 0, "preparation": 1, "warEnded": 2}
|
|
70
|
+
tag, war = min(mine, key=lambda tw: priority.get(tw[1].get("state", ""), 3))
|
|
71
|
+
war = dict(war)
|
|
72
|
+
# Orient so that "clan" is always the requested clan
|
|
73
|
+
if war.get("opponent", {}).get("tag") == want:
|
|
74
|
+
war["clan"], war["opponent"] = war["opponent"], war["clan"]
|
|
75
|
+
war["is_cwl"] = True
|
|
76
|
+
war["war_tag"] = tag
|
|
77
|
+
return war
|
|
78
|
+
|
|
79
|
+
|
|
31
80
|
class CocApi(ApiMethods):
|
|
32
81
|
"""
|
|
33
82
|
Clash of Clans API Wrapper with enhanced v3.0.0 features
|
|
@@ -270,7 +319,7 @@ class CocApi(ApiMethods):
|
|
|
270
319
|
url = build_url(self.config.base_url, uri, params)
|
|
271
320
|
|
|
272
321
|
# Check cache first
|
|
273
|
-
if self.config.enable_caching:
|
|
322
|
+
if self.config.enable_caching and cache_lookup_enabled():
|
|
274
323
|
cached_response = self.cache.get(url, params)
|
|
275
324
|
if cached_response is not None:
|
|
276
325
|
cache_hit = True
|
|
@@ -332,7 +381,7 @@ class CocApi(ApiMethods):
|
|
|
332
381
|
)
|
|
333
382
|
|
|
334
383
|
# Cache successful response
|
|
335
|
-
if self.config.enable_caching:
|
|
384
|
+
if self.config.enable_caching and cache_update_enabled():
|
|
336
385
|
self.cache.set(url, params, json_response)
|
|
337
386
|
|
|
338
387
|
# Record metrics
|
|
@@ -374,7 +423,10 @@ class CocApi(ApiMethods):
|
|
|
374
423
|
|
|
375
424
|
# Handle HTTP errors
|
|
376
425
|
error_response = self._handle_http_error(
|
|
377
|
-
response.status_code,
|
|
426
|
+
response.status_code,
|
|
427
|
+
attempt,
|
|
428
|
+
safe_json(response),
|
|
429
|
+
uri,
|
|
378
430
|
)
|
|
379
431
|
|
|
380
432
|
if self.config.enable_metrics:
|
|
@@ -454,7 +506,13 @@ class CocApi(ApiMethods):
|
|
|
454
506
|
response["status_code"] = status_code
|
|
455
507
|
return response
|
|
456
508
|
|
|
457
|
-
def _handle_http_error(
|
|
509
|
+
def _handle_http_error(
|
|
510
|
+
self,
|
|
511
|
+
status: int,
|
|
512
|
+
attempt: int,
|
|
513
|
+
body: Any = None,
|
|
514
|
+
endpoint: str | None = None,
|
|
515
|
+
) -> dict[str, Any]:
|
|
458
516
|
"""Handle HTTP error responses"""
|
|
459
517
|
error_messages = {
|
|
460
518
|
400: "Bad request - check your parameters",
|
|
@@ -466,12 +524,16 @@ class CocApi(ApiMethods):
|
|
|
466
524
|
if status >= 500:
|
|
467
525
|
error_messages[status] = "Server error - try again later"
|
|
468
526
|
|
|
469
|
-
|
|
527
|
+
error_kind, reason = classify_http_error(status, body, endpoint)
|
|
528
|
+
error_response: dict[str, Any] = {
|
|
470
529
|
"result": "error",
|
|
471
530
|
"message": error_messages.get(status, f"HTTP error {status}"),
|
|
531
|
+
"error_kind": error_kind,
|
|
472
532
|
"error_type": "http",
|
|
473
533
|
"http_status": status,
|
|
474
534
|
}
|
|
535
|
+
if reason:
|
|
536
|
+
error_response["reason"] = reason
|
|
475
537
|
|
|
476
538
|
return error_response
|
|
477
539
|
|
|
@@ -640,7 +702,10 @@ class CocApi(ApiMethods):
|
|
|
640
702
|
pass
|
|
641
703
|
|
|
642
704
|
error_response = self._handle_http_error(
|
|
643
|
-
response.status_code,
|
|
705
|
+
response.status_code,
|
|
706
|
+
attempt,
|
|
707
|
+
safe_json(response),
|
|
708
|
+
uri,
|
|
644
709
|
)
|
|
645
710
|
|
|
646
711
|
if self.config.enable_metrics:
|
|
@@ -775,6 +840,64 @@ class CocApi(ApiMethods):
|
|
|
775
840
|
return response
|
|
776
841
|
|
|
777
842
|
# Pagination
|
|
843
|
+
# ------------------------------------------------------------------
|
|
844
|
+
# Current war with automatic CWL fallback
|
|
845
|
+
# ------------------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
def clan_current_war_auto(
|
|
848
|
+
self, clan_tag: str
|
|
849
|
+
) -> dict[str, Any] | Awaitable[dict[str, Any]]:
|
|
850
|
+
"""Get the clan's current war, falling back to its Clan War League war.
|
|
851
|
+
|
|
852
|
+
Tries ``/currentwar`` first. If the clan is not in a regular war (or the
|
|
853
|
+
war log is private), looks up the CWL group and returns the most relevant
|
|
854
|
+
CWL war involving the clan. CWL results include ``"is_cwl": True`` and
|
|
855
|
+
``"war_tag"``. Private war logs return an error with
|
|
856
|
+
``error_kind == "private_war_log"`` when no CWL war is available either.
|
|
857
|
+
"""
|
|
858
|
+
if self.async_mode:
|
|
859
|
+
return self._async_current_war_auto(clan_tag)
|
|
860
|
+
return self._sync_current_war_auto(clan_tag)
|
|
861
|
+
|
|
862
|
+
def _sync_current_war_auto(self, clan_tag: str) -> dict[str, Any]:
|
|
863
|
+
war = self.clan_current_war(clan_tag)
|
|
864
|
+
assert isinstance(war, dict)
|
|
865
|
+
if not _needs_cwl_fallback(war):
|
|
866
|
+
return war
|
|
867
|
+
group = self.clan_leaguegroup(clan_tag)
|
|
868
|
+
assert isinstance(group, dict)
|
|
869
|
+
if group.get("result") == "error" or group.get("state") in (
|
|
870
|
+
None,
|
|
871
|
+
"notInWar",
|
|
872
|
+
):
|
|
873
|
+
return war
|
|
874
|
+
wars: list[tuple[str, dict[str, Any]]] = []
|
|
875
|
+
for tag in _cwl_candidate_tags(group):
|
|
876
|
+
w = self.warleague(tag)
|
|
877
|
+
assert isinstance(w, dict)
|
|
878
|
+
if w.get("result") != "error":
|
|
879
|
+
wars.append((tag, w))
|
|
880
|
+
return _pick_cwl_war(wars, clan_tag) or war
|
|
881
|
+
|
|
882
|
+
async def _async_current_war_auto(self, clan_tag: str) -> dict[str, Any]:
|
|
883
|
+
war = await self.clan_current_war(clan_tag) # type: ignore[misc]
|
|
884
|
+
if not _needs_cwl_fallback(war):
|
|
885
|
+
return war # type: ignore[no-any-return]
|
|
886
|
+
group = await self.clan_leaguegroup(clan_tag) # type: ignore[misc]
|
|
887
|
+
if group.get("result") == "error" or group.get("state") in (
|
|
888
|
+
None,
|
|
889
|
+
"notInWar",
|
|
890
|
+
):
|
|
891
|
+
return war # type: ignore[no-any-return]
|
|
892
|
+
tags = _cwl_candidate_tags(group)
|
|
893
|
+
results = await asyncio.gather(*(self.warleague(t) for t in tags)) # type: ignore[arg-type]
|
|
894
|
+
wars = [
|
|
895
|
+
(t, w)
|
|
896
|
+
for t, w in zip(tags, results, strict=True)
|
|
897
|
+
if w.get("result") != "error"
|
|
898
|
+
]
|
|
899
|
+
return _pick_cwl_war(wars, clan_tag) or war # type: ignore[no-any-return]
|
|
900
|
+
|
|
778
901
|
def paginate(
|
|
779
902
|
self,
|
|
780
903
|
method: Callable[..., Any],
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-call request controls (cache and rate-limit overrides).
|
|
3
|
+
|
|
4
|
+
These use :mod:`contextvars`, so they work identically for sync code and
|
|
5
|
+
asyncio tasks, and never leak across concurrent tasks.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from cocapi import cache_control, without_rate_limit
|
|
10
|
+
|
|
11
|
+
with cache_control(lookup=False): # force a fresh fetch
|
|
12
|
+
clan = api.clan_tag("#ABC")
|
|
13
|
+
|
|
14
|
+
with cache_control(update=False): # don't store this response
|
|
15
|
+
clan = api.clan_tag("#ABC")
|
|
16
|
+
|
|
17
|
+
with without_rate_limit(): # skip the async rate limiter
|
|
18
|
+
data = await api.clan_tag("#ABC")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from contextvars import ContextVar
|
|
24
|
+
|
|
25
|
+
_cache_lookup: ContextVar[bool] = ContextVar("cocapi_cache_lookup", default=True)
|
|
26
|
+
_cache_update: ContextVar[bool] = ContextVar("cocapi_cache_update", default=True)
|
|
27
|
+
_skip_rate_limit: ContextVar[bool] = ContextVar("cocapi_skip_rate_limit", default=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def cache_control(lookup: bool = True, update: bool = True) -> Iterator[None]:
|
|
32
|
+
"""Override response caching for requests made inside the ``with`` block.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
lookup: If False, ignore any cached entry and hit the API.
|
|
36
|
+
update: If False, don't store the fresh response in the cache.
|
|
37
|
+
"""
|
|
38
|
+
t1 = _cache_lookup.set(lookup)
|
|
39
|
+
t2 = _cache_update.set(update)
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
_cache_lookup.reset(t1)
|
|
44
|
+
_cache_update.reset(t2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def without_rate_limit() -> Iterator[None]:
|
|
49
|
+
"""Bypass the async rate limiter for requests made inside the block."""
|
|
50
|
+
token = _skip_rate_limit.set(True)
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
_skip_rate_limit.reset(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cache_lookup_enabled() -> bool:
|
|
58
|
+
return _cache_lookup.get()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cache_update_enabled() -> bool:
|
|
62
|
+
return _cache_update.get()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rate_limit_skipped() -> bool:
|
|
66
|
+
return _skip_rate_limit.get()
|