python-kanka 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kanka/__init__.py +90 -0
- kanka/_version.py +3 -0
- kanka/client.py +540 -0
- kanka/exceptions.py +132 -0
- kanka/managers.py +464 -0
- kanka/models/__init__.py +58 -0
- kanka/models/base.py +91 -0
- kanka/models/common.py +175 -0
- kanka/models/entities.py +349 -0
- kanka/py.typed +2 -0
- kanka/types.py +8 -0
- python_kanka-2.0.0.dist-info/METADATA +377 -0
- python_kanka-2.0.0.dist-info/RECORD +16 -0
- python_kanka-2.0.0.dist-info/WHEEL +5 -0
- python_kanka-2.0.0.dist-info/licenses/LICENSE +21 -0
- python_kanka-2.0.0.dist-info/top_level.txt +1 -0
kanka/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Python client library for the Kanka API.
|
|
2
|
+
|
|
3
|
+
Kanka is a collaborative world-building and campaign management tool for
|
|
4
|
+
tabletop RPGs. This library provides a Python interface to interact with
|
|
5
|
+
the Kanka API, allowing you to programmatically manage your campaign data.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
- Full support for all Kanka entity types
|
|
9
|
+
- Type-safe models using Pydantic v2
|
|
10
|
+
- Comprehensive error handling
|
|
11
|
+
- Filtering and search capabilities
|
|
12
|
+
- Post/comment management
|
|
13
|
+
|
|
14
|
+
Quick Start:
|
|
15
|
+
>>> from kanka import KankaClient
|
|
16
|
+
>>> client = KankaClient("your-api-token", campaign_id=12345)
|
|
17
|
+
>>> characters = client.characters.list()
|
|
18
|
+
>>> dragon = client.search("dragon")
|
|
19
|
+
|
|
20
|
+
Main Classes:
|
|
21
|
+
- KankaClient: Main client for API interaction
|
|
22
|
+
- Entity models: Character, Location, Organisation, etc.
|
|
23
|
+
- Exceptions: KankaException and specific error types
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Version
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
|
|
29
|
+
# Import the client
|
|
30
|
+
from .client import KankaClient
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
AuthenticationError,
|
|
33
|
+
ForbiddenError,
|
|
34
|
+
KankaException,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
RateLimitError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Import models for easier access
|
|
41
|
+
from .models import ( # Base models; Entity models; Common models
|
|
42
|
+
Calendar,
|
|
43
|
+
Character,
|
|
44
|
+
Creature,
|
|
45
|
+
Entity,
|
|
46
|
+
Event,
|
|
47
|
+
Family,
|
|
48
|
+
Journal,
|
|
49
|
+
KankaModel,
|
|
50
|
+
Location,
|
|
51
|
+
Note,
|
|
52
|
+
Organisation,
|
|
53
|
+
Post,
|
|
54
|
+
Quest,
|
|
55
|
+
Race,
|
|
56
|
+
SearchResult,
|
|
57
|
+
Tag,
|
|
58
|
+
Trait,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"KankaClient",
|
|
63
|
+
"KankaException",
|
|
64
|
+
"NotFoundError",
|
|
65
|
+
"ValidationError",
|
|
66
|
+
"AuthenticationError",
|
|
67
|
+
"ForbiddenError",
|
|
68
|
+
"RateLimitError",
|
|
69
|
+
# Base models
|
|
70
|
+
"KankaModel",
|
|
71
|
+
"Entity",
|
|
72
|
+
# Entity models
|
|
73
|
+
"Calendar",
|
|
74
|
+
"Character",
|
|
75
|
+
"Creature",
|
|
76
|
+
"Event",
|
|
77
|
+
"Family",
|
|
78
|
+
"Journal",
|
|
79
|
+
"Location",
|
|
80
|
+
"Note",
|
|
81
|
+
"Organisation",
|
|
82
|
+
"Quest",
|
|
83
|
+
"Race",
|
|
84
|
+
"Tag",
|
|
85
|
+
# Common models
|
|
86
|
+
"Post",
|
|
87
|
+
"SearchResult",
|
|
88
|
+
"Trait",
|
|
89
|
+
"__version__",
|
|
90
|
+
]
|
kanka/_version.py
ADDED
kanka/client.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""Main Kanka API client for interacting with the Kanka API.
|
|
2
|
+
|
|
3
|
+
This module provides the primary interface for working with Kanka's RESTful API.
|
|
4
|
+
It handles authentication, request management, and provides convenient access to
|
|
5
|
+
all entity types through manager objects.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
Basic usage of the KankaClient:
|
|
9
|
+
|
|
10
|
+
>>> from kanka import KankaClient
|
|
11
|
+
>>> client = KankaClient(token="your-api-token", campaign_id=12345)
|
|
12
|
+
>>> characters = client.characters.list()
|
|
13
|
+
>>> dragon = client.search("dragon")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional, Union
|
|
22
|
+
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
ForbiddenError,
|
|
26
|
+
KankaException,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .managers import EntityManager
|
|
32
|
+
from .models.common import SearchResult
|
|
33
|
+
from .models.entities import (
|
|
34
|
+
Calendar,
|
|
35
|
+
Character,
|
|
36
|
+
Creature,
|
|
37
|
+
Event,
|
|
38
|
+
Family,
|
|
39
|
+
Journal,
|
|
40
|
+
Location,
|
|
41
|
+
Note,
|
|
42
|
+
Organisation,
|
|
43
|
+
Quest,
|
|
44
|
+
Race,
|
|
45
|
+
Tag,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KankaClient:
|
|
50
|
+
"""Main client for Kanka API interaction with automatic rate limit handling.
|
|
51
|
+
|
|
52
|
+
This client provides a unified interface to access all Kanka entities
|
|
53
|
+
within a specific campaign. It handles authentication, request management,
|
|
54
|
+
automatic retry on rate limits, and provides entity-specific managers
|
|
55
|
+
for CRUD operations.
|
|
56
|
+
|
|
57
|
+
The client automatically handles rate limiting by:
|
|
58
|
+
- Retrying requests that receive 429 (Rate Limit) responses
|
|
59
|
+
- Using exponential backoff between retries
|
|
60
|
+
- Parsing rate limit headers to determine optimal retry delays
|
|
61
|
+
- Respecting the API's rate limit reset times
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
BASE_URL (str): The base URL for the Kanka API
|
|
65
|
+
token (str): Authentication token for API access
|
|
66
|
+
campaign_id (int): ID of the campaign to work with
|
|
67
|
+
session: Configured requests.Session instance
|
|
68
|
+
enable_rate_limit_retry (bool): Whether to automatically retry on rate limits
|
|
69
|
+
max_retries (int): Maximum number of retries for rate limited requests
|
|
70
|
+
retry_delay (float): Initial delay between retries in seconds
|
|
71
|
+
max_retry_delay (float): Maximum delay between retries in seconds
|
|
72
|
+
|
|
73
|
+
Entity Managers:
|
|
74
|
+
calendars: Access to Calendar entities
|
|
75
|
+
characters: Access to Character entities
|
|
76
|
+
creatures: Access to Creature entities
|
|
77
|
+
events: Access to Event entities
|
|
78
|
+
families: Access to Family entities
|
|
79
|
+
journals: Access to Journal entities
|
|
80
|
+
locations: Access to Location entities
|
|
81
|
+
notes: Access to Note entities
|
|
82
|
+
organisations: Access to Organisation entities
|
|
83
|
+
quests: Access to Quest entities
|
|
84
|
+
races: Access to Race entities
|
|
85
|
+
tags: Access to Tag entities
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> # Basic usage with automatic rate limit handling
|
|
89
|
+
>>> client = KankaClient("your-token", 12345)
|
|
90
|
+
>>>
|
|
91
|
+
>>> # Disable automatic retry for rate limits
|
|
92
|
+
>>> client = KankaClient("your-token", 12345, enable_rate_limit_retry=False)
|
|
93
|
+
>>>
|
|
94
|
+
>>> # Customize retry behavior
|
|
95
|
+
>>> client = KankaClient(
|
|
96
|
+
... "your-token", 12345,
|
|
97
|
+
... max_retries=5,
|
|
98
|
+
... retry_delay=2.0,
|
|
99
|
+
... max_retry_delay=120.0
|
|
100
|
+
... )
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
BASE_URL = "https://api.kanka.io/1.0"
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
token: str,
|
|
108
|
+
campaign_id: int,
|
|
109
|
+
*,
|
|
110
|
+
enable_rate_limit_retry: bool = True,
|
|
111
|
+
max_retries: int = 8,
|
|
112
|
+
retry_delay: float = 1.0,
|
|
113
|
+
max_retry_delay: float = 15.0,
|
|
114
|
+
):
|
|
115
|
+
"""Initialize the Kanka client.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
token: API authentication token
|
|
119
|
+
campaign_id: Campaign ID to work with
|
|
120
|
+
enable_rate_limit_retry: Whether to automatically retry on rate limits
|
|
121
|
+
max_retries: Maximum number of retries for rate limited requests
|
|
122
|
+
retry_delay: Initial delay between retries in seconds
|
|
123
|
+
max_retry_delay: Maximum delay between retries in seconds
|
|
124
|
+
"""
|
|
125
|
+
self.token = token
|
|
126
|
+
self.campaign_id = campaign_id
|
|
127
|
+
self.enable_rate_limit_retry = enable_rate_limit_retry
|
|
128
|
+
self.max_retries = max_retries
|
|
129
|
+
self.retry_delay = retry_delay
|
|
130
|
+
self.max_retry_delay = max_retry_delay
|
|
131
|
+
|
|
132
|
+
# Debug mode configuration
|
|
133
|
+
self._debug_mode = os.environ.get("KANKA_DEBUG_MODE", "").lower() == "true"
|
|
134
|
+
self._debug_dir = Path(os.environ.get("KANKA_DEBUG_DIR", "kanka_debug"))
|
|
135
|
+
self._request_counter = 0
|
|
136
|
+
|
|
137
|
+
# Create debug directory if in debug mode
|
|
138
|
+
if self._debug_mode:
|
|
139
|
+
self._debug_dir.mkdir(exist_ok=True)
|
|
140
|
+
|
|
141
|
+
# Set up session with default headers
|
|
142
|
+
# Import requests here to avoid import issues
|
|
143
|
+
import requests
|
|
144
|
+
|
|
145
|
+
self.session = requests.Session()
|
|
146
|
+
self.session.headers.update(
|
|
147
|
+
{
|
|
148
|
+
"Authorization": f"Bearer {token}",
|
|
149
|
+
"Accept": "application/json",
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Initialize entity managers
|
|
155
|
+
self._init_managers()
|
|
156
|
+
|
|
157
|
+
def _init_managers(self):
|
|
158
|
+
"""Initialize entity managers for each entity type."""
|
|
159
|
+
# Core entities
|
|
160
|
+
self._calendars = EntityManager(self, "calendars", Calendar)
|
|
161
|
+
self._characters = EntityManager(self, "characters", Character)
|
|
162
|
+
self._creatures = EntityManager(self, "creatures", Creature)
|
|
163
|
+
self._events = EntityManager(self, "events", Event)
|
|
164
|
+
self._families = EntityManager(self, "families", Family)
|
|
165
|
+
self._journals = EntityManager(self, "journals", Journal)
|
|
166
|
+
self._locations = EntityManager(self, "locations", Location)
|
|
167
|
+
self._notes = EntityManager(self, "notes", Note)
|
|
168
|
+
self._organisations = EntityManager(self, "organisations", Organisation)
|
|
169
|
+
self._quests = EntityManager(self, "quests", Quest)
|
|
170
|
+
self._races = EntityManager(self, "races", Race)
|
|
171
|
+
self._tags = EntityManager(self, "tags", Tag)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def calendars(self) -> EntityManager[Calendar]:
|
|
175
|
+
"""Access calendar entities.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
EntityManager[Calendar]: Manager for Calendar entity operations
|
|
179
|
+
"""
|
|
180
|
+
return self._calendars
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def characters(self) -> EntityManager[Character]:
|
|
184
|
+
"""Access character entities.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
EntityManager[Character]: Manager for Character entity operations
|
|
188
|
+
"""
|
|
189
|
+
return self._characters
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def creatures(self) -> EntityManager[Creature]:
|
|
193
|
+
"""Access creature entities.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
EntityManager[Creature]: Manager for Creature entity operations
|
|
197
|
+
"""
|
|
198
|
+
return self._creatures
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def events(self) -> EntityManager[Event]:
|
|
202
|
+
"""Access event entities.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
EntityManager[Event]: Manager for Event entity operations
|
|
206
|
+
"""
|
|
207
|
+
return self._events
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def families(self) -> EntityManager[Family]:
|
|
211
|
+
"""Access family entities.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
EntityManager[Family]: Manager for Family entity operations
|
|
215
|
+
"""
|
|
216
|
+
return self._families
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def journals(self) -> EntityManager[Journal]:
|
|
220
|
+
"""Access journal entities.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
EntityManager[Journal]: Manager for Journal entity operations
|
|
224
|
+
"""
|
|
225
|
+
return self._journals
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def locations(self) -> EntityManager[Location]:
|
|
229
|
+
"""Access location entities.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
EntityManager[Location]: Manager for Location entity operations
|
|
233
|
+
"""
|
|
234
|
+
return self._locations
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def notes(self) -> EntityManager[Note]:
|
|
238
|
+
"""Access note entities.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
EntityManager[Note]: Manager for Note entity operations
|
|
242
|
+
"""
|
|
243
|
+
return self._notes
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def organisations(self) -> EntityManager[Organisation]:
|
|
247
|
+
"""Access organisation entities.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
EntityManager[Organisation]: Manager for Organisation entity operations
|
|
251
|
+
"""
|
|
252
|
+
return self._organisations
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def quests(self) -> EntityManager[Quest]:
|
|
256
|
+
"""Access quest entities.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
EntityManager[Quest]: Manager for Quest entity operations
|
|
260
|
+
"""
|
|
261
|
+
return self._quests
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def races(self) -> EntityManager[Race]:
|
|
265
|
+
"""Access race entities.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
EntityManager[Race]: Manager for Race entity operations
|
|
269
|
+
"""
|
|
270
|
+
return self._races
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def tags(self) -> EntityManager[Tag]:
|
|
274
|
+
"""Access tag entities.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
EntityManager[Tag]: Manager for Tag entity operations
|
|
278
|
+
"""
|
|
279
|
+
return self._tags
|
|
280
|
+
|
|
281
|
+
def search(self, term: str, page: int = 1) -> list[SearchResult]:
|
|
282
|
+
"""Search across all entity types.
|
|
283
|
+
|
|
284
|
+
Note: The Kanka API search endpoint does not respect limit parameters,
|
|
285
|
+
so pagination control is limited to page selection only.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
term: Search term
|
|
289
|
+
page: Page number (default: 1)
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
List of search results
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
results = client.search("dragon")
|
|
296
|
+
results = client.search("dragon", page=2)
|
|
297
|
+
"""
|
|
298
|
+
params: dict[str, Union[int, str]] = {"page": page}
|
|
299
|
+
response = self._request("GET", f"search/{term}", params=params)
|
|
300
|
+
|
|
301
|
+
# Store pagination metadata for access if needed
|
|
302
|
+
self._last_search_meta = response.get("meta", {})
|
|
303
|
+
self._last_search_links = response.get("links", {})
|
|
304
|
+
|
|
305
|
+
return [SearchResult(**item) for item in response["data"]]
|
|
306
|
+
|
|
307
|
+
def entities(self, **filters) -> list[dict[str, Any]]:
|
|
308
|
+
"""Access the /entities endpoint with filters.
|
|
309
|
+
|
|
310
|
+
This endpoint provides a unified way to query entities across all types
|
|
311
|
+
with various filtering options.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
**filters: Filter parameters like types, name, is_private, tags
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
List of entity data
|
|
318
|
+
"""
|
|
319
|
+
params: dict[str, Union[int, str]] = {}
|
|
320
|
+
|
|
321
|
+
# Handle special filters
|
|
322
|
+
if "types" in filters and isinstance(filters["types"], list):
|
|
323
|
+
params["types"] = ",".join(filters["types"])
|
|
324
|
+
elif "types" in filters:
|
|
325
|
+
params["types"] = filters["types"]
|
|
326
|
+
|
|
327
|
+
if "tags" in filters and isinstance(filters["tags"], list):
|
|
328
|
+
params["tags"] = ",".join(map(str, filters["tags"]))
|
|
329
|
+
elif "tags" in filters:
|
|
330
|
+
params["tags"] = filters["tags"]
|
|
331
|
+
|
|
332
|
+
# Add other filters
|
|
333
|
+
for key in ["name", "is_private", "created_by", "updated_by"]:
|
|
334
|
+
if key in filters and filters[key] is not None:
|
|
335
|
+
if isinstance(filters[key], bool):
|
|
336
|
+
params[key] = int(filters[key])
|
|
337
|
+
else:
|
|
338
|
+
params[key] = filters[key]
|
|
339
|
+
|
|
340
|
+
response = self._request("GET", "entities", params=params)
|
|
341
|
+
return response["data"] # type: ignore[no-any-return]
|
|
342
|
+
|
|
343
|
+
def _parse_rate_limit_headers(self, response) -> Optional[float]:
|
|
344
|
+
"""Parse rate limit headers from response.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Suggested retry delay in seconds, or None if not available
|
|
348
|
+
"""
|
|
349
|
+
# Common rate limit headers
|
|
350
|
+
retry_after = response.headers.get("Retry-After")
|
|
351
|
+
if retry_after:
|
|
352
|
+
try:
|
|
353
|
+
# Could be seconds or a date
|
|
354
|
+
return float(retry_after)
|
|
355
|
+
except ValueError:
|
|
356
|
+
# Try parsing as date
|
|
357
|
+
from email.utils import parsedate_to_datetime
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
retry_date = parsedate_to_datetime(retry_after)
|
|
361
|
+
return (
|
|
362
|
+
retry_date
|
|
363
|
+
- parsedate_to_datetime(response.headers.get("Date", ""))
|
|
364
|
+
).total_seconds()
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
# Check X-RateLimit headers
|
|
369
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
370
|
+
reset = response.headers.get("X-RateLimit-Reset")
|
|
371
|
+
|
|
372
|
+
if remaining and reset:
|
|
373
|
+
try:
|
|
374
|
+
if int(remaining) == 0:
|
|
375
|
+
# Calculate seconds until reset
|
|
376
|
+
reset_time = int(reset)
|
|
377
|
+
current_time = int(time.time())
|
|
378
|
+
return max(0, reset_time - current_time)
|
|
379
|
+
except (ValueError, TypeError):
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
def _log_debug_request(
|
|
385
|
+
self, method: str, url: str, request_data: dict, response, response_time: float
|
|
386
|
+
):
|
|
387
|
+
"""Log request and response to debug file."""
|
|
388
|
+
if not self._debug_mode:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
self._request_counter += 1
|
|
392
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
393
|
+
|
|
394
|
+
# Extract endpoint info from URL for filename
|
|
395
|
+
endpoint_parts = url.replace(self.BASE_URL, "").strip("/").split("/")
|
|
396
|
+
endpoint_name = "_".join(endpoint_parts[2:]) # Skip 'campaigns/{id}/'
|
|
397
|
+
if not endpoint_name:
|
|
398
|
+
endpoint_name = "root"
|
|
399
|
+
|
|
400
|
+
# Create descriptive filename
|
|
401
|
+
filename = (
|
|
402
|
+
f"{self._request_counter:04d}_{timestamp}_{method}_{endpoint_name}.json"
|
|
403
|
+
)
|
|
404
|
+
filepath = self._debug_dir / filename
|
|
405
|
+
|
|
406
|
+
# Prepare debug data
|
|
407
|
+
debug_data = {
|
|
408
|
+
"timestamp": datetime.now().isoformat(),
|
|
409
|
+
"request_number": self._request_counter,
|
|
410
|
+
"request": {
|
|
411
|
+
"method": method,
|
|
412
|
+
"url": url,
|
|
413
|
+
"headers": dict(self.session.headers),
|
|
414
|
+
"params": request_data.get("params", {}),
|
|
415
|
+
"json": request_data.get("json", {}),
|
|
416
|
+
},
|
|
417
|
+
"response": {
|
|
418
|
+
"status_code": response.status_code,
|
|
419
|
+
"headers": dict(response.headers),
|
|
420
|
+
"time_seconds": response_time,
|
|
421
|
+
"body": None,
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
# Try to parse response body
|
|
426
|
+
try:
|
|
427
|
+
response_body = response.json()
|
|
428
|
+
if "response" in debug_data and isinstance(debug_data["response"], dict):
|
|
429
|
+
debug_data["response"]["body"] = response_body
|
|
430
|
+
except Exception:
|
|
431
|
+
if "response" in debug_data and isinstance(debug_data["response"], dict):
|
|
432
|
+
debug_data["response"]["body"] = response.text
|
|
433
|
+
|
|
434
|
+
# Write to file
|
|
435
|
+
with open(filepath, "w") as f:
|
|
436
|
+
json.dump(debug_data, f, indent=2, default=str)
|
|
437
|
+
|
|
438
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
439
|
+
"""Make HTTP request to Kanka API with automatic retry on rate limits.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
method: HTTP method (GET, POST, etc.)
|
|
443
|
+
endpoint: API endpoint (relative to campaign)
|
|
444
|
+
**kwargs: Additional request parameters
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Response data
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
Various exceptions based on status code
|
|
451
|
+
"""
|
|
452
|
+
# Build full URL
|
|
453
|
+
url = f"{self.BASE_URL}/campaigns/{self.campaign_id}/{endpoint}"
|
|
454
|
+
|
|
455
|
+
attempts = 0
|
|
456
|
+
delay = self.retry_delay
|
|
457
|
+
last_exception = None
|
|
458
|
+
|
|
459
|
+
while attempts <= self.max_retries:
|
|
460
|
+
try:
|
|
461
|
+
# Track request time
|
|
462
|
+
start_time = time.time()
|
|
463
|
+
|
|
464
|
+
# Make request
|
|
465
|
+
response = self.session.request(method, url, **kwargs)
|
|
466
|
+
|
|
467
|
+
# Calculate response time
|
|
468
|
+
response_time = time.time() - start_time
|
|
469
|
+
|
|
470
|
+
# Log to debug file if enabled
|
|
471
|
+
self._log_debug_request(method, url, kwargs, response, response_time)
|
|
472
|
+
|
|
473
|
+
# Handle errors
|
|
474
|
+
if response.status_code == 401:
|
|
475
|
+
raise AuthenticationError("Invalid authentication token")
|
|
476
|
+
elif response.status_code == 403:
|
|
477
|
+
raise ForbiddenError("Access forbidden")
|
|
478
|
+
elif response.status_code == 404:
|
|
479
|
+
raise NotFoundError(f"Resource not found: {endpoint}")
|
|
480
|
+
elif response.status_code == 422:
|
|
481
|
+
error_data = response.json() if response.text else {}
|
|
482
|
+
raise ValidationError(f"Validation error: {error_data}")
|
|
483
|
+
elif response.status_code == 429:
|
|
484
|
+
# Rate limit exceeded
|
|
485
|
+
attempts += 1
|
|
486
|
+
if not self.enable_rate_limit_retry or attempts > self.max_retries:
|
|
487
|
+
raise RateLimitError(
|
|
488
|
+
f"Rate limit exceeded after {attempts-1} retries"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Parse rate limit headers for smart retry
|
|
492
|
+
suggested_delay = self._parse_rate_limit_headers(response)
|
|
493
|
+
if suggested_delay is not None:
|
|
494
|
+
delay = min(suggested_delay, self.max_retry_delay)
|
|
495
|
+
|
|
496
|
+
time.sleep(delay)
|
|
497
|
+
# Exponential backoff for next attempt
|
|
498
|
+
delay = min(delay * 2, self.max_retry_delay)
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
elif response.status_code >= 400:
|
|
502
|
+
raise KankaException(
|
|
503
|
+
f"API error {response.status_code}: {response.text}"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Success - return response
|
|
507
|
+
# Return empty dict for DELETE requests
|
|
508
|
+
if method == "DELETE":
|
|
509
|
+
return {}
|
|
510
|
+
|
|
511
|
+
return response.json() # type: ignore[no-any-return]
|
|
512
|
+
|
|
513
|
+
except RateLimitError as e:
|
|
514
|
+
last_exception = e
|
|
515
|
+
if attempts >= self.max_retries:
|
|
516
|
+
raise
|
|
517
|
+
|
|
518
|
+
# Should not reach here, but just in case
|
|
519
|
+
if last_exception:
|
|
520
|
+
raise last_exception
|
|
521
|
+
raise KankaException("Unexpected error in request retry logic")
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def last_search_meta(self) -> dict[str, Any]:
|
|
525
|
+
"""Get metadata from the last search() call.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Dict[str, Any]: Pagination metadata including current_page, from, to,
|
|
529
|
+
last_page, per_page, total
|
|
530
|
+
"""
|
|
531
|
+
return getattr(self, "_last_search_meta", {})
|
|
532
|
+
|
|
533
|
+
@property
|
|
534
|
+
def last_search_links(self) -> dict[str, Any]:
|
|
535
|
+
"""Get pagination links from the last search() call.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Dict[str, Any]: Links for pagination including first, last, prev, next URLs
|
|
539
|
+
"""
|
|
540
|
+
return getattr(self, "_last_search_links", {})
|