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 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
@@ -0,0 +1,3 @@
1
+ """Version information for python-kanka."""
2
+
3
+ __version__ = "2.0.0"
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", {})