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.
Files changed (65) hide show
  1. {cocapi-4.0.1 → cocapi-4.1.0}/.github/FUNDING.yml +1 -0
  2. {cocapi-4.0.1 → cocapi-4.1.0}/.github/workflows/ci.yml +5 -5
  3. {cocapi-4.0.1 → cocapi-4.1.0}/.github/workflows/release.yml +5 -5
  4. {cocapi-4.0.1 → cocapi-4.1.0}/PKG-INFO +3 -2
  5. {cocapi-4.0.1 → cocapi-4.1.0}/README.md +1 -0
  6. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/__init__.py +46 -1
  7. cocapi-4.1.0/cocapi/army.py +89 -0
  8. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/async_client.py +41 -10
  9. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/client.py +129 -6
  10. cocapi-4.1.0/cocapi/controls.py +66 -0
  11. cocapi-4.1.0/cocapi/errors.py +106 -0
  12. cocapi-4.1.0/cocapi/game_time.py +68 -0
  13. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/schemas.py +1 -0
  14. cocapi-4.1.0/cocapi/static_data.py +125 -0
  15. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/utils.py +8 -0
  16. {cocapi-4.0.1 → cocapi-4.1.0}/docs/changelog.md +10 -0
  17. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/caching.md +17 -0
  18. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/error-handling.md +33 -0
  19. cocapi-4.1.0/docs/guides/game-data.md +40 -0
  20. {cocapi-4.0.1 → cocapi-4.1.0}/mkdocs.yml +1 -0
  21. {cocapi-4.0.1 → cocapi-4.1.0}/pyproject.toml +1 -1
  22. cocapi-4.1.0/uv.lock +1937 -0
  23. cocapi-4.0.1/uv.lock +0 -1305
  24. {cocapi-4.0.1 → cocapi-4.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {cocapi-4.0.1 → cocapi-4.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {cocapi-4.0.1 → cocapi-4.1.0}/.github/workflows/docs.yml +0 -0
  27. {cocapi-4.0.1 → cocapi-4.1.0}/.gitignore +0 -0
  28. {cocapi-4.0.1 → cocapi-4.1.0}/.pre-commit-config.yaml +0 -0
  29. {cocapi-4.0.1 → cocapi-4.1.0}/CODE_OF_CONDUCT.md +0 -0
  30. {cocapi-4.0.1 → cocapi-4.1.0}/LICENSE +0 -0
  31. {cocapi-4.0.1 → cocapi-4.1.0}/SECURITY.md +0 -0
  32. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/api_methods.py +0 -0
  33. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/cache.py +0 -0
  34. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/cli.py +0 -0
  35. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/cocapi.py +0 -0
  36. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/config.py +0 -0
  37. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/__init__.py +0 -0
  38. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_diff.py +0 -0
  39. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_state.py +0 -0
  40. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_stream.py +0 -0
  41. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_types.py +0 -0
  42. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_war_fsm.py +0 -0
  43. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/events/_watchers.py +0 -0
  44. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/key_manager.py +0 -0
  45. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/metrics.py +0 -0
  46. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/middleware.py +0 -0
  47. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/model_registry.py +0 -0
  48. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/models.py +0 -0
  49. {cocapi-4.0.1 → cocapi-4.1.0}/cocapi/py.typed +0 -0
  50. {cocapi-4.0.1 → cocapi-4.1.0}/docs/examples.md +0 -0
  51. {cocapi-4.0.1 → cocapi-4.1.0}/docs/getting-started/authentication.md +0 -0
  52. {cocapi-4.0.1 → cocapi-4.1.0}/docs/getting-started/installation.md +0 -0
  53. {cocapi-4.0.1 → cocapi-4.1.0}/docs/getting-started/quickstart.md +0 -0
  54. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/async.md +0 -0
  55. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/cli.md +0 -0
  56. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/configuration.md +0 -0
  57. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/events.md +0 -0
  58. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/metrics.md +0 -0
  59. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/middleware.md +0 -0
  60. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/pagination-batch.md +0 -0
  61. {cocapi-4.0.1 → cocapi-4.1.0}/docs/guides/pydantic-models.md +0 -0
  62. {cocapi-4.0.1 → cocapi-4.1.0}/docs/index.md +0 -0
  63. {cocapi-4.0.1 → cocapi-4.1.0}/mypy.ini +0 -0
  64. {cocapi-4.0.1 → cocapi-4.1.0}/setup.py +0 -0
  65. {cocapi-4.0.1 → cocapi-4.1.0}/sonar-project.properties +0 -0
@@ -2,3 +2,4 @@
2
2
 
3
3
  github: tonybenoy
4
4
  patreon: tonybenoy
5
+ buy_me_a_coffee: tonybenoy
@@ -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/test_cocapi.py -v --tb=short
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/test_cocapi.py -v --tb=short
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: cocapi
3
- Version: 4.0.1
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.1"
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=True,
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, attempt
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, attempt
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(self, status: int, attempt: int) -> dict[str, Any]:
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
- error_response = {
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, attempt
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(self, status: int, attempt: int) -> dict[str, Any]:
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
- error_response = {
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, attempt
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()