tracktolib 0.67.0__py3-none-any.whl → 0.68.0__py3-none-any.whl

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.
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from types import TracebackType
9
+ from typing import TYPE_CHECKING, Any, Mapping, Self, Sequence, TypedDict, cast
10
+
11
+ if TYPE_CHECKING:
12
+ from .utils import PageComment
13
+
14
+
15
+ class CachedDatabase(TypedDict):
16
+ id: str
17
+ title: str
18
+ properties: dict[str, Any]
19
+ cached_at: str
20
+
21
+
22
+ class CachedPageBlocks(TypedDict):
23
+ """Cached blocks for a page."""
24
+
25
+ page_id: str
26
+ blocks: list[dict[str, Any]]
27
+ cached_at: str
28
+
29
+
30
+ class CachedPageComments(TypedDict):
31
+ """Cached comments for a page."""
32
+
33
+ page_id: str
34
+ comments: list[dict[str, Any]]
35
+ cached_at: str
36
+
37
+
38
+ class CacheData(TypedDict, total=False):
39
+ databases: dict[str, CachedDatabase]
40
+ page_blocks: dict[str, CachedPageBlocks]
41
+ page_comments: dict[str, CachedPageComments]
42
+
43
+
44
+ def _default_cache_dir() -> Path:
45
+ """Default cache directory: $XDG_CACHE_HOME/tracktolib/notion or ~/.cache/tracktolib/notion."""
46
+ xdg_cache = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
47
+ return Path(xdg_cache) / "tracktolib" / "notion"
48
+
49
+
50
+ @dataclass
51
+ class NotionCache:
52
+ """Persistent cache for Notion data.
53
+
54
+ Use as a context manager to load on entry and save on exit:
55
+
56
+ with NotionCache() as cache:
57
+ db = cache.get_database("db-id")
58
+ cache.set_database({"id": "new-db", ...})
59
+ # Automatically saved on exit
60
+ """
61
+
62
+ # Directory for cache file.
63
+ cache_dir: Path = field(default_factory=_default_cache_dir)
64
+ _file_path: Path = field(init=False)
65
+ _data: CacheData = field(init=False)
66
+ _dirty: bool = field(init=False, default=False)
67
+
68
+ def __post_init__(self) -> None:
69
+ self._file_path = self.cache_dir / "cache.json"
70
+ self._data = {}
71
+
72
+ def load(self) -> None:
73
+ """Load cache from disk into memory."""
74
+ if self._file_path.exists():
75
+ self._data = json.loads(self._file_path.read_text())
76
+ else:
77
+ self._data = {}
78
+ self._dirty = False
79
+
80
+ def save(self) -> None:
81
+ """Save in-memory cache to disk (only if modified)."""
82
+ if not self._dirty:
83
+ return
84
+ self._file_path.parent.mkdir(parents=True, exist_ok=True)
85
+ tmp = self._file_path.with_suffix(".tmp")
86
+ tmp.write_text(json.dumps(self._data, indent=2, default=str))
87
+ tmp.rename(self._file_path)
88
+ self._dirty = False
89
+
90
+ def __enter__(self) -> Self:
91
+ self.load()
92
+ return self
93
+
94
+ def __exit__(
95
+ self,
96
+ exc_type: type[BaseException] | None,
97
+ exc_val: BaseException | None,
98
+ exc_tb: TracebackType | None,
99
+ ) -> None:
100
+ self.save()
101
+
102
+ def get_database(self, database_id: str) -> CachedDatabase | None:
103
+ """Get a cached database by ID."""
104
+ return self._data.get("databases", {}).get(database_id)
105
+
106
+ def get_databases(self) -> dict[str, CachedDatabase]:
107
+ """Get all cached databases."""
108
+ return self._data.get("databases", {})
109
+
110
+ def set_database(self, database: Mapping[str, Any]) -> CachedDatabase:
111
+ """Cache a database from Notion API response."""
112
+ title_prop = database.get("title", [])
113
+ title = next(
114
+ (el["plain_text"] for el in title_prop if isinstance(el, Mapping) and "plain_text" in el),
115
+ "",
116
+ )
117
+
118
+ entry: CachedDatabase = {
119
+ "id": database["id"],
120
+ "title": title,
121
+ "properties": database["properties"],
122
+ "cached_at": datetime.now().isoformat(),
123
+ }
124
+
125
+ if "databases" not in self._data:
126
+ self._data["databases"] = {}
127
+ self._data["databases"][database["id"]] = entry
128
+ self._dirty = True
129
+ return entry
130
+
131
+ def delete_database(self, database_id: str) -> None:
132
+ """Remove a database from cache."""
133
+ if "databases" in self._data:
134
+ self._data["databases"].pop(database_id, None)
135
+ self._dirty = True
136
+
137
+ def clear(self) -> None:
138
+ """Clear all cached data."""
139
+ self._data = {}
140
+ self._dirty = True
141
+
142
+ def get_page_blocks(self, page_id: str) -> list[dict[str, Any]] | None:
143
+ """Get cached blocks for the given page_id, or None if not cached."""
144
+ cached = self._data.get("page_blocks", {}).get(page_id)
145
+ if cached:
146
+ return cached["blocks"]
147
+ return None
148
+
149
+ def set_page_blocks(
150
+ self,
151
+ page_id: str,
152
+ blocks: Sequence[dict[str, Any]],
153
+ ) -> CachedPageBlocks:
154
+ """Cache the given blocks for page_id and return the cached entry."""
155
+ entry: CachedPageBlocks = {
156
+ "page_id": page_id,
157
+ "blocks": list(blocks),
158
+ "cached_at": datetime.now().isoformat(),
159
+ }
160
+
161
+ if "page_blocks" not in self._data:
162
+ self._data["page_blocks"] = {}
163
+ self._data["page_blocks"][page_id] = entry
164
+ self._dirty = True
165
+ return entry
166
+
167
+ def delete_page_blocks(self, page_id: str) -> None:
168
+ """Remove cached blocks for the given page_id."""
169
+ if "page_blocks" in self._data:
170
+ self._data["page_blocks"].pop(page_id, None)
171
+ self._dirty = True
172
+
173
+ def get_page_comments(self, page_id: str) -> list[PageComment] | None:
174
+ """Get cached comments for the given page_id, or None if not cached."""
175
+ cached = self._data.get("page_comments", {}).get(page_id)
176
+ if cached:
177
+ return cached["comments"] # type: ignore[return-value]
178
+ return None
179
+
180
+ def set_page_comments(
181
+ self,
182
+ page_id: str,
183
+ comments: Sequence[PageComment],
184
+ ) -> CachedPageComments:
185
+ """Cache the given comments for page_id and return the cached entry."""
186
+ entry: CachedPageComments = {
187
+ "page_id": page_id,
188
+ "comments": cast(list[dict[str, Any]], list(comments)),
189
+ "cached_at": datetime.now().isoformat(),
190
+ }
191
+
192
+ if "page_comments" not in self._data:
193
+ self._data["page_comments"] = {}
194
+ self._data["page_comments"][page_id] = entry
195
+ self._dirty = True
196
+ return entry
197
+
198
+ def delete_page_comments(self, page_id: str) -> None:
199
+ """Remove cached comments for the given page_id."""
200
+ if "page_comments" in self._data:
201
+ self._data["page_comments"].pop(page_id, None)
202
+ self._dirty = True
@@ -1,14 +1,22 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
- from typing import Any, Literal
4
+ from typing import TYPE_CHECKING, Any, Literal, Sequence, overload
3
5
 
4
6
  try:
5
7
  import niquests
6
8
  except ImportError:
7
9
  raise ImportError('Please install niquests or tracktolib with "notion" to use this module')
8
10
 
11
+ if TYPE_CHECKING:
12
+ from .blocks import NotionBlock
13
+ from .cache import CachedDatabase, NotionCache
14
+
9
15
  from .models import (
10
16
  Block,
11
17
  BlockListResponse,
18
+ Comment,
19
+ CommentListResponse,
12
20
  Database,
13
21
  IntrospectTokenResponse,
14
22
  Page,
@@ -61,6 +69,10 @@ __all__ = (
61
69
  "fetch_block",
62
70
  "fetch_block_children",
63
71
  "fetch_append_block_children",
72
+ "delete_block",
73
+ # Comments
74
+ "fetch_comments",
75
+ "create_comment",
64
76
  # Search
65
77
  "fetch_search",
66
78
  )
@@ -217,7 +229,7 @@ async def create_page(
217
229
  *,
218
230
  parent: dict[str, Any],
219
231
  properties: dict[str, Any],
220
- children: list[dict[str, Any]] | None = None,
232
+ children: Sequence[NotionBlock | dict[str, Any]] | None = None,
221
233
  icon: dict[str, Any] | None = None,
222
234
  cover: dict[str, Any] | None = None,
223
235
  api_version: ApiVersion | None = None,
@@ -274,17 +286,46 @@ async def update_page(
274
286
  # Databases endpoints
275
287
 
276
288
 
289
+ @overload
290
+ async def fetch_database(
291
+ session: niquests.AsyncSession,
292
+ database_id: str,
293
+ *,
294
+ api_version: ApiVersion | None = None,
295
+ cache: None = None,
296
+ ) -> Database: ...
297
+
298
+
299
+ @overload
277
300
  async def fetch_database(
278
301
  session: niquests.AsyncSession,
279
302
  database_id: str,
280
303
  *,
281
304
  api_version: ApiVersion | None = None,
282
- ) -> Database:
305
+ cache: NotionCache,
306
+ ) -> Database | CachedDatabase: ...
307
+
308
+
309
+ async def fetch_database(
310
+ session: niquests.AsyncSession,
311
+ database_id: str,
312
+ *,
313
+ api_version: ApiVersion | None = None,
314
+ cache: NotionCache | None = None,
315
+ ) -> Database | CachedDatabase:
283
316
  """Retrieve a database/data source by ID.
284
317
 
285
318
  For API version 2025-09-03+, uses /v1/data_sources/{id} endpoint.
286
319
  For older API versions, uses /v1/databases/{id} endpoint.
320
+
321
+ If cache is provided, the database will be looked up in the cache first.
322
+ On cache miss, the database is fetched from the API and stored in the cache.
323
+ When returning from cache, returns a CachedDatabase (partial) instead of full Database.
287
324
  """
325
+ if cache:
326
+ if cached := cache.get_database(database_id):
327
+ return cached
328
+
288
329
  _api_version = _get_api_version(session, api_version)
289
330
  if _use_data_source_api(_api_version):
290
331
  endpoint = f"{NOTION_API_URL}/v1/data_sources/{database_id}"
@@ -293,7 +334,12 @@ async def fetch_database(
293
334
 
294
335
  response = await session.get(endpoint)
295
336
  response.raise_for_status()
296
- return response.json() # type: ignore[return-value]
337
+ result: Database = response.json()
338
+
339
+ if cache:
340
+ cache.set_database(result)
341
+
342
+ return result
297
343
 
298
344
 
299
345
  async def query_database(
@@ -364,7 +410,7 @@ async def fetch_block_children(
364
410
  async def fetch_append_block_children(
365
411
  session: niquests.AsyncSession,
366
412
  block_id: str,
367
- children: list[dict[str, Any]],
413
+ children: Sequence[NotionBlock | dict[str, Any]],
368
414
  ) -> BlockListResponse:
369
415
  """Append children blocks to a parent block."""
370
416
  payload = {"children": children}
@@ -374,6 +420,76 @@ async def fetch_append_block_children(
374
420
  return response.json() # type: ignore[return-value]
375
421
 
376
422
 
423
+ async def delete_block(session: niquests.AsyncSession, block_id: str) -> Block:
424
+ """Delete (archive) a block.
425
+
426
+ Args:
427
+ session: Authenticated niquests session
428
+ block_id: The ID of the block to delete
429
+
430
+ Returns:
431
+ The deleted block object with archived=True
432
+ """
433
+ response = await session.delete(f"{NOTION_API_URL}/v1/blocks/{block_id}")
434
+ response.raise_for_status()
435
+ return response.json() # type: ignore[return-value]
436
+
437
+
438
+ # Comments endpoints
439
+
440
+
441
+ async def fetch_comments(
442
+ session: niquests.AsyncSession,
443
+ block_id: str,
444
+ *,
445
+ start_cursor: str | None = None,
446
+ page_size: int | None = None,
447
+ ) -> CommentListResponse:
448
+ """Retrieve comments for a block or page.
449
+
450
+ Args:
451
+ session: Authenticated niquests session
452
+ block_id: The ID of the block or page to get comments for
453
+ start_cursor: Pagination cursor
454
+ page_size: Number of results per page
455
+ """
456
+ params: dict[str, str] = {"block_id": block_id}
457
+ if start_cursor:
458
+ params["start_cursor"] = start_cursor
459
+ if page_size:
460
+ params["page_size"] = str(page_size)
461
+
462
+ response = (await session.get(f"{NOTION_API_URL}/v1/comments", params=params)).raise_for_status()
463
+ return response.json() # type: ignore[return-value]
464
+
465
+
466
+ async def create_comment(
467
+ session: niquests.AsyncSession,
468
+ *,
469
+ parent: dict[str, str],
470
+ rich_text: list[dict[str, Any]],
471
+ discussion_id: str | None = None,
472
+ ) -> Comment:
473
+ """Create a comment on a page or in an existing discussion.
474
+
475
+ Args:
476
+ session: Authenticated niquests session
477
+ parent: Parent object, e.g. {"page_id": "..."} for page-level comments
478
+ rich_text: The comment content as rich text array
479
+ discussion_id: Optional discussion ID to reply to an existing thread
480
+ """
481
+ payload: dict[str, Any] = {
482
+ "parent": parent,
483
+ "rich_text": rich_text,
484
+ }
485
+ if discussion_id:
486
+ payload["discussion_id"] = discussion_id
487
+
488
+ response = await session.post(f"{NOTION_API_URL}/v1/comments", json=payload)
489
+ response.raise_for_status()
490
+ return response.json() # type: ignore[return-value]
491
+
492
+
377
493
  # Search endpoint
378
494
 
379
495