tracktolib 0.65.1__py3-none-any.whl → 0.66.2__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.
tracktolib/api.py CHANGED
@@ -4,7 +4,6 @@ from collections.abc import Mapping
4
4
  from dataclasses import field, dataclass
5
5
  from inspect import getdoc
6
6
  from typing import (
7
- TypeVar,
8
7
  Callable,
9
8
  Any,
10
9
  Literal,
@@ -31,11 +30,9 @@ try:
31
30
  except ImportError:
32
31
  raise ImportError('Please install fastapi, pydantic or tracktolib with "api" to use this module')
33
32
 
34
- D = TypeVar("D")
35
-
36
33
 
37
34
  # noqa: N802
38
- def Depends(
35
+ def Depends[D](
39
36
  dependency: Callable[
40
37
  ...,
41
38
  Coroutine[Any, Any, D] | Coroutine[Any, Any, D | None] | AsyncIterator[D] | D,
@@ -48,20 +45,19 @@ def Depends(
48
45
  return params.Depends(dependency, use_cache=use_cache) # pyright: ignore [reportReturnType]
49
46
 
50
47
 
51
- B = TypeVar("B", bound=BaseModel | None | Sequence[BaseModel])
52
-
53
- Response = Mapping | Sequence[Mapping] | B
48
+ type _BaseModelBound = BaseModel | None | Sequence[BaseModel]
49
+ type Response[B: _BaseModelBound] = Mapping | Sequence[Mapping] | B
54
50
 
55
51
  Method = Literal["GET", "POST", "DELETE", "PATCH", "PUT"]
56
52
 
57
- EnpointFn = Callable[..., Response]
53
+ type EnpointFn[B: _BaseModelBound] = Callable[..., Response[B]]
58
54
 
59
55
  Dependencies: TypeAlias = Sequence[params.Depends] | None
60
56
  StatusCode: TypeAlias = int | None
61
57
 
62
58
 
63
59
  class MethodMeta(TypedDict):
64
- fn: EnpointFn
60
+ fn: EnpointFn[Any]
65
61
  status_code: StatusCode
66
62
  dependencies: Dependencies
67
63
  path: str | None
@@ -81,7 +77,7 @@ class Endpoint:
81
77
  def methods(self):
82
78
  return self._methods
83
79
 
84
- def get(
80
+ def get[B: _BaseModelBound](
85
81
  self,
86
82
  status_code: StatusCode = None,
87
83
  dependencies: Dependencies = None,
@@ -107,7 +103,7 @@ class Endpoint:
107
103
  deprecated=deprecated,
108
104
  )
109
105
 
110
- def post(
106
+ def post[B: _BaseModelBound](
111
107
  self,
112
108
  *,
113
109
  status_code: StatusCode = None,
@@ -134,7 +130,7 @@ class Endpoint:
134
130
  deprecated=deprecated,
135
131
  )
136
132
 
137
- def put(
133
+ def put[B: _BaseModelBound](
138
134
  self,
139
135
  status_code: StatusCode = None,
140
136
  dependencies: Dependencies = None,
@@ -160,7 +156,7 @@ class Endpoint:
160
156
  deprecated=deprecated,
161
157
  )
162
158
 
163
- def delete(
159
+ def delete[B: _BaseModelBound](
164
160
  self,
165
161
  status_code: StatusCode = None,
166
162
  dependencies: Dependencies = None,
@@ -186,7 +182,7 @@ class Endpoint:
186
182
  deprecated=deprecated,
187
183
  )
188
184
 
189
- def patch(
185
+ def patch[B: _BaseModelBound](
190
186
  self,
191
187
  status_code: StatusCode = None,
192
188
  dependencies: Dependencies = None,
@@ -213,7 +209,7 @@ class Endpoint:
213
209
  )
214
210
 
215
211
 
216
- def _get_method_wrapper(
212
+ def _get_method_wrapper[B: _BaseModelBound](
217
213
  cls: Endpoint,
218
214
  method: Method,
219
215
  *,
@@ -339,7 +335,7 @@ def check_status(resp, status: int = starlette.status.HTTP_200_OK):
339
335
  raise AssertionError(json.dumps(resp.json(), indent=4))
340
336
 
341
337
 
342
- def generate_list_name_model(model: Type[B], status: int | None = None) -> dict:
338
+ def generate_list_name_model[B: _BaseModelBound](model: Type[B], status: int | None = None) -> dict:
343
339
  _status = "200" if status is None else str(status)
344
340
  if get_origin(model) and get_origin(model) is list:
345
341
  _title = f"Array[{get_args(model)[0].__name__}]"
tracktolib/http_utils.py CHANGED
@@ -1,7 +1,14 @@
1
1
  import typing
2
+ import warnings
2
3
  from io import TextIOWrapper, BufferedWriter
3
4
  from typing import BinaryIO, Callable, TextIO
4
5
 
6
+ warnings.warn(
7
+ "tracktolib.http_utils is deprecated and will be removed in a future version.",
8
+ DeprecationWarning,
9
+ stacklevel=2,
10
+ )
11
+
5
12
  try:
6
13
  import httpx
7
14
  from httpx._types import QueryParamTypes
@@ -33,6 +33,11 @@ def _use_data_source_api(api_version: str) -> bool:
33
33
  return api_version >= "2025-09-03"
34
34
 
35
35
 
36
+ def _get_api_version(session: niquests.Session, api_version: str | None) -> str:
37
+ """Get API version from parameter or session headers."""
38
+ return api_version or str(session.headers.get("Notion-Version", DEFAULT_API_VERSION))
39
+
40
+
36
41
  __all__ = (
37
42
  # Auth helpers
38
43
  "get_notion_headers",
@@ -223,7 +228,7 @@ async def create_page(
223
228
  For older API versions, parent should use {"database_id": "..."}.
224
229
  The function will automatically convert between the two formats.
225
230
  """
226
- _api_version = api_version or session.headers.get("Notion-Version", DEFAULT_API_VERSION)
231
+ _api_version = _get_api_version(session, api_version)
227
232
  converted_parent = _convert_parent_for_api_version(parent, _api_version)
228
233
  payload: dict[str, Any] = {
229
234
  "parent": converted_parent,
@@ -280,7 +285,7 @@ async def fetch_database(
280
285
  For API version 2025-09-03+, uses /v1/data_sources/{id} endpoint.
281
286
  For older API versions, uses /v1/databases/{id} endpoint.
282
287
  """
283
- _api_version = api_version or session.headers.get("Notion-Version", DEFAULT_API_VERSION)
288
+ _api_version = _get_api_version(session, api_version)
284
289
  if _use_data_source_api(_api_version):
285
290
  endpoint = f"{NOTION_API_URL}/v1/data_sources/{database_id}"
286
291
  else:
@@ -306,7 +311,7 @@ async def query_database(
306
311
  For API version 2025-09-03+, uses /v1/data_sources/{id}/query endpoint.
307
312
  For older API versions, uses /v1/databases/{id}/query endpoint.
308
313
  """
309
- _api_version = api_version or session.headers.get("Notion-Version", DEFAULT_API_VERSION)
314
+ _api_version = _get_api_version(session, api_version)
310
315
  payload: dict[str, Any] = {}
311
316
  if filter:
312
317
  payload["filter"] = filter
@@ -405,7 +410,7 @@ async def fetch_search(
405
410
  converted to 'data_source'. For older versions, 'data_source' is
406
411
  converted to 'database'.
407
412
  """
408
- _api_version = api_version or session.headers.get("Notion-Version", DEFAULT_API_VERSION)
413
+ _api_version = _get_api_version(session, api_version)
409
414
  payload: dict[str, Any] = {}
410
415
  if query:
411
416
  payload["query"] = query
tracktolib/pg/query.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import typing
2
2
  from dataclasses import dataclass, field
3
- from typing import TypeVar, Iterable, Callable, Generic, Iterator, TypeAlias, overload, Any, Literal
3
+ from typing import Iterable, Callable, Iterator, TypeAlias, overload, Any, Literal
4
4
 
5
5
  from ..pg_utils import get_conflict_query
6
6
 
@@ -11,21 +11,18 @@ except ImportError:
11
11
 
12
12
  from tracktolib.utils import fill_dict
13
13
 
14
- K = TypeVar("K", bound=str)
15
- V = TypeVar("V")
16
14
 
17
-
18
- def _get_insert_query(table: str, columns: Iterable[K], values: str) -> str:
15
+ def _get_insert_query[K: str](table: str, columns: Iterable[K], values: str) -> str:
19
16
  _columns = ", ".join(columns)
20
17
  return f"INSERT INTO {table} AS t ({_columns}) VALUES ({values})"
21
18
 
22
19
 
23
- def _get_returning_query(query: str, returning: Iterable[K]) -> str:
20
+ def _get_returning_query[K: str](query: str, returning: Iterable[K]) -> str:
24
21
  _returning = ", ".join(returning)
25
22
  return f"{query} RETURNING {_returning}"
26
23
 
27
24
 
28
- def _get_on_conflict_query(
25
+ def _get_on_conflict_query[K: str](
29
26
  query: str,
30
27
  columns: Iterable[K],
31
28
  update_columns: Iterable[K] | None,
@@ -47,14 +44,14 @@ def _get_on_conflict_query(
47
44
  return f"{query} {_on_conflict}"
48
45
 
49
46
 
50
- ReturningFn = Callable[[Iterable[K] | None, K | None], None]
51
- ConflictFn = Callable[[Iterable[K] | None, Iterable[K] | None, str | None], None]
47
+ type ReturningFn[K: str] = Callable[[Iterable[K] | None, K | None], None]
48
+ type ConflictFn[K: str] = Callable[[Iterable[K] | None, Iterable[K] | None, str | None], None]
52
49
 
53
- _Connection = asyncpg.Connection | asyncpg.pool.Pool
50
+ type _Connection = asyncpg.Connection | asyncpg.pool.Pool
54
51
 
55
52
 
56
53
  @dataclass
57
- class PGReturningQuery(Generic[K]):
54
+ class PGReturningQuery[K: str]:
58
55
  returning_ids: Iterable[K] | None = None
59
56
  query: str | None = None
60
57
 
@@ -69,7 +66,7 @@ class PGReturningQuery(Generic[K]):
69
66
 
70
67
 
71
68
  @dataclass
72
- class PGConflictQuery(Generic[K]):
69
+ class PGConflictQuery[K: str]:
73
70
  keys: Iterable[K] | None = None
74
71
  ignore_keys: Iterable[K] | None = None
75
72
  query: str | None = None
@@ -86,7 +83,7 @@ class PGConflictQuery(Generic[K]):
86
83
 
87
84
 
88
85
  @dataclass
89
- class PGQuery(Generic[K, V]):
86
+ class PGQuery[K: str, V]:
90
87
  table: str
91
88
  items: list[dict[K, V]]
92
89
 
@@ -328,7 +325,7 @@ class PGUpdateQuery(PGQuery):
328
325
  OnConflict: TypeAlias = PGConflictQuery | str
329
326
 
330
327
 
331
- def insert_pg(
328
+ def insert_pg[K: str](
332
329
  table: str,
333
330
  items: list[dict],
334
331
  *,
@@ -344,8 +341,7 @@ def insert_pg(
344
341
  )
345
342
 
346
343
 
347
- Q = TypeVar("Q", bound=PGInsertQuery | PGUpdateQuery)
348
- QueryCallback = Callable[[Q], None]
344
+ type QueryCallback[Q: PGInsertQuery | PGUpdateQuery] = Callable[[Q], None]
349
345
 
350
346
 
351
347
  async def insert_one(
@@ -488,7 +484,7 @@ async def fetch_count(conn: _Connection, query: str, *args) -> int:
488
484
  return typing.cast(int, c)
489
485
 
490
486
 
491
- def Conflict(
487
+ def Conflict[K: str](
492
488
  keys: Iterable[K],
493
489
  ignore_keys: Iterable[K] | None = None,
494
490
  ) -> PGConflictQuery:
tracktolib/pg_sync.py CHANGED
@@ -47,18 +47,18 @@ def fetch_count(engine: Connection, table: str, *args, where: str | None = None)
47
47
 
48
48
 
49
49
  @overload
50
- def fetch_one(engine: Connection, query: Query, *args, required: Literal[False]) -> dict | None: ...
50
+ def fetch_one(engine: Connection, query: LiteralString, *args, required: Literal[False]) -> dict | None: ...
51
51
 
52
52
 
53
53
  @overload
54
- def fetch_one(engine: Connection, query: Query, *args, required: Literal[True]) -> dict: ...
54
+ def fetch_one(engine: Connection, query: LiteralString, *args, required: Literal[True]) -> dict: ...
55
55
 
56
56
 
57
57
  @overload
58
- def fetch_one(engine: Connection, query: Query, *args) -> dict | None: ...
58
+ def fetch_one(engine: Connection, query: LiteralString, *args) -> dict | None: ...
59
59
 
60
60
 
61
- def fetch_one(engine: Connection, query: Query, *args, required: bool = False) -> dict | None:
61
+ def fetch_one(engine: Connection, query: LiteralString, *args, required: bool = False) -> dict | None:
62
62
  with engine.cursor(row_factory=dict_row) as cur:
63
63
  _data = cur.execute(query, args).fetchone()
64
64
  engine.commit()
tracktolib/s3/__init__.py CHANGED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,669 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import namedtuple
4
+ from pathlib import Path
5
+
6
+ import http
7
+ import xml.etree.ElementTree as ET
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass, field
10
+ from typing import AsyncIterator, Callable, Literal, Self, TypedDict, Unpack
11
+
12
+ try:
13
+ import botocore.client
14
+ import botocore.session
15
+ import jmespath
16
+ from botocore.config import Config
17
+ except ImportError as e:
18
+ raise ImportError("botocore is required for S3 operations. Install with tracktolib[s3-niquests]") from e
19
+
20
+ try:
21
+ import niquests
22
+ except ImportError as e:
23
+ raise ImportError("niquests is required for S3 operations. Install with tracktolib[s3-niquests]") from e
24
+
25
+ from ..utils import get_stream_chunk
26
+
27
+ __all__ = (
28
+ "S3Session",
29
+ "s3_delete_object",
30
+ "s3_delete_objects",
31
+ "s3_list_files",
32
+ "s3_put_object",
33
+ "s3_get_object",
34
+ "s3_download_file",
35
+ "s3_upload_file",
36
+ "s3_create_multipart_upload",
37
+ "s3_multipart_upload",
38
+ "s3_file_upload",
39
+ "S3MultipartUpload",
40
+ "S3Object",
41
+ "S3ObjectParams",
42
+ "UploadPart",
43
+ "build_s3_headers",
44
+ "build_s3_presigned_params",
45
+ )
46
+
47
+ ACL = Literal[
48
+ "private",
49
+ "public-read",
50
+ "public-read-write",
51
+ "authenticated-read",
52
+ "aws-exec-read",
53
+ "bucket-owner-read",
54
+ "bucket-owner-full-control",
55
+ ]
56
+
57
+ StorageClass = Literal[
58
+ "STANDARD",
59
+ "REDUCED_REDUNDANCY",
60
+ "STANDARD_IA",
61
+ "ONEZONE_IA",
62
+ "INTELLIGENT_TIERING",
63
+ "GLACIER",
64
+ "DEEP_ARCHIVE",
65
+ "OUTPOSTS",
66
+ "GLACIER_IR",
67
+ "EXPRESS_ONEZONE",
68
+ ]
69
+
70
+ ServerSideEncryption = Literal["AES256", "aws:kms", "aws:kms:dsse"]
71
+
72
+
73
+ class S3ObjectParams(TypedDict, total=False):
74
+ """
75
+ Parameters for S3 object uploads (PutObject, CreateMultipartUpload).
76
+
77
+ See:
78
+ - https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
79
+ - https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
80
+ """
81
+
82
+ acl: ACL | None
83
+ content_type: str | None
84
+ content_disposition: str | None
85
+ content_encoding: str | None
86
+ content_language: str | None
87
+ cache_control: str | None
88
+ storage_class: StorageClass | None
89
+ server_side_encryption: ServerSideEncryption | None
90
+ sse_kms_key_id: str | None
91
+ tagging: str | None # URL-encoded key=value pairs
92
+ metadata: dict[str, str] | None # User-defined metadata (x-amz-meta-*)
93
+
94
+
95
+ def build_s3_headers(params: S3ObjectParams) -> dict[str, str]:
96
+ """
97
+ Build S3 request headers from S3ObjectParams.
98
+
99
+ Returns a dict of HTTP headers to include in the request.
100
+ """
101
+ headers: dict[str, str] = {}
102
+
103
+ if (acl := params.get("acl")) is not None:
104
+ headers["x-amz-acl"] = acl
105
+ if (content_type := params.get("content_type")) is not None:
106
+ headers["Content-Type"] = content_type
107
+ if (content_disposition := params.get("content_disposition")) is not None:
108
+ headers["Content-Disposition"] = content_disposition
109
+ if (content_encoding := params.get("content_encoding")) is not None:
110
+ headers["Content-Encoding"] = content_encoding
111
+ if (content_language := params.get("content_language")) is not None:
112
+ headers["Content-Language"] = content_language
113
+ if (cache_control := params.get("cache_control")) is not None:
114
+ headers["Cache-Control"] = cache_control
115
+ if (storage_class := params.get("storage_class")) is not None:
116
+ headers["x-amz-storage-class"] = storage_class
117
+ if (sse := params.get("server_side_encryption")) is not None:
118
+ headers["x-amz-server-side-encryption"] = sse
119
+ if (sse_kms_key_id := params.get("sse_kms_key_id")) is not None:
120
+ headers["x-amz-server-side-encryption-aws-kms-key-id"] = sse_kms_key_id
121
+ if (tagging := params.get("tagging")) is not None:
122
+ headers["x-amz-tagging"] = tagging
123
+ if (metadata := params.get("metadata")) is not None:
124
+ for key, value in metadata.items():
125
+ headers[f"x-amz-meta-{key}"] = value
126
+
127
+ return headers
128
+
129
+
130
+ def build_s3_presigned_params(bucket: str, key: str, params: S3ObjectParams) -> dict:
131
+ """
132
+ Build parameters dict for botocore generate_presigned_url.
133
+
134
+ Maps S3ObjectParams to the Params dict expected by botocore.
135
+ """
136
+ presigned_params: dict = {"Bucket": bucket, "Key": key}
137
+
138
+ if (acl := params.get("acl")) is not None:
139
+ presigned_params["ACL"] = acl
140
+ if (content_type := params.get("content_type")) is not None:
141
+ presigned_params["ContentType"] = content_type
142
+ if (content_disposition := params.get("content_disposition")) is not None:
143
+ presigned_params["ContentDisposition"] = content_disposition
144
+ if (content_encoding := params.get("content_encoding")) is not None:
145
+ presigned_params["ContentEncoding"] = content_encoding
146
+ if (content_language := params.get("content_language")) is not None:
147
+ presigned_params["ContentLanguage"] = content_language
148
+ if (cache_control := params.get("cache_control")) is not None:
149
+ presigned_params["CacheControl"] = cache_control
150
+ if (storage_class := params.get("storage_class")) is not None:
151
+ presigned_params["StorageClass"] = storage_class
152
+ if (sse := params.get("server_side_encryption")) is not None:
153
+ presigned_params["ServerSideEncryption"] = sse
154
+ if (sse_kms_key_id := params.get("sse_kms_key_id")) is not None:
155
+ presigned_params["SSEKMSKeyId"] = sse_kms_key_id
156
+ if (tagging := params.get("tagging")) is not None:
157
+ presigned_params["Tagging"] = tagging
158
+ if (metadata := params.get("metadata")) is not None:
159
+ presigned_params["Metadata"] = metadata
160
+
161
+ return presigned_params
162
+
163
+
164
+ @dataclass
165
+ class S3Session:
166
+ """
167
+ Utility class that wraps botocore S3 client and niquests async session.
168
+
169
+ Usage:
170
+ async with S3Session(
171
+ endpoint_url='http://localhost:9000',
172
+ access_key='foo',
173
+ secret_key='bar',
174
+ ) as s3:
175
+ await s3.put_object('my-bucket', 'path/to/file.txt', b'content')
176
+ content = await s3.get_object('my-bucket', 'path/to/file.txt')
177
+
178
+ # With custom clients:
179
+ async with S3Session(
180
+ endpoint_url='...',
181
+ access_key='...',
182
+ secret_key='...',
183
+ s3_client=my_s3_client,
184
+ http_client=my_http_session,
185
+ ) as s3:
186
+ ...
187
+ """
188
+
189
+ endpoint_url: str
190
+ access_key: str
191
+ secret_key: str
192
+ region: str
193
+ s3_config: Config | None = None
194
+ s3_client: botocore.client.BaseClient | None = None
195
+ http_client: niquests.AsyncSession = field(default_factory=niquests.AsyncSession)
196
+
197
+ def __post_init__(self):
198
+ if self.s3_client is None:
199
+ session = botocore.session.Session()
200
+ self.s3_client = session.create_client(
201
+ "s3",
202
+ endpoint_url=self.endpoint_url,
203
+ region_name=self.region,
204
+ aws_access_key_id=self.access_key,
205
+ aws_secret_access_key=self.secret_key,
206
+ config=self.s3_config,
207
+ )
208
+
209
+ @property
210
+ def _s3(self) -> botocore.client.BaseClient:
211
+ if self.s3_client is None:
212
+ raise ValueError("s3_client not initialized")
213
+ return self.s3_client
214
+
215
+ async def __aenter__(self) -> Self:
216
+ await self.http_client.__aenter__()
217
+ return self
218
+
219
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
220
+ await self.http_client.__aexit__(exc_type, exc_val, exc_tb)
221
+ self._s3.close()
222
+
223
+ async def delete_object(self, bucket: str, key: str) -> niquests.Response:
224
+ """Delete an object from S3."""
225
+ return await s3_delete_object(self._s3, self.http_client, bucket, key)
226
+
227
+ async def delete_objects(self, bucket: str, keys: list[str]) -> list[niquests.Response]:
228
+ """Delete multiple objects from S3."""
229
+ return await s3_delete_objects(self._s3, self.http_client, bucket, keys)
230
+
231
+ def list_files(
232
+ self,
233
+ bucket: str,
234
+ prefix: str,
235
+ *,
236
+ search_query: str | None = None,
237
+ max_items: int | None = None,
238
+ page_size: int | None = None,
239
+ starting_token: str | None = None,
240
+ ) -> AsyncIterator[S3Object]:
241
+ """List files in an S3 bucket with a given prefix."""
242
+ return s3_list_files(
243
+ self._s3,
244
+ self.http_client,
245
+ bucket,
246
+ prefix,
247
+ search_query=search_query,
248
+ max_items=max_items,
249
+ page_size=page_size,
250
+ starting_token=starting_token,
251
+ )
252
+
253
+ async def put_object(
254
+ self, bucket: str, key: str, data: bytes, **kwargs: Unpack[S3ObjectParams]
255
+ ) -> niquests.Response:
256
+ """Upload an object to S3."""
257
+ return await s3_put_object(self._s3, self.http_client, bucket, key, data, **kwargs)
258
+
259
+ async def upload_file(
260
+ self, bucket: str, file: Path, path: str, **kwargs: Unpack[S3ObjectParams]
261
+ ) -> niquests.Response:
262
+ """Upload a file to S3."""
263
+ return await s3_upload_file(self._s3, self.http_client, bucket, file, path, **kwargs)
264
+
265
+ async def get_object(self, bucket: str, key: str) -> bytes | None:
266
+ """Download an object from S3."""
267
+ return await s3_get_object(self._s3, self.http_client, bucket, key)
268
+
269
+ async def download_file(
270
+ self,
271
+ bucket: str,
272
+ key: str,
273
+ on_chunk: Callable[[bytes], None] | None = None,
274
+ chunk_size: int = 1024 * 1024,
275
+ ) -> AsyncIterator[bytes]:
276
+ """Download a file from S3 with streaming support."""
277
+ async for chunk in s3_download_file(self._s3, self.http_client, bucket, key, chunk_size=chunk_size):
278
+ if on_chunk:
279
+ on_chunk(chunk)
280
+ yield chunk
281
+
282
+ def multipart_upload(self, bucket: str, key: str, *, expires_in: int = 3600, **kwargs: Unpack[S3ObjectParams]):
283
+ """Create a multipart upload context manager."""
284
+ return s3_multipart_upload(self._s3, self.http_client, bucket, key, expires_in=expires_in, **kwargs)
285
+
286
+ async def file_upload(
287
+ self,
288
+ bucket: str,
289
+ key: str,
290
+ data: AsyncIterator[bytes],
291
+ *,
292
+ min_part_size: int = 5 * 1024 * 1024,
293
+ on_chunk_received: Callable[[bytes], None] | None = None,
294
+ content_length: int | None = None,
295
+ **kwargs: Unpack[S3ObjectParams],
296
+ ) -> None:
297
+ """Upload a file to S3 using streaming (multipart for large files)."""
298
+ return await s3_file_upload(
299
+ self._s3,
300
+ self.http_client,
301
+ bucket,
302
+ key,
303
+ data,
304
+ min_part_size=min_part_size,
305
+ on_chunk_received=on_chunk_received,
306
+ content_length=content_length,
307
+ **kwargs,
308
+ )
309
+
310
+
311
+ S3MultipartUpload = namedtuple(
312
+ "S3MultipartUpload", ["fetch_create", "fetch_complete", "upload_part", "generate_presigned_url", "fetch_abort"]
313
+ )
314
+
315
+
316
+ class UploadPart(TypedDict):
317
+ PartNumber: int
318
+ ETag: str | None
319
+
320
+
321
+ class S3Object(TypedDict, total=False):
322
+ Key: str
323
+ LastModified: str
324
+ ETag: str
325
+ Size: int
326
+ StorageClass: str
327
+
328
+
329
+ async def s3_delete_object(
330
+ s3: botocore.client.BaseClient, client: niquests.AsyncSession, bucket: str, key: str
331
+ ) -> niquests.Response:
332
+ """Delete an object from S3 using presigned URL."""
333
+ url = s3.generate_presigned_url(
334
+ ClientMethod="delete_object",
335
+ Params={
336
+ "Bucket": bucket,
337
+ "Key": key,
338
+ },
339
+ )
340
+ return (await client.delete(url)).raise_for_status()
341
+
342
+
343
+ async def s3_delete_objects(
344
+ s3: botocore.client.BaseClient, client: niquests.AsyncSession, bucket: str, keys: list[str]
345
+ ) -> list[niquests.Response]:
346
+ """Delete multiple objects from S3 using presigned URLs."""
347
+ responses = []
348
+ for key in keys:
349
+ resp = await s3_delete_object(s3, client, bucket, key)
350
+ responses.append(resp)
351
+ return responses
352
+
353
+
354
+ async def s3_list_files(
355
+ s3: botocore.client.BaseClient,
356
+ client: niquests.AsyncSession,
357
+ bucket: str,
358
+ prefix: str,
359
+ *,
360
+ search_query: str | None = None,
361
+ max_items: int | None = None,
362
+ page_size: int | None = None,
363
+ starting_token: str | None = None,
364
+ ) -> AsyncIterator[S3Object]:
365
+ """
366
+ List files in an S3 bucket with a given prefix.
367
+
368
+ Yields dicts with 'Key', 'LastModified', 'Size', etc. Use `search_query` for
369
+ JMESPath filtering (e.g. "Contents[?Size > `100`][]"), `max_items` to limit
370
+ total results, `page_size` to control items per request, and `starting_token`
371
+ to resume from a previous continuation token.
372
+ """
373
+ api_version = s3.meta.service_model.api_version
374
+ ns = {"s3": f"http://s3.amazonaws.com/doc/{api_version}/"}
375
+
376
+ continuation_token = starting_token
377
+ items_yielded = 0
378
+
379
+ while True:
380
+ params: dict = {"Bucket": bucket, "Prefix": prefix}
381
+ if continuation_token:
382
+ params["ContinuationToken"] = continuation_token
383
+ if page_size is not None:
384
+ params["MaxKeys"] = page_size
385
+
386
+ url = s3.generate_presigned_url(
387
+ ClientMethod="list_objects_v2",
388
+ Params=params,
389
+ )
390
+
391
+ resp = (await client.get(url)).raise_for_status()
392
+ if resp.content is None:
393
+ return
394
+ root = ET.fromstring(resp.content)
395
+
396
+ page_items: list[S3Object] = []
397
+ for contents in root.findall("s3:Contents", ns):
398
+ item: S3Object = {}
399
+ for child in contents:
400
+ tag = child.tag.replace(f"{{{ns['s3']}}}", "")
401
+ item[tag] = child.text
402
+ if "Size" in item:
403
+ item["Size"] = int(item["Size"])
404
+ page_items.append(item)
405
+
406
+ if search_query:
407
+ page_items = jmespath.search(search_query, {"Contents": page_items}) or []
408
+
409
+ for item in page_items:
410
+ if max_items is not None and items_yielded >= max_items:
411
+ return
412
+ yield item
413
+ items_yielded += 1
414
+
415
+ is_truncated = root.find("s3:IsTruncated", ns)
416
+ if is_truncated is not None and is_truncated.text == "true":
417
+ next_token = root.find("s3:NextContinuationToken", ns)
418
+ continuation_token = next_token.text if next_token is not None else None
419
+ else:
420
+ break
421
+
422
+
423
+ async def s3_put_object(
424
+ s3: botocore.client.BaseClient,
425
+ client: niquests.AsyncSession,
426
+ bucket: str,
427
+ key: str,
428
+ data: bytes,
429
+ **kwargs: Unpack[S3ObjectParams],
430
+ ) -> niquests.Response:
431
+ """
432
+ Upload an object to S3 using presigned URL.
433
+
434
+ See: https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
435
+ """
436
+ obj_params: S3ObjectParams = kwargs
437
+ presigned_params = build_s3_presigned_params(bucket, key, obj_params)
438
+ headers = build_s3_headers(obj_params)
439
+
440
+ url = s3.generate_presigned_url(
441
+ ClientMethod="put_object",
442
+ Params=presigned_params,
443
+ )
444
+ resp = (await client.put(url, data=data, headers=headers if headers else None)).raise_for_status()
445
+ return resp
446
+
447
+
448
+ async def s3_upload_file(
449
+ s3: botocore.client.BaseClient,
450
+ client: niquests.AsyncSession,
451
+ bucket: str,
452
+ file: Path,
453
+ path: str,
454
+ **kwargs: Unpack[S3ObjectParams],
455
+ ) -> niquests.Response:
456
+ """
457
+ Upload a file to S3 using presigned URL.
458
+ This is a convenience wrapper around s3_put_object that reads the file content.
459
+ """
460
+ return await s3_put_object(s3, client, bucket, path, file.read_bytes(), **kwargs)
461
+
462
+
463
+ async def s3_get_object(
464
+ s3: botocore.client.BaseClient, client: niquests.AsyncSession, bucket: str, key: str
465
+ ) -> bytes | None:
466
+ """Download an object from S3 using presigned URL."""
467
+ url = s3.generate_presigned_url(
468
+ ClientMethod="get_object",
469
+ Params={
470
+ "Bucket": bucket,
471
+ "Key": key,
472
+ },
473
+ )
474
+ resp = await client.get(url)
475
+ if resp.status_code == http.HTTPStatus.NOT_FOUND:
476
+ return None
477
+ resp.raise_for_status()
478
+ return resp.content
479
+
480
+
481
+ async def s3_download_file(
482
+ s3: botocore.client.BaseClient,
483
+ client: niquests.AsyncSession,
484
+ bucket: str,
485
+ key: str,
486
+ *,
487
+ chunk_size: int = 1024 * 1024,
488
+ ) -> AsyncIterator[bytes]:
489
+ """Download an object from S3 with streaming support."""
490
+ url = s3.generate_presigned_url(
491
+ ClientMethod="get_object",
492
+ Params={"Bucket": bucket, "Key": key},
493
+ )
494
+ resp = await client.get(url, stream=True)
495
+ resp.raise_for_status()
496
+ async for chunk in await resp.iter_content(chunk_size):
497
+ yield chunk
498
+
499
+
500
+ async def s3_create_multipart_upload(
501
+ s3: botocore.client.BaseClient,
502
+ client: niquests.AsyncSession,
503
+ bucket: str,
504
+ key: str,
505
+ *,
506
+ expires_in: int = 3600,
507
+ generate_presigned_url: Callable[..., str] | None = None,
508
+ **kwargs: Unpack[S3ObjectParams],
509
+ ) -> str:
510
+ """
511
+ Initiate a multipart upload and return the UploadId.
512
+
513
+ See: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
514
+ """
515
+ obj_params: S3ObjectParams = kwargs
516
+ headers = build_s3_headers(obj_params)
517
+
518
+ if generate_presigned_url is not None:
519
+ url = generate_presigned_url("create_multipart_upload")
520
+ else:
521
+ presigned_params = build_s3_presigned_params(bucket, key, obj_params)
522
+ url = s3.generate_presigned_url(
523
+ ClientMethod="create_multipart_upload",
524
+ Params=presigned_params,
525
+ ExpiresIn=expires_in,
526
+ )
527
+ resp = (await client.post(url, headers=headers if headers else None)).raise_for_status()
528
+ if resp.content is None:
529
+ raise ValueError("Empty response from create_multipart_upload")
530
+ api_version = s3.meta.service_model.api_version
531
+ ns = {"s3": f"http://s3.amazonaws.com/doc/{api_version}/"}
532
+ root = ET.fromstring(resp.content)
533
+ upload_id_elem = root.find("s3:UploadId", ns)
534
+ if upload_id_elem is None or upload_id_elem.text is None:
535
+ raise ValueError("UploadId not found in response")
536
+ return upload_id_elem.text
537
+
538
+
539
+ @asynccontextmanager
540
+ async def s3_multipart_upload(
541
+ s3: botocore.client.BaseClient,
542
+ client: niquests.AsyncSession,
543
+ bucket: str,
544
+ key: str,
545
+ *,
546
+ expires_in: int = 3600,
547
+ **kwargs: Unpack[S3ObjectParams],
548
+ ) -> AsyncIterator[S3MultipartUpload]:
549
+ """Async context manager for S3 multipart upload with automatic cleanup."""
550
+ obj_params: S3ObjectParams = kwargs
551
+ upload_id: str | None = None
552
+ _part_number: int = 1
553
+ _parts: list[UploadPart] = []
554
+ _has_been_aborted = False
555
+
556
+ async def fetch_complete():
557
+ if upload_id is None:
558
+ raise ValueError("Upload ID is not set")
559
+ complete_url = _generate_presigned_url("complete_multipart_upload", UploadId=upload_id)
560
+ # Create XML payload for completing multipart upload
561
+ parts_xml = "".join(
562
+ f"<Part><PartNumber>{part['PartNumber']}</PartNumber><ETag>{part['ETag']}</ETag></Part>" for part in _parts
563
+ )
564
+ xml_payload = f"<CompleteMultipartUpload>{parts_xml}</CompleteMultipartUpload>"
565
+
566
+ return (
567
+ await client.post(complete_url, data=xml_payload, headers={"Content-Type": "application/xml"})
568
+ ).raise_for_status()
569
+
570
+ async def fetch_abort():
571
+ nonlocal _has_been_aborted
572
+ if upload_id is None:
573
+ raise ValueError("Upload ID is not set")
574
+ abort_url = _generate_presigned_url("abort_multipart_upload", UploadId=upload_id)
575
+ abort_resp = (await client.delete(abort_url)).raise_for_status()
576
+ _has_been_aborted = True
577
+ return abort_resp
578
+
579
+ async def upload_part(data: bytes) -> UploadPart:
580
+ nonlocal _part_number, _parts
581
+ if upload_id is None:
582
+ raise ValueError("Upload ID is not set")
583
+ presigned_url = _generate_presigned_url("upload_part", UploadId=upload_id, PartNumber=_part_number)
584
+ upload_resp = (await client.put(presigned_url, data=data)).raise_for_status()
585
+ _etag = upload_resp.headers.get("ETag")
586
+ etag: str | None = _etag.decode() if isinstance(_etag, bytes) else _etag
587
+ _part: UploadPart = {"PartNumber": _part_number, "ETag": etag}
588
+ _parts.append(_part)
589
+ _part_number += 1
590
+ return _part
591
+
592
+ def _generate_presigned_url(method: str, **params):
593
+ if method == "create_multipart_upload":
594
+ _params = {**build_s3_presigned_params(bucket, key, obj_params), **params}
595
+ else:
596
+ _params = {"Bucket": bucket, "Key": key, **params}
597
+ return s3.generate_presigned_url(ClientMethod=method, Params=_params, ExpiresIn=expires_in)
598
+
599
+ async def fetch_create() -> str:
600
+ nonlocal upload_id
601
+ upload_id = await s3_create_multipart_upload(
602
+ s3, client, bucket, key, expires_in=expires_in, generate_presigned_url=_generate_presigned_url, **kwargs
603
+ )
604
+ return upload_id
605
+
606
+ try:
607
+ yield S3MultipartUpload(
608
+ fetch_create=fetch_create,
609
+ fetch_complete=fetch_complete,
610
+ upload_part=upload_part,
611
+ fetch_abort=fetch_abort,
612
+ generate_presigned_url=_generate_presigned_url,
613
+ )
614
+ except Exception as e:
615
+ if not _has_been_aborted and upload_id is not None:
616
+ await fetch_abort()
617
+ raise e
618
+ else:
619
+ if not _has_been_aborted and upload_id is not None:
620
+ await fetch_complete()
621
+
622
+
623
+ async def s3_file_upload(
624
+ s3: botocore.client.BaseClient,
625
+ client: niquests.AsyncSession,
626
+ bucket: str,
627
+ key: str,
628
+ data: AsyncIterator[bytes],
629
+ *,
630
+ # 5MB minimum for S3 parts
631
+ min_part_size: int = 5 * 1024 * 1024,
632
+ on_chunk_received: Callable[[bytes], None] | None = None,
633
+ content_length: int | None = None,
634
+ **kwargs: Unpack[S3ObjectParams],
635
+ ) -> None:
636
+ """
637
+ Upload a file to S3 from an async byte stream.
638
+
639
+ Uses multipart upload for large files. If `content_length` is provided and smaller
640
+ than `min_part_size`, uses a single PUT instead. Use `on_chunk_received` callback
641
+ to track upload progress.
642
+ """
643
+ if content_length is not None and content_length < min_part_size:
644
+ # Small file - use single PUT operation
645
+ _data = b""
646
+ async for chunk in data:
647
+ _data += chunk
648
+ if on_chunk_received:
649
+ on_chunk_received(chunk)
650
+ await s3_put_object(s3, client, bucket=bucket, key=key, data=_data, **kwargs)
651
+ return
652
+
653
+ async with s3_multipart_upload(s3, client, bucket=bucket, key=key, **kwargs) as mpart:
654
+ await mpart.fetch_create()
655
+ has_uploaded_parts = False
656
+ async for chunk in get_stream_chunk(data, min_size=min_part_size):
657
+ if on_chunk_received:
658
+ on_chunk_received(chunk)
659
+ if len(chunk) < min_part_size:
660
+ if not has_uploaded_parts:
661
+ # No parts uploaded yet, abort multipart and use single PUT
662
+ await mpart.fetch_abort()
663
+ await s3_put_object(s3, client, bucket=bucket, key=key, data=chunk, **kwargs)
664
+ else:
665
+ # Parts already uploaded, upload final chunk as last part (S3 allows last part to be smaller)
666
+ await mpart.upload_part(chunk)
667
+ return
668
+ await mpart.upload_part(chunk)
669
+ has_uploaded_parts = True
tracktolib/s3/s3.py CHANGED
@@ -1,8 +1,15 @@
1
1
  import datetime as dt
2
+ import warnings
2
3
  from io import BytesIO
3
4
  from pathlib import Path
4
5
  from typing import TypedDict, Literal, Callable
5
6
 
7
+ warnings.warn(
8
+ "tracktolib.s3.s3 is deprecated, use tracktolib.s3.niquests instead",
9
+ DeprecationWarning,
10
+ stacklevel=2,
11
+ )
12
+
6
13
  try:
7
14
  from aiobotocore.client import AioBaseClient
8
15
  except ImportError:
@@ -82,37 +89,12 @@ async def download_file(
82
89
 
83
90
 
84
91
  async def delete_file(client: AioBaseClient, bucket: str, path: str) -> dict:
85
- """
86
- Delete a file from an S3 bucket.
87
-
88
- Args:
89
- client (AioBaseClient): The client to interact with the S3 service.
90
- bucket (str): The name of the S3 bucket.
91
- path (str): The path to the file within the S3 bucket.
92
-
93
- Return:
94
- dict: The response from the S3 service after attempting to delete the file.
95
- This typically includes metadata about the operation, such as HTTP status code,
96
- any errors encountered, and information about the deleted object.
97
- """
92
+ """Delete a file from an S3 bucket."""
98
93
  return await client.delete_object(Bucket=bucket, Key=path) # type:ignore
99
94
 
100
95
 
101
96
  async def delete_files(client: AioBaseClient, bucket: str, paths: list[str], quiet: bool = True) -> dict:
102
- """
103
- Delete multiple files from an S3 bucket.
104
-
105
- Args:
106
- client (AioBaseClient): The client to interact with the S3 service.
107
- bucket (str): The name of the S3 bucket.
108
- paths (str): The paths to the files to delete within the S3 bucket.
109
- quiet (bool): Whether to suppress printing messages to stdout (default: True).
110
-
111
- Return:
112
- dict: The response from the S3 service after attempting to delete the files.
113
- This typically includes metadata about the operation, such as HTTP status code,
114
- any errors encountered, and information about the deleted object.
115
- """
97
+ """Delete multiple files from an S3 bucket. Set `quiet=False` to print deletion messages."""
116
98
  delete_request = {"Objects": [{"Key": path} for path in paths], "Quiet": quiet}
117
99
  return await client.delete_objects(Bucket=bucket, Delete=delete_request) # type:ignore
118
100
 
tracktolib/utils.py CHANGED
@@ -1,5 +1,5 @@
1
- import sys
2
- from types import ModuleType
1
+ from pathlib import Path
2
+
3
3
  import asyncio
4
4
  import datetime as dt
5
5
  import importlib.util
@@ -7,13 +7,11 @@ import itertools
7
7
  import mmap
8
8
  import os
9
9
  import subprocess
10
+ import sys
10
11
  from decimal import Decimal
11
12
  from ipaddress import IPv4Address, IPv6Address
12
- from pathlib import Path
13
- from typing import Iterable, TypeVar, Iterator, Literal, overload, Any, Callable
14
-
15
-
16
- T = TypeVar("T")
13
+ from types import ModuleType
14
+ from typing import AsyncIterable, AsyncIterator, Iterable, Iterator, Literal, overload, Any, Callable
17
15
 
18
16
  type OnCmdUpdate = Callable[[str], None]
19
17
  type OnCmdDone = Callable[[str, str, int], None]
@@ -87,24 +85,46 @@ def import_module(path: Path):
87
85
 
88
86
 
89
87
  @overload
90
- def get_chunks(it: Iterable[T], size: int, *, as_list: Literal[False]) -> Iterator[Iterable[T]]: ...
88
+ def get_chunks[T](it: Iterable[T], size: int, *, as_list: Literal[False]) -> Iterator[Iterable[T]]: ...
91
89
 
92
90
 
93
91
  @overload
94
- def get_chunks(it: Iterable[T], size: int, *, as_list: Literal[True]) -> Iterator[list[T]]: ...
92
+ def get_chunks[T](it: Iterable[T], size: int, *, as_list: Literal[True]) -> Iterator[list[T]]: ...
95
93
 
96
94
 
97
95
  @overload
98
- def get_chunks(it: Iterable[T], size: int) -> Iterator[list[T]]: ...
96
+ def get_chunks[T](it: Iterable[T], size: int) -> Iterator[list[T]]: ...
99
97
 
100
98
 
101
- def get_chunks(it: Iterable[T], size: int, *, as_list: bool = True) -> Iterator[Iterable[T]]:
99
+ def get_chunks[T](it: Iterable[T], size: int, *, as_list: bool = True) -> Iterator[Iterable[T]]:
102
100
  iterator = iter(it)
103
101
  for first in iterator:
104
102
  d = itertools.chain([first], itertools.islice(iterator, size - 1))
105
103
  yield d if not as_list else list(d)
106
104
 
107
105
 
106
+ async def get_stream_chunk[S: (bytes, str)](data_stream: AsyncIterable[S], min_size: int) -> AsyncIterator[S]:
107
+ """Buffers an async stream and yields chunks of at least `min_size`."""
108
+ buffer: S | None = None
109
+ buffer_size = 0
110
+
111
+ async for chunk in data_stream:
112
+ if not chunk:
113
+ continue
114
+ buffer = chunk if buffer is None else buffer + chunk # type: ignore[operator]
115
+ buffer_size += len(chunk)
116
+
117
+ # Yield chunks of min_size while we have enough data for at least 2 chunks
118
+ while buffer_size >= min_size * 2:
119
+ yield buffer[:min_size]
120
+ buffer = buffer[min_size:]
121
+ buffer_size -= min_size
122
+
123
+ # Handle the final chunk(s)
124
+ if buffer is not None and buffer_size > 0:
125
+ yield buffer
126
+
127
+
108
128
  def json_serial(obj):
109
129
  """JSON serializer for objects not serializable by default json code"""
110
130
  if isinstance(obj, (dt.datetime, dt.date)):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tracktolib
3
- Version: 0.65.1
3
+ Version: 0.66.2
4
4
  Summary: Utility library for python
5
5
  Keywords: utility
6
6
  Author-email: julien.brayere@tracktor.fr
@@ -14,13 +14,15 @@ Requires-Dist: fastapi>=0.103.2 ; extra == 'api'
14
14
  Requires-Dist: pydantic>=2 ; extra == 'api'
15
15
  Requires-Dist: httpx>=0.25.0 ; extra == 'http'
16
16
  Requires-Dist: python-json-logger>=3.2.1 ; extra == 'logs'
17
- Requires-Dist: niquests>=3.15.2 ; extra == 'notion'
17
+ Requires-Dist: niquests>=3.17.0 ; extra == 'notion'
18
18
  Requires-Dist: asyncpg>=0.27.0 ; extra == 'pg'
19
19
  Requires-Dist: rich>=13.6.0 ; extra == 'pg'
20
20
  Requires-Dist: psycopg>=3.1.12 ; extra == 'pg-sync'
21
21
  Requires-Dist: aiobotocore>=2.9.0 ; extra == 's3'
22
22
  Requires-Dist: minio>=7.2.0 ; extra == 's3-minio'
23
23
  Requires-Dist: pycryptodome>=3.20.0 ; extra == 's3-minio'
24
+ Requires-Dist: botocore>=1.35.36 ; extra == 's3-niquests'
25
+ Requires-Dist: niquests>=3.17.0 ; extra == 's3-niquests'
24
26
  Requires-Dist: deepdiff>=8.1.0 ; extra == 'tests'
25
27
  Requires-Python: >=3.12, <4.0
26
28
  Provides-Extra: api
@@ -31,6 +33,7 @@ Provides-Extra: pg
31
33
  Provides-Extra: pg-sync
32
34
  Provides-Extra: s3
33
35
  Provides-Extra: s3-minio
36
+ Provides-Extra: s3-niquests
34
37
  Provides-Extra: tests
35
38
  Description-Content-Type: text/markdown
36
39
 
@@ -38,9 +41,9 @@ Description-Content-Type: text/markdown
38
41
 
39
42
  [![Python versions](https://img.shields.io/pypi/pyversions/tracktolib)](https://pypi.python.org/pypi/tracktolib)
40
43
  [![Latest PyPI version](https://img.shields.io/pypi/v/tracktolib?logo=pypi)](https://pypi.python.org/pypi/tracktolib)
41
- [![CircleCI](https://circleci.com/gh/Tracktor/tracktolib/tree/master.svg?style=shield)](https://app.circleci.com/pipelines/github/Tracktor/tracktolib?branch=master)
44
+ [![CI](https://github.com/Tracktor/tracktolib/actions/workflows/ci.yml/badge.svg)](https://github.com/Tracktor/tracktolib/actions/workflows/ci.yml)
42
45
 
43
- Utility library for Python 3.12+
46
+ Tracktor Swiss-knife Utility library.
44
47
 
45
48
  ## Installation
46
49
 
@@ -121,7 +124,15 @@ S3 helpers using [minio](https://min.io/docs/minio/linux/developers/python/API.h
121
124
  uv add tracktolib[s3-minio]
122
125
  ```
123
126
 
124
- ### http
127
+ ### s3-niquests
128
+
129
+ Async S3 helpers using [niquests](https://github.com/jawah/niquests) and [botocore](https://github.com/boto/botocore).
130
+
131
+ ```bash
132
+ uv add tracktolib[s3-niquests]
133
+ ```
134
+
135
+ ### http (deprecated)
125
136
 
126
137
  HTTP client helpers using [httpx](https://www.python-httpx.org/).
127
138
 
@@ -151,4 +162,4 @@ Testing utilities using [deepdiff](https://github.com/seperman/deepdiff).
151
162
 
152
163
  ```bash
153
164
  uv add tracktolib[tests]
154
- ```
165
+ ```
@@ -0,0 +1,21 @@
1
+ tracktolib/__init__.py,sha256=Q9d6h2lNjcYzxvfJ3zlNcpiP_Ak0T3TBPWINzZNrhu0,173
2
+ tracktolib/api.py,sha256=-TepGdrKH7SAvQBEuSt49aE5-XaSHIX9ugUActEwgqY,10389
3
+ tracktolib/http_utils.py,sha256=_PJlvmKBwaJAGOWYnwU4LP_yV3oaMCk9nrI1u2iFBuk,2785
4
+ tracktolib/logs.py,sha256=D2hx6urXl5l4PBGP8mCpcT4GX7tJeFfNY-7oBfHczBU,2191
5
+ tracktolib/notion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ tracktolib/notion/fetch.py,sha256=fQw42gab5eDfphmtzeRYZKWFLFZztjxTLgX5xfc46Pc,13437
7
+ tracktolib/notion/models.py,sha256=FbTJcK0eA-4phpfjUxyAW7cs5jHZQxB6qqZ75ZcJ7uw,5608
8
+ tracktolib/pg/__init__.py,sha256=Ul_hgwvTXZvQBt7sHKi4ZI-0DDpnXmoFtmVkGRy-1J0,366
9
+ tracktolib/pg/query.py,sha256=Sarwvs8cSqiOQLUnpTOx2XsDClr0dKACPvQfTl_v8_Y,19346
10
+ tracktolib/pg/utils.py,sha256=ygQn63EBDaEGB0p7P2ibellO2mv-StafanpXKcCUiZU,6324
11
+ tracktolib/pg_sync.py,sha256=PDTN37kU0BxkSZetwSAtqcW2aA8Nn4gUI2mC54gSJhg,6750
12
+ tracktolib/pg_utils.py,sha256=ArYNdf9qsdYdzGEWmev8tZpyx8_1jaGGdkfYkauM7UM,2582
13
+ tracktolib/s3/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
14
+ tracktolib/s3/minio.py,sha256=wMEjkSes9Fp39fD17IctALpD6zB2xwDRQEmO7Vzan3g,1387
15
+ tracktolib/s3/niquests.py,sha256=HTKEDyD9W30Ru7jeNSl9gOHgpa3UHkEdQD6cBVp_l0Q,23206
16
+ tracktolib/s3/s3.py,sha256=39QLyi7rqsQL0bv6GdeJVZ8LRL2JGV7gT0Y-r3N82cM,5072
17
+ tracktolib/tests.py,sha256=gKE--epQjgMZGXc5ydbl4zjOdmwztJS42UMV0p4hXEA,399
18
+ tracktolib/utils.py,sha256=FP87gbL27zHXaI9My2VZYEG5ZJ7eL6SiljW5MyRutOY,6553
19
+ tracktolib-0.66.2.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
20
+ tracktolib-0.66.2.dist-info/METADATA,sha256=-BNkenTXSetFAIDr4Ua9I3SowpN6tfnBE8VCkefi-VM,4045
21
+ tracktolib-0.66.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.15
2
+ Generator: uv 0.9.26
3
3
  Root-Is-Purelib: true
4
- Tag: py3-none-any
4
+ Tag: py3-none-any
@@ -1,20 +0,0 @@
1
- tracktolib/__init__.py,sha256=Q9d6h2lNjcYzxvfJ3zlNcpiP_Ak0T3TBPWINzZNrhu0,173
2
- tracktolib/api.py,sha256=ZLMgjH3Y8r3MpXc8m3IuZbzTj3fgrZKZORtSVgbuP-M,10221
3
- tracktolib/http_utils.py,sha256=c10JGmHaBw3VSDMYhz2dvVw2lo4PUAq1xMub74I7xDc,2625
4
- tracktolib/logs.py,sha256=D2hx6urXl5l4PBGP8mCpcT4GX7tJeFfNY-7oBfHczBU,2191
5
- tracktolib/notion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- tracktolib/notion/fetch.py,sha256=2GS6L0Xv5UJZhQdqwrUJM11ors5hAH5WdFpuLWhXWTQ,13343
7
- tracktolib/notion/models.py,sha256=FbTJcK0eA-4phpfjUxyAW7cs5jHZQxB6qqZ75ZcJ7uw,5608
8
- tracktolib/pg/__init__.py,sha256=Ul_hgwvTXZvQBt7sHKi4ZI-0DDpnXmoFtmVkGRy-1J0,366
9
- tracktolib/pg/query.py,sha256=zstc-QkBby7e6LybS8ed0d_6QLQNujY2H0lLNXFLNQ8,19366
10
- tracktolib/pg/utils.py,sha256=ygQn63EBDaEGB0p7P2ibellO2mv-StafanpXKcCUiZU,6324
11
- tracktolib/pg_sync.py,sha256=MKDaV7dYsRy59Y0EE5RGZL0DlZ-RUdBeaN9eSBwiQJg,6718
12
- tracktolib/pg_utils.py,sha256=ArYNdf9qsdYdzGEWmev8tZpyx8_1jaGGdkfYkauM7UM,2582
13
- tracktolib/s3/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- tracktolib/s3/minio.py,sha256=wMEjkSes9Fp39fD17IctALpD6zB2xwDRQEmO7Vzan3g,1387
15
- tracktolib/s3/s3.py,sha256=0HbSAPoaup5-W4LK54zRCjrQ5mr8OWR-N9WjW99Q4aw,5937
16
- tracktolib/tests.py,sha256=gKE--epQjgMZGXc5ydbl4zjOdmwztJS42UMV0p4hXEA,399
17
- tracktolib/utils.py,sha256=ysTBF9V35fVXQVBPk0kfE_84SGRxzrayqmg9RbtoJq4,5761
18
- tracktolib-0.65.1.dist-info/WHEEL,sha256=z-mOpxbJHqy3cq6SvUThBZdaLGFZzdZPtgWLcP2NKjQ,79
19
- tracktolib-0.65.1.dist-info/METADATA,sha256=Kr8EquD4NLUsi7Hyadl-Ij2JRB48rnzItX2o0bcLv0Y,3719
20
- tracktolib-0.65.1.dist-info/RECORD,,