tracktolib 0.68.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.
- tracktolib/api.py +12 -13
- tracktolib/cf/__init__.py +8 -0
- tracktolib/cf/client.py +149 -0
- tracktolib/cf/types.py +17 -0
- tracktolib/gh/__init__.py +11 -0
- tracktolib/gh/client.py +206 -0
- tracktolib/gh/types.py +203 -0
- tracktolib/http_utils.py +1 -1
- tracktolib/logs.py +1 -1
- tracktolib/notion/markdown.py +3 -3
- tracktolib/notion/models.py +0 -1
- tracktolib/notion/utils.py +5 -5
- tracktolib/pg/__init__.py +10 -10
- tracktolib/pg/query.py +1 -1
- tracktolib/pg/utils.py +5 -5
- tracktolib/pg_sync.py +3 -5
- tracktolib/pg_utils.py +1 -4
- tracktolib/s3/minio.py +1 -1
- tracktolib/s3/niquests.py +235 -32
- tracktolib/s3/s3.py +1 -1
- tracktolib/utils.py +12 -3
- {tracktolib-0.68.0.dist-info → tracktolib-0.69.0.dist-info}/METADATA +95 -2
- tracktolib-0.69.0.dist-info/RECORD +31 -0
- {tracktolib-0.68.0.dist-info → tracktolib-0.69.0.dist-info}/WHEEL +1 -1
- tracktolib-0.68.0.dist-info/RECORD +0 -25
tracktolib/http_utils.py
CHANGED
tracktolib/logs.py
CHANGED
tracktolib/notion/markdown.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Sequence
|
|
7
7
|
|
|
8
|
+
from ..utils import get_chunks
|
|
8
9
|
from .blocks import (
|
|
9
10
|
BulletedListBlock,
|
|
10
11
|
DividerBlock,
|
|
@@ -21,7 +22,6 @@ from .blocks import (
|
|
|
21
22
|
make_quote_block,
|
|
22
23
|
make_todo_block,
|
|
23
24
|
)
|
|
24
|
-
from ..utils import get_chunks
|
|
25
25
|
|
|
26
26
|
# Union type for all Notion blocks used in markdown conversion
|
|
27
27
|
NotionBlock = ParagraphBlock | DividerBlock | BulletedListBlock | NumberedListBlock | TodoBlock | QuoteBlock
|
|
@@ -32,11 +32,11 @@ if TYPE_CHECKING:
|
|
|
32
32
|
__all__ = [
|
|
33
33
|
"NOTION_CHAR_LIMIT",
|
|
34
34
|
"NotionBlock",
|
|
35
|
-
"rich_text_to_markdown",
|
|
36
|
-
"markdown_to_blocks",
|
|
37
35
|
"blocks_to_markdown",
|
|
38
36
|
"blocks_to_markdown_with_comments",
|
|
39
37
|
"comments_to_markdown",
|
|
38
|
+
"markdown_to_blocks",
|
|
39
|
+
"rich_text_to_markdown",
|
|
40
40
|
"strip_comments_from_markdown",
|
|
41
41
|
]
|
|
42
42
|
|
tracktolib/notion/models.py
CHANGED
tracktolib/notion/utils.py
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast
|
|
6
8
|
|
|
7
|
-
import asyncio
|
|
8
9
|
import niquests
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast
|
|
10
10
|
|
|
11
11
|
from .markdown import (
|
|
12
|
-
markdown_to_blocks,
|
|
13
12
|
blocks_to_markdown_with_comments,
|
|
14
13
|
comments_to_markdown,
|
|
14
|
+
markdown_to_blocks,
|
|
15
15
|
strip_comments_from_markdown,
|
|
16
16
|
)
|
|
17
17
|
|
|
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
|
|
19
19
|
from .cache import NotionCache
|
|
20
20
|
from .models import Block, Comment, PartialBlock
|
|
21
21
|
|
|
22
|
+
from ..utils import get_chunks, run_coros
|
|
22
23
|
from .blocks import (
|
|
23
24
|
ExportResult,
|
|
24
25
|
find_divergence_index,
|
|
@@ -32,11 +33,10 @@ from .fetch import (
|
|
|
32
33
|
fetch_comments,
|
|
33
34
|
fetch_user,
|
|
34
35
|
)
|
|
35
|
-
from ..utils import get_chunks, run_coros
|
|
36
36
|
|
|
37
37
|
__all__ = [
|
|
38
|
-
"ClearResult",
|
|
39
38
|
"DEFAULT_CONCURRENCY",
|
|
39
|
+
"ClearResult",
|
|
40
40
|
"PageComment",
|
|
41
41
|
"ProgressCallback",
|
|
42
42
|
"UpdateResult",
|
tracktolib/pg/__init__.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
from .query import (
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Conflict,
|
|
3
|
+
OnConflict,
|
|
4
|
+
PGConflictQuery,
|
|
4
5
|
PGInsertQuery,
|
|
5
6
|
PGReturningQuery,
|
|
6
|
-
PGConflictQuery,
|
|
7
|
-
insert_returning,
|
|
8
|
-
Conflict,
|
|
9
|
-
fetch_count,
|
|
10
7
|
PGUpdateQuery,
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
fetch_count,
|
|
9
|
+
insert_many,
|
|
10
|
+
insert_one,
|
|
13
11
|
insert_pg,
|
|
14
|
-
|
|
12
|
+
insert_returning,
|
|
15
13
|
update_many,
|
|
14
|
+
update_one,
|
|
15
|
+
update_returning,
|
|
16
16
|
)
|
|
17
|
-
from .utils import
|
|
17
|
+
from .utils import PGError, PGException, iterate_pg, safe_pg, safe_pg_context, upsert_csv
|
tracktolib/pg/query.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import Any, Callable, Iterable, Iterator, Literal, TypeAlias, overload
|
|
4
4
|
|
|
5
5
|
from ..pg_utils import get_conflict_query
|
|
6
6
|
|
tracktolib/pg/utils.py
CHANGED
|
@@ -2,11 +2,11 @@ import csv
|
|
|
2
2
|
import datetime as dt
|
|
3
3
|
import functools
|
|
4
4
|
import logging
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import AsyncIterator, Iterable, cast, NamedTuple, Sequence
|
|
7
|
-
from typing_extensions import LiteralString
|
|
8
|
-
from dataclasses import dataclass
|
|
9
5
|
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncIterator, Iterable, LiteralString, NamedTuple, Sequence, cast
|
|
9
|
+
|
|
10
10
|
from ..pg_utils import get_conflict_query
|
|
11
11
|
|
|
12
12
|
try:
|
|
@@ -21,8 +21,8 @@ from asyncpg.exceptions import (
|
|
|
21
21
|
UniqueViolationError,
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
from tracktolib.utils import get_chunks
|
|
25
24
|
from tracktolib.pg_utils import get_tmp_table_query
|
|
25
|
+
from tracktolib.utils import get_chunks
|
|
26
26
|
|
|
27
27
|
logger = logging.Logger("tracktolib-pg")
|
|
28
28
|
|
tracktolib/pg_sync.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import
|
|
3
|
-
|
|
4
|
-
from typing_extensions import LiteralString
|
|
2
|
+
from typing import Any, Iterable, Literal, LiteralString, Mapping, Optional, Sequence, cast, overload
|
|
5
3
|
|
|
6
4
|
try:
|
|
7
5
|
from psycopg import Connection, Cursor
|
|
8
6
|
from psycopg.abc import Query, QueryNoTemplate
|
|
9
7
|
from psycopg.errors import InvalidCatalogName
|
|
10
|
-
from psycopg.rows import
|
|
8
|
+
from psycopg.rows import DictRow, TupleRow, dict_row
|
|
11
9
|
from psycopg.types.json import Json
|
|
12
10
|
except ImportError:
|
|
13
11
|
raise ImportError('Please install psycopg or tracktolib with "pg-sync" to use this module')
|
|
@@ -22,9 +20,9 @@ __all__ = (
|
|
|
22
20
|
"fetch_one",
|
|
23
21
|
"get_insert_data",
|
|
24
22
|
"get_tables",
|
|
23
|
+
"insert_csv",
|
|
25
24
|
"insert_many",
|
|
26
25
|
"insert_one",
|
|
27
|
-
"insert_csv",
|
|
28
26
|
"set_seq_max",
|
|
29
27
|
)
|
|
30
28
|
|
tracktolib/pg_utils.py
CHANGED
tracktolib/s3/minio.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
3
|
try:
|
|
4
|
-
from minio.deleteobjects import DeleteObject
|
|
5
4
|
from minio import Minio
|
|
5
|
+
from minio.deleteobjects import DeleteObject
|
|
6
6
|
except ImportError:
|
|
7
7
|
raise ImportError('Please install minio or tracktolib with "s3-minio" to use this module')
|
|
8
8
|
|
tracktolib/s3/niquests.py
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
3
|
+
import hashlib
|
|
6
4
|
import http
|
|
7
5
|
import xml.etree.ElementTree as ET
|
|
8
6
|
from contextlib import asynccontextmanager
|
|
9
7
|
from dataclasses import dataclass, field
|
|
10
|
-
from
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncIterator, Awaitable, Callable, Literal, NamedTuple, Required, Self, TypedDict, Unpack
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
11
12
|
|
|
12
13
|
try:
|
|
13
14
|
import botocore.client
|
|
14
15
|
import botocore.session
|
|
15
16
|
import jmespath
|
|
17
|
+
from botocore.auth import SigV4Auth
|
|
18
|
+
from botocore.awsrequest import AWSRequest
|
|
16
19
|
from botocore.config import Config
|
|
17
20
|
except ImportError as e:
|
|
18
21
|
raise ImportError("botocore is required for S3 operations. Install with tracktolib[s3-niquests]") from e
|
|
@@ -22,26 +25,37 @@ try:
|
|
|
22
25
|
except ImportError as e:
|
|
23
26
|
raise ImportError("niquests is required for S3 operations. Install with tracktolib[s3-niquests]") from e
|
|
24
27
|
|
|
28
|
+
try:
|
|
29
|
+
import ujson as json
|
|
30
|
+
except ImportError:
|
|
31
|
+
import json
|
|
32
|
+
|
|
25
33
|
from ..utils import get_stream_chunk
|
|
26
34
|
|
|
27
35
|
__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
36
|
"S3MultipartUpload",
|
|
40
37
|
"S3Object",
|
|
41
38
|
"S3ObjectParams",
|
|
39
|
+
"S3Session",
|
|
42
40
|
"UploadPart",
|
|
43
41
|
"build_s3_headers",
|
|
44
42
|
"build_s3_presigned_params",
|
|
43
|
+
"s3_create_multipart_upload",
|
|
44
|
+
"s3_delete_bucket_policy",
|
|
45
|
+
"s3_delete_bucket_website",
|
|
46
|
+
"s3_delete_object",
|
|
47
|
+
"s3_delete_objects",
|
|
48
|
+
"s3_download_file",
|
|
49
|
+
"s3_empty_bucket",
|
|
50
|
+
"s3_file_upload",
|
|
51
|
+
"s3_get_bucket_policy",
|
|
52
|
+
"s3_get_object",
|
|
53
|
+
"s3_list_files",
|
|
54
|
+
"s3_multipart_upload",
|
|
55
|
+
"s3_put_bucket_policy",
|
|
56
|
+
"s3_put_bucket_website",
|
|
57
|
+
"s3_put_object",
|
|
58
|
+
"s3_upload_file",
|
|
45
59
|
)
|
|
46
60
|
|
|
47
61
|
ACL = Literal[
|
|
@@ -193,16 +207,16 @@ class S3Session:
|
|
|
193
207
|
s3_config: Config | None = None
|
|
194
208
|
s3_client: botocore.client.BaseClient | None = None
|
|
195
209
|
http_client: niquests.AsyncSession = field(default_factory=niquests.AsyncSession)
|
|
210
|
+
_botocore_session: botocore.session.Session | None = field(default=None, init=False, repr=False)
|
|
196
211
|
|
|
197
212
|
def __post_init__(self):
|
|
198
213
|
if self.s3_client is None:
|
|
199
|
-
|
|
200
|
-
self.
|
|
214
|
+
self._botocore_session = botocore.session.Session()
|
|
215
|
+
self._botocore_session.set_credentials(self.access_key, self.secret_key)
|
|
216
|
+
self.s3_client = self._botocore_session.create_client(
|
|
201
217
|
"s3",
|
|
202
218
|
endpoint_url=self.endpoint_url,
|
|
203
219
|
region_name=self.region,
|
|
204
|
-
aws_access_key_id=self.access_key,
|
|
205
|
-
aws_secret_access_key=self.secret_key,
|
|
206
220
|
config=self.s3_config,
|
|
207
221
|
)
|
|
208
222
|
|
|
@@ -220,13 +234,13 @@ class S3Session:
|
|
|
220
234
|
await self.http_client.__aexit__(exc_type, exc_val, exc_tb)
|
|
221
235
|
self._s3.close()
|
|
222
236
|
|
|
223
|
-
async def delete_object(self, bucket: str, key: str) -> niquests.
|
|
237
|
+
async def delete_object(self, bucket: str, key: str) -> niquests.AsyncResponse:
|
|
224
238
|
"""Delete an object from S3."""
|
|
225
|
-
return await s3_delete_object(self._s3, self.http_client, bucket, key)
|
|
239
|
+
return await s3_delete_object(self._s3, self.http_client, bucket, key) # pyright: ignore[reportReturnType]
|
|
226
240
|
|
|
227
|
-
async def delete_objects(self, bucket: str, keys: list[str]) -> list[niquests.
|
|
241
|
+
async def delete_objects(self, bucket: str, keys: list[str]) -> list[niquests.AsyncResponse]:
|
|
228
242
|
"""Delete multiple objects from S3."""
|
|
229
|
-
return await s3_delete_objects(self._s3, self.http_client, bucket, keys)
|
|
243
|
+
return await s3_delete_objects(self._s3, self.http_client, bucket, keys) # pyright: ignore[reportReturnType]
|
|
230
244
|
|
|
231
245
|
def list_files(
|
|
232
246
|
self,
|
|
@@ -252,15 +266,15 @@ class S3Session:
|
|
|
252
266
|
|
|
253
267
|
async def put_object(
|
|
254
268
|
self, bucket: str, key: str, data: bytes, **kwargs: Unpack[S3ObjectParams]
|
|
255
|
-
) -> niquests.
|
|
269
|
+
) -> niquests.AsyncResponse:
|
|
256
270
|
"""Upload an object to S3."""
|
|
257
|
-
return await s3_put_object(self._s3, self.http_client, bucket, key, data, **kwargs)
|
|
271
|
+
return await s3_put_object(self._s3, self.http_client, bucket, key, data, **kwargs) # pyright: ignore[reportReturnType]
|
|
258
272
|
|
|
259
273
|
async def upload_file(
|
|
260
274
|
self, bucket: str, file: Path, path: str, **kwargs: Unpack[S3ObjectParams]
|
|
261
|
-
) -> niquests.
|
|
275
|
+
) -> niquests.AsyncResponse:
|
|
262
276
|
"""Upload a file to S3."""
|
|
263
|
-
return await s3_upload_file(self._s3, self.http_client, bucket, file, path, **kwargs)
|
|
277
|
+
return await s3_upload_file(self._s3, self.http_client, bucket, file, path, **kwargs) # pyright: ignore[reportReturnType]
|
|
264
278
|
|
|
265
279
|
async def get_object(self, bucket: str, key: str) -> bytes | None:
|
|
266
280
|
"""Download an object from S3."""
|
|
@@ -310,10 +324,33 @@ class S3Session:
|
|
|
310
324
|
**kwargs,
|
|
311
325
|
)
|
|
312
326
|
|
|
327
|
+
async def put_bucket_policy(self, bucket: str, policy: str | dict) -> niquests.AsyncResponse:
|
|
328
|
+
"""Set a bucket policy."""
|
|
329
|
+
return await s3_put_bucket_policy(self._s3, self.http_client, bucket, policy, self._botocore_session) # pyright: ignore[reportReturnType]
|
|
313
330
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
)
|
|
331
|
+
async def get_bucket_policy(self, bucket: str) -> dict | None:
|
|
332
|
+
"""Get a bucket policy. Returns None if no policy exists."""
|
|
333
|
+
return await s3_get_bucket_policy(self._s3, self.http_client, bucket)
|
|
334
|
+
|
|
335
|
+
async def delete_bucket_policy(self, bucket: str) -> niquests.AsyncResponse:
|
|
336
|
+
"""Delete a bucket policy."""
|
|
337
|
+
return await s3_delete_bucket_policy(self._s3, self.http_client, bucket) # pyright: ignore[reportReturnType]
|
|
338
|
+
|
|
339
|
+
async def put_bucket_website(
|
|
340
|
+
self, bucket: str, index_document: str = "index.html", error_document: str | None = None
|
|
341
|
+
) -> niquests.AsyncResponse:
|
|
342
|
+
"""Configure a bucket as a static website."""
|
|
343
|
+
return await s3_put_bucket_website(
|
|
344
|
+
self._s3, self.http_client, bucket, index_document, error_document, self._botocore_session
|
|
345
|
+
) # pyright: ignore[reportReturnType]
|
|
346
|
+
|
|
347
|
+
async def delete_bucket_website(self, bucket: str) -> niquests.AsyncResponse:
|
|
348
|
+
"""Remove website configuration from a bucket."""
|
|
349
|
+
return await s3_delete_bucket_website(self._s3, self.http_client, bucket) # pyright: ignore[reportReturnType]
|
|
350
|
+
|
|
351
|
+
async def empty_bucket(self, bucket: str, *, on_progress: Callable[[str], None] | None = None) -> int:
|
|
352
|
+
"""Delete all objects from a bucket. Returns count of deleted objects."""
|
|
353
|
+
return await s3_empty_bucket(self._s3, self.http_client, bucket, on_progress=on_progress)
|
|
317
354
|
|
|
318
355
|
|
|
319
356
|
class UploadPart(TypedDict):
|
|
@@ -321,8 +358,16 @@ class UploadPart(TypedDict):
|
|
|
321
358
|
ETag: str | None
|
|
322
359
|
|
|
323
360
|
|
|
361
|
+
class S3MultipartUpload(NamedTuple):
|
|
362
|
+
fetch_create: Callable[[], Awaitable[str]]
|
|
363
|
+
fetch_complete: Callable[[], Awaitable[niquests.Response]]
|
|
364
|
+
upload_part: Callable[[bytes], Awaitable[UploadPart]]
|
|
365
|
+
generate_presigned_url: Callable[..., str]
|
|
366
|
+
fetch_abort: Callable[[], Awaitable[niquests.Response]]
|
|
367
|
+
|
|
368
|
+
|
|
324
369
|
class S3Object(TypedDict, total=False):
|
|
325
|
-
Key: str
|
|
370
|
+
Key: Required[str]
|
|
326
371
|
LastModified: str
|
|
327
372
|
ETag: str
|
|
328
373
|
Size: int
|
|
@@ -398,12 +443,15 @@ async def s3_list_files(
|
|
|
398
443
|
|
|
399
444
|
page_items: list[S3Object] = []
|
|
400
445
|
for contents in root.findall("s3:Contents", ns):
|
|
401
|
-
item: S3Object = {}
|
|
446
|
+
item: S3Object = {} # pyright: ignore[reportAssignmentType]
|
|
402
447
|
for child in contents:
|
|
403
448
|
tag = child.tag.replace(f"{{{ns['s3']}}}", "")
|
|
404
449
|
item[tag] = child.text
|
|
405
450
|
if "Size" in item:
|
|
406
451
|
item["Size"] = int(item["Size"])
|
|
452
|
+
# URL-decode the Key (some S3-compatible servers like Garage return URL-encoded keys)
|
|
453
|
+
if "Key" in item and item["Key"]:
|
|
454
|
+
item["Key"] = unquote(item["Key"])
|
|
407
455
|
page_items.append(item)
|
|
408
456
|
|
|
409
457
|
if search_query:
|
|
@@ -676,3 +724,158 @@ async def s3_file_upload(
|
|
|
676
724
|
return
|
|
677
725
|
await mpart.upload_part(chunk)
|
|
678
726
|
has_uploaded_parts = True
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _get_credentials(s3: botocore.client.BaseClient, session: botocore.session.Session | None = None):
|
|
730
|
+
"""Get credentials from session (public API) or fall back to client internals."""
|
|
731
|
+
if session is not None:
|
|
732
|
+
return session.get_credentials()
|
|
733
|
+
# Fall back to private API for backward compatibility with standalone function usage
|
|
734
|
+
return s3._request_signer._credentials # pyright: ignore[reportAttributeAccessIssue]
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _sign_s3_request(
|
|
738
|
+
s3: botocore.client.BaseClient,
|
|
739
|
+
method: str,
|
|
740
|
+
url: str,
|
|
741
|
+
data: bytes,
|
|
742
|
+
content_type: str,
|
|
743
|
+
botocore_session: botocore.session.Session | None = None,
|
|
744
|
+
) -> dict[str, str]:
|
|
745
|
+
"""Create and sign an S3 request, returning the headers to use."""
|
|
746
|
+
request = AWSRequest(method=method, url=url, data=data, headers={"Content-Type": content_type})
|
|
747
|
+
request.headers["x-amz-content-sha256"] = hashlib.sha256(data).hexdigest()
|
|
748
|
+
|
|
749
|
+
credentials = _get_credentials(s3, botocore_session)
|
|
750
|
+
region = s3.meta.region_name
|
|
751
|
+
signer = SigV4Auth(credentials, "s3", region)
|
|
752
|
+
signer.add_auth(request)
|
|
753
|
+
|
|
754
|
+
return dict(request.headers)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
async def s3_put_bucket_policy(
|
|
758
|
+
s3: botocore.client.BaseClient,
|
|
759
|
+
client: niquests.AsyncSession,
|
|
760
|
+
bucket: str,
|
|
761
|
+
policy: str | dict,
|
|
762
|
+
botocore_session: botocore.session.Session | None = None,
|
|
763
|
+
) -> niquests.Response:
|
|
764
|
+
"""
|
|
765
|
+
Set a bucket policy using a signed request.
|
|
766
|
+
|
|
767
|
+
The policy can be provided as a JSON string or a dict (which will be serialized).
|
|
768
|
+
If botocore_session is provided, credentials are retrieved via the public API;
|
|
769
|
+
otherwise falls back to the client's internal credentials.
|
|
770
|
+
"""
|
|
771
|
+
policy_str = policy if isinstance(policy, str) else json.dumps(policy)
|
|
772
|
+
policy_bytes = policy_str.encode("utf-8")
|
|
773
|
+
url = f"{s3.meta.endpoint_url}/{bucket}?policy"
|
|
774
|
+
headers = _sign_s3_request(s3, "PUT", url, policy_bytes, "application/json", botocore_session)
|
|
775
|
+
return (await client.put(url, data=policy_bytes, headers=headers)).raise_for_status()
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
async def s3_get_bucket_policy(
|
|
779
|
+
s3: botocore.client.BaseClient,
|
|
780
|
+
client: niquests.AsyncSession,
|
|
781
|
+
bucket: str,
|
|
782
|
+
) -> dict | None:
|
|
783
|
+
"""
|
|
784
|
+
Get a bucket policy using presigned URL.
|
|
785
|
+
|
|
786
|
+
Returns the policy as a dict, or None if no policy exists.
|
|
787
|
+
"""
|
|
788
|
+
url = s3.generate_presigned_url(
|
|
789
|
+
ClientMethod="get_bucket_policy",
|
|
790
|
+
Params={"Bucket": bucket},
|
|
791
|
+
)
|
|
792
|
+
resp = await client.get(url)
|
|
793
|
+
if resp.status_code == http.HTTPStatus.NOT_FOUND:
|
|
794
|
+
return None
|
|
795
|
+
# NoSuchBucketPolicy returns 404 on AWS, but some providers may return other codes
|
|
796
|
+
if resp.status_code == http.HTTPStatus.NO_CONTENT:
|
|
797
|
+
return None
|
|
798
|
+
resp.raise_for_status()
|
|
799
|
+
if resp.content is None:
|
|
800
|
+
return None
|
|
801
|
+
return json.loads(resp.content)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
async def s3_delete_bucket_policy(
|
|
805
|
+
s3: botocore.client.BaseClient,
|
|
806
|
+
client: niquests.AsyncSession,
|
|
807
|
+
bucket: str,
|
|
808
|
+
) -> niquests.Response:
|
|
809
|
+
"""Delete a bucket policy using presigned URL."""
|
|
810
|
+
url = s3.generate_presigned_url(
|
|
811
|
+
ClientMethod="delete_bucket_policy",
|
|
812
|
+
Params={"Bucket": bucket},
|
|
813
|
+
)
|
|
814
|
+
return (await client.delete(url)).raise_for_status()
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _build_website_xml(index_document: str, error_document: str | None, api_version: str) -> str:
|
|
818
|
+
"""Build XML payload for S3 website configuration."""
|
|
819
|
+
index_xml = f"<IndexDocument><Suffix>{xml_escape(index_document)}</Suffix></IndexDocument>"
|
|
820
|
+
error_xml = f"<ErrorDocument><Key>{xml_escape(error_document)}</Key></ErrorDocument>" if error_document else ""
|
|
821
|
+
return f'<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/{api_version}/">{index_xml}{error_xml}</WebsiteConfiguration>'
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
async def s3_put_bucket_website(
|
|
825
|
+
s3: botocore.client.BaseClient,
|
|
826
|
+
client: niquests.AsyncSession,
|
|
827
|
+
bucket: str,
|
|
828
|
+
index_document: str = "index.html",
|
|
829
|
+
error_document: str | None = None,
|
|
830
|
+
botocore_session: botocore.session.Session | None = None,
|
|
831
|
+
) -> niquests.AsyncResponse:
|
|
832
|
+
"""
|
|
833
|
+
Configure a bucket as a static website using a signed request.
|
|
834
|
+
|
|
835
|
+
Note: This operation is not supported by MinIO.
|
|
836
|
+
If botocore_session is provided, credentials are retrieved via the public API;
|
|
837
|
+
otherwise falls back to the client's internal credentials.
|
|
838
|
+
"""
|
|
839
|
+
api_version = s3.meta.service_model.api_version
|
|
840
|
+
xml_payload = _build_website_xml(index_document, error_document, api_version)
|
|
841
|
+
xml_bytes = xml_payload.encode("utf-8")
|
|
842
|
+
url = f"{s3.meta.endpoint_url}/{bucket}?website"
|
|
843
|
+
headers = _sign_s3_request(s3, "PUT", url, xml_bytes, "application/xml", botocore_session)
|
|
844
|
+
return (await client.put(url, data=xml_bytes, headers=headers)).raise_for_status() # pyright: ignore[reportReturnType]
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
async def s3_delete_bucket_website(
|
|
848
|
+
s3: botocore.client.BaseClient,
|
|
849
|
+
client: niquests.AsyncSession,
|
|
850
|
+
bucket: str,
|
|
851
|
+
) -> niquests.Response:
|
|
852
|
+
"""Remove website configuration from a bucket using presigned URL."""
|
|
853
|
+
url = s3.generate_presigned_url(
|
|
854
|
+
ClientMethod="delete_bucket_website",
|
|
855
|
+
Params={"Bucket": bucket},
|
|
856
|
+
)
|
|
857
|
+
return (await client.delete(url)).raise_for_status()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
async def s3_empty_bucket(
|
|
861
|
+
s3: botocore.client.BaseClient,
|
|
862
|
+
client: niquests.AsyncSession,
|
|
863
|
+
bucket: str,
|
|
864
|
+
*,
|
|
865
|
+
on_progress: Callable[[str], None] | None = None,
|
|
866
|
+
) -> int:
|
|
867
|
+
"""
|
|
868
|
+
Delete all objects from a bucket.
|
|
869
|
+
|
|
870
|
+
The optional on_progress callback is called with each deleted key.
|
|
871
|
+
Returns the count of deleted objects.
|
|
872
|
+
"""
|
|
873
|
+
deleted_count = 0
|
|
874
|
+
async for obj in s3_list_files(s3, client, bucket, ""):
|
|
875
|
+
key = obj.get("Key")
|
|
876
|
+
if key:
|
|
877
|
+
await s3_delete_object(s3, client, bucket, key)
|
|
878
|
+
deleted_count += 1
|
|
879
|
+
if on_progress:
|
|
880
|
+
on_progress(key)
|
|
881
|
+
return deleted_count
|
tracktolib/s3/s3.py
CHANGED
tracktolib/utils.py
CHANGED
|
@@ -8,10 +8,19 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
from decimal import Decimal
|
|
10
10
|
from ipaddress import IPv4Address, IPv6Address
|
|
11
|
-
from types import ModuleType
|
|
12
11
|
from pathlib import Path
|
|
13
|
-
from
|
|
14
|
-
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
AsyncIterable,
|
|
16
|
+
AsyncIterator,
|
|
17
|
+
Callable,
|
|
18
|
+
Coroutine,
|
|
19
|
+
Iterable,
|
|
20
|
+
Iterator,
|
|
21
|
+
Literal,
|
|
22
|
+
overload,
|
|
23
|
+
)
|
|
15
24
|
|
|
16
25
|
type OnCmdUpdate = Callable[[str], None]
|
|
17
26
|
type OnCmdDone = Callable[[str, str, int], None]
|