python-kanka 2.4.0__tar.gz → 2.5.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 (82) hide show
  1. {python_kanka-2.4.0 → python_kanka-2.5.0}/.bumpversion.cfg +1 -1
  2. {python_kanka-2.4.0 → python_kanka-2.5.0}/.gitignore +2 -3
  3. {python_kanka-2.4.0 → python_kanka-2.5.0}/PKG-INFO +1 -1
  4. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/api-reference.md +17 -3
  5. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/assets-and-images.md +5 -2
  6. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/pagination.md +8 -0
  7. {python_kanka-2.4.0 → python_kanka-2.5.0}/pyproject.toml +1 -1
  8. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/_version.py +1 -1
  9. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/client.py +38 -2
  10. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/managers.py +49 -7
  11. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_assets_integration.py +156 -0
  12. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_client.py +77 -1
  13. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_managers.py +176 -7
  14. {python_kanka-2.4.0 → python_kanka-2.5.0}/uv.lock +1 -1
  15. python_kanka-2.4.0/tests/integration/full_test_output_fixed.txt +0 -442
  16. {python_kanka-2.4.0 → python_kanka-2.5.0}/.github/renovate.json +0 -0
  17. {python_kanka-2.4.0 → python_kanka-2.5.0}/.github/workflows/ci.yml +0 -0
  18. {python_kanka-2.4.0 → python_kanka-2.5.0}/.github/workflows/publish.yml +0 -0
  19. {python_kanka-2.4.0 → python_kanka-2.5.0}/.pre-commit-config.yaml +0 -0
  20. {python_kanka-2.4.0 → python_kanka-2.5.0}/CHANGELOG.md +0 -0
  21. {python_kanka-2.4.0 → python_kanka-2.5.0}/CLAUDE.md +0 -0
  22. {python_kanka-2.4.0 → python_kanka-2.5.0}/LICENSE +0 -0
  23. {python_kanka-2.4.0 → python_kanka-2.5.0}/MANIFEST.in +0 -0
  24. {python_kanka-2.4.0 → python_kanka-2.5.0}/Makefile +0 -0
  25. {python_kanka-2.4.0 → python_kanka-2.5.0}/PUBLISHING.md +0 -0
  26. {python_kanka-2.4.0 → python_kanka-2.5.0}/README.md +0 -0
  27. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/README.md +0 -0
  28. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/core-concepts.md +0 -0
  29. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/debug-mode.md +0 -0
  30. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/entities.md +0 -0
  31. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/entity-types-reference.md +0 -0
  32. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/error-handling.md +0 -0
  33. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/gallery.md +0 -0
  34. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/getting-started.md +0 -0
  35. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/known-limitations.md +0 -0
  36. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/posts.md +0 -0
  37. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/rate-limiting.md +0 -0
  38. {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/search-and-filtering.md +0 -0
  39. {python_kanka-2.4.0 → python_kanka-2.5.0}/pytest.ini +0 -0
  40. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/__init__.py +0 -0
  41. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/exceptions.py +0 -0
  42. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/__init__.py +0 -0
  43. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/base.py +0 -0
  44. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/common.py +0 -0
  45. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/entities.py +0 -0
  46. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/py.typed +0 -0
  47. {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/types.py +0 -0
  48. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/__init__.py +0 -0
  49. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/README.md +0 -0
  50. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/__init__.py +0 -0
  51. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/base.py +0 -0
  52. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/run_integration_tests.py +0 -0
  53. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/setup_test_env.py +0 -0
  54. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_calendars_integration.py +0 -0
  55. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_calendars_simple.py +0 -0
  56. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_characters_integration.py +0 -0
  57. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_creatures_integration.py +0 -0
  58. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entities_api_integration.py +0 -0
  59. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_images_integration.py +0 -0
  60. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_tags_integration.py +0 -0
  61. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_events_integration.py +0 -0
  62. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_families_integration.py +0 -0
  63. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_gallery_integration.py +0 -0
  64. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_journals_integration.py +0 -0
  65. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_locations_integration.py +0 -0
  66. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_mentions_integration.py +0 -0
  67. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_notes_integration.py +0 -0
  68. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_organisations_integration.py +0 -0
  69. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_posts_integration.py +0 -0
  70. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_quests_integration.py +0 -0
  71. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_races_integration.py +0 -0
  72. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_rate_limiting.py +0 -0
  73. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_search_integration.py +0 -0
  74. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_tag_colour.py +0 -0
  75. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_tags_integration.py +0 -0
  76. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/mypy.ini +0 -0
  77. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_client_advanced.py +0 -0
  78. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_entity_models.py +0 -0
  79. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_exceptions.py +0 -0
  80. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_models.py +0 -0
  81. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_models_advanced.py +0 -0
  82. {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/utils.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.4.0
2
+ current_version = 2.5.0
3
3
  commit = True
4
4
  tag = True
5
5
  tag_name = v{new_version}
@@ -171,8 +171,7 @@ Thumbs.db
171
171
  tests/integration/.env
172
172
  tests/integration/*.log
173
173
 
174
- # Test output files
175
- *_test_output.txt
176
- *_output.txt
174
+ # Test and debug output files
175
+ *_output*.txt
177
176
  test_api_response.py
178
177
  claude-notes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-kanka
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Python client for the Kanka API
5
5
  Project-URL: Homepage, https://github.com/ervwalter/python-kanka
6
6
  Project-URL: Documentation, https://github.com/ervwalter/python-kanka#readme
@@ -90,12 +90,21 @@ Get a single entity by its universal `entity_id`. Returns a raw dictionary.
90
90
  #### entities
91
91
 
92
92
  ```python
93
- entities(**filters) → list[dict[str, Any]]
93
+ entities(page: int = 1, limit: int = 15, **filters) → list[dict[str, Any]]
94
94
  ```
95
95
 
96
96
  Query the generic `/entities` endpoint. Returns raw dictionaries.
97
97
 
98
- **Filters:** `types` (list[str]), `name` (str), `tags` (list[int]), `is_private` (bool), `created_by` (int), `updated_by` (int)
98
+ | Parameter | Type | Default | Description |
99
+ |-----------|------|---------|-------------|
100
+ | `page` | `int` | `1` | Page number |
101
+ | `limit` | `int` | `15` | Results per page |
102
+ | `types` | `list[str]` | — | Filter by entity types |
103
+ | `name` | `str` | — | Filter by name |
104
+ | `tags` | `list[int]` | — | Filter by tag IDs |
105
+ | `is_private` | `bool` | — | Filter by privacy |
106
+ | `created_by` | `int` | — | Filter by creator ID |
107
+ | `updated_by` | `int` | — | Filter by updater ID |
99
108
 
100
109
  #### gallery
101
110
 
@@ -137,6 +146,9 @@ Delete a gallery image by UUID.
137
146
 
138
147
  | Property | Type | Description |
139
148
  |----------|------|-------------|
149
+ | `last_entities_meta` | `dict` | Pagination metadata from last `entities()` |
150
+ | `last_entities_links` | `dict` | Pagination links from last `entities()` |
151
+ | `entities_has_next_page` | `bool` | Whether next page exists for `entities()` |
140
152
  | `last_search_meta` | `dict` | Pagination metadata from last `search()` |
141
153
  | `last_search_links` | `dict` | Pagination links from last `search()` |
142
154
  | `last_gallery_meta` | `dict` | Pagination metadata from last `gallery()` |
@@ -388,9 +400,11 @@ create_alias_asset(
388
400
  #### delete_asset
389
401
 
390
402
  ```python
391
- delete_asset(entity_or_id: T | int, asset_id: int) → bool
403
+ delete_asset(entity_or_id: T | int, asset_id: int, *, delete_gallery_image: bool = False) → bool
392
404
  ```
393
405
 
406
+ When `delete_gallery_image=True`, also deletes the underlying campaign gallery image to prevent orphaned images from accumulating. The gallery image UUID is extracted from the asset's CDN URL.
407
+
394
408
  ### Image Methods
395
409
 
396
410
  All image methods accept an entity object or an `entity_id` (integer).
@@ -86,6 +86,9 @@ asset = client.characters.create_alias_asset(
86
86
 
87
87
  ```python
88
88
  client.characters.delete_asset(character, asset.id)
89
+
90
+ # Also delete the underlying gallery image to prevent orphans:
91
+ client.characters.delete_asset(character, asset.id, delete_gallery_image=True)
89
92
  ```
90
93
 
91
94
  ### EntityAsset Model Fields
@@ -204,8 +207,8 @@ The SDK uses a naming convention for assets it manages: `{name}:{sha256_first12}
204
207
  When you call `update()` or `update_post()` with `images`, the SDK compares file hashes:
205
208
 
206
209
  - **Unchanged files** (same SHA-256 hash) — reused without re-uploading
207
- - **Changed files** (different hash) — old asset deleted, new one uploaded
208
- - **Orphaned managed assets** (no longer referenced in `images` dict) — automatically cleaned up
210
+ - **Changed files** (different hash) — old asset deleted (along with its gallery image), new one uploaded
211
+ - **Orphaned managed assets** (no longer referenced in `images` dict) — automatically cleaned up (both entity asset and gallery image)
209
212
  - **Non-managed assets** (user-created assets without the hash suffix) — never touched
210
213
 
211
214
  ### Full Lifecycle Example
@@ -83,6 +83,14 @@ Each list endpoint stores its own pagination state:
83
83
  | `manager.last_assets_meta` | Metadata from last `list_assets()` |
84
84
  | `manager.last_assets_links` | Links from last `list_assets()` |
85
85
 
86
+ ### Entities
87
+
88
+ | Property | Description |
89
+ |----------|-------------|
90
+ | `client.last_entities_meta` | Metadata from last `entities()` |
91
+ | `client.last_entities_links` | Links from last `entities()` |
92
+ | `client.entities_has_next_page` | `True` if next page exists |
93
+
86
94
  ### Gallery
87
95
 
88
96
  | Property | Description |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-kanka"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "Python client for the Kanka API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """Version information for python-kanka."""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.5.0"
@@ -321,19 +321,29 @@ class KankaClient:
321
321
  response = self._request("GET", f"entities/{entity_id}")
322
322
  return response["data"] # type: ignore[no-any-return]
323
323
 
324
- def entities(self, **filters) -> list[dict[str, Any]]:
324
+ def entities(
325
+ self, page: int = 1, limit: int = 15, **filters
326
+ ) -> list[dict[str, Any]]:
325
327
  """Access the /entities endpoint with filters.
326
328
 
327
329
  This endpoint provides a unified way to query entities across all types
328
330
  with various filtering options.
329
331
 
330
332
  Args:
333
+ page: Page number (default: 1)
334
+ limit: Number of results per page (default: 15)
331
335
  **filters: Filter parameters like types, name, is_private, tags
332
336
 
333
337
  Returns:
334
338
  List of entity data
339
+
340
+ Note:
341
+ Pagination information is available after calling entities():
342
+ - client.last_entities_meta: Contains total count, current page, etc.
343
+ - client.last_entities_links: Contains URLs for first, last, prev, next
344
+ - client.entities_has_next_page: True if more pages are available
335
345
  """
336
- params: dict[str, int | str] = {}
346
+ params: dict[str, int | str] = {"page": page, "limit": limit}
337
347
 
338
348
  # Handle special filters
339
349
  if "types" in filters and isinstance(filters["types"], list):
@@ -355,6 +365,8 @@ class KankaClient:
355
365
  params[key] = filters[key]
356
366
 
357
367
  response = self._request("GET", "entities", params=params)
368
+ self._last_entities_meta = response.get("meta", {})
369
+ self._last_entities_links = response.get("links", {})
358
370
  return response["data"] # type: ignore[no-any-return]
359
371
 
360
372
  def _parse_rate_limit_headers(self, response) -> float | None:
@@ -692,3 +704,27 @@ class KankaClient:
692
704
  Dict[str, Any]: Links for pagination including first, last, prev, next URLs
693
705
  """
694
706
  return getattr(self, "_last_search_links", {})
707
+
708
+ @property
709
+ def last_entities_meta(self) -> dict[str, Any]:
710
+ """Get metadata from the last entities() call.
711
+
712
+ Returns:
713
+ Dict[str, Any]: Pagination metadata including current_page, from, to,
714
+ last_page, per_page, total
715
+ """
716
+ return getattr(self, "_last_entities_meta", {})
717
+
718
+ @property
719
+ def last_entities_links(self) -> dict[str, Any]:
720
+ """Get pagination links from the last entities() call.
721
+
722
+ Returns:
723
+ Dict[str, Any]: Links for pagination including first, last, prev, next URLs
724
+ """
725
+ return getattr(self, "_last_entities_links", {})
726
+
727
+ @property
728
+ def entities_has_next_page(self) -> bool:
729
+ """Check if more pages are available from the last entities() call."""
730
+ return bool(getattr(self, "_last_entities_links", {}).get("next"))
@@ -1,10 +1,12 @@
1
1
  """Entity managers for Kanka API."""
2
2
 
3
+ import contextlib
3
4
  import hashlib
4
5
  import re
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING, Any, List, TypeVar # noqa: UP035
7
8
 
9
+ from .exceptions import NotFoundError
8
10
  from .models.base import Entity, Post
9
11
  from .models.common import EntityAsset, EntityImageInfo
10
12
 
@@ -420,6 +422,22 @@ class EntityManager[T: Entity]:
420
422
  return m.group(1), m.group(2)
421
423
  return None
422
424
 
425
+ @staticmethod
426
+ def _extract_gallery_uuid(url: str | None) -> str | None:
427
+ """Extract gallery image UUID from a CDN URL.
428
+
429
+ The UUID is the filename stem in the URL path, e.g.
430
+ ``https://cdn-ugc.kanka.io/campaigns/123/a1b2-...-c3d4.webp``
431
+ yields ``a1b2-...-c3d4``.
432
+ """
433
+ if not url:
434
+ return None
435
+ m = re.search(
436
+ r"/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.\w+",
437
+ url,
438
+ )
439
+ return m.group(1) if m else None
440
+
423
441
  @staticmethod
424
442
  def _rewrite_image_srcs(html: str, url_map: dict[str, str]) -> str:
425
443
  """Rewrite <img src="key"> tags, replacing keys with CDN URLs."""
@@ -480,18 +498,22 @@ class EntityManager[T: Entity]:
480
498
  for placeholder, file_path in images.items():
481
499
  file_path = Path(file_path)
482
500
  file_hash = self._compute_file_hash(file_path)
483
- used_names.add(placeholder)
501
+ # Truncate to match what _format_managed_asset_name stores
502
+ lookup_name = placeholder[:32]
503
+ used_names.add(lookup_name)
484
504
 
485
- if placeholder in managed:
486
- old_hash, old_asset = managed[placeholder]
505
+ if lookup_name in managed:
506
+ old_hash, old_asset = managed[lookup_name]
487
507
  if old_hash == file_hash:
488
508
  # Same file, reuse URL
489
509
  if old_asset.url:
490
510
  url_map[placeholder] = old_asset.url
491
511
  continue
492
512
  else:
493
- # Changed file, delete old and upload new
494
- self.delete_asset(entity_id, old_asset.id)
513
+ # Changed file, delete old asset and its gallery image
514
+ self.delete_asset(
515
+ entity_id, old_asset.id, delete_gallery_image=True
516
+ )
495
517
 
496
518
  managed_name = self._format_managed_asset_name(placeholder, file_hash)
497
519
  asset = self.create_file_asset(entity_id, file_path, name=managed_name)
@@ -501,7 +523,7 @@ class EntityManager[T: Entity]:
501
523
  # Orphan cleanup: delete managed assets not in current images dict
502
524
  for name_part, (_, old_asset) in managed.items():
503
525
  if name_part not in used_names:
504
- self.delete_asset(entity_id, old_asset.id)
526
+ self.delete_asset(entity_id, old_asset.id, delete_gallery_image=True)
505
527
 
506
528
  if url_map:
507
529
  return self._rewrite_image_srcs(entry, url_map)
@@ -860,19 +882,39 @@ class EntityManager[T: Entity]:
860
882
  response = self.client._request("POST", endpoint, json=data)
861
883
  return EntityAsset(**response["data"])
862
884
 
863
- def delete_asset(self, entity_or_id: T | int, asset_id: int) -> bool:
885
+ def delete_asset(
886
+ self,
887
+ entity_or_id: T | int,
888
+ asset_id: int,
889
+ *,
890
+ delete_gallery_image: bool = False,
891
+ ) -> bool:
864
892
  """Delete an asset from an entity.
865
893
 
866
894
  Args:
867
895
  entity_or_id: The entity or its entity_id
868
896
  asset_id: The asset ID
897
+ delete_gallery_image: If True, also delete the underlying gallery
898
+ image. This prevents orphaned images from accumulating in the
899
+ campaign gallery.
869
900
 
870
901
  Returns:
871
902
  True if successful
872
903
  """
873
904
  entity_id = self._extract_entity_id(entity_or_id)
905
+
906
+ gallery_uuid: str | None = None
907
+ if delete_gallery_image:
908
+ asset = self.get_asset(entity_id, asset_id)
909
+ gallery_uuid = self._extract_gallery_uuid(asset.url)
910
+
874
911
  url = f"entities/{entity_id}/entity_assets/{asset_id}"
875
912
  self.client._request("DELETE", url)
913
+
914
+ if gallery_uuid:
915
+ with contextlib.suppress(NotFoundError):
916
+ self.client.gallery_delete(gallery_uuid)
917
+
876
918
  return True
877
919
 
878
920
  @property
@@ -55,6 +55,22 @@ class TestEntityAssetIntegration(IntegrationTestBase):
55
55
  f"Delete character '{name}' (ID: {character_id})", cleanup
56
56
  )
57
57
 
58
+ def _register_gallery_cleanup(self, asset_url: str | None):
59
+ """Register gallery image cleanup from an asset's CDN URL."""
60
+ from kanka.exceptions import NotFoundError
61
+ from kanka.managers import EntityManager
62
+
63
+ gallery_uuid = EntityManager._extract_gallery_uuid(asset_url)
64
+ if not gallery_uuid:
65
+ return
66
+
67
+ def cleanup():
68
+ if self.client:
69
+ with contextlib.suppress(NotFoundError):
70
+ self.client.gallery_delete(gallery_uuid)
71
+
72
+ self.register_cleanup(f"Delete gallery image '{gallery_uuid}'", cleanup)
73
+
58
74
  def test_create_file_asset(self):
59
75
  """Test creating a file asset on an entity."""
60
76
  character = self.client.characters.create(
@@ -68,6 +84,7 @@ class TestEntityAssetIntegration(IntegrationTestBase):
68
84
  asset = self.client.characters.create_file_asset(
69
85
  character, image_path, name="test-file-asset"
70
86
  )
87
+ self._register_gallery_cleanup(asset.url)
71
88
 
72
89
  self.assert_not_none(asset.id, "Asset ID should not be None")
73
90
  self.assert_equal(asset.type_id, 1, "File asset type_id should be 1")
@@ -131,6 +148,7 @@ class TestEntityAssetIntegration(IntegrationTestBase):
131
148
  file_asset = self.client.characters.create_file_asset(
132
149
  character, image_path, name="list-test-file"
133
150
  )
151
+ self._register_gallery_cleanup(file_asset.url)
134
152
 
135
153
  self.wait_for_api()
136
154
 
@@ -177,6 +195,7 @@ class TestEntityAssetIntegration(IntegrationTestBase):
177
195
  created = self.client.characters.create_file_asset(
178
196
  character, image_path, name="get-test-asset"
179
197
  )
198
+ self._register_gallery_cleanup(created.url)
180
199
 
181
200
  self.wait_for_api()
182
201
 
@@ -202,6 +221,7 @@ class TestEntityAssetIntegration(IntegrationTestBase):
202
221
  character, image_path, name="delete-test-asset"
203
222
  )
204
223
  asset_id = asset.id
224
+ self._register_gallery_cleanup(asset.url)
205
225
 
206
226
  self.wait_for_api()
207
227
 
@@ -220,6 +240,134 @@ class TestEntityAssetIntegration(IntegrationTestBase):
220
240
 
221
241
  print(f" Deleted asset {asset_id}")
222
242
 
243
+ def test_delete_asset_with_gallery_cleanup(self):
244
+ """Test deleting an asset also deletes the gallery image."""
245
+ from kanka.managers import EntityManager
246
+
247
+ character = self.client.characters.create(
248
+ name=f"Integration Test Assets - DELETE ME - {datetime.now().isoformat()}"
249
+ )
250
+ self._register_character_cleanup(character.id, character.name)
251
+
252
+ self.wait_for_api()
253
+
254
+ image_path = self._create_test_image()
255
+ asset = self.client.characters.create_file_asset(
256
+ character, image_path, name="gallery-cleanup-test"
257
+ )
258
+ asset_id = asset.id
259
+ asset_url = asset.url
260
+ self.assert_not_none(asset_url, "Asset should have a CDN URL")
261
+
262
+ # Extract gallery UUID from the URL
263
+ gallery_uuid = EntityManager._extract_gallery_uuid(asset_url)
264
+ print(f" Created asset {asset_id} with gallery UUID: {gallery_uuid}")
265
+
266
+ if gallery_uuid:
267
+ # Verify gallery image exists before deletion
268
+ self.wait_for_api()
269
+ gallery_image = self.client.gallery_get(gallery_uuid)
270
+ self.assert_not_none(
271
+ gallery_image, "Gallery image should exist before delete"
272
+ )
273
+ print(f" Gallery image confirmed: {gallery_image.id}")
274
+
275
+ self.wait_for_api()
276
+
277
+ # Delete with gallery cleanup
278
+ result = self.client.characters.delete_asset(
279
+ character, asset_id, delete_gallery_image=True
280
+ )
281
+ self.assert_true(result, "delete_asset should return True")
282
+
283
+ self.wait_for_api()
284
+
285
+ # Verify the entity asset is gone
286
+ assets = self.client.characters.list_assets(character)
287
+ asset_ids = {a.id for a in assets}
288
+ self.assert_true(
289
+ asset_id not in asset_ids,
290
+ f"Deleted asset {asset_id} should not appear in list",
291
+ )
292
+
293
+ # Verify gallery image is also gone
294
+ if gallery_uuid:
295
+ from kanka.exceptions import NotFoundError
296
+
297
+ try:
298
+ self.client.gallery_get(gallery_uuid)
299
+ self.assert_true(False, "Gallery image should have been deleted")
300
+ except NotFoundError:
301
+ pass # Expected
302
+
303
+ print(f" Deleted asset {asset_id} and gallery image {gallery_uuid}")
304
+
305
+ def test_managed_image_update_cleans_gallery(self):
306
+ """Test that updating managed images cleans up gallery orphans."""
307
+ from kanka.managers import EntityManager
308
+
309
+ character = self.client.characters.create(
310
+ name=f"Integration Test Assets - DELETE ME - {datetime.now().isoformat()}"
311
+ )
312
+ self._register_character_cleanup(character.id, character.name)
313
+
314
+ self.wait_for_api()
315
+
316
+ # Create with an image
317
+ image_path_1 = self._create_test_image(prefix="managed_v1")
318
+ character = self.client.characters.update(
319
+ character,
320
+ entry='<p><img src="test.png" /> Hello</p>',
321
+ images={"test.png": image_path_1},
322
+ )
323
+
324
+ self.wait_for_api()
325
+
326
+ # Find the managed asset and its gallery UUID
327
+ assets = self.client.characters.list_assets(character)
328
+ managed = [a for a in assets if EntityManager._parse_managed_asset_name(a.name)]
329
+ self.assert_equal(len(managed), 1, "Should have 1 managed asset")
330
+ old_uuid = EntityManager._extract_gallery_uuid(managed[0].url)
331
+ print(f" First image gallery UUID: {old_uuid}")
332
+
333
+ self.wait_for_api()
334
+
335
+ # Update with a different image (write different bytes)
336
+ image_path_2 = self._create_test_image(prefix="managed_v2")
337
+ # Write different content to ensure different hash
338
+ with open(image_path_2, "ab") as f:
339
+ f.write(b"extra bytes to change hash")
340
+
341
+ character = self.client.characters.update(
342
+ character,
343
+ entry='<p><img src="test.png" /> Updated</p>',
344
+ images={"test.png": image_path_2},
345
+ )
346
+
347
+ self.wait_for_api()
348
+
349
+ # Verify old gallery image is cleaned up
350
+ if old_uuid:
351
+ from kanka.exceptions import NotFoundError
352
+
353
+ try:
354
+ self.client.gallery_get(old_uuid)
355
+ self.assert_true(False, "Old gallery image should have been deleted")
356
+ except NotFoundError:
357
+ pass # Expected
358
+
359
+ # Verify new asset exists
360
+ assets = self.client.characters.list_assets(character)
361
+ managed = [a for a in assets if EntityManager._parse_managed_asset_name(a.name)]
362
+ self.assert_equal(len(managed), 1, "Should still have 1 managed asset")
363
+ new_uuid = EntityManager._extract_gallery_uuid(managed[0].url)
364
+ self.assert_true(
365
+ new_uuid != old_uuid, "New gallery UUID should differ from old"
366
+ )
367
+
368
+ print(f" New image gallery UUID: {new_uuid}")
369
+ print(" Old gallery image was properly cleaned up")
370
+
223
371
  def run_all_tests(self):
224
372
  """Run all entity asset integration tests."""
225
373
  tests = [
@@ -229,6 +377,14 @@ class TestEntityAssetIntegration(IntegrationTestBase):
229
377
  ("Entity Asset - List", self.test_list_assets),
230
378
  ("Entity Asset - Get", self.test_get_asset),
231
379
  ("Entity Asset - Delete", self.test_delete_asset),
380
+ (
381
+ "Entity Asset - Delete with Gallery Cleanup",
382
+ self.test_delete_asset_with_gallery_cleanup,
383
+ ),
384
+ (
385
+ "Entity Asset - Managed Image Update Cleans Gallery",
386
+ self.test_managed_image_update_cleans_gallery,
387
+ ),
232
388
  ]
233
389
 
234
390
  results = []
@@ -151,7 +151,7 @@ class TestKankaClient:
151
151
  mock_session.request.assert_called_with(
152
152
  "GET",
153
153
  "https://api.kanka.io/1.0/campaigns/123/entities",
154
- params={"types": "character,location"},
154
+ params={"page": 1, "limit": 15, "types": "character,location"},
155
155
  )
156
156
 
157
157
  assert len(results) == 2
@@ -181,6 +181,8 @@ class TestKankaClient:
181
181
  "GET",
182
182
  "https://api.kanka.io/1.0/campaigns/123/entities",
183
183
  params={
184
+ "page": 1,
185
+ "limit": 15,
184
186
  "types": "character",
185
187
  "tags": "1,2,3",
186
188
  "name": "test",
@@ -189,6 +191,80 @@ class TestKankaClient:
189
191
  },
190
192
  )
191
193
 
194
+ @patch("requests.Session")
195
+ def test_entities_pagination(self, mock_session_class):
196
+ """Test entities endpoint pagination parameters and metadata."""
197
+ mock_session = MagicMock()
198
+ mock_session_class.return_value = mock_session
199
+
200
+ meta = {
201
+ "total": 50,
202
+ "current_page": 2,
203
+ "per_page": 15,
204
+ "last_page": 4,
205
+ "from": 16,
206
+ "to": 30,
207
+ }
208
+ links = {
209
+ "first": "https://api.kanka.io/1.0/campaigns/123/entities?page=1",
210
+ "last": "https://api.kanka.io/1.0/campaigns/123/entities?page=4",
211
+ "prev": "https://api.kanka.io/1.0/campaigns/123/entities?page=1",
212
+ "next": "https://api.kanka.io/1.0/campaigns/123/entities?page=3",
213
+ }
214
+ mock_response = MockResponse(
215
+ create_api_response(
216
+ [{"id": 1, "name": "Test", "type": "character"}],
217
+ meta=meta,
218
+ links=links,
219
+ ),
220
+ status_code=200,
221
+ )
222
+ mock_session.request.return_value = mock_response
223
+
224
+ client = KankaClient(token="test_token", campaign_id=123)
225
+ results = client.entities(page=2, limit=15, types=["character"])
226
+
227
+ mock_session.request.assert_called_with(
228
+ "GET",
229
+ "https://api.kanka.io/1.0/campaigns/123/entities",
230
+ params={"page": 2, "limit": 15, "types": "character"},
231
+ )
232
+
233
+ assert len(results) == 1
234
+ assert client.last_entities_meta["total"] == 50
235
+ assert client.last_entities_meta["current_page"] == 2
236
+ assert client.last_entities_links["next"] is not None
237
+ assert client.entities_has_next_page is True
238
+
239
+ @patch("requests.Session")
240
+ def test_entities_no_next_page(self, mock_session_class):
241
+ """Test entities_has_next_page is False on last page."""
242
+ mock_session = MagicMock()
243
+ mock_session_class.return_value = mock_session
244
+
245
+ mock_response = MockResponse(
246
+ create_api_response([{"id": 1, "name": "Test", "type": "character"}]),
247
+ status_code=200,
248
+ )
249
+ mock_session.request.return_value = mock_response
250
+
251
+ client = KankaClient(token="test_token", campaign_id=123)
252
+ client.entities()
253
+
254
+ assert client.entities_has_next_page is False
255
+
256
+ @patch("requests.Session")
257
+ def test_entities_pagination_defaults(self, mock_session_class):
258
+ """Test entities pagination properties before any call."""
259
+ mock_session = MagicMock()
260
+ mock_session_class.return_value = mock_session
261
+
262
+ client = KankaClient(token="test_token", campaign_id=123)
263
+
264
+ assert client.last_entities_meta == {}
265
+ assert client.last_entities_links == {}
266
+ assert client.entities_has_next_page is False
267
+
192
268
  @patch("requests.Session")
193
269
  def test_request_error_handling(self, mock_session_class):
194
270
  """Test error handling in _request method."""