tracktolib 0.65.1__tar.gz → 0.66.2__tar.gz
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-0.65.1 → tracktolib-0.66.2}/PKG-INFO +17 -6
- {tracktolib-0.65.1 → tracktolib-0.66.2}/README.md +12 -4
- {tracktolib-0.65.1 → tracktolib-0.66.2}/pyproject.toml +16 -4
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/api.py +12 -16
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/http_utils.py +7 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/notion/fetch.py +9 -4
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/pg/query.py +13 -17
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/pg_sync.py +4 -4
- tracktolib-0.66.2/tracktolib/s3/__init__.py +1 -0
- tracktolib-0.66.2/tracktolib/s3/niquests.py +669 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/s3/s3.py +9 -27
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/utils.py +31 -11
- tracktolib-0.65.1/tracktolib/s3/__init__.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/__init__.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/logs.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/notion/__init__.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/notion/models.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/pg/__init__.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/pg/utils.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/pg_utils.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/s3/minio.py +0 -0
- {tracktolib-0.65.1 → tracktolib-0.66.2}/tracktolib/tests.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tracktolib
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
[](https://pypi.python.org/pypi/tracktolib)
|
|
40
43
|
[](https://pypi.python.org/pypi/tracktolib)
|
|
41
|
-
[](https://github.com/Tracktor/tracktolib/actions/workflows/ci.yml)
|
|
42
45
|
|
|
43
|
-
Utility library
|
|
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
|
-
###
|
|
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
|
+
```
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://pypi.python.org/pypi/tracktolib)
|
|
4
4
|
[](https://pypi.python.org/pypi/tracktolib)
|
|
5
|
-
[](https://github.com/Tracktor/tracktolib/actions/workflows/ci.yml)
|
|
6
6
|
|
|
7
|
-
Utility library
|
|
7
|
+
Tracktor Swiss-knife Utility library.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -85,7 +85,15 @@ S3 helpers using [minio](https://min.io/docs/minio/linux/developers/python/API.h
|
|
|
85
85
|
uv add tracktolib[s3-minio]
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
###
|
|
88
|
+
### s3-niquests
|
|
89
|
+
|
|
90
|
+
Async S3 helpers using [niquests](https://github.com/jawah/niquests) and [botocore](https://github.com/boto/botocore).
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv add tracktolib[s3-niquests]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### http (deprecated)
|
|
89
97
|
|
|
90
98
|
HTTP client helpers using [httpx](https://www.python-httpx.org/).
|
|
91
99
|
|
|
@@ -115,4 +123,4 @@ Testing utilities using [deepdiff](https://github.com/seperman/deepdiff).
|
|
|
115
123
|
|
|
116
124
|
```bash
|
|
117
125
|
uv add tracktolib[tests]
|
|
118
|
-
```
|
|
126
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tracktolib"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.66.2"
|
|
4
4
|
authors = [
|
|
5
5
|
{ email = "julien.brayere@tracktor.fr" }
|
|
6
6
|
]
|
|
@@ -21,6 +21,7 @@ include = [
|
|
|
21
21
|
"LICENSE"
|
|
22
22
|
]
|
|
23
23
|
requires-python = ">=3.12,<4.0"
|
|
24
|
+
dependencies = []
|
|
24
25
|
|
|
25
26
|
[project.optional-dependencies]
|
|
26
27
|
logs = ["python-json-logger>=3.2.1"]
|
|
@@ -31,6 +32,10 @@ s3-minio = [
|
|
|
31
32
|
"pycryptodome>=3.20.0",
|
|
32
33
|
]
|
|
33
34
|
s3 = ["aiobotocore>=2.9.0"]
|
|
35
|
+
s3-niquests = [
|
|
36
|
+
"botocore>=1.35.36",
|
|
37
|
+
"niquests>=3.17.0",
|
|
38
|
+
]
|
|
34
39
|
tests = ["deepdiff>=8.1.0"]
|
|
35
40
|
http = ["httpx>=0.25.0"]
|
|
36
41
|
api = [
|
|
@@ -42,7 +47,7 @@ pg = [
|
|
|
42
47
|
"rich>=13.6.0",
|
|
43
48
|
]
|
|
44
49
|
notion = [
|
|
45
|
-
"niquests>=3.
|
|
50
|
+
"niquests>=3.17.0"
|
|
46
51
|
]
|
|
47
52
|
|
|
48
53
|
[dependency-groups]
|
|
@@ -60,14 +65,21 @@ dev = [
|
|
|
60
65
|
"minio>=7.2.7",
|
|
61
66
|
"pycryptodome>=3.23.0",
|
|
62
67
|
"aiobotocore==2.15.2",
|
|
68
|
+
# niquests for s3-niquests tests (botocore comes from aiobotocore)
|
|
69
|
+
"niquests>=3.17.0",
|
|
63
70
|
"python-json-logger>=3.2.0",
|
|
64
|
-
"
|
|
71
|
+
"prek>=0.2.30",
|
|
65
72
|
]
|
|
66
73
|
|
|
67
74
|
bump = [
|
|
68
75
|
"commitizen>=4.10.0"
|
|
69
76
|
]
|
|
70
77
|
|
|
78
|
+
docs = [
|
|
79
|
+
"mkdocs>=1.6.0",
|
|
80
|
+
"mkdocs-material>=9.5.0",
|
|
81
|
+
]
|
|
82
|
+
|
|
71
83
|
[build-system]
|
|
72
84
|
requires = ["uv_build>=0.9.4,<0.10.0"]
|
|
73
85
|
build-backend = "uv_build"
|
|
@@ -96,7 +108,7 @@ pythonPlatform = "Linux"
|
|
|
96
108
|
|
|
97
109
|
[tool.commitizen]
|
|
98
110
|
name = "cz_conventional_commits"
|
|
99
|
-
version = "0.
|
|
111
|
+
version = "0.66.2"
|
|
100
112
|
tag_format = "$version"
|
|
101
113
|
version_files = [
|
|
102
114
|
"pyproject.toml:version"
|
|
@@ -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
|
-
|
|
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__}]"
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
413
|
+
_api_version = _get_api_version(session, api_version)
|
|
409
414
|
payload: dict[str, Any] = {}
|
|
410
415
|
if query:
|
|
411
416
|
payload["query"] = query
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
-
from typing import
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
58
|
+
def fetch_one(engine: Connection, query: LiteralString, *args) -> dict | None: ...
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def fetch_one(engine: Connection, query:
|
|
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()
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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
|
|
13
|
-
from typing import
|
|
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)):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|