tracktolib 0.62.1__py3-none-any.whl → 0.64.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.
File without changes
@@ -0,0 +1,352 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ try:
5
+ import niquests
6
+ except ImportError:
7
+ raise ImportError('Please install niquests or tracktolib with "notion" to use this module')
8
+
9
+ from .models import (
10
+ Block,
11
+ BlockListResponse,
12
+ Database,
13
+ IntrospectTokenResponse,
14
+ Page,
15
+ PageListResponse,
16
+ RevokeTokenResponse,
17
+ SearchResponse,
18
+ TokenResponse,
19
+ User,
20
+ UserListResponse,
21
+ )
22
+
23
+ __all__ = (
24
+ # Auth helpers
25
+ "get_notion_headers",
26
+ # OAuth
27
+ "create_token",
28
+ "introspect_token",
29
+ "revoke_token",
30
+ "refresh_token",
31
+ # Users
32
+ "fetch_users",
33
+ "fetch_user",
34
+ "fetch_me",
35
+ # Pages
36
+ "fetch_page",
37
+ "create_page",
38
+ "update_page",
39
+ # Databases
40
+ "fetch_database",
41
+ "query_database",
42
+ # Blocks
43
+ "fetch_block",
44
+ "fetch_block_children",
45
+ "fetch_append_block_children",
46
+ # Search
47
+ "fetch_search",
48
+ )
49
+
50
+ NOTION_API_URL = "https://api.notion.com"
51
+
52
+
53
+ def _get_notion_token() -> str:
54
+ """Get Notion token from config or environment."""
55
+ token = os.environ.get("NOTION_TOKEN")
56
+ if not token:
57
+ raise ValueError("Notion token not found. Set NOTION_TOKEN env var.")
58
+ return token
59
+
60
+
61
+ def get_notion_headers(api_version: str = "2025-09-03", token: str | None = None):
62
+ """Get headers for Notion API requests."""
63
+ _token = token or _get_notion_token()
64
+ return {
65
+ "Authorization": f"Bearer {_token}",
66
+ "Notion-Version": api_version,
67
+ }
68
+
69
+
70
+ # OAuth endpoints
71
+
72
+
73
+ async def create_token(
74
+ session: niquests.AsyncSession,
75
+ client_id: str,
76
+ client_secret: str,
77
+ code: str,
78
+ redirect_uri: str | None = None,
79
+ ) -> TokenResponse:
80
+ """Create an access token from an OAuth authorization code."""
81
+ payload: dict[str, str] = {
82
+ "grant_type": "authorization_code",
83
+ "code": code,
84
+ }
85
+ if redirect_uri:
86
+ payload["redirect_uri"] = redirect_uri
87
+
88
+ response = await session.post(f"{NOTION_API_URL}/v1/oauth/token", json=payload, auth=(client_id, client_secret))
89
+ response.raise_for_status()
90
+ return response.json() # type: ignore[return-value]
91
+
92
+
93
+ async def introspect_token(
94
+ session: niquests.AsyncSession,
95
+ client_id: str,
96
+ client_secret: str,
97
+ token: str,
98
+ ) -> IntrospectTokenResponse:
99
+ """Get a token's active status, scope, and issued time."""
100
+ payload = {"token": token}
101
+ response = await session.post(
102
+ f"{NOTION_API_URL}/v1/oauth/introspect", json=payload, auth=(client_id, client_secret)
103
+ )
104
+ response.raise_for_status()
105
+ return response.json() # type: ignore[return-value]
106
+
107
+
108
+ async def revoke_token(
109
+ session: niquests.AsyncSession,
110
+ client_id: str,
111
+ client_secret: str,
112
+ token: str,
113
+ ) -> RevokeTokenResponse:
114
+ """Revoke an access token."""
115
+ payload = {"token": token}
116
+
117
+ response = await session.post(f"{NOTION_API_URL}/v1/oauth/revoke", json=payload, auth=(client_id, client_secret))
118
+ response.raise_for_status()
119
+ return response.json() # type: ignore[return-value]
120
+
121
+
122
+ async def refresh_token(
123
+ session: niquests.AsyncSession,
124
+ client_id: str,
125
+ client_secret: str,
126
+ refresh_token_value: str,
127
+ ) -> TokenResponse:
128
+ """Refresh an access token, generating new access and refresh tokens."""
129
+ payload = {
130
+ "grant_type": "refresh_token",
131
+ "refresh_token": refresh_token_value,
132
+ }
133
+
134
+ response = await session.post(f"{NOTION_API_URL}/v1/oauth/token", json=payload, auth=(client_id, client_secret))
135
+ response.raise_for_status()
136
+ return response.json() # type: ignore[return-value]
137
+
138
+
139
+ # Users endpoints
140
+
141
+
142
+ async def fetch_users(
143
+ session: niquests.AsyncSession,
144
+ *,
145
+ start_cursor: str | None = None,
146
+ page_size: int | None = None,
147
+ ) -> UserListResponse:
148
+ """List all users in the workspace."""
149
+ params: dict[str, str] = {}
150
+ if start_cursor:
151
+ params["start_cursor"] = start_cursor
152
+ if page_size:
153
+ params["page_size"] = str(page_size)
154
+
155
+ response = await session.get(f"{NOTION_API_URL}/v1/users", params=params or None)
156
+ response.raise_for_status()
157
+ return response.json() # type: ignore[return-value]
158
+
159
+
160
+ async def fetch_user(session: niquests.AsyncSession, user_id: str) -> User:
161
+ """Retrieve a user by ID."""
162
+ response = await session.get(f"{NOTION_API_URL}/v1/users/{user_id}")
163
+ response.raise_for_status()
164
+ return response.json() # type: ignore[return-value]
165
+
166
+
167
+ async def fetch_me(session: niquests.AsyncSession) -> User:
168
+ """Retrieve the bot user associated with the token."""
169
+ response = await session.get(f"{NOTION_API_URL}/v1/users/me")
170
+ response.raise_for_status()
171
+ return response.json() # type: ignore[return-value]
172
+
173
+
174
+ # Pages endpoints
175
+
176
+
177
+ async def fetch_page(session: niquests.AsyncSession, page_id: str) -> Page:
178
+ """Retrieve a page by ID."""
179
+ response = await session.get(f"{NOTION_API_URL}/v1/pages/{page_id}")
180
+ response.raise_for_status()
181
+ return response.json() # type: ignore[return-value]
182
+
183
+
184
+ async def create_page(
185
+ session: niquests.AsyncSession,
186
+ *,
187
+ parent: dict[str, Any],
188
+ properties: dict[str, Any],
189
+ children: list[dict[str, Any]] | None = None,
190
+ icon: dict[str, Any] | None = None,
191
+ cover: dict[str, Any] | None = None,
192
+ ) -> Page:
193
+ """Create a new page."""
194
+ payload: dict[str, Any] = {
195
+ "parent": parent,
196
+ "properties": properties,
197
+ }
198
+ if children:
199
+ payload["children"] = children
200
+ if icon:
201
+ payload["icon"] = icon
202
+ if cover:
203
+ payload["cover"] = cover
204
+
205
+ response = await session.post(f"{NOTION_API_URL}/v1/pages", json=payload)
206
+ response.raise_for_status()
207
+ return response.json() # type: ignore[return-value]
208
+
209
+
210
+ async def update_page(
211
+ session: niquests.AsyncSession,
212
+ page_id: str,
213
+ *,
214
+ properties: dict[str, Any] | None = None,
215
+ archived: bool | None = None,
216
+ icon: dict[str, Any] | None = None,
217
+ cover: dict[str, Any] | None = None,
218
+ ) -> Page:
219
+ """Update a page's properties."""
220
+ payload: dict[str, Any] = {}
221
+ if properties is not None:
222
+ payload["properties"] = properties
223
+ if archived is not None:
224
+ payload["archived"] = archived
225
+ if icon is not None:
226
+ payload["icon"] = icon
227
+ if cover is not None:
228
+ payload["cover"] = cover
229
+
230
+ response = await session.patch(f"{NOTION_API_URL}/v1/pages/{page_id}", json=payload)
231
+ response.raise_for_status()
232
+ return response.json() # type: ignore[return-value]
233
+
234
+
235
+ # Databases endpoints
236
+
237
+
238
+ async def fetch_database(session: niquests.AsyncSession, database_id: str) -> Database:
239
+ """Retrieve a database by ID."""
240
+ response = await session.get(f"{NOTION_API_URL}/v1/databases/{database_id}")
241
+ response.raise_for_status()
242
+ return response.json() # type: ignore[return-value]
243
+
244
+
245
+ async def query_database(
246
+ session: niquests.AsyncSession,
247
+ database_id: str,
248
+ *,
249
+ filter: dict[str, Any] | None = None,
250
+ sorts: list[dict[str, Any]] | None = None,
251
+ start_cursor: str | None = None,
252
+ page_size: int | None = None,
253
+ ) -> PageListResponse:
254
+ """Query a database."""
255
+ payload: dict[str, Any] = {}
256
+ if filter:
257
+ payload["filter"] = filter
258
+ if sorts:
259
+ payload["sorts"] = sorts
260
+ if start_cursor:
261
+ payload["start_cursor"] = start_cursor
262
+ if page_size:
263
+ payload["page_size"] = page_size
264
+
265
+ response = await session.post(f"{NOTION_API_URL}/v1/databases/{database_id}/query", json=payload or None)
266
+ response.raise_for_status()
267
+ return response.json() # type: ignore[return-value]
268
+
269
+
270
+ # Blocks endpoints
271
+
272
+
273
+ async def fetch_block(session: niquests.AsyncSession, block_id: str) -> Block:
274
+ """Retrieve a block by ID."""
275
+ response = await session.get(f"{NOTION_API_URL}/v1/blocks/{block_id}")
276
+ response.raise_for_status()
277
+ return response.json() # type: ignore[return-value]
278
+
279
+
280
+ async def fetch_block_children(
281
+ session: niquests.AsyncSession,
282
+ block_id: str,
283
+ *,
284
+ start_cursor: str | None = None,
285
+ page_size: int | None = None,
286
+ ) -> BlockListResponse:
287
+ """Retrieve a block's children."""
288
+ params: dict[str, str] = {}
289
+ if start_cursor:
290
+ params["start_cursor"] = start_cursor
291
+ if page_size:
292
+ params["page_size"] = str(page_size)
293
+
294
+ response = await session.get(f"{NOTION_API_URL}/v1/blocks/{block_id}/children", params=params or None)
295
+ response.raise_for_status()
296
+ return response.json() # type: ignore[return-value]
297
+
298
+
299
+ async def fetch_append_block_children(
300
+ session: niquests.AsyncSession,
301
+ block_id: str,
302
+ children: list[dict[str, Any]],
303
+ ) -> BlockListResponse:
304
+ """Append children blocks to a parent block."""
305
+ payload = {"children": children}
306
+
307
+ response = await session.patch(f"{NOTION_API_URL}/v1/blocks/{block_id}/children", json=payload)
308
+ response.raise_for_status()
309
+ return response.json() # type: ignore[return-value]
310
+
311
+
312
+ # Search endpoint
313
+
314
+
315
+ async def fetch_search(
316
+ session: niquests.AsyncSession,
317
+ *,
318
+ query: str | None = None,
319
+ filter: dict[str, Any] | None = None,
320
+ sort: dict[str, Any] | None = None,
321
+ start_cursor: str | None = None,
322
+ page_size: int | None = None,
323
+ ) -> SearchResponse:
324
+ """Search pages and databases."""
325
+ payload: dict[str, Any] = {}
326
+ if query:
327
+ payload["query"] = query
328
+ if filter:
329
+ payload["filter"] = filter
330
+ if sort:
331
+ payload["sort"] = sort
332
+ if start_cursor:
333
+ payload["start_cursor"] = start_cursor
334
+ if page_size:
335
+ payload["page_size"] = page_size
336
+
337
+ response = await session.post(f"{NOTION_API_URL}/v1/search", json=payload or None)
338
+ response.raise_for_status()
339
+ return response.json() # type: ignore[return-value]
340
+
341
+
342
+ if __name__ == "__main__":
343
+ import asyncio
344
+
345
+ async def main():
346
+ async with niquests.AsyncSession() as session:
347
+ session.headers.update(get_notion_headers())
348
+ me = await fetch_me(session)
349
+ print("Me:", me)
350
+ # print(await fetch_search(session, filter="aaa"))
351
+
352
+ asyncio.run(main())
@@ -0,0 +1,281 @@
1
+ """Notion API models based on official notion-sdk-js types."""
2
+
3
+ from typing import Any, Literal, NotRequired, TypedDict
4
+
5
+ # Base types
6
+
7
+
8
+ class PartialUser(TypedDict):
9
+ """Partial user object."""
10
+
11
+ id: str
12
+ object: Literal["user"]
13
+
14
+
15
+ class PersonDetails(TypedDict):
16
+ """Person details."""
17
+
18
+ email: NotRequired[str]
19
+
20
+
21
+ class PersonUserObjectResponse(TypedDict):
22
+ """Person user type details."""
23
+
24
+ type: Literal["person"]
25
+ person: PersonDetails
26
+
27
+
28
+ class BotUserObjectResponse(TypedDict):
29
+ """Bot user type details."""
30
+
31
+ type: Literal["bot"]
32
+ bot: dict[str, Any]
33
+
34
+
35
+ class UserObjectResponseCommon(TypedDict):
36
+ """Common user object fields."""
37
+
38
+ id: str
39
+ object: Literal["user"]
40
+ name: str | None
41
+ avatar_url: str | None
42
+
43
+
44
+ # User object is common fields + either person or bot
45
+ class User(UserObjectResponseCommon):
46
+ """Full user object."""
47
+
48
+ type: Literal["person", "bot"]
49
+ person: NotRequired[PersonDetails]
50
+ bot: NotRequired[dict[str, Any]]
51
+
52
+
53
+ # OAuth types
54
+
55
+
56
+ class UserOwner(TypedDict):
57
+ """User owner for OAuth."""
58
+
59
+ type: Literal["user"]
60
+ user: User | PartialUser
61
+
62
+
63
+ class WorkspaceOwner(TypedDict):
64
+ """Workspace owner for OAuth."""
65
+
66
+ type: Literal["workspace"]
67
+ workspace: Literal[True]
68
+
69
+
70
+ Owner = UserOwner | WorkspaceOwner
71
+
72
+
73
+ class TokenResponse(TypedDict):
74
+ """Response from creating or refreshing an access token."""
75
+
76
+ access_token: str
77
+ token_type: Literal["bearer"]
78
+ refresh_token: str | None
79
+ bot_id: str
80
+ workspace_icon: str | None
81
+ workspace_name: str | None
82
+ workspace_id: str
83
+ owner: Owner
84
+ duplicated_template_id: str | None
85
+ request_id: NotRequired[str]
86
+
87
+
88
+ class IntrospectTokenResponse(TypedDict):
89
+ """Response from introspecting a token."""
90
+
91
+ active: bool
92
+ scope: NotRequired[str]
93
+ iat: NotRequired[int]
94
+ request_id: NotRequired[str]
95
+
96
+
97
+ class RevokeTokenResponse(TypedDict):
98
+ """Response from revoking a token."""
99
+
100
+ request_id: NotRequired[str]
101
+
102
+
103
+ # Rich text types
104
+
105
+
106
+ class RichTextItemResponse(TypedDict):
107
+ """Rich text item."""
108
+
109
+ type: str
110
+ plain_text: str
111
+ href: str | None
112
+ annotations: dict[str, Any]
113
+ text: NotRequired[dict[str, Any]]
114
+ mention: NotRequired[dict[str, Any]]
115
+ equation: NotRequired[dict[str, Any]]
116
+
117
+
118
+ # Parent types
119
+
120
+
121
+ class PageParent(TypedDict):
122
+ """Page parent."""
123
+
124
+ type: Literal["page_id"]
125
+ page_id: str
126
+
127
+
128
+ class DatabaseParent(TypedDict):
129
+ """Database parent."""
130
+
131
+ type: Literal["database_id"]
132
+ database_id: str
133
+
134
+
135
+ class WorkspaceParent(TypedDict):
136
+ """Workspace parent."""
137
+
138
+ type: Literal["workspace"]
139
+ workspace: Literal[True]
140
+
141
+
142
+ class BlockParent(TypedDict):
143
+ """Block parent."""
144
+
145
+ type: Literal["block_id"]
146
+ block_id: str
147
+
148
+
149
+ Parent = PageParent | DatabaseParent | WorkspaceParent | BlockParent
150
+
151
+
152
+ # Page types
153
+
154
+
155
+ class Page(TypedDict):
156
+ """Page object response."""
157
+
158
+ object: Literal["page"]
159
+ id: str
160
+ created_time: str
161
+ last_edited_time: str
162
+ created_by: PartialUser
163
+ last_edited_by: PartialUser
164
+ archived: bool
165
+ in_trash: bool
166
+ is_locked: bool
167
+ url: str
168
+ public_url: str | None
169
+ parent: Parent
170
+ properties: dict[str, Any]
171
+ icon: dict[str, Any] | None
172
+ cover: dict[str, Any] | None
173
+
174
+
175
+ class PartialPage(TypedDict):
176
+ """Partial page object."""
177
+
178
+ object: Literal["page"]
179
+ id: str
180
+
181
+
182
+ # Database types
183
+
184
+
185
+ class Database(TypedDict):
186
+ """Database object response."""
187
+
188
+ object: Literal["database"]
189
+ id: str
190
+ title: list[RichTextItemResponse]
191
+ description: list[RichTextItemResponse]
192
+ parent: Parent
193
+ is_inline: bool
194
+ in_trash: bool
195
+ is_locked: bool
196
+ created_time: str
197
+ last_edited_time: str
198
+ icon: dict[str, Any] | None
199
+ cover: dict[str, Any] | None
200
+ properties: dict[str, Any]
201
+ url: str
202
+ public_url: str | None
203
+ archived: bool
204
+ created_by: PartialUser
205
+ last_edited_by: PartialUser
206
+
207
+
208
+ class PartialDatabase(TypedDict):
209
+ """Partial database object."""
210
+
211
+ object: Literal["database"]
212
+ id: str
213
+
214
+
215
+ # Block types
216
+
217
+
218
+ class Block(TypedDict):
219
+ """Block object response."""
220
+
221
+ object: Literal["block"]
222
+ id: str
223
+ parent: Parent
224
+ type: str
225
+ created_time: str
226
+ last_edited_time: str
227
+ created_by: PartialUser
228
+ last_edited_by: PartialUser
229
+ has_children: bool
230
+ archived: bool
231
+ in_trash: bool
232
+
233
+
234
+ class PartialBlock(TypedDict):
235
+ """Partial block object."""
236
+
237
+ object: Literal["block"]
238
+ id: str
239
+
240
+
241
+ # List response types
242
+
243
+
244
+ class UserListResponse(TypedDict):
245
+ """Response from listing users."""
246
+
247
+ object: Literal["list"]
248
+ type: Literal["user"]
249
+ results: list[User]
250
+ next_cursor: str | None
251
+ has_more: bool
252
+
253
+
254
+ class PageListResponse(TypedDict):
255
+ """Response from querying a database."""
256
+
257
+ object: Literal["list"]
258
+ type: Literal["page_or_data_source"]
259
+ results: list[Page | PartialPage]
260
+ next_cursor: str | None
261
+ has_more: bool
262
+
263
+
264
+ class BlockListResponse(TypedDict):
265
+ """Response from listing block children."""
266
+
267
+ object: Literal["list"]
268
+ type: Literal["block"]
269
+ results: list[Block | PartialBlock]
270
+ next_cursor: str | None
271
+ has_more: bool
272
+
273
+
274
+ class SearchResponse(TypedDict):
275
+ """Response from search."""
276
+
277
+ object: Literal["list"]
278
+ type: Literal["page_or_data_source"]
279
+ results: list[Page | PartialPage | Database | PartialDatabase]
280
+ next_cursor: str | None
281
+ has_more: bool
tracktolib/pg/query.py CHANGED
@@ -160,12 +160,34 @@ class PGInsertQuery(PGQuery):
160
160
  def is_returning(self):
161
161
  return self.returning is not None
162
162
 
163
+ def _get_values_query(self) -> str:
164
+ """Generate the VALUES clause for the query."""
165
+ _columns = self.columns
166
+ num_cols = len(_columns)
167
+
168
+ if len(self.items) == 1 or not self.is_returning:
169
+ # Single row or no returning: simple placeholders
170
+ return ", ".join(f"${i + 1}" for i in range(num_cols))
171
+ else:
172
+ # Multiple rows with returning: generate all value groups
173
+ value_groups = []
174
+ for row_idx in range(len(self.items)):
175
+ offset = row_idx * num_cols
176
+ group = ", ".join(f"${offset + i + 1}" for i in range(num_cols))
177
+ value_groups.append(f"({group})")
178
+ return ", ".join(value_groups)
179
+
163
180
  @property
164
181
  def query(self) -> str:
165
182
  _columns = self.columns
166
- _values = ", ".join(f"${i + 1}" for i, _ in enumerate(_columns))
183
+ _values = self._get_values_query()
167
184
 
168
- query = _get_insert_query(self.table, _columns, _values)
185
+ if len(self.items) > 1 and self.is_returning:
186
+ # For multi-row insert with returning, build full VALUES clause
187
+ _columns_str = ", ".join(_columns)
188
+ query = f"INSERT INTO {self.table} AS t ({_columns_str}) VALUES {_values}"
189
+ else:
190
+ query = _get_insert_query(self.table, _columns, _values)
169
191
 
170
192
  # Conflict
171
193
  if self.on_conflict:
@@ -184,13 +206,14 @@ class PGInsertQuery(PGQuery):
184
206
  if self.returning is not None:
185
207
  if self.returning.returning_ids is None:
186
208
  raise ValueError("No returning ids found")
187
- if len(self.items) == 1:
188
- query = _get_returning_query(query, self.returning.returning_ids)
189
- else:
190
- raise NotImplementedError("Cannot return value when inserting many.")
209
+ query = _get_returning_query(query, self.returning.returning_ids)
191
210
 
192
211
  return query
193
212
 
213
+ def _get_flat_values(self) -> list:
214
+ """Get all values as a flat list for multi-row insert with returning."""
215
+ return [val for item_values in self.iter_values() for val in item_values]
216
+
194
217
 
195
218
  def get_update_fields(
196
219
  item: dict,
@@ -341,6 +364,7 @@ async def insert_one(
341
364
  await query.run(conn)
342
365
 
343
366
 
367
+ @overload
344
368
  async def insert_many(
345
369
  conn: _Connection,
346
370
  table: str,
@@ -349,12 +373,53 @@ async def insert_many(
349
373
  on_conflict: OnConflict | None = None,
350
374
  fill: bool = False,
351
375
  quote_columns: bool = False,
376
+ returning: None = None,
352
377
  query_callback: QueryCallback[PGInsertQuery] | None = None,
353
- ):
354
- query = insert_pg(table=table, items=items, on_conflict=on_conflict, fill=fill, quote_columns=quote_columns)
378
+ ) -> None: ...
379
+
380
+
381
+ @overload
382
+ async def insert_many(
383
+ conn: _Connection,
384
+ table: str,
385
+ items: list[dict],
386
+ *,
387
+ on_conflict: OnConflict | None = None,
388
+ fill: bool = False,
389
+ quote_columns: bool = False,
390
+ returning: str | list[str],
391
+ query_callback: QueryCallback[PGInsertQuery] | None = None,
392
+ ) -> list[asyncpg.Record]: ...
393
+
394
+
395
+ async def insert_many(
396
+ conn: _Connection,
397
+ table: str,
398
+ items: list[dict],
399
+ *,
400
+ on_conflict: OnConflict | None = None,
401
+ fill: bool = False,
402
+ quote_columns: bool = False,
403
+ returning: str | list[str] | None = None,
404
+ query_callback: QueryCallback[PGInsertQuery] | None = None,
405
+ ) -> list[asyncpg.Record] | None:
406
+ returning_values = [returning] if isinstance(returning, str) else returning
407
+ query = insert_pg(
408
+ table=table,
409
+ items=items,
410
+ on_conflict=on_conflict,
411
+ fill=fill,
412
+ quote_columns=quote_columns,
413
+ returning=returning_values,
414
+ )
355
415
  if query_callback is not None:
356
416
  query_callback(query)
357
- await query.run(conn)
417
+
418
+ if returning is not None:
419
+ return await conn.fetch(query.query, *query._get_flat_values())
420
+ else:
421
+ await query.run(conn)
422
+ return None
358
423
 
359
424
 
360
425
  @overload
@@ -381,22 +446,41 @@ async def insert_returning(
381
446
  ) -> asyncpg.Record | None: ...
382
447
 
383
448
 
449
+ @overload
384
450
  async def insert_returning(
385
451
  conn: _Connection,
386
452
  table: str,
387
- item: dict,
453
+ item: list[dict],
454
+ returning: str | list[str],
455
+ on_conflict: OnConflict | None = None,
456
+ fill: bool = False,
457
+ query_callback: QueryCallback[PGInsertQuery] | None = None,
458
+ ) -> list[asyncpg.Record]: ...
459
+
460
+
461
+ async def insert_returning(
462
+ conn: _Connection,
463
+ table: str,
464
+ item: dict | list[dict],
388
465
  returning: list[str] | str,
389
466
  on_conflict: OnConflict | None = None,
390
467
  fill: bool = False,
391
468
  query_callback: QueryCallback[PGInsertQuery] | None = None,
392
- ) -> asyncpg.Record | Any | None:
469
+ ) -> asyncpg.Record | Any | list[asyncpg.Record] | None:
393
470
  returning_values = [returning] if isinstance(returning, str) else returning
394
- query = insert_pg(table=table, items=[item], on_conflict=on_conflict, fill=fill, returning=returning_values)
471
+ items = item if isinstance(item, list) else [item]
472
+
473
+ query = insert_pg(table=table, items=items, on_conflict=on_conflict, fill=fill, returning=returning_values)
395
474
  if query_callback is not None:
396
475
  query_callback(query)
397
- fn = conn.fetchval if len(returning_values) == 1 and returning != "*" else conn.fetchrow
398
476
 
399
- return await fn(query.query, *query.values)
477
+ if len(items) > 1:
478
+ # Multi-row insert: use fetch() with flat values
479
+ return await conn.fetch(query.query, *query._get_flat_values())
480
+ else:
481
+ # Single row insert: use fetchval or fetchrow
482
+ fn = conn.fetchval if len(returning_values) == 1 and returning != "*" else conn.fetchrow
483
+ return await fn(query.query, *query.values)
400
484
 
401
485
 
402
486
  async def fetch_count(conn: _Connection, query: str, *args) -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tracktolib
3
- Version: 0.62.1
3
+ Version: 0.64.0
4
4
  Summary: Utility library for python
5
5
  Keywords: utility
6
6
  Author-email: julien.brayere@tracktor.fr
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.103.2 ; extra == 'api'
10
10
  Requires-Dist: pydantic>=2 ; extra == 'api'
11
11
  Requires-Dist: httpx>=0.25.0 ; extra == 'http'
12
12
  Requires-Dist: python-json-logger>=3.2.1 ; extra == 'logs'
13
+ Requires-Dist: niquests>=3.15.2 ; extra == 'notion'
13
14
  Requires-Dist: asyncpg>=0.27.0 ; extra == 'pg'
14
15
  Requires-Dist: rich>=13.6.0 ; extra == 'pg'
15
16
  Requires-Dist: psycopg>=3.1.12 ; extra == 'pg-sync'
@@ -21,6 +22,7 @@ Requires-Python: >=3.12, <4.0
21
22
  Provides-Extra: api
22
23
  Provides-Extra: http
23
24
  Provides-Extra: logs
25
+ Provides-Extra: notion
24
26
  Provides-Extra: pg
25
27
  Provides-Extra: pg-sync
26
28
  Provides-Extra: s3
@@ -2,8 +2,11 @@ tracktolib/__init__.py,sha256=Q9d6h2lNjcYzxvfJ3zlNcpiP_Ak0T3TBPWINzZNrhu0,173
2
2
  tracktolib/api.py,sha256=ZLMgjH3Y8r3MpXc8m3IuZbzTj3fgrZKZORtSVgbuP-M,10221
3
3
  tracktolib/http_utils.py,sha256=c10JGmHaBw3VSDMYhz2dvVw2lo4PUAq1xMub74I7xDc,2625
4
4
  tracktolib/logs.py,sha256=D2hx6urXl5l4PBGP8mCpcT4GX7tJeFfNY-7oBfHczBU,2191
5
+ tracktolib/notion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ tracktolib/notion/fetch.py,sha256=zIhFo9T18eS75l0wZN_WaXD51OUxTsgvFuvqk3R87o4,9974
7
+ tracktolib/notion/models.py,sha256=D-px7Ht2xoXKKZICuEkZhRwVBvVCaKCvhpzPtVVYawI,5390
5
8
  tracktolib/pg/__init__.py,sha256=Ul_hgwvTXZvQBt7sHKi4ZI-0DDpnXmoFtmVkGRy-1J0,366
6
- tracktolib/pg/query.py,sha256=_wL9MQU_z8Sk0ZOYGVE0TjUbwBZ1OJJuEq2jlmWoeeM,16693
9
+ tracktolib/pg/query.py,sha256=zstc-QkBby7e6LybS8ed0d_6QLQNujY2H0lLNXFLNQ8,19366
7
10
  tracktolib/pg/utils.py,sha256=ygQn63EBDaEGB0p7P2ibellO2mv-StafanpXKcCUiZU,6324
8
11
  tracktolib/pg_sync.py,sha256=MKDaV7dYsRy59Y0EE5RGZL0DlZ-RUdBeaN9eSBwiQJg,6718
9
12
  tracktolib/pg_utils.py,sha256=ArYNdf9qsdYdzGEWmev8tZpyx8_1jaGGdkfYkauM7UM,2582
@@ -12,6 +15,6 @@ tracktolib/s3/minio.py,sha256=wMEjkSes9Fp39fD17IctALpD6zB2xwDRQEmO7Vzan3g,1387
12
15
  tracktolib/s3/s3.py,sha256=0HbSAPoaup5-W4LK54zRCjrQ5mr8OWR-N9WjW99Q4aw,5937
13
16
  tracktolib/tests.py,sha256=gKE--epQjgMZGXc5ydbl4zjOdmwztJS42UMV0p4hXEA,399
14
17
  tracktolib/utils.py,sha256=ysTBF9V35fVXQVBPk0kfE_84SGRxzrayqmg9RbtoJq4,5761
15
- tracktolib-0.62.1.dist-info/WHEEL,sha256=3id4o64OvRm9dUknh3mMJNcfoTRK08ua5cU6DFyVy-4,79
16
- tracktolib-0.62.1.dist-info/METADATA,sha256=dKxWZjdrwg0x4Hak-Wux5mQcjfT2ZL0in7ZQp_dyaXo,3053
17
- tracktolib-0.62.1.dist-info/RECORD,,
18
+ tracktolib-0.64.0.dist-info/WHEEL,sha256=z-mOpxbJHqy3cq6SvUThBZdaLGFZzdZPtgWLcP2NKjQ,79
19
+ tracktolib-0.64.0.dist-info/METADATA,sha256=ypN7PUQQ45bxXO0jJDuvQjZE0_T2aHiLfFV_ekUY_4U,3128
20
+ tracktolib-0.64.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.13
2
+ Generator: uv 0.9.15
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any