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.
- {python_kanka-2.4.0 → python_kanka-2.5.0}/.bumpversion.cfg +1 -1
- {python_kanka-2.4.0 → python_kanka-2.5.0}/.gitignore +2 -3
- {python_kanka-2.4.0 → python_kanka-2.5.0}/PKG-INFO +1 -1
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/api-reference.md +17 -3
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/assets-and-images.md +5 -2
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/pagination.md +8 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/pyproject.toml +1 -1
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/_version.py +1 -1
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/client.py +38 -2
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/managers.py +49 -7
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_assets_integration.py +156 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_client.py +77 -1
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_managers.py +176 -7
- {python_kanka-2.4.0 → python_kanka-2.5.0}/uv.lock +1 -1
- python_kanka-2.4.0/tests/integration/full_test_output_fixed.txt +0 -442
- {python_kanka-2.4.0 → python_kanka-2.5.0}/.github/renovate.json +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/.github/workflows/ci.yml +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/.github/workflows/publish.yml +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/.pre-commit-config.yaml +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/CHANGELOG.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/CLAUDE.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/LICENSE +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/MANIFEST.in +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/Makefile +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/PUBLISHING.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/README.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/README.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/core-concepts.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/debug-mode.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/entities.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/entity-types-reference.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/error-handling.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/gallery.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/getting-started.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/known-limitations.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/posts.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/rate-limiting.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/docs/search-and-filtering.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/pytest.ini +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/__init__.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/exceptions.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/__init__.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/base.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/common.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/models/entities.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/py.typed +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/src/kanka/types.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/__init__.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/README.md +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/__init__.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/base.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/run_integration_tests.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/setup_test_env.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_calendars_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_calendars_simple.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_characters_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_creatures_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entities_api_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_images_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_tags_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_events_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_families_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_gallery_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_journals_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_locations_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_mentions_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_notes_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_organisations_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_posts_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_quests_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_races_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_rate_limiting.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_search_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_tag_colour.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_tags_integration.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/mypy.ini +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_client_advanced.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_entity_models.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_exceptions.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_models.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/test_models_advanced.py +0 -0
- {python_kanka-2.4.0 → python_kanka-2.5.0}/tests/utils.py +0 -0
|
@@ -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
|
-
|
|
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 |
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
486
|
-
old_hash, old_asset = managed[
|
|
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
|
|
494
|
-
self.delete_asset(
|
|
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(
|
|
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
|
{python_kanka-2.4.0 → python_kanka-2.5.0}/tests/integration/test_entity_assets_integration.py
RENAMED
|
@@ -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."""
|