tracktolib 0.67.0__py3-none-any.whl → 0.69.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.
@@ -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
 
@@ -0,0 +1,468 @@
1
+ """Markdown conversion utilities for Notion blocks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Any, Sequence
7
+
8
+ from ..utils import get_chunks
9
+ from .blocks import (
10
+ BulletedListBlock,
11
+ DividerBlock,
12
+ NumberedListBlock,
13
+ ParagraphBlock,
14
+ QuoteBlock,
15
+ TodoBlock,
16
+ make_bulleted_list_block,
17
+ make_code_block,
18
+ make_divider_block,
19
+ make_heading_block,
20
+ make_numbered_list_block,
21
+ make_paragraph_block,
22
+ make_quote_block,
23
+ make_todo_block,
24
+ )
25
+
26
+ # Union type for all Notion blocks used in markdown conversion
27
+ NotionBlock = ParagraphBlock | DividerBlock | BulletedListBlock | NumberedListBlock | TodoBlock | QuoteBlock
28
+
29
+ if TYPE_CHECKING:
30
+ from .models import Block, Comment, PartialBlock, RichTextItemResponse
31
+
32
+ __all__ = [
33
+ "NOTION_CHAR_LIMIT",
34
+ "NotionBlock",
35
+ "blocks_to_markdown",
36
+ "blocks_to_markdown_with_comments",
37
+ "comments_to_markdown",
38
+ "markdown_to_blocks",
39
+ "rich_text_to_markdown",
40
+ "strip_comments_from_markdown",
41
+ ]
42
+
43
+ # Notion's character limit per rich_text element
44
+ NOTION_CHAR_LIMIT = 2000
45
+
46
+ # Markdown block patterns (pre-compiled for performance)
47
+ _CODE_FENCE_PATTERN = re.compile(r"^```(\w*)$")
48
+ _HORIZONTAL_RULE_PATTERN = re.compile(r"^[-*_]{3,}\s*$")
49
+ _HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+)$")
50
+ _TODO_PATTERN = re.compile(r"^\s*[-*]\s*\[([xX ])\]\s*(.*)$")
51
+ _BULLET_PATTERN = re.compile(r"^\s*[-*]\s+(.+)$")
52
+ _NUMBERED_PATTERN = re.compile(r"^\s*\d+\.\s+(.+)$")
53
+ _QUOTE_PATTERN = re.compile(r"^>\s*(.*)$")
54
+
55
+
56
+ def rich_text_to_markdown(rich_text: Sequence[RichTextItemResponse] | Sequence[dict[str, Any]]) -> str:
57
+ """Convert Notion rich_text array to markdown string.
58
+
59
+ Handles:
60
+ - Bold (annotations.bold)
61
+ - Italic (annotations.italic)
62
+ - Inline code (annotations.code)
63
+ - Links (text.link.url)
64
+ """
65
+ result = []
66
+ for item in rich_text:
67
+ text_obj = item.get("text", {})
68
+ content = text_obj.get("content", "")
69
+
70
+ if not content:
71
+ continue
72
+
73
+ annotations = item.get("annotations", {})
74
+ link = text_obj.get("link")
75
+
76
+ # Apply formatting in order: code, bold, italic
77
+ if annotations.get("code"):
78
+ content = f"`{content}`"
79
+ if annotations.get("bold"):
80
+ content = f"**{content}**"
81
+ if annotations.get("italic"):
82
+ content = f"*{content}*"
83
+ if link:
84
+ content = f"[{content}]({link['url']})"
85
+
86
+ result.append(content)
87
+
88
+ return "".join(result)
89
+
90
+
91
+ def markdown_to_blocks(content: str) -> list[NotionBlock | dict[str, Any]]:
92
+ """Convert markdown content to Notion blocks with proper formatting.
93
+
94
+ Handles:
95
+ - Code blocks (```)
96
+ - Headings (# ## ### etc)
97
+ - Bold (**text**)
98
+ - Inline code (`code`)
99
+ - Italic (*text*)
100
+ - Todo items (- [ ] or - [x])
101
+ - Bulleted lists (- or *)
102
+ - Numbered lists (1. 2. etc)
103
+ - Horizontal rules (---)
104
+
105
+ Args:
106
+ content: Markdown content to convert
107
+
108
+ Returns:
109
+ List of Notion block objects (caller handles chunking for API limits)
110
+ """
111
+ blocks: list[NotionBlock | dict[str, Any]] = []
112
+ lines = content.split("\n")
113
+ i = 0
114
+
115
+ while i < len(lines):
116
+ line = lines[i]
117
+
118
+ # Check for fenced code block
119
+ code_match = _CODE_FENCE_PATTERN.match(line)
120
+ if code_match:
121
+ language = code_match.group(1) or "plain text"
122
+ code_lines = []
123
+ i += 1
124
+ while i < len(lines) and not lines[i].startswith("```"):
125
+ code_lines.append(lines[i])
126
+ i += 1
127
+ code_content = "\n".join(code_lines)
128
+ if code_content:
129
+ blocks.extend(make_code_block(code_content, language))
130
+ i += 1 # Skip closing ```
131
+ continue
132
+
133
+ # Check for horizontal rule (---, ***, ___)
134
+ if _HORIZONTAL_RULE_PATTERN.match(line):
135
+ blocks.append(make_divider_block())
136
+ i += 1
137
+ continue
138
+
139
+ # Check for heading
140
+ heading_match = _HEADING_PATTERN.match(line)
141
+ if heading_match:
142
+ level = len(heading_match.group(1))
143
+ text = heading_match.group(2).strip()
144
+ blocks.append(make_heading_block(text, level))
145
+ i += 1
146
+ continue
147
+
148
+ # Check for todo item (- [ ] or - [x]) - must be before bullet list
149
+ todo_match = _TODO_PATTERN.match(line)
150
+ if todo_match:
151
+ checked = todo_match.group(1).lower() == "x"
152
+ text = todo_match.group(2).strip()
153
+ blocks.append(make_todo_block(text, checked))
154
+ i += 1
155
+ continue
156
+
157
+ # Check for bulleted list
158
+ bullet_match = _BULLET_PATTERN.match(line)
159
+ if bullet_match:
160
+ text = bullet_match.group(1).strip()
161
+ blocks.append(make_bulleted_list_block(text))
162
+ i += 1
163
+ continue
164
+
165
+ # Check for numbered list
166
+ numbered_match = _NUMBERED_PATTERN.match(line)
167
+ if numbered_match:
168
+ text = numbered_match.group(1).strip()
169
+ blocks.append(make_numbered_list_block(text))
170
+ i += 1
171
+ continue
172
+
173
+ # Check for blockquote
174
+ quote_match = _QUOTE_PATTERN.match(line)
175
+ if quote_match:
176
+ text = quote_match.group(1)
177
+ blocks.append(make_quote_block(text))
178
+ i += 1
179
+ continue
180
+
181
+ # Empty line - check if it separates quote blocks
182
+ if not line.strip():
183
+ # Look ahead to see if next non-empty line is a quote
184
+ # and previous block was also a quote
185
+ if blocks and blocks[-1].get("type") == "quote":
186
+ j = i + 1
187
+ while j < len(lines) and not lines[j].strip():
188
+ j += 1
189
+ if j < len(lines) and lines[j].startswith(">"):
190
+ # Insert empty paragraph to preserve blank line between quotes
191
+ blocks.append(make_paragraph_block(""))
192
+ i += 1
193
+ continue
194
+
195
+ # Regular paragraph - collect consecutive non-empty lines
196
+ para_lines = [line]
197
+ i += 1
198
+ while i < len(lines):
199
+ next_line = lines[i]
200
+ # Stop at special lines
201
+ if (
202
+ not next_line.strip()
203
+ or next_line.startswith("#")
204
+ or next_line.startswith("```")
205
+ or next_line.startswith(">")
206
+ or _HORIZONTAL_RULE_PATTERN.match(next_line)
207
+ or _TODO_PATTERN.match(next_line)
208
+ or _BULLET_PATTERN.match(next_line)
209
+ or _NUMBERED_PATTERN.match(next_line)
210
+ ):
211
+ break
212
+ para_lines.append(next_line)
213
+ i += 1
214
+
215
+ para_text = " ".join(ln.strip() for ln in para_lines)
216
+ if para_text:
217
+ # Split long paragraphs into chunks
218
+ if len(para_text) > NOTION_CHAR_LIMIT:
219
+ for chunk in get_chunks(para_text, NOTION_CHAR_LIMIT):
220
+ blocks.append(make_paragraph_block("".join(chunk)))
221
+ else:
222
+ blocks.append(make_paragraph_block(para_text))
223
+
224
+ return blocks
225
+
226
+
227
+ def _block_to_markdown(block: Block | PartialBlock | dict[str, Any]) -> str | None:
228
+ """Convert a single Notion block to markdown.
229
+
230
+ Returns None for unsupported block types.
231
+ """
232
+ block_type = block.get("type")
233
+ if not block_type:
234
+ return None
235
+
236
+ block_data = block.get(block_type, {})
237
+
238
+ if block_type == "paragraph":
239
+ rich_text = block_data.get("rich_text", [])
240
+ text = rich_text_to_markdown(rich_text)
241
+ return text if text else ""
242
+
243
+ if block_type in ("heading_1", "heading_2", "heading_3"):
244
+ level = int(block_type[-1])
245
+ rich_text = block_data.get("rich_text", [])
246
+ text = rich_text_to_markdown(rich_text)
247
+ return f"{'#' * level} {text}"
248
+
249
+ if block_type == "code":
250
+ rich_text = block_data.get("rich_text", [])
251
+ code = "".join(item.get("text", {}).get("content", "") for item in rich_text)
252
+ language = block_data.get("language", "")
253
+ # Map Notion language back to common alias
254
+ if language == "plain text":
255
+ language = ""
256
+ return f"```{language}\n{code}\n```"
257
+
258
+ if block_type == "bulleted_list_item":
259
+ rich_text = block_data.get("rich_text", [])
260
+ text = rich_text_to_markdown(rich_text)
261
+ return f"- {text}"
262
+
263
+ if block_type == "numbered_list_item":
264
+ rich_text = block_data.get("rich_text", [])
265
+ text = rich_text_to_markdown(rich_text)
266
+ return f"1. {text}"
267
+
268
+ if block_type == "to_do":
269
+ rich_text = block_data.get("rich_text", [])
270
+ text = rich_text_to_markdown(rich_text)
271
+ checked = block_data.get("checked", False)
272
+ checkbox = "[x]" if checked else "[ ]"
273
+ return f"- {checkbox} {text}"
274
+
275
+ if block_type == "divider":
276
+ return "---"
277
+
278
+ if block_type == "quote":
279
+ rich_text = block_data.get("rich_text", [])
280
+ text = rich_text_to_markdown(rich_text)
281
+ return f"> {text}"
282
+
283
+ if block_type == "callout":
284
+ rich_text = block_data.get("rich_text", [])
285
+ text = rich_text_to_markdown(rich_text)
286
+ icon = block_data.get("icon", {})
287
+ emoji = icon.get("emoji", "")
288
+ prefix = f"{emoji} " if emoji else ""
289
+ return f"> {prefix}{text}"
290
+
291
+ # Unsupported block type
292
+ return None
293
+
294
+
295
+ def blocks_to_markdown(blocks: list[Block | PartialBlock] | list[dict[str, Any]]) -> str:
296
+ """Convert a list of Notion blocks to markdown content.
297
+
298
+ Handles:
299
+ - Paragraphs
300
+ - Headings (h1, h2, h3)
301
+ - Code blocks
302
+ - Bulleted lists
303
+ - Numbered lists
304
+ - Todo items
305
+ - Dividers
306
+ - Quotes
307
+ - Callouts
308
+
309
+ Args:
310
+ blocks: List of Notion block objects
311
+
312
+ Returns:
313
+ Markdown string
314
+ """
315
+ result: list[str] = []
316
+ prev_type: str | None = None
317
+
318
+ for block in blocks:
319
+ block_type = block.get("type")
320
+ md_line = _block_to_markdown(block)
321
+ if md_line is not None:
322
+ # Empty paragraph acts as separator (resets consecutive quote joining)
323
+ if block_type == "paragraph" and md_line == "":
324
+ prev_type = None
325
+ continue
326
+ # Join consecutive quotes with single newline
327
+ if prev_type == "quote" and block_type == "quote":
328
+ result.append(f"\n{md_line}")
329
+ elif result:
330
+ result.append(f"\n\n{md_line}")
331
+ else:
332
+ result.append(md_line)
333
+ prev_type = block_type
334
+
335
+ return "".join(result)
336
+
337
+
338
+ def _inline_comment_to_markdown(comment: Comment | dict[str, Any]) -> str:
339
+ """Convert a single inline comment to markdown format."""
340
+ rich_text = comment.get("rich_text", [])
341
+ text = rich_text_to_markdown(rich_text)
342
+
343
+ created_by = comment.get("created_by", {})
344
+ author = created_by.get("name") or created_by.get("id", "Unknown")
345
+
346
+ created_time = comment.get("created_time", "")
347
+ if created_time:
348
+ timestamp = created_time[:16].replace("T", " ")
349
+ else:
350
+ timestamp = ""
351
+
352
+ header = f"**{author}**"
353
+ if timestamp:
354
+ header += f" - {timestamp}"
355
+
356
+ return f"> 💬 {header}: {text}"
357
+
358
+
359
+ def blocks_to_markdown_with_comments(
360
+ blocks: list[Block | PartialBlock] | list[dict[str, Any]],
361
+ block_comments: dict[str, list[Comment]] | dict[str, list[dict[str, Any]]] | None = None,
362
+ ) -> str:
363
+ """Convert a list of Notion blocks to markdown content with inline comments.
364
+
365
+ Args:
366
+ blocks: List of Notion block objects
367
+ block_comments: Dictionary mapping block IDs to their comments
368
+
369
+ Returns:
370
+ Markdown string with inline comments after their respective blocks
371
+ """
372
+ if block_comments is None:
373
+ block_comments = {}
374
+
375
+ result: list[str] = []
376
+ prev_type: str | None = None
377
+
378
+ for block in blocks:
379
+ block_type = block.get("type")
380
+ md_line = _block_to_markdown(block)
381
+ if md_line is not None:
382
+ # Empty paragraph acts as separator (resets consecutive quote joining)
383
+ if block_type == "paragraph" and md_line == "":
384
+ prev_type = None
385
+ continue
386
+ # Join consecutive quotes with single newline
387
+ if prev_type == "quote" and block_type == "quote":
388
+ result.append(f"\n{md_line}")
389
+ elif result:
390
+ result.append(f"\n\n{md_line}")
391
+ else:
392
+ result.append(md_line)
393
+ prev_type = block_type
394
+
395
+ # Add inline comments for this block
396
+ block_id = block.get("id")
397
+ if block_id and block_id in block_comments:
398
+ for comment in block_comments[block_id]:
399
+ comment_md = _inline_comment_to_markdown(comment)
400
+ result.append(f"\n\n{comment_md}")
401
+ prev_type = None # Reset after comment
402
+
403
+ return "".join(result)
404
+
405
+
406
+ def comments_to_markdown(comments: list[Comment] | list[dict[str, Any]]) -> str:
407
+ """Convert a list of Notion comments to markdown.
408
+
409
+ Each comment is formatted as a blockquote with author and timestamp.
410
+
411
+ Args:
412
+ comments: List of Notion comment objects
413
+
414
+ Returns:
415
+ Markdown string with comments section
416
+ """
417
+ if not comments:
418
+ return ""
419
+
420
+ lines: list[str] = ["## Comments", ""]
421
+
422
+ for comment in comments:
423
+ rich_text = comment.get("rich_text", [])
424
+ text = rich_text_to_markdown(rich_text)
425
+
426
+ # Get author info
427
+ created_by = comment.get("created_by", {})
428
+ author = created_by.get("name") or created_by.get("id", "Unknown")
429
+
430
+ # Get timestamp
431
+ created_time = comment.get("created_time", "")
432
+ if created_time:
433
+ # Format: 2024-01-15T10:30:00.000Z -> 2024-01-15 10:30
434
+ timestamp = created_time[:16].replace("T", " ")
435
+ else:
436
+ timestamp = ""
437
+
438
+ # Format as blockquote with metadata
439
+ header = f"**{author}**"
440
+ if timestamp:
441
+ header += f" - {timestamp}"
442
+
443
+ lines.append(f"> {header}")
444
+ lines.append(f"> {text}")
445
+ lines.append("")
446
+
447
+ return "\n".join(lines)
448
+
449
+
450
+ def strip_comments_from_markdown(content: str) -> str:
451
+ """Remove comment blockquotes (> 💬) from markdown content.
452
+
453
+ This is useful when re-uploading markdown that was downloaded with comments,
454
+ to avoid converting comments into regular quote blocks.
455
+
456
+ Args:
457
+ content: Markdown content potentially containing comment blockquotes
458
+
459
+ Returns:
460
+ Markdown content with comment lines removed
461
+ """
462
+ lines = content.splitlines()
463
+ result = []
464
+ for line in lines:
465
+ if line.startswith("> 💬"):
466
+ continue
467
+ result.append(line)
468
+ return "\n".join(result)
@@ -286,3 +286,29 @@ class SearchResponse(TypedDict):
286
286
  results: list[Page | PartialPage | Database | PartialDatabase]
287
287
  next_cursor: str | None
288
288
  has_more: bool
289
+
290
+
291
+ # Comment types
292
+
293
+
294
+ class Comment(TypedDict):
295
+ """Comment object response."""
296
+
297
+ object: Literal["comment"]
298
+ id: str
299
+ parent: Parent
300
+ discussion_id: str
301
+ created_time: str
302
+ last_edited_time: str
303
+ created_by: PartialUser
304
+ rich_text: list[RichTextItemResponse]
305
+
306
+
307
+ class CommentListResponse(TypedDict):
308
+ """Response from listing comments."""
309
+
310
+ object: Literal["list"]
311
+ type: Literal["comment"]
312
+ results: list[Comment]
313
+ next_cursor: str | None
314
+ has_more: bool