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.
Files changed (82) hide show
  1. {python_kanka-2.5.0 → python_kanka-2.6.0}/.bumpversion.cfg +1 -1
  2. {python_kanka-2.5.0 → python_kanka-2.6.0}/.github/workflows/publish.yml +2 -2
  3. {python_kanka-2.5.0 → python_kanka-2.6.0}/CLAUDE.md +41 -0
  4. {python_kanka-2.5.0 → python_kanka-2.6.0}/PKG-INFO +1 -1
  5. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/README.md +1 -0
  6. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/api-reference.md +12 -2
  7. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/entities.md +7 -5
  8. python_kanka-2.6.0/docs/last-sync.md +132 -0
  9. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/search-and-filtering.md +2 -9
  10. {python_kanka-2.5.0 → python_kanka-2.6.0}/pyproject.toml +1 -1
  11. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/_version.py +1 -1
  12. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/client.py +40 -3
  13. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/managers.py +31 -3
  14. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entities_api_integration.py +136 -0
  15. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_client.py +90 -0
  16. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_managers.py +54 -0
  17. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/utils.py +10 -1
  18. {python_kanka-2.5.0 → python_kanka-2.6.0}/uv.lock +1 -1
  19. {python_kanka-2.5.0 → python_kanka-2.6.0}/.github/renovate.json +0 -0
  20. {python_kanka-2.5.0 → python_kanka-2.6.0}/.github/workflows/ci.yml +0 -0
  21. {python_kanka-2.5.0 → python_kanka-2.6.0}/.gitignore +0 -0
  22. {python_kanka-2.5.0 → python_kanka-2.6.0}/.pre-commit-config.yaml +0 -0
  23. {python_kanka-2.5.0 → python_kanka-2.6.0}/CHANGELOG.md +0 -0
  24. {python_kanka-2.5.0 → python_kanka-2.6.0}/LICENSE +0 -0
  25. {python_kanka-2.5.0 → python_kanka-2.6.0}/MANIFEST.in +0 -0
  26. {python_kanka-2.5.0 → python_kanka-2.6.0}/Makefile +0 -0
  27. {python_kanka-2.5.0 → python_kanka-2.6.0}/PUBLISHING.md +0 -0
  28. {python_kanka-2.5.0 → python_kanka-2.6.0}/README.md +0 -0
  29. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/assets-and-images.md +0 -0
  30. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/core-concepts.md +0 -0
  31. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/debug-mode.md +0 -0
  32. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/entity-types-reference.md +0 -0
  33. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/error-handling.md +0 -0
  34. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/gallery.md +0 -0
  35. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/getting-started.md +0 -0
  36. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/known-limitations.md +0 -0
  37. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/pagination.md +0 -0
  38. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/posts.md +0 -0
  39. {python_kanka-2.5.0 → python_kanka-2.6.0}/docs/rate-limiting.md +0 -0
  40. {python_kanka-2.5.0 → python_kanka-2.6.0}/pytest.ini +0 -0
  41. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/__init__.py +0 -0
  42. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/exceptions.py +0 -0
  43. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/__init__.py +0 -0
  44. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/base.py +0 -0
  45. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/common.py +0 -0
  46. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/models/entities.py +0 -0
  47. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/py.typed +0 -0
  48. {python_kanka-2.5.0 → python_kanka-2.6.0}/src/kanka/types.py +0 -0
  49. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/__init__.py +0 -0
  50. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/README.md +0 -0
  51. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/__init__.py +0 -0
  52. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/base.py +0 -0
  53. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/run_integration_tests.py +0 -0
  54. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/setup_test_env.py +0 -0
  55. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_calendars_integration.py +0 -0
  56. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_calendars_simple.py +0 -0
  57. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_characters_integration.py +0 -0
  58. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_creatures_integration.py +0 -0
  59. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_assets_integration.py +0 -0
  60. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_images_integration.py +0 -0
  61. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_entity_tags_integration.py +0 -0
  62. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_events_integration.py +0 -0
  63. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_families_integration.py +0 -0
  64. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_gallery_integration.py +0 -0
  65. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_journals_integration.py +0 -0
  66. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_locations_integration.py +0 -0
  67. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_mentions_integration.py +0 -0
  68. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_notes_integration.py +0 -0
  69. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_organisations_integration.py +0 -0
  70. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_posts_integration.py +0 -0
  71. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_quests_integration.py +0 -0
  72. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_races_integration.py +0 -0
  73. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_rate_limiting.py +0 -0
  74. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_search_integration.py +0 -0
  75. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_tag_colour.py +0 -0
  76. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/integration/test_tags_integration.py +0 -0
  77. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/mypy.ini +0 -0
  78. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_client_advanced.py +0 -0
  79. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_entity_models.py +0 -0
  80. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_exceptions.py +0 -0
  81. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_models.py +0 -0
  82. {python_kanka-2.5.0 → python_kanka-2.6.0}/tests/test_models_advanced.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.5.0
2
+ current_version = 2.6.0
3
3
  commit = True
4
4
  tag = True
5
5
  tag_name = v{new_version}
@@ -56,7 +56,7 @@ jobs:
56
56
  uv build
57
57
 
58
58
  - name: Upload artifacts
59
- uses: actions/upload-artifact@v5
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@v6
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-kanka
3
- Version: 2.5.0
3
+ Version: 2.6.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
@@ -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(page: int = 1, limit: int = 15, **filters) → list[dict[str, Any]]
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 `lastSync` Filter
75
+ ### The `last_sync` Parameter
76
76
 
77
- Use the `lastSync` filter with an ISO 8601 timestamp to retrieve only entities modified after a specific time. This is useful for synchronization workflows:
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
- from datetime import datetime, timedelta, timezone
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
- last_sync = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
83
- updated = client.characters.list(lastSync=last_sync, related=True)
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
- | `lastSync` | `str` | Only return entities modified after this ISO 8601 timestamp |
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 `lastSync` filter is useful for synchronization workflows — it returns only entities that have been modified since the given timestamp:
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-kanka"
7
- version = "2.5.0"
7
+ version = "2.6.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.5.0"
3
+ __version__ = "2.6.0"
@@ -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, page: int = 1, limit: int = 15, **filters
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
- Pagination information is available after calling entities():
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, page: int = 1, limit: int = 30, related: bool = False, **filters
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
- Pagination information is available after calling list():
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 on the manager for access if needed
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
 
@@ -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, meta: dict | None = None, links: dict | None = None
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
 
@@ -853,7 +853,7 @@ wheels = [
853
853
 
854
854
  [[package]]
855
855
  name = "python-kanka"
856
- version = "2.5.0"
856
+ version = "2.6.0"
857
857
  source = { editable = "." }
858
858
  dependencies = [
859
859
  { name = "pydantic" },
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