python-kanka 2.4.1__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.4.1 → python_kanka-2.6.0}/.bumpversion.cfg +1 -1
  2. {python_kanka-2.4.1 → python_kanka-2.6.0}/.github/workflows/publish.yml +2 -2
  3. {python_kanka-2.4.1 → python_kanka-2.6.0}/CLAUDE.md +41 -0
  4. {python_kanka-2.4.1 → python_kanka-2.6.0}/PKG-INFO +1 -1
  5. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/README.md +1 -0
  6. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/api-reference.md +25 -3
  7. {python_kanka-2.4.1 → 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.4.1 → python_kanka-2.6.0}/docs/pagination.md +8 -0
  10. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/search-and-filtering.md +2 -9
  11. {python_kanka-2.4.1 → python_kanka-2.6.0}/pyproject.toml +1 -1
  12. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/_version.py +1 -1
  13. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/client.py +76 -3
  14. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/managers.py +31 -3
  15. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_entities_api_integration.py +136 -0
  16. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_client.py +167 -1
  17. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_managers.py +54 -0
  18. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/utils.py +10 -1
  19. {python_kanka-2.4.1 → python_kanka-2.6.0}/uv.lock +1 -1
  20. {python_kanka-2.4.1 → python_kanka-2.6.0}/.github/renovate.json +0 -0
  21. {python_kanka-2.4.1 → python_kanka-2.6.0}/.github/workflows/ci.yml +0 -0
  22. {python_kanka-2.4.1 → python_kanka-2.6.0}/.gitignore +0 -0
  23. {python_kanka-2.4.1 → python_kanka-2.6.0}/.pre-commit-config.yaml +0 -0
  24. {python_kanka-2.4.1 → python_kanka-2.6.0}/CHANGELOG.md +0 -0
  25. {python_kanka-2.4.1 → python_kanka-2.6.0}/LICENSE +0 -0
  26. {python_kanka-2.4.1 → python_kanka-2.6.0}/MANIFEST.in +0 -0
  27. {python_kanka-2.4.1 → python_kanka-2.6.0}/Makefile +0 -0
  28. {python_kanka-2.4.1 → python_kanka-2.6.0}/PUBLISHING.md +0 -0
  29. {python_kanka-2.4.1 → python_kanka-2.6.0}/README.md +0 -0
  30. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/assets-and-images.md +0 -0
  31. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/core-concepts.md +0 -0
  32. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/debug-mode.md +0 -0
  33. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/entity-types-reference.md +0 -0
  34. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/error-handling.md +0 -0
  35. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/gallery.md +0 -0
  36. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/getting-started.md +0 -0
  37. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/known-limitations.md +0 -0
  38. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/posts.md +0 -0
  39. {python_kanka-2.4.1 → python_kanka-2.6.0}/docs/rate-limiting.md +0 -0
  40. {python_kanka-2.4.1 → python_kanka-2.6.0}/pytest.ini +0 -0
  41. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/__init__.py +0 -0
  42. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/exceptions.py +0 -0
  43. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/models/__init__.py +0 -0
  44. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/models/base.py +0 -0
  45. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/models/common.py +0 -0
  46. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/models/entities.py +0 -0
  47. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/py.typed +0 -0
  48. {python_kanka-2.4.1 → python_kanka-2.6.0}/src/kanka/types.py +0 -0
  49. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/__init__.py +0 -0
  50. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/README.md +0 -0
  51. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/__init__.py +0 -0
  52. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/base.py +0 -0
  53. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/run_integration_tests.py +0 -0
  54. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/setup_test_env.py +0 -0
  55. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_calendars_integration.py +0 -0
  56. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_calendars_simple.py +0 -0
  57. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_characters_integration.py +0 -0
  58. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_creatures_integration.py +0 -0
  59. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_entity_assets_integration.py +0 -0
  60. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_entity_images_integration.py +0 -0
  61. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_entity_tags_integration.py +0 -0
  62. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_events_integration.py +0 -0
  63. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_families_integration.py +0 -0
  64. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_gallery_integration.py +0 -0
  65. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_journals_integration.py +0 -0
  66. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_locations_integration.py +0 -0
  67. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_mentions_integration.py +0 -0
  68. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_notes_integration.py +0 -0
  69. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_organisations_integration.py +0 -0
  70. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_posts_integration.py +0 -0
  71. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_quests_integration.py +0 -0
  72. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_races_integration.py +0 -0
  73. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_rate_limiting.py +0 -0
  74. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_search_integration.py +0 -0
  75. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_tag_colour.py +0 -0
  76. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/integration/test_tags_integration.py +0 -0
  77. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/mypy.ini +0 -0
  78. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_client_advanced.py +0 -0
  79. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_entity_models.py +0 -0
  80. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_exceptions.py +0 -0
  81. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_models.py +0 -0
  82. {python_kanka-2.4.1 → python_kanka-2.6.0}/tests/test_models_advanced.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.4.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.4.1
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,12 +90,27 @@ Get a single entity by its universal `entity_id`. Returns a raw dictionary.
90
90
  #### entities
91
91
 
92
92
  ```python
93
- entities(**filters) → list[dict[str, Any]]
93
+ entities(
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.
97
102
 
98
- **Filters:** `types` (list[str]), `name` (str), `tags` (list[int]), `is_private` (bool), `created_by` (int), `updated_by` (int)
103
+ | Parameter | Type | Default | Description |
104
+ |-----------|------|---------|-------------|
105
+ | `page` | `int` | `1` | Page number |
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). |
108
+ | `types` | `list[str]` | — | Filter by entity types |
109
+ | `name` | `str` | — | Filter by name |
110
+ | `tags` | `list[int]` | — | Filter by tag IDs |
111
+ | `is_private` | `bool` | — | Filter by privacy |
112
+ | `created_by` | `int` | — | Filter by creator ID |
113
+ | `updated_by` | `int` | — | Filter by updater ID |
99
114
 
100
115
  #### gallery
101
116
 
@@ -137,8 +152,13 @@ Delete a gallery image by UUID.
137
152
 
138
153
  | Property | Type | Description |
139
154
  |----------|------|-------------|
155
+ | `last_entities_meta` | `dict` | Pagination metadata from last `entities()` |
156
+ | `last_entities_links` | `dict` | Pagination links from last `entities()` |
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). |
140
159
  | `last_search_meta` | `dict` | Pagination metadata from last `search()` |
141
160
  | `last_search_links` | `dict` | Pagination links from last `search()` |
161
+ | `last_search_sync` | `str \| None` | Sync timestamp from last `search()` call |
142
162
  | `last_gallery_meta` | `dict` | Pagination metadata from last `gallery()` |
143
163
  | `last_gallery_links` | `dict` | Pagination links from last `gallery()` |
144
164
 
@@ -191,6 +211,7 @@ list(
191
211
  page: int = 1,
192
212
  limit: int = 30,
193
213
  related: bool = False,
214
+ last_sync: str | None = None,
194
215
  **filters,
195
216
  ) → list[T]
196
217
  ```
@@ -202,6 +223,7 @@ List entities with optional filtering.
202
223
  | `page` | `int` | `1` | Page number |
203
224
  | `limit` | `int` | `30` | Results per page |
204
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). |
205
227
  | `name` | `str` | — | Filter by name (partial match) |
206
228
  | `type` | `str` | — | Filter by entity type |
207
229
  | `types` | `list[str]` | — | Filter by multiple types |
@@ -211,7 +233,6 @@ List entities with optional filtering.
211
233
  | `updated_at` | `str` | — | Filter by update date |
212
234
  | `created_by` | `int` | — | Filter by creator ID |
213
235
  | `updated_by` | `int` | — | Filter by updater ID |
214
- | `lastSync` | `str` | — | Only entities modified after this ISO 8601 timestamp |
215
236
 
216
237
  **Returns:** List of entity model instances
217
238
 
@@ -426,6 +447,7 @@ delete_image(entity_or_id: T | int, is_header: bool = False) → bool
426
447
  | `pagination_meta` | `dict` | Metadata from last `list()` |
427
448
  | `pagination_links` | `dict` | Links from last `list()` |
428
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). |
429
451
  | `last_page_meta` | `dict` | Alias for `pagination_meta` |
430
452
  | `last_page_links` | `dict` | Alias for `pagination_links` |
431
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.
@@ -83,6 +83,14 @@ Each list endpoint stores its own pagination state:
83
83
  | `manager.last_assets_meta` | Metadata from last `list_assets()` |
84
84
  | `manager.last_assets_links` | Links from last `list_assets()` |
85
85
 
86
+ ### Entities
87
+
88
+ | Property | Description |
89
+ |----------|-------------|
90
+ | `client.last_entities_meta` | Metadata from last `entities()` |
91
+ | `client.last_entities_links` | Links from last `entities()` |
92
+ | `client.entities_has_next_page` | `True` if next page exists |
93
+
86
94
  ### Gallery
87
95
 
88
96
  | Property | Description |
@@ -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.4.1"
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.4.1"
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
 
@@ -321,19 +322,42 @@ class KankaClient:
321
322
  response = self._request("GET", f"entities/{entity_id}")
322
323
  return response["data"] # type: ignore[no-any-return]
323
324
 
324
- def entities(self, **filters) -> list[dict[str, Any]]:
325
+ def entities(
326
+ self,
327
+ page: int = 1,
328
+ limit: int = 15,
329
+ last_sync: str | None = None,
330
+ **filters,
331
+ ) -> list[dict[str, Any]]:
325
332
  """Access the /entities endpoint with filters.
326
333
 
327
334
  This endpoint provides a unified way to query entities across all types
328
335
  with various filtering options.
329
336
 
330
337
  Args:
338
+ page: Page number (default: 1)
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.
331
343
  **filters: Filter parameters like types, name, is_private, tags
332
344
 
333
345
  Returns:
334
346
  List of entity data
347
+
348
+ Note:
349
+ After calling entities(), the following properties are available:
350
+ - client.last_entities_meta: Contains total count, current page, etc.
351
+ - client.last_entities_links: Contains URLs for first, last, prev, next
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
335
355
  """
336
- params: dict[str, int | str] = {}
356
+ params: dict[str, int | str] = {"page": page, "limit": limit}
357
+
358
+ # Add lastSync parameter for incremental sync
359
+ if last_sync is not None:
360
+ params["lastSync"] = last_sync
337
361
 
338
362
  # Handle special filters
339
363
  if "types" in filters and isinstance(filters["types"], list):
@@ -355,6 +379,9 @@ class KankaClient:
355
379
  params[key] = filters[key]
356
380
 
357
381
  response = self._request("GET", "entities", params=params)
382
+ self._last_entities_meta = response.get("meta", {})
383
+ self._last_entities_links = response.get("links", {})
384
+ self._last_entities_sync: str | None = response.get("sync")
358
385
  return response["data"] # type: ignore[no-any-return]
359
386
 
360
387
  def _parse_rate_limit_headers(self, response) -> float | None:
@@ -692,3 +719,49 @@ class KankaClient:
692
719
  Dict[str, Any]: Links for pagination including first, last, prev, next URLs
693
720
  """
694
721
  return getattr(self, "_last_search_links", {})
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
+
732
+ @property
733
+ def last_entities_meta(self) -> dict[str, Any]:
734
+ """Get metadata from the last entities() call.
735
+
736
+ Returns:
737
+ Dict[str, Any]: Pagination metadata including current_page, from, to,
738
+ last_page, per_page, total
739
+ """
740
+ return getattr(self, "_last_entities_meta", {})
741
+
742
+ @property
743
+ def last_entities_links(self) -> dict[str, Any]:
744
+ """Get pagination links from the last entities() call.
745
+
746
+ Returns:
747
+ Dict[str, Any]: Links for pagination including first, last, prev, next URLs
748
+ """
749
+ return getattr(self, "_last_entities_links", {})
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
+
764
+ @property
765
+ def entities_has_next_page(self) -> bool:
766
+ """Check if more pages are available from the last entities() call."""
767
+ return bool(getattr(self, "_last_entities_links", {}).get("next"))
@@ -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