python-kanka 2.5.0__tar.gz → 2.6.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.5.0 → python_kanka-2.6.0}/.bumpversion.cfg +1 -1
- {python_kanka-2.5.0 → python_kanka-2.6.0}/.github/workflows/publish.yml +2 -2
- {python_kanka-2.5.0 → python_kanka-2.6.0}/CLAUDE.md +41 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/PKG-INFO +1 -1
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/README.md +1 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/api-reference.md +12 -2
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/entities.md +7 -5
- python_kanka-2.6.0/docs/last-sync.md +132 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/search-and-filtering.md +2 -9
- {python_kanka-2.5.0 → python_kanka-2.6.0}/pyproject.toml +1 -1
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/_version.py +1 -1
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/client.py +40 -3
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/managers.py +31 -3
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entities_api_integration.py +136 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_client.py +90 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_managers.py +54 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/utils.py +10 -1
- {python_kanka-2.5.0 → python_kanka-2.6.0}/uv.lock +1 -1
- {python_kanka-2.5.0 → python_kanka-2.6.0}/.github/renovate.json +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/.github/workflows/ci.yml +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/.gitignore +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/.pre-commit-config.yaml +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/CHANGELOG.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/LICENSE +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/MANIFEST.in +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/Makefile +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/PUBLISHING.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/README.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/assets-and-images.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/core-concepts.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/debug-mode.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/entity-types-reference.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/error-handling.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/gallery.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/getting-started.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/known-limitations.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/pagination.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/posts.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/rate-limiting.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/pytest.ini +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/__init__.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/exceptions.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/__init__.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/base.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/common.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/entities.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/py.typed +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/types.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/__init__.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/README.md +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/__init__.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/base.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/run_integration_tests.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/setup_test_env.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_calendars_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_calendars_simple.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_characters_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_creatures_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_assets_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_images_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_tags_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_events_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_families_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_gallery_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_journals_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_locations_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_mentions_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_notes_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_organisations_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_posts_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_quests_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_races_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_rate_limiting.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_search_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_tag_colour.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_tags_integration.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/mypy.ini +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_client_advanced.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_entity_models.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_exceptions.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_models.py +0 -0
- {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_models_advanced.py +0 -0
|
@@ -56,7 +56,7 @@ jobs:
|
|
|
56
56
|
uv build
|
|
57
57
|
|
|
58
58
|
- name: Upload artifacts
|
|
59
|
-
uses: actions/upload-artifact@
|
|
59
|
+
uses: actions/upload-artifact@v6
|
|
60
60
|
with:
|
|
61
61
|
name: dist
|
|
62
62
|
path: dist/
|
|
@@ -71,7 +71,7 @@ jobs:
|
|
|
71
71
|
|
|
72
72
|
steps:
|
|
73
73
|
- name: Download artifacts
|
|
74
|
-
uses: actions/download-artifact@
|
|
74
|
+
uses: actions/download-artifact@v7
|
|
75
75
|
with:
|
|
76
76
|
name: dist
|
|
77
77
|
path: dist/
|
|
@@ -142,6 +142,46 @@ Integration tests are NOT pytest tests - they have custom runners:
|
|
|
142
142
|
- **Available in Kanka API but not yet implemented**: Timeline, Item, Relation, DiceRoll, Conversation, AttributeTemplate, Bookmark, Ability, Map, Inventory
|
|
143
143
|
- **Never existed/removed**: EntityNote, EntityEvent, Attribute, Species
|
|
144
144
|
|
|
145
|
+
## Version Bumping
|
|
146
|
+
|
|
147
|
+
This project uses `bump2version` for version management. The version is tracked in three files:
|
|
148
|
+
- `pyproject.toml` — package version
|
|
149
|
+
- `src/kanka/_version.py` — runtime version
|
|
150
|
+
- `.bumpversion.cfg` — bump2version's own tracking (must stay in sync)
|
|
151
|
+
|
|
152
|
+
**To bump the version:**
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# Patch (bug fixes): 2.4.1 → 2.4.2
|
|
156
|
+
uv run bump2version patch
|
|
157
|
+
|
|
158
|
+
# Minor (new features): 2.4.1 → 2.5.0
|
|
159
|
+
uv run bump2version minor
|
|
160
|
+
|
|
161
|
+
# Major (breaking changes): 2.4.1 → 3.0.0
|
|
162
|
+
uv run bump2version major
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This automatically:
|
|
166
|
+
1. Updates the version in `pyproject.toml`, `src/kanka/_version.py`, and `.bumpversion.cfg`
|
|
167
|
+
2. Creates a commit with message `Bump version: X.Y.Z → A.B.C`
|
|
168
|
+
3. Creates a git tag `vA.B.C`
|
|
169
|
+
|
|
170
|
+
**After bumping, always run `uv sync`** to update `uv.lock`, then amend the lock file into the bump commit and retag:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
uv sync
|
|
174
|
+
git add uv.lock
|
|
175
|
+
git commit --amend --no-edit
|
|
176
|
+
git tag -d vA.B.C
|
|
177
|
+
git tag vA.B.C
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Important notes:**
|
|
181
|
+
- The working tree must be clean before running bump2version (use `--allow-dirty` only if `.bumpversion.cfg` is the dirty file due to a sync fix)
|
|
182
|
+
- If `.bumpversion.cfg`'s `current_version` is out of sync with the actual version in `pyproject.toml`, fix it manually before bumping
|
|
183
|
+
- Use `feat` commits → minor bump, `fix` commits → patch bump
|
|
184
|
+
|
|
145
185
|
## Development Preferences
|
|
146
186
|
|
|
147
187
|
- When executing test scripts with long output, redirect to file for parsing
|
|
@@ -204,6 +244,7 @@ When testing against the real API:
|
|
|
204
244
|
- `docs/assets-and-images.md` — Assets, images, automatic image management
|
|
205
245
|
- `docs/gallery.md` — Campaign gallery
|
|
206
246
|
- `docs/search-and-filtering.md` — Search and filtering
|
|
247
|
+
- `docs/last-sync.md` — Incremental sync using lastSync/sync
|
|
207
248
|
- `docs/pagination.md` — Pagination
|
|
208
249
|
- `docs/error-handling.md` — Exception types and patterns
|
|
209
250
|
- `docs/rate-limiting.md` — Rate limit configuration
|
|
@@ -12,6 +12,7 @@ A modern Python client for the [Kanka API](https://app.kanka.io/api-docs/1.0), t
|
|
|
12
12
|
| [Entity Types Reference](entity-types-reference.md) | All 12 entity types with their specific fields |
|
|
13
13
|
| [Posts](posts.md) | Attach notes and comments to any entity |
|
|
14
14
|
| [Search and Filtering](search-and-filtering.md) | Global search, entities endpoint, list filters |
|
|
15
|
+
| [Last Sync](last-sync.md) | Incremental sync using the lastSync/sync mechanism |
|
|
15
16
|
| [Pagination](pagination.md) | Navigate large result sets |
|
|
16
17
|
| [Assets and Images](assets-and-images.md) | File/link/alias assets, entity images, automatic image management |
|
|
17
18
|
| [Campaign Gallery](gallery.md) | Campaign-level image storage |
|
|
@@ -90,7 +90,12 @@ Get a single entity by its universal `entity_id`. Returns a raw dictionary.
|
|
|
90
90
|
#### entities
|
|
91
91
|
|
|
92
92
|
```python
|
|
93
|
-
entities(
|
|
93
|
+
entities(
|
|
94
|
+
page: int = 1,
|
|
95
|
+
limit: int = 15,
|
|
96
|
+
last_sync: str | None = None,
|
|
97
|
+
**filters,
|
|
98
|
+
) → list[dict[str, Any]]
|
|
94
99
|
```
|
|
95
100
|
|
|
96
101
|
Query the generic `/entities` endpoint. Returns raw dictionaries.
|
|
@@ -99,6 +104,7 @@ Query the generic `/entities` endpoint. Returns raw dictionaries.
|
|
|
99
104
|
|-----------|------|---------|-------------|
|
|
100
105
|
| `page` | `int` | `1` | Page number |
|
|
101
106
|
| `limit` | `int` | `15` | Results per page |
|
|
107
|
+
| `last_sync` | `str \| None` | `None` | Only return entities modified after this ISO 8601 timestamp. See [Last Sync](last-sync.md). |
|
|
102
108
|
| `types` | `list[str]` | — | Filter by entity types |
|
|
103
109
|
| `name` | `str` | — | Filter by name |
|
|
104
110
|
| `tags` | `list[int]` | — | Filter by tag IDs |
|
|
@@ -149,8 +155,10 @@ Delete a gallery image by UUID.
|
|
|
149
155
|
| `last_entities_meta` | `dict` | Pagination metadata from last `entities()` |
|
|
150
156
|
| `last_entities_links` | `dict` | Pagination links from last `entities()` |
|
|
151
157
|
| `entities_has_next_page` | `bool` | Whether next page exists for `entities()` |
|
|
158
|
+
| `last_entities_sync` | `str \| None` | Sync timestamp from last `entities()` call. See [Last Sync](last-sync.md). |
|
|
152
159
|
| `last_search_meta` | `dict` | Pagination metadata from last `search()` |
|
|
153
160
|
| `last_search_links` | `dict` | Pagination links from last `search()` |
|
|
161
|
+
| `last_search_sync` | `str \| None` | Sync timestamp from last `search()` call |
|
|
154
162
|
| `last_gallery_meta` | `dict` | Pagination metadata from last `gallery()` |
|
|
155
163
|
| `last_gallery_links` | `dict` | Pagination links from last `gallery()` |
|
|
156
164
|
|
|
@@ -203,6 +211,7 @@ list(
|
|
|
203
211
|
page: int = 1,
|
|
204
212
|
limit: int = 30,
|
|
205
213
|
related: bool = False,
|
|
214
|
+
last_sync: str | None = None,
|
|
206
215
|
**filters,
|
|
207
216
|
) → list[T]
|
|
208
217
|
```
|
|
@@ -214,6 +223,7 @@ List entities with optional filtering.
|
|
|
214
223
|
| `page` | `int` | `1` | Page number |
|
|
215
224
|
| `limit` | `int` | `30` | Results per page |
|
|
216
225
|
| `related` | `bool` | `False` | Include posts and attributes |
|
|
226
|
+
| `last_sync` | `str \| None` | `None` | Only return entities modified after this ISO 8601 timestamp. See [Last Sync](last-sync.md). |
|
|
217
227
|
| `name` | `str` | — | Filter by name (partial match) |
|
|
218
228
|
| `type` | `str` | — | Filter by entity type |
|
|
219
229
|
| `types` | `list[str]` | — | Filter by multiple types |
|
|
@@ -223,7 +233,6 @@ List entities with optional filtering.
|
|
|
223
233
|
| `updated_at` | `str` | — | Filter by update date |
|
|
224
234
|
| `created_by` | `int` | — | Filter by creator ID |
|
|
225
235
|
| `updated_by` | `int` | — | Filter by updater ID |
|
|
226
|
-
| `lastSync` | `str` | — | Only entities modified after this ISO 8601 timestamp |
|
|
227
236
|
|
|
228
237
|
**Returns:** List of entity model instances
|
|
229
238
|
|
|
@@ -438,6 +447,7 @@ delete_image(entity_or_id: T | int, is_header: bool = False) → bool
|
|
|
438
447
|
| `pagination_meta` | `dict` | Metadata from last `list()` |
|
|
439
448
|
| `pagination_links` | `dict` | Links from last `list()` |
|
|
440
449
|
| `has_next_page` | `bool` | Whether next page exists |
|
|
450
|
+
| `last_sync` | `str \| None` | Sync timestamp from last `list()` call. See [Last Sync](last-sync.md). |
|
|
441
451
|
| `last_page_meta` | `dict` | Alias for `pagination_meta` |
|
|
442
452
|
| `last_page_links` | `dict` | Alias for `pagination_links` |
|
|
443
453
|
| `last_posts_meta` | `dict` | Metadata from last `list_posts()` |
|
|
@@ -72,15 +72,17 @@ results = client.characters.list(
|
|
|
72
72
|
)
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
### The `
|
|
75
|
+
### The `last_sync` Parameter
|
|
76
76
|
|
|
77
|
-
Use the `
|
|
77
|
+
Use the `last_sync` parameter with an ISO 8601 timestamp to retrieve only entities modified after a specific time. This is useful for keeping a local cache in sync with Kanka without re-fetching everything. See [Last Sync](last-sync.md) for a complete guide including pagination, persistence, and important caveats.
|
|
78
78
|
|
|
79
79
|
```python
|
|
80
|
-
|
|
80
|
+
# Fetch all characters, capturing the sync timestamp
|
|
81
|
+
characters = client.characters.list(page=1, limit=100)
|
|
82
|
+
sync_timestamp = client.characters.last_sync
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
updated = client.characters.list(
|
|
84
|
+
# Later, fetch only characters that changed since then
|
|
85
|
+
updated = client.characters.list(last_sync=sync_timestamp)
|
|
84
86
|
```
|
|
85
87
|
|
|
86
88
|
### Iterating All Pages
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Last Sync
|
|
2
|
+
|
|
3
|
+
The Kanka API provides a synchronization mechanism that lets you efficiently fetch only entities that have changed since your last request. This avoids repeatedly downloading your entire campaign and is essential for applications that need to keep a local cache or database in sync with Kanka.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
Every list response from the Kanka API includes a top-level `sync` timestamp. This timestamp represents the exact moment the server processed your request. To get only entities that changed since then, pass that timestamp back as the `last_sync` parameter on your next request. The API returns only entities created or modified after that timestamp.
|
|
8
|
+
|
|
9
|
+
The workflow is:
|
|
10
|
+
|
|
11
|
+
1. **Initial fetch** — Call the API without `last_sync`. Capture the sync timestamp from the response.
|
|
12
|
+
2. **Subsequent fetches** — Call the API with `last_sync` set to the previously captured value. Only modified entities are returned. Capture the new sync timestamp for next time.
|
|
13
|
+
3. **Repeat** — Each call gives you a new sync timestamp to use on the next call.
|
|
14
|
+
|
|
15
|
+
## Using last_sync with Entity Managers
|
|
16
|
+
|
|
17
|
+
The `last_sync` parameter is a named parameter on all entity manager `list()` calls. After each call, the sync timestamp is available via the `last_sync` property.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from kanka import KankaClient
|
|
21
|
+
|
|
22
|
+
client = KankaClient(token="your-token", campaign_id=12345)
|
|
23
|
+
|
|
24
|
+
# Initial fetch — get all characters
|
|
25
|
+
characters = client.characters.list(page=1, limit=100)
|
|
26
|
+
sync_timestamp = client.characters.last_sync
|
|
27
|
+
print(f"Fetched {len(characters)} characters")
|
|
28
|
+
print(f"Sync timestamp: {sync_timestamp}")
|
|
29
|
+
|
|
30
|
+
# ... later, fetch only characters that changed ...
|
|
31
|
+
updated = client.characters.list(last_sync=sync_timestamp)
|
|
32
|
+
new_sync = client.characters.last_sync
|
|
33
|
+
print(f"{len(updated)} characters changed since last sync")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Using last_sync with the Generic Entities Endpoint
|
|
37
|
+
|
|
38
|
+
The generic `client.entities()` endpoint also supports `last_sync` as a named parameter. This is useful when you want to sync all entity types at once rather than syncing each type separately. The sync timestamp is available via the `last_entities_sync` property.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# Initial fetch — get all entities
|
|
42
|
+
entities = client.entities(page=1, limit=100)
|
|
43
|
+
sync_timestamp = client.last_entities_sync
|
|
44
|
+
|
|
45
|
+
# ... later, fetch only entities that changed ...
|
|
46
|
+
updated = client.entities(last_sync=sync_timestamp)
|
|
47
|
+
new_sync = client.last_entities_sync
|
|
48
|
+
print(f"{len(updated)} entities changed since last sync")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Paginating with last_sync
|
|
52
|
+
|
|
53
|
+
When your campaign has more entities than fit in a single page, you need to paginate through all pages on both the initial fetch and on subsequent syncs. Use the same `last_sync` value across all pages of a single sync operation, and capture the sync timestamp from the **first** page to use for the next sync.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
def sync_all_characters(client, last_sync=None):
|
|
57
|
+
"""Fetch all characters, optionally only those changed since last_sync."""
|
|
58
|
+
all_characters = []
|
|
59
|
+
page = 1
|
|
60
|
+
next_sync = None
|
|
61
|
+
|
|
62
|
+
while True:
|
|
63
|
+
batch = client.characters.list(
|
|
64
|
+
page=page, limit=100, last_sync=last_sync
|
|
65
|
+
)
|
|
66
|
+
all_characters.extend(batch)
|
|
67
|
+
|
|
68
|
+
# Capture sync timestamp from the first page
|
|
69
|
+
if page == 1:
|
|
70
|
+
next_sync = client.characters.last_sync
|
|
71
|
+
|
|
72
|
+
if not client.characters.has_next_page:
|
|
73
|
+
break
|
|
74
|
+
page += 1
|
|
75
|
+
|
|
76
|
+
return all_characters, next_sync
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Initial full sync
|
|
80
|
+
characters, sync_timestamp = sync_all_characters(client)
|
|
81
|
+
print(f"Full sync: {len(characters)} characters")
|
|
82
|
+
|
|
83
|
+
# ... time passes, entities are modified in Kanka ...
|
|
84
|
+
|
|
85
|
+
# Incremental sync — only fetches what changed
|
|
86
|
+
changed, sync_timestamp = sync_all_characters(client, last_sync=sync_timestamp)
|
|
87
|
+
print(f"Incremental sync: {len(changed)} characters changed")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Persisting the Sync Timestamp
|
|
91
|
+
|
|
92
|
+
For the sync pattern to work across application restarts, you need to persist the sync timestamp. How you store it depends on your application — a file, database, or configuration store all work:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import json
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
SYNC_FILE = Path("kanka_sync_state.json")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_sync_state():
|
|
102
|
+
"""Load the last sync timestamp from disk."""
|
|
103
|
+
if SYNC_FILE.exists():
|
|
104
|
+
return json.loads(SYNC_FILE.read_text())
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_sync_state(state):
|
|
109
|
+
"""Save the sync timestamp to disk."""
|
|
110
|
+
SYNC_FILE.write_text(json.dumps(state, indent=2))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Load previous state
|
|
114
|
+
state = load_sync_state()
|
|
115
|
+
|
|
116
|
+
# Sync characters
|
|
117
|
+
characters, new_sync = sync_all_characters(
|
|
118
|
+
client, last_sync=state.get("characters_sync")
|
|
119
|
+
)
|
|
120
|
+
state["characters_sync"] = new_sync
|
|
121
|
+
|
|
122
|
+
# Save state for next run
|
|
123
|
+
save_sync_state(state)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Important Notes
|
|
127
|
+
|
|
128
|
+
- **The sync timestamp is server-generated.** Always use the value from the API response rather than generating your own timestamp. This avoids clock skew issues between your machine and the Kanka server.
|
|
129
|
+
- **`last_sync` filters by modification time.** It returns entities that were created or updated after the given timestamp. It does **not** tell you about deleted entities.
|
|
130
|
+
- **Deleted entities are not included.** The `last_sync` response only contains entities that still exist. If you maintain a local cache, you may need a separate mechanism to detect deletions (e.g., periodically doing a full sync and comparing IDs).
|
|
131
|
+
- **Use ISO 8601 format.** The sync value from the API is already in the correct format. If you construct a timestamp manually, use full ISO 8601 with timezone (e.g., `2026-02-25T01:35:39.768695Z`).
|
|
132
|
+
- **Works with all filters.** You can combine `last_sync` with other filters like `type`, `name`, or `tags`. The API returns entities matching all filters that were also modified after the `last_sync` timestamp.
|
|
@@ -137,7 +137,7 @@ results = client.characters.list(
|
|
|
137
137
|
| `updated_at` | `str` | Filter by update date (ISO format, supports `>=` prefix) |
|
|
138
138
|
| `created_by` | `int` | Filter by creator user ID |
|
|
139
139
|
| `updated_by` | `int` | Filter by last updater user ID |
|
|
140
|
-
| `
|
|
140
|
+
| `last_sync` | `str` | Only return entities modified after this ISO 8601 timestamp |
|
|
141
141
|
|
|
142
142
|
### Filter Value Handling
|
|
143
143
|
|
|
@@ -147,14 +147,7 @@ results = client.characters.list(
|
|
|
147
147
|
|
|
148
148
|
### Sync Filter
|
|
149
149
|
|
|
150
|
-
The `
|
|
151
|
-
|
|
152
|
-
```python
|
|
153
|
-
from datetime import datetime, timedelta, timezone
|
|
154
|
-
|
|
155
|
-
last_sync = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
|
156
|
-
updated_chars = client.characters.list(lastSync=last_sync, related=True)
|
|
157
|
-
```
|
|
150
|
+
The `last_sync` parameter returns only entities modified since a given timestamp. Each API response includes a sync timestamp (available via the `last_sync` property) that you should capture and reuse. See [Last Sync](last-sync.md) for a complete guide including the full sync workflow, pagination, persistence, and important caveats.
|
|
158
151
|
|
|
159
152
|
## Choosing the Right Approach
|
|
160
153
|
|
|
@@ -298,9 +298,10 @@ class KankaClient:
|
|
|
298
298
|
params: dict[str, int | str] = {"page": page}
|
|
299
299
|
response = self._request("GET", f"search/{term}", params=params)
|
|
300
300
|
|
|
301
|
-
# Store pagination metadata for access if needed
|
|
301
|
+
# Store pagination metadata and sync timestamp for access if needed
|
|
302
302
|
self._last_search_meta = response.get("meta", {})
|
|
303
303
|
self._last_search_links = response.get("links", {})
|
|
304
|
+
self._last_search_sync: str | None = response.get("sync")
|
|
304
305
|
|
|
305
306
|
return [SearchResult(**item) for item in response["data"]]
|
|
306
307
|
|
|
@@ -322,7 +323,11 @@ class KankaClient:
|
|
|
322
323
|
return response["data"] # type: ignore[no-any-return]
|
|
323
324
|
|
|
324
325
|
def entities(
|
|
325
|
-
self,
|
|
326
|
+
self,
|
|
327
|
+
page: int = 1,
|
|
328
|
+
limit: int = 15,
|
|
329
|
+
last_sync: str | None = None,
|
|
330
|
+
**filters,
|
|
326
331
|
) -> list[dict[str, Any]]:
|
|
327
332
|
"""Access the /entities endpoint with filters.
|
|
328
333
|
|
|
@@ -332,19 +337,28 @@ class KankaClient:
|
|
|
332
337
|
Args:
|
|
333
338
|
page: Page number (default: 1)
|
|
334
339
|
limit: Number of results per page (default: 15)
|
|
340
|
+
last_sync: ISO 8601 timestamp; only return entities modified after
|
|
341
|
+
this time. Use the ``last_entities_sync`` property from a
|
|
342
|
+
previous call to implement incremental sync.
|
|
335
343
|
**filters: Filter parameters like types, name, is_private, tags
|
|
336
344
|
|
|
337
345
|
Returns:
|
|
338
346
|
List of entity data
|
|
339
347
|
|
|
340
348
|
Note:
|
|
341
|
-
|
|
349
|
+
After calling entities(), the following properties are available:
|
|
342
350
|
- client.last_entities_meta: Contains total count, current page, etc.
|
|
343
351
|
- client.last_entities_links: Contains URLs for first, last, prev, next
|
|
344
352
|
- client.entities_has_next_page: True if more pages are available
|
|
353
|
+
- client.last_entities_sync: Server-generated sync timestamp for use
|
|
354
|
+
with the ``last_sync`` parameter on subsequent calls
|
|
345
355
|
"""
|
|
346
356
|
params: dict[str, int | str] = {"page": page, "limit": limit}
|
|
347
357
|
|
|
358
|
+
# Add lastSync parameter for incremental sync
|
|
359
|
+
if last_sync is not None:
|
|
360
|
+
params["lastSync"] = last_sync
|
|
361
|
+
|
|
348
362
|
# Handle special filters
|
|
349
363
|
if "types" in filters and isinstance(filters["types"], list):
|
|
350
364
|
params["types"] = ",".join(filters["types"])
|
|
@@ -367,6 +381,7 @@ class KankaClient:
|
|
|
367
381
|
response = self._request("GET", "entities", params=params)
|
|
368
382
|
self._last_entities_meta = response.get("meta", {})
|
|
369
383
|
self._last_entities_links = response.get("links", {})
|
|
384
|
+
self._last_entities_sync: str | None = response.get("sync")
|
|
370
385
|
return response["data"] # type: ignore[no-any-return]
|
|
371
386
|
|
|
372
387
|
def _parse_rate_limit_headers(self, response) -> float | None:
|
|
@@ -705,6 +720,15 @@ class KankaClient:
|
|
|
705
720
|
"""
|
|
706
721
|
return getattr(self, "_last_search_links", {})
|
|
707
722
|
|
|
723
|
+
@property
|
|
724
|
+
def last_search_sync(self) -> str | None:
|
|
725
|
+
"""Get the sync timestamp from the last search() call.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
The sync timestamp string, or None if search() has not been called.
|
|
729
|
+
"""
|
|
730
|
+
return getattr(self, "_last_search_sync", None)
|
|
731
|
+
|
|
708
732
|
@property
|
|
709
733
|
def last_entities_meta(self) -> dict[str, Any]:
|
|
710
734
|
"""Get metadata from the last entities() call.
|
|
@@ -724,6 +748,19 @@ class KankaClient:
|
|
|
724
748
|
"""
|
|
725
749
|
return getattr(self, "_last_entities_links", {})
|
|
726
750
|
|
|
751
|
+
@property
|
|
752
|
+
def last_entities_sync(self) -> str | None:
|
|
753
|
+
"""Get the sync timestamp from the last entities() call.
|
|
754
|
+
|
|
755
|
+
This is a server-generated ISO 8601 timestamp representing when the
|
|
756
|
+
response was produced. Pass it as the ``last_sync`` parameter on a
|
|
757
|
+
subsequent ``entities()`` call to fetch only entities modified since then.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
The sync timestamp string, or None if entities() has not been called.
|
|
761
|
+
"""
|
|
762
|
+
return getattr(self, "_last_entities_sync", None)
|
|
763
|
+
|
|
727
764
|
@property
|
|
728
765
|
def entities_has_next_page(self) -> bool:
|
|
729
766
|
"""Check if more pages are available from the last entities() call."""
|
|
@@ -80,7 +80,12 @@ class EntityManager[T: Entity]:
|
|
|
80
80
|
return bool(self.pagination_links.get("next"))
|
|
81
81
|
|
|
82
82
|
def list(
|
|
83
|
-
self,
|
|
83
|
+
self,
|
|
84
|
+
page: int = 1,
|
|
85
|
+
limit: int = 30,
|
|
86
|
+
related: bool = False,
|
|
87
|
+
last_sync: str | None = None,
|
|
88
|
+
**filters,
|
|
84
89
|
) -> list[T]:
|
|
85
90
|
"""List entities with optional filters.
|
|
86
91
|
|
|
@@ -88,6 +93,9 @@ class EntityManager[T: Entity]:
|
|
|
88
93
|
page: Page number (default: 1)
|
|
89
94
|
limit: Number of results per page (default: 30)
|
|
90
95
|
related: Include related data (posts, attributes, etc.)
|
|
96
|
+
last_sync: ISO 8601 timestamp; only return entities modified after
|
|
97
|
+
this time. Use the ``last_sync`` property from a previous call
|
|
98
|
+
to implement incremental sync.
|
|
91
99
|
**filters: Additional filters supported by the API
|
|
92
100
|
|
|
93
101
|
Supported filters:
|
|
@@ -104,10 +112,12 @@ class EntityManager[T: Entity]:
|
|
|
104
112
|
List of entity instances
|
|
105
113
|
|
|
106
114
|
Note:
|
|
107
|
-
|
|
115
|
+
After calling list(), the following properties are available:
|
|
108
116
|
- manager.pagination_meta: Contains total count, current page, etc.
|
|
109
117
|
- manager.pagination_links: Contains URLs for first, last, prev, next
|
|
110
118
|
- manager.has_next_page: True if more pages are available
|
|
119
|
+
- manager.last_sync: Server-generated sync timestamp for use with
|
|
120
|
+
the ``last_sync`` parameter on subsequent calls
|
|
111
121
|
|
|
112
122
|
Examples:
|
|
113
123
|
# Get all public characters
|
|
@@ -154,6 +164,10 @@ class EntityManager[T: Entity]:
|
|
|
154
164
|
if related:
|
|
155
165
|
params["related"] = 1
|
|
156
166
|
|
|
167
|
+
# Add lastSync parameter for incremental sync
|
|
168
|
+
if last_sync is not None:
|
|
169
|
+
params["lastSync"] = last_sync
|
|
170
|
+
|
|
157
171
|
# Add filters
|
|
158
172
|
for key, value in filters.items():
|
|
159
173
|
if value is not None:
|
|
@@ -175,9 +189,10 @@ class EntityManager[T: Entity]:
|
|
|
175
189
|
|
|
176
190
|
response = self.client._request("GET", self.endpoint, params=params)
|
|
177
191
|
|
|
178
|
-
# Store pagination metadata
|
|
192
|
+
# Store pagination metadata and sync timestamp for access if needed
|
|
179
193
|
self._last_meta = response.get("meta", {})
|
|
180
194
|
self._last_links = response.get("links", {})
|
|
195
|
+
self._last_sync: str | None = response.get("sync")
|
|
181
196
|
|
|
182
197
|
return [self.model(**item) for item in response["data"]]
|
|
183
198
|
|
|
@@ -379,6 +394,19 @@ class EntityManager[T: Entity]:
|
|
|
379
394
|
"""
|
|
380
395
|
return getattr(self, "_last_links", {})
|
|
381
396
|
|
|
397
|
+
@property
|
|
398
|
+
def last_sync(self) -> str | None:
|
|
399
|
+
"""Get the sync timestamp from the last list() call.
|
|
400
|
+
|
|
401
|
+
This is a server-generated ISO 8601 timestamp representing when the
|
|
402
|
+
response was produced. Pass it as the ``last_sync`` parameter on a
|
|
403
|
+
subsequent ``list()`` call to fetch only entities modified since then.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
The sync timestamp string, or None if list() has not been called.
|
|
407
|
+
"""
|
|
408
|
+
return getattr(self, "_last_sync", None)
|
|
409
|
+
|
|
382
410
|
def _extract_entity_id(self, entity_or_id: T | int) -> int:
|
|
383
411
|
"""Extract entity_id from an entity object or integer.
|
|
384
412
|
|
{python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entities_api_integration.py
RENAMED
|
@@ -398,6 +398,140 @@ class TestEntitiesApiIntegration(IntegrationTestBase):
|
|
|
398
398
|
|
|
399
399
|
print(" Verified entity structure for generic API response")
|
|
400
400
|
|
|
401
|
+
def test_sync_timestamp(self):
|
|
402
|
+
"""Test that entities() captures sync and last_sync filters correctly."""
|
|
403
|
+
# Verify last_entities_sync is None before any call
|
|
404
|
+
self.assert_true(
|
|
405
|
+
self.client.last_entities_sync is None,
|
|
406
|
+
"last_entities_sync should be None before any entities() call",
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Initial call — capture the sync timestamp
|
|
410
|
+
self.client.entities(limit=1)
|
|
411
|
+
sync_ts = self.client.last_entities_sync
|
|
412
|
+
|
|
413
|
+
self.assert_not_none(
|
|
414
|
+
sync_ts, "last_entities_sync should not be None after call"
|
|
415
|
+
)
|
|
416
|
+
self.assert_true(
|
|
417
|
+
isinstance(sync_ts, str) and len(sync_ts) > 0,
|
|
418
|
+
"last_entities_sync should be a non-empty string",
|
|
419
|
+
)
|
|
420
|
+
print(f" Initial sync timestamp: {sync_ts}")
|
|
421
|
+
|
|
422
|
+
# Nothing should have changed yet
|
|
423
|
+
self.wait_for_api()
|
|
424
|
+
unchanged = self.client.entities(last_sync=sync_ts)
|
|
425
|
+
print(f" Before creating entity: {len(unchanged)} entities changed")
|
|
426
|
+
|
|
427
|
+
# Now create an entity — this should show up as changed
|
|
428
|
+
self.wait_for_api()
|
|
429
|
+
character = self.client.characters.create(
|
|
430
|
+
name=f"Sync Test Character - DELETE ME - {datetime.now().isoformat()}",
|
|
431
|
+
type="NPC",
|
|
432
|
+
)
|
|
433
|
+
self._register_entity_cleanup("characters", character.id, character.name)
|
|
434
|
+
|
|
435
|
+
self.wait_for_api()
|
|
436
|
+
|
|
437
|
+
# Fetch with the old sync timestamp — the new entity should appear
|
|
438
|
+
updated = self.client.entities(last_sync=sync_ts)
|
|
439
|
+
new_sync = self.client.last_entities_sync
|
|
440
|
+
|
|
441
|
+
self.assert_true(
|
|
442
|
+
len(updated) > 0,
|
|
443
|
+
"Should find at least 1 entity changed after creating one",
|
|
444
|
+
)
|
|
445
|
+
self.assert_not_none(
|
|
446
|
+
new_sync, "last_entities_sync should be set after lastSync call"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Verify the created entity is in the results
|
|
450
|
+
found = any(e.get("child_id") == character.id for e in updated)
|
|
451
|
+
self.assert_true(
|
|
452
|
+
found,
|
|
453
|
+
f"Created character (ID {character.id}) should appear in lastSync results",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Fetch again with the NEW sync timestamp — should get nothing new
|
|
457
|
+
self.wait_for_api()
|
|
458
|
+
updated_again = self.client.entities(last_sync=new_sync)
|
|
459
|
+
self.assert_equal(
|
|
460
|
+
len(updated_again),
|
|
461
|
+
0,
|
|
462
|
+
f"No entities should have changed since new sync, got {len(updated_again)}",
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
print(f" After creating entity: {len(updated)} entities changed")
|
|
466
|
+
print(
|
|
467
|
+
f" After second sync: {len(updated_again)} entities changed (expected 0)"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def test_entity_manager_sync_timestamp(self):
|
|
471
|
+
"""Test that EntityManager.list() captures sync and last_sync filters correctly."""
|
|
472
|
+
# Verify last_sync is None before any call
|
|
473
|
+
self.assert_true(
|
|
474
|
+
self.client.characters.last_sync is None,
|
|
475
|
+
"last_sync should be None before any list() call",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Initial call — capture the sync timestamp
|
|
479
|
+
self.client.characters.list(limit=1)
|
|
480
|
+
sync_ts = self.client.characters.last_sync
|
|
481
|
+
|
|
482
|
+
self.assert_not_none(sync_ts, "last_sync should not be None after list()")
|
|
483
|
+
self.assert_true(
|
|
484
|
+
isinstance(sync_ts, str) and len(sync_ts) > 0,
|
|
485
|
+
"last_sync should be a non-empty string",
|
|
486
|
+
)
|
|
487
|
+
print(f" Characters sync timestamp: {sync_ts}")
|
|
488
|
+
|
|
489
|
+
# Nothing should have changed yet
|
|
490
|
+
self.wait_for_api()
|
|
491
|
+
unchanged = self.client.characters.list(last_sync=sync_ts)
|
|
492
|
+
print(f" Before creating character: {len(unchanged)} characters changed")
|
|
493
|
+
|
|
494
|
+
# Create a character — this should show up
|
|
495
|
+
self.wait_for_api()
|
|
496
|
+
character = self.client.characters.create(
|
|
497
|
+
name=f"Sync Test Character - DELETE ME - {datetime.now().isoformat()}",
|
|
498
|
+
type="NPC",
|
|
499
|
+
)
|
|
500
|
+
self._register_entity_cleanup("characters", character.id, character.name)
|
|
501
|
+
|
|
502
|
+
self.wait_for_api()
|
|
503
|
+
|
|
504
|
+
# Fetch with old sync timestamp — the new character should appear
|
|
505
|
+
updated = self.client.characters.list(last_sync=sync_ts)
|
|
506
|
+
new_sync = self.client.characters.last_sync
|
|
507
|
+
|
|
508
|
+
self.assert_true(
|
|
509
|
+
len(updated) > 0,
|
|
510
|
+
"Should find at least 1 character changed after creating one",
|
|
511
|
+
)
|
|
512
|
+
self.assert_not_none(new_sync, "last_sync should be set after lastSync call")
|
|
513
|
+
|
|
514
|
+
# Verify the created character is in the results
|
|
515
|
+
found = any(c.id == character.id for c in updated)
|
|
516
|
+
self.assert_true(
|
|
517
|
+
found,
|
|
518
|
+
f"Created character (ID {character.id}) should appear in lastSync results",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Fetch again with the NEW sync — should get nothing new
|
|
522
|
+
self.wait_for_api()
|
|
523
|
+
updated_again = self.client.characters.list(last_sync=new_sync)
|
|
524
|
+
self.assert_equal(
|
|
525
|
+
len(updated_again),
|
|
526
|
+
0,
|
|
527
|
+
f"No characters should have changed since new sync, got {len(updated_again)}",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
print(f" After creating character: {len(updated)} characters changed")
|
|
531
|
+
print(
|
|
532
|
+
f" After second sync: {len(updated_again)} characters changed (expected 0)"
|
|
533
|
+
)
|
|
534
|
+
|
|
401
535
|
def run_all_tests(self):
|
|
402
536
|
"""Run all entities API integration tests."""
|
|
403
537
|
tests = [
|
|
@@ -409,6 +543,8 @@ class TestEntitiesApiIntegration(IntegrationTestBase):
|
|
|
409
543
|
("Filter by Tags", self.test_filter_with_tags),
|
|
410
544
|
("Combined Filters", self.test_combined_filters),
|
|
411
545
|
("Entity Response Structure", self.test_entity_response_structure),
|
|
546
|
+
("Sync Timestamp", self.test_sync_timestamp),
|
|
547
|
+
("Entity Manager Sync Timestamp", self.test_entity_manager_sync_timestamp),
|
|
412
548
|
]
|
|
413
549
|
|
|
414
550
|
results = []
|
|
@@ -265,6 +265,96 @@ class TestKankaClient:
|
|
|
265
265
|
assert client.last_entities_links == {}
|
|
266
266
|
assert client.entities_has_next_page is False
|
|
267
267
|
|
|
268
|
+
@patch("requests.Session")
|
|
269
|
+
def test_entities_sync_captured(self, mock_session_class):
|
|
270
|
+
"""Test that entities() captures the sync timestamp."""
|
|
271
|
+
mock_session = MagicMock()
|
|
272
|
+
mock_session_class.return_value = mock_session
|
|
273
|
+
|
|
274
|
+
sync_ts = "2025-06-15T10:30:00.123456Z"
|
|
275
|
+
mock_response = MockResponse(
|
|
276
|
+
create_api_response(
|
|
277
|
+
[{"id": 1, "name": "Test", "type": "character"}], sync=sync_ts
|
|
278
|
+
),
|
|
279
|
+
status_code=200,
|
|
280
|
+
)
|
|
281
|
+
mock_session.request.return_value = mock_response
|
|
282
|
+
|
|
283
|
+
client = KankaClient(token="test_token", campaign_id=123)
|
|
284
|
+
client.entities()
|
|
285
|
+
|
|
286
|
+
assert client.last_entities_sync == sync_ts
|
|
287
|
+
|
|
288
|
+
@patch("requests.Session")
|
|
289
|
+
def test_entities_sync_default_is_none(self, mock_session_class):
|
|
290
|
+
"""Test that last_entities_sync is None before any call."""
|
|
291
|
+
mock_session = MagicMock()
|
|
292
|
+
mock_session_class.return_value = mock_session
|
|
293
|
+
|
|
294
|
+
client = KankaClient(token="test_token", campaign_id=123)
|
|
295
|
+
|
|
296
|
+
assert client.last_entities_sync is None
|
|
297
|
+
|
|
298
|
+
@patch("requests.Session")
|
|
299
|
+
def test_entities_last_sync_parameter(self, mock_session_class):
|
|
300
|
+
"""Test that last_sync parameter sends lastSync query param."""
|
|
301
|
+
mock_session = MagicMock()
|
|
302
|
+
mock_session_class.return_value = mock_session
|
|
303
|
+
|
|
304
|
+
mock_response = MockResponse(
|
|
305
|
+
create_api_response([], sync="2025-06-15T11:00:00.000000Z"),
|
|
306
|
+
status_code=200,
|
|
307
|
+
)
|
|
308
|
+
mock_session.request.return_value = mock_response
|
|
309
|
+
|
|
310
|
+
client = KankaClient(token="test_token", campaign_id=123)
|
|
311
|
+
sync_ts = "2025-06-15T10:00:00.000000Z"
|
|
312
|
+
client.entities(last_sync=sync_ts)
|
|
313
|
+
|
|
314
|
+
# Verify lastSync was included in the request params
|
|
315
|
+
call_args = mock_session.request.call_args
|
|
316
|
+
assert "lastSync" in call_args[1]["params"]
|
|
317
|
+
assert call_args[1]["params"]["lastSync"] == sync_ts
|
|
318
|
+
|
|
319
|
+
@patch("requests.Session")
|
|
320
|
+
def test_search_sync_captured(self, mock_session_class):
|
|
321
|
+
"""Test that search() captures the sync timestamp."""
|
|
322
|
+
mock_session = MagicMock()
|
|
323
|
+
mock_session_class.return_value = mock_session
|
|
324
|
+
|
|
325
|
+
sync_ts = "2025-06-15T10:30:00.123456Z"
|
|
326
|
+
search_data = [
|
|
327
|
+
{
|
|
328
|
+
"id": 1,
|
|
329
|
+
"entity_id": 100,
|
|
330
|
+
"name": "Dragon",
|
|
331
|
+
"type": "character",
|
|
332
|
+
"url": "https://app.kanka.io/w/1/entities/100",
|
|
333
|
+
"is_private": False,
|
|
334
|
+
"tags": [],
|
|
335
|
+
}
|
|
336
|
+
]
|
|
337
|
+
mock_response = MockResponse(
|
|
338
|
+
create_api_response(search_data, sync=sync_ts),
|
|
339
|
+
status_code=200,
|
|
340
|
+
)
|
|
341
|
+
mock_session.request.return_value = mock_response
|
|
342
|
+
|
|
343
|
+
client = KankaClient(token="test_token", campaign_id=123)
|
|
344
|
+
client.search("dragon")
|
|
345
|
+
|
|
346
|
+
assert client.last_search_sync == sync_ts
|
|
347
|
+
|
|
348
|
+
@patch("requests.Session")
|
|
349
|
+
def test_search_sync_default_is_none(self, mock_session_class):
|
|
350
|
+
"""Test that last_search_sync is None before any call."""
|
|
351
|
+
mock_session = MagicMock()
|
|
352
|
+
mock_session_class.return_value = mock_session
|
|
353
|
+
|
|
354
|
+
client = KankaClient(token="test_token", campaign_id=123)
|
|
355
|
+
|
|
356
|
+
assert client.last_search_sync is None
|
|
357
|
+
|
|
268
358
|
@patch("requests.Session")
|
|
269
359
|
def test_request_error_handling(self, mock_session_class):
|
|
270
360
|
"""Test error handling in _request method."""
|
|
@@ -145,6 +145,60 @@ class TestEntityManager:
|
|
|
145
145
|
call_args = self.mock_client._request.call_args
|
|
146
146
|
assert call_args[1]["params"]["types"] == "character,npc"
|
|
147
147
|
|
|
148
|
+
def test_list_captures_sync_timestamp(self):
|
|
149
|
+
"""Test that list() captures the sync timestamp from the response."""
|
|
150
|
+
mock_data = [create_mock_entity("character", 1)]
|
|
151
|
+
sync_ts = "2025-06-15T10:30:00.123456Z"
|
|
152
|
+
mock_response = create_api_response(mock_data, sync=sync_ts)
|
|
153
|
+
self.mock_client._request.return_value = mock_response
|
|
154
|
+
|
|
155
|
+
self.manager.list()
|
|
156
|
+
|
|
157
|
+
assert self.manager.last_sync == sync_ts
|
|
158
|
+
|
|
159
|
+
def test_last_sync_is_none_before_list(self):
|
|
160
|
+
"""Test that last_sync is None before any list() call."""
|
|
161
|
+
assert self.manager.last_sync is None
|
|
162
|
+
|
|
163
|
+
def test_last_sync_updates_on_subsequent_calls(self):
|
|
164
|
+
"""Test that last_sync updates with each list() call."""
|
|
165
|
+
first_sync = "2025-06-15T10:00:00.000000Z"
|
|
166
|
+
second_sync = "2025-06-15T11:00:00.000000Z"
|
|
167
|
+
|
|
168
|
+
self.mock_client._request.return_value = create_api_response(
|
|
169
|
+
[], sync=first_sync
|
|
170
|
+
)
|
|
171
|
+
self.manager.list()
|
|
172
|
+
assert self.manager.last_sync == first_sync
|
|
173
|
+
|
|
174
|
+
self.mock_client._request.return_value = create_api_response(
|
|
175
|
+
[], sync=second_sync
|
|
176
|
+
)
|
|
177
|
+
self.manager.list()
|
|
178
|
+
assert self.manager.last_sync == second_sync
|
|
179
|
+
|
|
180
|
+
def test_list_with_last_sync_parameter(self):
|
|
181
|
+
"""Test that last_sync parameter is sent as lastSync query param."""
|
|
182
|
+
self.mock_client._request.return_value = create_api_response([])
|
|
183
|
+
sync_ts = "2025-06-15T10:00:00.000000Z"
|
|
184
|
+
|
|
185
|
+
self.manager.list(last_sync=sync_ts)
|
|
186
|
+
|
|
187
|
+
self.mock_client._request.assert_called_with(
|
|
188
|
+
"GET",
|
|
189
|
+
"characters",
|
|
190
|
+
params={"page": 1, "limit": 30, "lastSync": sync_ts},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def test_list_without_last_sync_omits_param(self):
|
|
194
|
+
"""Test that lastSync is not sent when last_sync is None."""
|
|
195
|
+
self.mock_client._request.return_value = create_api_response([])
|
|
196
|
+
|
|
197
|
+
self.manager.list()
|
|
198
|
+
|
|
199
|
+
call_params = self.mock_client._request.call_args[1]["params"]
|
|
200
|
+
assert "lastSync" not in call_params
|
|
201
|
+
|
|
148
202
|
def test_create_entity(self):
|
|
149
203
|
"""Test creating an entity."""
|
|
150
204
|
# Setup mock response
|
|
@@ -154,7 +154,10 @@ def create_mock_search_result(
|
|
|
154
154
|
|
|
155
155
|
|
|
156
156
|
def create_api_response(
|
|
157
|
-
data: Any,
|
|
157
|
+
data: Any,
|
|
158
|
+
meta: dict | None = None,
|
|
159
|
+
links: dict | None = None,
|
|
160
|
+
sync: str | None = "2025-01-15T12:00:00.000000Z",
|
|
158
161
|
) -> dict[str, Any]:
|
|
159
162
|
"""Create a mock API response with standard structure.
|
|
160
163
|
|
|
@@ -162,6 +165,8 @@ def create_api_response(
|
|
|
162
165
|
data: The response data (single item or list)
|
|
163
166
|
meta: Pagination metadata
|
|
164
167
|
links: Pagination links
|
|
168
|
+
sync: Sync timestamp (included by default for list responses,
|
|
169
|
+
matching real API behavior). Pass None to omit.
|
|
165
170
|
|
|
166
171
|
Returns:
|
|
167
172
|
Dict representing an API response
|
|
@@ -196,6 +201,10 @@ def create_api_response(
|
|
|
196
201
|
"next": None,
|
|
197
202
|
}
|
|
198
203
|
|
|
204
|
+
# Include sync timestamp for list responses, matching real API behavior
|
|
205
|
+
if sync is not None and isinstance(data, list):
|
|
206
|
+
response["sync"] = sync
|
|
207
|
+
|
|
199
208
|
return response
|
|
200
209
|
|
|
201
210
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_assets_integration.py
RENAMED
|
File without changes
|
{python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_images_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_organisations_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|