asanypath-native 2.1.1__tar.gz → 2.3.0__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.
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/Cargo.lock +112 -1
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/Cargo.toml +5 -3
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/PKG-INFO +1 -1
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/asanypath_native.pyi +21 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/artifactory.rs +39 -1
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/azure.rs +91 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/backend.rs +33 -9
- asanypath_native-2.3.0/src/cache.rs +198 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/gcs.rs +112 -4
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/http.rs +40 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/lib.rs +12 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/s3.rs +230 -3
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/ssh.rs +48 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/pyproject.toml +0 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/src/xml.rs +0 -0
- {asanypath_native-2.1.1 → asanypath_native-2.3.0}/uv.lock +0 -0
|
@@ -96,7 +96,7 @@ dependencies = [
|
|
|
96
96
|
|
|
97
97
|
[[package]]
|
|
98
98
|
name = "asanypath-native"
|
|
99
|
-
version = "2.
|
|
99
|
+
version = "2.3.0"
|
|
100
100
|
dependencies = [
|
|
101
101
|
"anyhow",
|
|
102
102
|
"aws-config",
|
|
@@ -114,6 +114,7 @@ dependencies = [
|
|
|
114
114
|
"quick-xml",
|
|
115
115
|
"reqwest",
|
|
116
116
|
"ring",
|
|
117
|
+
"rusqlite",
|
|
117
118
|
"russh",
|
|
118
119
|
"russh-sftp",
|
|
119
120
|
"scraper",
|
|
@@ -122,6 +123,7 @@ dependencies = [
|
|
|
122
123
|
"sonic-rs",
|
|
123
124
|
"ssh2-config",
|
|
124
125
|
"tokio",
|
|
126
|
+
"tokio-util",
|
|
125
127
|
"url",
|
|
126
128
|
"urlencoding",
|
|
127
129
|
]
|
|
@@ -1196,6 +1198,18 @@ dependencies = [
|
|
|
1196
1198
|
"windows-sys 0.61.2",
|
|
1197
1199
|
]
|
|
1198
1200
|
|
|
1201
|
+
[[package]]
|
|
1202
|
+
name = "fallible-iterator"
|
|
1203
|
+
version = "0.3.0"
|
|
1204
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1205
|
+
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
|
1206
|
+
|
|
1207
|
+
[[package]]
|
|
1208
|
+
name = "fallible-streaming-iterator"
|
|
1209
|
+
version = "0.1.9"
|
|
1210
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1211
|
+
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
|
1212
|
+
|
|
1199
1213
|
[[package]]
|
|
1200
1214
|
name = "fastrand"
|
|
1201
1215
|
version = "2.5.0"
|
|
@@ -1253,6 +1267,12 @@ version = "1.0.7"
|
|
|
1253
1267
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1254
1268
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
|
1255
1269
|
|
|
1270
|
+
[[package]]
|
|
1271
|
+
name = "foldhash"
|
|
1272
|
+
version = "0.2.0"
|
|
1273
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1274
|
+
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
|
1275
|
+
|
|
1256
1276
|
[[package]]
|
|
1257
1277
|
name = "form_urlencoded"
|
|
1258
1278
|
version = "1.2.2"
|
|
@@ -1492,11 +1512,32 @@ version = "0.14.5"
|
|
|
1492
1512
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1493
1513
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|
1494
1514
|
|
|
1515
|
+
[[package]]
|
|
1516
|
+
name = "hashbrown"
|
|
1517
|
+
version = "0.16.1"
|
|
1518
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1519
|
+
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|
1520
|
+
dependencies = [
|
|
1521
|
+
"foldhash",
|
|
1522
|
+
]
|
|
1523
|
+
|
|
1495
1524
|
[[package]]
|
|
1496
1525
|
name = "hashbrown"
|
|
1497
1526
|
version = "0.17.1"
|
|
1498
1527
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1499
1528
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
1529
|
+
dependencies = [
|
|
1530
|
+
"foldhash",
|
|
1531
|
+
]
|
|
1532
|
+
|
|
1533
|
+
[[package]]
|
|
1534
|
+
name = "hashlink"
|
|
1535
|
+
version = "0.12.2"
|
|
1536
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1537
|
+
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
|
1538
|
+
dependencies = [
|
|
1539
|
+
"hashbrown 0.17.1",
|
|
1540
|
+
]
|
|
1500
1541
|
|
|
1501
1542
|
[[package]]
|
|
1502
1543
|
name = "heck"
|
|
@@ -1894,6 +1935,17 @@ dependencies = [
|
|
|
1894
1935
|
"libc",
|
|
1895
1936
|
]
|
|
1896
1937
|
|
|
1938
|
+
[[package]]
|
|
1939
|
+
name = "libsqlite3-sys"
|
|
1940
|
+
version = "0.38.2"
|
|
1941
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1942
|
+
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
|
1943
|
+
dependencies = [
|
|
1944
|
+
"cc",
|
|
1945
|
+
"pkg-config",
|
|
1946
|
+
"vcpkg",
|
|
1947
|
+
]
|
|
1948
|
+
|
|
1897
1949
|
[[package]]
|
|
1898
1950
|
name = "litemap"
|
|
1899
1951
|
version = "0.8.3"
|
|
@@ -2694,6 +2746,7 @@ dependencies = [
|
|
|
2694
2746
|
"base64 0.22.1",
|
|
2695
2747
|
"bytes",
|
|
2696
2748
|
"futures-core",
|
|
2749
|
+
"futures-util",
|
|
2697
2750
|
"h2",
|
|
2698
2751
|
"http 1.5.0",
|
|
2699
2752
|
"http-body 1.1.0",
|
|
@@ -2714,12 +2767,14 @@ dependencies = [
|
|
|
2714
2767
|
"sync_wrapper",
|
|
2715
2768
|
"tokio",
|
|
2716
2769
|
"tokio-rustls",
|
|
2770
|
+
"tokio-util",
|
|
2717
2771
|
"tower",
|
|
2718
2772
|
"tower-http",
|
|
2719
2773
|
"tower-service",
|
|
2720
2774
|
"url",
|
|
2721
2775
|
"wasm-bindgen",
|
|
2722
2776
|
"wasm-bindgen-futures",
|
|
2777
|
+
"wasm-streams",
|
|
2723
2778
|
"web-sys",
|
|
2724
2779
|
"webpki-roots",
|
|
2725
2780
|
]
|
|
@@ -2796,6 +2851,31 @@ dependencies = [
|
|
|
2796
2851
|
"zeroize",
|
|
2797
2852
|
]
|
|
2798
2853
|
|
|
2854
|
+
[[package]]
|
|
2855
|
+
name = "rsqlite-vfs"
|
|
2856
|
+
version = "0.1.1"
|
|
2857
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2858
|
+
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
|
2859
|
+
dependencies = [
|
|
2860
|
+
"hashbrown 0.16.1",
|
|
2861
|
+
"thiserror",
|
|
2862
|
+
]
|
|
2863
|
+
|
|
2864
|
+
[[package]]
|
|
2865
|
+
name = "rusqlite"
|
|
2866
|
+
version = "0.40.2"
|
|
2867
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2868
|
+
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
|
2869
|
+
dependencies = [
|
|
2870
|
+
"bitflags",
|
|
2871
|
+
"fallible-iterator",
|
|
2872
|
+
"fallible-streaming-iterator",
|
|
2873
|
+
"hashlink",
|
|
2874
|
+
"libsqlite3-sys",
|
|
2875
|
+
"smallvec",
|
|
2876
|
+
"sqlite-wasm-rs",
|
|
2877
|
+
]
|
|
2878
|
+
|
|
2799
2879
|
[[package]]
|
|
2800
2880
|
name = "russh"
|
|
2801
2881
|
version = "0.63.3"
|
|
@@ -3359,6 +3439,18 @@ version = "0.1.0"
|
|
|
3359
3439
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3360
3440
|
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
|
3361
3441
|
|
|
3442
|
+
[[package]]
|
|
3443
|
+
name = "sqlite-wasm-rs"
|
|
3444
|
+
version = "0.5.5"
|
|
3445
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3446
|
+
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
|
3447
|
+
dependencies = [
|
|
3448
|
+
"cc",
|
|
3449
|
+
"js-sys",
|
|
3450
|
+
"rsqlite-vfs",
|
|
3451
|
+
"wasm-bindgen",
|
|
3452
|
+
]
|
|
3453
|
+
|
|
3362
3454
|
[[package]]
|
|
3363
3455
|
name = "ssh-cipher"
|
|
3364
3456
|
version = "0.3.0"
|
|
@@ -3793,6 +3885,12 @@ dependencies = [
|
|
|
3793
3885
|
"wasm-bindgen",
|
|
3794
3886
|
]
|
|
3795
3887
|
|
|
3888
|
+
[[package]]
|
|
3889
|
+
name = "vcpkg"
|
|
3890
|
+
version = "0.2.15"
|
|
3891
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3892
|
+
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
|
3893
|
+
|
|
3796
3894
|
[[package]]
|
|
3797
3895
|
name = "version_check"
|
|
3798
3896
|
version = "0.9.5"
|
|
@@ -3884,6 +3982,19 @@ dependencies = [
|
|
|
3884
3982
|
"unicode-ident",
|
|
3885
3983
|
]
|
|
3886
3984
|
|
|
3985
|
+
[[package]]
|
|
3986
|
+
name = "wasm-streams"
|
|
3987
|
+
version = "0.4.2"
|
|
3988
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3989
|
+
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
|
3990
|
+
dependencies = [
|
|
3991
|
+
"futures-util",
|
|
3992
|
+
"js-sys",
|
|
3993
|
+
"wasm-bindgen",
|
|
3994
|
+
"wasm-bindgen-futures",
|
|
3995
|
+
"web-sys",
|
|
3996
|
+
]
|
|
3997
|
+
|
|
3887
3998
|
[[package]]
|
|
3888
3999
|
name = "web-sys"
|
|
3889
4000
|
version = "0.3.105"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "asanypath-native"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
license = "MIT"
|
|
6
6
|
publish = false
|
|
@@ -12,9 +12,9 @@ crate-type = ["cdylib"]
|
|
|
12
12
|
[dependencies]
|
|
13
13
|
pyo3 = { version = "0.29", features = ["extension-module"] }
|
|
14
14
|
pyo3-async-runtimes = { version = "0.29", features = ["tokio-runtime"] }
|
|
15
|
-
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
|
15
|
+
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] }
|
|
16
16
|
aws-config = { version = "1", features = ["behavior-version-latest"] }
|
|
17
|
-
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2"] }
|
|
17
|
+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2", "stream"] }
|
|
18
18
|
futures = "0.3"
|
|
19
19
|
aws-sigv4 = "1"
|
|
20
20
|
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
|
|
@@ -34,8 +34,10 @@ scraper = { version = "0.27", default-features = false }
|
|
|
34
34
|
url = "2"
|
|
35
35
|
russh = "0.63"
|
|
36
36
|
russh-sftp = "3"
|
|
37
|
+
tokio-util = { version = "0.7", features = ["io"] }
|
|
37
38
|
anyhow = "1"
|
|
38
39
|
ssh2-config = { version = "0.8", default-features = false, features = ["nolog"] }
|
|
40
|
+
rusqlite = { version = "0.40", features = ["bundled"] }
|
|
39
41
|
|
|
40
42
|
[profile.release]
|
|
41
43
|
strip = "symbols"
|
|
@@ -8,9 +8,23 @@
|
|
|
8
8
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
class DownloadCache:
|
|
12
|
+
def __init__(self, directory: str, size_limit: int) -> None: ...
|
|
13
|
+
@property
|
|
14
|
+
def size_limit(self) -> int: ...
|
|
15
|
+
def get(self, key: str) -> bytes | None: ...
|
|
16
|
+
def set(self, key: str, value: bytes, expire: float | None = None) -> None: ...
|
|
17
|
+
def delete(self, key: str) -> None: ...
|
|
18
|
+
def clear(self) -> None: ...
|
|
19
|
+
def close(self) -> None: ...
|
|
20
|
+
|
|
11
21
|
# -- S3 --
|
|
12
22
|
async def s3_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
13
23
|
async def s3_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
24
|
+
async def s3_create_multipart(*args: Any, **kwargs: Any) -> Any: ...
|
|
25
|
+
async def s3_upload_part(*args: Any, **kwargs: Any) -> Any: ...
|
|
26
|
+
async def s3_complete_multipart(*args: Any, **kwargs: Any) -> Any: ...
|
|
27
|
+
async def s3_abort_multipart(*args: Any, **kwargs: Any) -> Any: ...
|
|
14
28
|
async def s3_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
15
29
|
async def s3_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
16
30
|
async def s3_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -26,6 +40,7 @@ async def s3_get_credentials(
|
|
|
26
40
|
access_key: str | None,
|
|
27
41
|
secret_key: str | None,
|
|
28
42
|
session_token: str | None,
|
|
43
|
+
profile: str | None = None,
|
|
29
44
|
) -> tuple[str, str, str | None, str, str | None]: ...
|
|
30
45
|
def s3_presign(
|
|
31
46
|
endpoint: str,
|
|
@@ -42,6 +57,8 @@ def s3_presign(
|
|
|
42
57
|
# -- GCS --
|
|
43
58
|
async def gcs_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
44
59
|
async def gcs_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
60
|
+
async def gcs_start_resumable(*args: Any, **kwargs: Any) -> Any: ...
|
|
61
|
+
async def gcs_upload_chunk(*args: Any, **kwargs: Any) -> Any: ...
|
|
45
62
|
async def gcs_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
46
63
|
async def gcs_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
47
64
|
async def gcs_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -62,6 +79,8 @@ def gcs_presign(
|
|
|
62
79
|
# -- Azure --
|
|
63
80
|
async def az_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
64
81
|
async def az_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
82
|
+
async def az_put_block(*args: Any, **kwargs: Any) -> Any: ...
|
|
83
|
+
async def az_put_block_list(*args: Any, **kwargs: Any) -> Any: ...
|
|
65
84
|
async def az_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
66
85
|
async def az_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
67
86
|
async def az_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -84,6 +103,7 @@ def az_presign(
|
|
|
84
103
|
# -- Artifactory --
|
|
85
104
|
async def art_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
86
105
|
async def art_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
106
|
+
async def art_put_stream(*args: Any, **kwargs: Any) -> Any: ...
|
|
87
107
|
async def art_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
88
108
|
async def art_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
89
109
|
async def art_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -110,6 +130,7 @@ async def range_read(path: str, start: int, end: int, **kwargs: Any) -> bytes: .
|
|
|
110
130
|
async def ssh_read(*args: Any, **kwargs: Any) -> bytes: ...
|
|
111
131
|
async def ssh_read_range(*args: Any, **kwargs: Any) -> bytes: ...
|
|
112
132
|
async def ssh_write(*args: Any, **kwargs: Any) -> int: ...
|
|
133
|
+
async def ssh_write_chunk(*args: Any, **kwargs: Any) -> int: ...
|
|
113
134
|
async def ssh_exists(*args: Any, **kwargs: Any) -> bool: ...
|
|
114
135
|
async def ssh_stat(*args: Any, **kwargs: Any) -> Any: ...
|
|
115
136
|
async def ssh_lstat(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -19,7 +19,7 @@ use pyo3::prelude::*;
|
|
|
19
19
|
use serde::Deserialize;
|
|
20
20
|
use std::collections::HashMap;
|
|
21
21
|
|
|
22
|
-
use crate::http::{bearer_headers, do_request_light, status_to_pyerr};
|
|
22
|
+
use crate::http::{bearer_headers, do_request_light, do_request_stream_file, status_to_pyerr};
|
|
23
23
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
// JSON response types for Artifactory Storage API
|
|
@@ -465,6 +465,44 @@ pub fn art_put_batch<'py>(
|
|
|
465
465
|
})
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
/// Stream-upload a single file by PUT (body framed from disk; bounded memory).
|
|
469
|
+
/// Artifactory has no multipart API, so a large object is sent as one streamed PUT.
|
|
470
|
+
#[pyfunction]
|
|
471
|
+
#[pyo3(signature = (base_url, key, file_path, size, token, options_json=None, use_h2=None))]
|
|
472
|
+
pub fn art_put_stream<'py>(
|
|
473
|
+
py: Python<'py>,
|
|
474
|
+
base_url: String,
|
|
475
|
+
key: String,
|
|
476
|
+
file_path: String,
|
|
477
|
+
size: u64,
|
|
478
|
+
token: String,
|
|
479
|
+
options_json: Option<String>,
|
|
480
|
+
use_h2: Option<bool>,
|
|
481
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
482
|
+
let h2 = use_h2.unwrap_or(false);
|
|
483
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
484
|
+
let options: crate::backend::UploadOptions =
|
|
485
|
+
sonic_rs::from_str(&options_json.unwrap_or_else(|| "{}".to_string()))
|
|
486
|
+
.map_err(|e| PyValueError::new_err(format!("invalid backend_options: {e}")))?;
|
|
487
|
+
crate::backend::require_empty_provider(&options)?;
|
|
488
|
+
let creds = art_creds!(base_url, token);
|
|
489
|
+
let url = crate::backend::append_query(&creds.content_url(&key), &options.query);
|
|
490
|
+
let mut headers = creds.auth_headers();
|
|
491
|
+
headers.push((
|
|
492
|
+
"content-type".to_string(),
|
|
493
|
+
"application/octet-stream".to_string(),
|
|
494
|
+
));
|
|
495
|
+
crate::backend::merge_user_headers(&mut headers, &options)?;
|
|
496
|
+
let (status, body) = do_request_stream_file("PUT", &url, &headers, &file_path, size, h2)
|
|
497
|
+
.await
|
|
498
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
499
|
+
if !status.is_success() {
|
|
500
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
501
|
+
}
|
|
502
|
+
Ok(())
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
468
506
|
/// Copy multiple files concurrently (server-side). Pairs are `(src, dst)`.
|
|
469
507
|
#[pyfunction]
|
|
470
508
|
pub fn art_copy_batch<'py>(
|
|
@@ -943,6 +943,97 @@ pub fn az_put_batch<'py>(
|
|
|
943
943
|
})
|
|
944
944
|
}
|
|
945
945
|
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
// Block blob upload (orchestrated from Python): stage blocks, then commit list
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
|
|
950
|
+
/// Stage one block for a block blob. `block_id` is the raw (pre-URL-encode) id.
|
|
951
|
+
#[pyfunction]
|
|
952
|
+
#[pyo3(signature = (endpoint, container, blob, block_id, data, account_name, account_key=None, sas_token=None, use_h2=None))]
|
|
953
|
+
pub fn az_put_block<'py>(
|
|
954
|
+
py: Python<'py>,
|
|
955
|
+
endpoint: String,
|
|
956
|
+
container: String,
|
|
957
|
+
blob: String,
|
|
958
|
+
block_id: String,
|
|
959
|
+
data: Vec<u8>,
|
|
960
|
+
account_name: String,
|
|
961
|
+
account_key: Option<String>,
|
|
962
|
+
sas_token: Option<String>,
|
|
963
|
+
use_h2: Option<bool>,
|
|
964
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
965
|
+
let h2 = use_h2.unwrap_or(false);
|
|
966
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
967
|
+
let creds = az_creds!(endpoint, container, account_name, account_key, sas_token);
|
|
968
|
+
let base = creds.blob_url(&blob);
|
|
969
|
+
let sep = if base.contains('?') { "&" } else { "?" };
|
|
970
|
+
let url = format!(
|
|
971
|
+
"{}{}comp=block&blockid={}",
|
|
972
|
+
base,
|
|
973
|
+
sep,
|
|
974
|
+
urlencoding::encode(&block_id)
|
|
975
|
+
);
|
|
976
|
+
let mut headers = vec![("content-length".to_string(), data.len().to_string())];
|
|
977
|
+
let params = [("blockid", block_id.as_str()), ("comp", "block")];
|
|
978
|
+
creds.sign("PUT", &url, &mut headers, Some(¶ms));
|
|
979
|
+
let (status, body) = do_request_light("PUT", &url, &headers, Some(Bytes::from(data)), h2)
|
|
980
|
+
.await
|
|
981
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
982
|
+
if !status.is_success() {
|
|
983
|
+
return Err(status_to_pyerr(status, &creds.error_path(&blob), &body));
|
|
984
|
+
}
|
|
985
|
+
Ok(())
|
|
986
|
+
})
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/// Commit a block blob from the staged block ids (in order).
|
|
990
|
+
#[pyfunction]
|
|
991
|
+
#[pyo3(signature = (endpoint, container, blob, block_ids, account_name, account_key=None, sas_token=None, options_json=None, use_h2=None))]
|
|
992
|
+
pub fn az_put_block_list<'py>(
|
|
993
|
+
py: Python<'py>,
|
|
994
|
+
endpoint: String,
|
|
995
|
+
container: String,
|
|
996
|
+
blob: String,
|
|
997
|
+
block_ids: Vec<String>,
|
|
998
|
+
account_name: String,
|
|
999
|
+
account_key: Option<String>,
|
|
1000
|
+
sas_token: Option<String>,
|
|
1001
|
+
options_json: Option<String>,
|
|
1002
|
+
use_h2: Option<bool>,
|
|
1003
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1004
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1005
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1006
|
+
let options: crate::backend::UploadOptions =
|
|
1007
|
+
sonic_rs::from_str(&options_json.unwrap_or_else(|| "{}".to_string()))
|
|
1008
|
+
.map_err(|e| PyValueError::new_err(format!("invalid backend_options: {e}")))?;
|
|
1009
|
+
crate::backend::require_empty_provider(&options)?;
|
|
1010
|
+
let creds = az_creds!(endpoint, container, account_name, account_key, sas_token);
|
|
1011
|
+
let base = creds.blob_url(&blob);
|
|
1012
|
+
let sep = if base.contains('?') { "&" } else { "?" };
|
|
1013
|
+
let url = format!("{}{}comp=blocklist", base, sep);
|
|
1014
|
+
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><BlockList>");
|
|
1015
|
+
for id in &block_ids {
|
|
1016
|
+
xml.push_str(&format!("<Latest>{}</Latest>", id));
|
|
1017
|
+
}
|
|
1018
|
+
xml.push_str("</BlockList>");
|
|
1019
|
+
let body = Bytes::from(xml.into_bytes());
|
|
1020
|
+
let mut headers = vec![
|
|
1021
|
+
("content-length".to_string(), body.len().to_string()),
|
|
1022
|
+
("content-type".to_string(), "application/xml".to_string()),
|
|
1023
|
+
];
|
|
1024
|
+
crate::backend::merge_user_headers(&mut headers, &options)?;
|
|
1025
|
+
let params = [("comp", "blocklist")];
|
|
1026
|
+
creds.sign("PUT", &url, &mut headers, Some(¶ms));
|
|
1027
|
+
let (status, resp) = do_request_light("PUT", &url, &headers, Some(body), h2)
|
|
1028
|
+
.await
|
|
1029
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1030
|
+
if !status.is_success() {
|
|
1031
|
+
return Err(status_to_pyerr(status, &creds.error_path(&blob), &resp));
|
|
1032
|
+
}
|
|
1033
|
+
Ok(())
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
|
|
946
1037
|
#[pyfunction]
|
|
947
1038
|
pub fn az_copy_batch<'py>(
|
|
948
1039
|
py: Python<'py>,
|
|
@@ -170,11 +170,14 @@ pub trait CloudBackend {
|
|
|
170
170
|
start: u64,
|
|
171
171
|
end: u64,
|
|
172
172
|
use_h2: bool,
|
|
173
|
+
conditions: &[(String, String)],
|
|
173
174
|
) -> impl std::future::Future<Output = Result<PyBytesBuf, PyErr>> {
|
|
174
175
|
async move {
|
|
175
|
-
let
|
|
176
|
+
let mut headers = conditions.to_vec();
|
|
177
|
+
headers.push(("range".to_string(), format!("bytes={}-{}", start, end)));
|
|
178
|
+
headers.push(("accept-encoding".to_string(), "identity".to_string()));
|
|
176
179
|
let (status, body) = self
|
|
177
|
-
.do_request("GET", path, None, use_h2, &
|
|
180
|
+
.do_request("GET", path, None, use_h2, &headers)
|
|
178
181
|
.await
|
|
179
182
|
.map_err(|e| PyRuntimeError::new_err(e))?;
|
|
180
183
|
// A range entirely past end-of-object yields 416; treat as empty (EOF)
|
|
@@ -185,6 +188,11 @@ pub trait CloudBackend {
|
|
|
185
188
|
if !status.is_success() {
|
|
186
189
|
return Err(status_to_pyerr(status, &self.error_path(path), &body));
|
|
187
190
|
}
|
|
191
|
+
if status.as_u16() != 206 {
|
|
192
|
+
return Err(pyo3::exceptions::PyOSError::new_err(
|
|
193
|
+
"Server ignored range request",
|
|
194
|
+
));
|
|
195
|
+
}
|
|
188
196
|
Ok(PyBytesBuf(body))
|
|
189
197
|
}
|
|
190
198
|
}
|
|
@@ -363,9 +371,11 @@ macro_rules! batch_copy {
|
|
|
363
371
|
/// Usage: `range_read_impl!(py, creds, path, start, end)`
|
|
364
372
|
#[macro_export]
|
|
365
373
|
macro_rules! range_read_impl {
|
|
366
|
-
($py:expr, $creds:expr, $path:expr, $start:expr, $end:expr) => {
|
|
374
|
+
($py:expr, $creds:expr, $path:expr, $start:expr, $end:expr, $conditions:expr) => {
|
|
367
375
|
pyo3_async_runtimes::tokio::future_into_py($py, async move {
|
|
368
|
-
$creds
|
|
376
|
+
$creds
|
|
377
|
+
.range_get(&$path, $start, $end, false, &$conditions)
|
|
378
|
+
.await
|
|
369
379
|
})
|
|
370
380
|
};
|
|
371
381
|
}
|
|
@@ -387,7 +397,8 @@ use pyo3::prelude::*;
|
|
|
387
397
|
#[pyo3(signature = (path, start, end, endpoint=None, bucket=None, region=None,
|
|
388
398
|
access_key=None, secret_key=None, session_token=None,
|
|
389
399
|
container=None, account_name=None, account_key=None, sas_token=None,
|
|
390
|
-
access_token=None, base_url=None, token=None
|
|
400
|
+
access_token=None, base_url=None, token=None,
|
|
401
|
+
if_match=None, if_unmodified_since=None, if_generation_match=None))]
|
|
391
402
|
pub fn range_read<'py>(
|
|
392
403
|
py: Python<'py>,
|
|
393
404
|
path: String,
|
|
@@ -411,7 +422,20 @@ pub fn range_read<'py>(
|
|
|
411
422
|
// Artifactory
|
|
412
423
|
base_url: Option<String>,
|
|
413
424
|
token: Option<String>,
|
|
425
|
+
if_match: Option<String>,
|
|
426
|
+
if_unmodified_since: Option<String>,
|
|
427
|
+
if_generation_match: Option<String>,
|
|
414
428
|
) -> PyResult<Bound<'py, PyAny>> {
|
|
429
|
+
let mut conditions = Vec::new();
|
|
430
|
+
if let Some(value) = if_match {
|
|
431
|
+
conditions.push(("if-match".to_string(), value));
|
|
432
|
+
}
|
|
433
|
+
if let Some(value) = if_unmodified_since {
|
|
434
|
+
conditions.push(("if-unmodified-since".to_string(), value));
|
|
435
|
+
}
|
|
436
|
+
if let Some(value) = if_generation_match {
|
|
437
|
+
conditions.push(("x-asanypath-generation-match".to_string(), value));
|
|
438
|
+
}
|
|
415
439
|
if let Some(region) = region {
|
|
416
440
|
// S3
|
|
417
441
|
let creds = crate::s3::S3Creds {
|
|
@@ -422,7 +446,7 @@ pub fn range_read<'py>(
|
|
|
422
446
|
secret_key: secret_key.unwrap_or_default(),
|
|
423
447
|
session_token,
|
|
424
448
|
};
|
|
425
|
-
range_read_impl!(py, creds, path, start, end)
|
|
449
|
+
range_read_impl!(py, creds, path, start, end, conditions)
|
|
426
450
|
} else if let Some(container) = container {
|
|
427
451
|
// Azure
|
|
428
452
|
use crate::azure::AzCreds;
|
|
@@ -433,12 +457,12 @@ pub fn range_read<'py>(
|
|
|
433
457
|
account_key,
|
|
434
458
|
sas_token,
|
|
435
459
|
);
|
|
436
|
-
range_read_impl!(py, creds, path, start, end)
|
|
460
|
+
range_read_impl!(py, creds, path, start, end, conditions)
|
|
437
461
|
} else if let Some(base_url) = base_url {
|
|
438
462
|
// Artifactory
|
|
439
463
|
use crate::artifactory::ArtCreds;
|
|
440
464
|
let creds = ArtCreds::new(base_url, token.unwrap_or_default());
|
|
441
|
-
range_read_impl!(py, creds, path, start, end)
|
|
465
|
+
range_read_impl!(py, creds, path, start, end, conditions)
|
|
442
466
|
} else {
|
|
443
467
|
// GCS
|
|
444
468
|
use crate::gcs::GcsCreds;
|
|
@@ -447,6 +471,6 @@ pub fn range_read<'py>(
|
|
|
447
471
|
bucket.unwrap_or_default(),
|
|
448
472
|
access_token,
|
|
449
473
|
);
|
|
450
|
-
range_read_impl!(py, creds, path, start, end)
|
|
474
|
+
range_read_impl!(py, creds, path, start, end, conditions)
|
|
451
475
|
}
|
|
452
476
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Copyright (C) 2026 Axis Communications AB, Lund, Sweden
|
|
2
|
+
//
|
|
3
|
+
// Use of this source code is governed by an MIT-style
|
|
4
|
+
// license that can be found in the LICENSE file or at
|
|
5
|
+
// https://opensource.org/licenses/MIT.
|
|
6
|
+
|
|
7
|
+
use std::path::PathBuf;
|
|
8
|
+
use std::sync::Mutex;
|
|
9
|
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
10
|
+
|
|
11
|
+
use pyo3::exceptions::{PyOSError, PyRuntimeError, PyValueError};
|
|
12
|
+
use pyo3::prelude::*;
|
|
13
|
+
use pyo3::types::PyBytes;
|
|
14
|
+
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
|
|
15
|
+
|
|
16
|
+
fn database_error(error: rusqlite::Error) -> PyErr {
|
|
17
|
+
PyOSError::new_err(format!("Download cache database: {error}"))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn now() -> PyResult<f64> {
|
|
21
|
+
SystemTime::now()
|
|
22
|
+
.duration_since(UNIX_EPOCH)
|
|
23
|
+
.map(|time| time.as_secs_f64())
|
|
24
|
+
.map_err(|error| PyRuntimeError::new_err(error.to_string()))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Persistent byte-only cache shared through SQLite transactions.
|
|
28
|
+
#[pyclass]
|
|
29
|
+
pub struct DownloadCache {
|
|
30
|
+
connection: Mutex<Option<Connection>>,
|
|
31
|
+
#[pyo3(get)]
|
|
32
|
+
size_limit: i64,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl DownloadCache {
|
|
36
|
+
fn with_connection<T>(
|
|
37
|
+
&self,
|
|
38
|
+
operation: impl FnOnce(&mut Connection) -> rusqlite::Result<T>,
|
|
39
|
+
) -> PyResult<T> {
|
|
40
|
+
let mut guard = self
|
|
41
|
+
.connection
|
|
42
|
+
.lock()
|
|
43
|
+
.map_err(|_| PyRuntimeError::new_err("Download cache lock poisoned"))?;
|
|
44
|
+
let connection = guard
|
|
45
|
+
.as_mut()
|
|
46
|
+
.ok_or_else(|| PyValueError::new_err("Download cache is closed"))?;
|
|
47
|
+
operation(connection).map_err(database_error)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[pymethods]
|
|
52
|
+
impl DownloadCache {
|
|
53
|
+
#[new]
|
|
54
|
+
fn new(py: Python<'_>, directory: PathBuf, size_limit: i64) -> PyResult<Self> {
|
|
55
|
+
if size_limit <= 0 {
|
|
56
|
+
return Err(PyValueError::new_err("size_limit must be positive"));
|
|
57
|
+
}
|
|
58
|
+
py.detach(|| {
|
|
59
|
+
// Never open or migrate a legacy DiskCache database.
|
|
60
|
+
let directory = directory.join("native-v1");
|
|
61
|
+
std::fs::create_dir_all(&directory).map_err(|error| {
|
|
62
|
+
PyOSError::new_err(format!("Create download cache directory: {error}"))
|
|
63
|
+
})?;
|
|
64
|
+
let connection =
|
|
65
|
+
Connection::open(directory.join("cache.sqlite3")).map_err(database_error)?;
|
|
66
|
+
connection
|
|
67
|
+
.busy_timeout(Duration::from_secs(60))
|
|
68
|
+
.map_err(database_error)?;
|
|
69
|
+
connection
|
|
70
|
+
.execute_batch(
|
|
71
|
+
"PRAGMA trusted_schema = OFF;
|
|
72
|
+
PRAGMA auto_vacuum = FULL;
|
|
73
|
+
PRAGMA journal_mode = TRUNCATE;
|
|
74
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
75
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
76
|
+
value BLOB NOT NULL CHECK(typeof(value) = 'blob'),
|
|
77
|
+
expires REAL,
|
|
78
|
+
accessed INTEGER NOT NULL
|
|
79
|
+
);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS entries_lru ON entries(accessed);",
|
|
81
|
+
)
|
|
82
|
+
.map_err(database_error)?;
|
|
83
|
+
Ok(Self {
|
|
84
|
+
connection: Mutex::new(Some(connection)),
|
|
85
|
+
size_limit,
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn get(&self, py: Python<'_>, key: String) -> PyResult<Option<Py<PyBytes>>> {
|
|
91
|
+
let timestamp = now()?;
|
|
92
|
+
let value: Option<Vec<u8>> = py.detach(|| {
|
|
93
|
+
self.with_connection(|connection| {
|
|
94
|
+
let transaction =
|
|
95
|
+
connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
|
96
|
+
transaction.execute(
|
|
97
|
+
"DELETE FROM entries WHERE key = ?1 AND expires <= ?2",
|
|
98
|
+
params![key, timestamp],
|
|
99
|
+
)?;
|
|
100
|
+
let value = transaction
|
|
101
|
+
.query_row("SELECT value FROM entries WHERE key = ?1", [&key], |row| {
|
|
102
|
+
row.get(0)
|
|
103
|
+
})
|
|
104
|
+
.optional()?;
|
|
105
|
+
if value.is_some() {
|
|
106
|
+
transaction.execute(
|
|
107
|
+
"UPDATE entries SET accessed =
|
|
108
|
+
(SELECT COALESCE(MAX(accessed), 0) + 1 FROM entries) WHERE key = ?1",
|
|
109
|
+
[&key],
|
|
110
|
+
)?;
|
|
111
|
+
}
|
|
112
|
+
transaction.commit()?;
|
|
113
|
+
Ok(value)
|
|
114
|
+
})
|
|
115
|
+
})?;
|
|
116
|
+
Ok(value.map(|value| PyBytes::new(py, &value).unbind()))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[pyo3(signature = (key, value, expire=None))]
|
|
120
|
+
fn set(
|
|
121
|
+
&self,
|
|
122
|
+
py: Python<'_>,
|
|
123
|
+
key: String,
|
|
124
|
+
value: Vec<u8>,
|
|
125
|
+
expire: Option<f64>,
|
|
126
|
+
) -> PyResult<()> {
|
|
127
|
+
let timestamp = now()?;
|
|
128
|
+
if let Some(ttl) = expire
|
|
129
|
+
&& (!ttl.is_finite() || !(timestamp + ttl).is_finite())
|
|
130
|
+
{
|
|
131
|
+
return Err(PyValueError::new_err("ttl must be finite"));
|
|
132
|
+
}
|
|
133
|
+
py.detach(|| {
|
|
134
|
+
self.with_connection(|connection| {
|
|
135
|
+
let transaction =
|
|
136
|
+
connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
|
137
|
+
transaction.execute(
|
|
138
|
+
"DELETE FROM entries WHERE key = ?1 OR expires <= ?2",
|
|
139
|
+
params![key, timestamp],
|
|
140
|
+
)?;
|
|
141
|
+
let size = key.len().saturating_add(value.len()) as u64;
|
|
142
|
+
// Oversized objects are returned to the caller but never retained.
|
|
143
|
+
if size <= self.size_limit as u64 && expire.is_none_or(|ttl| ttl > 0.0) {
|
|
144
|
+
transaction.execute(
|
|
145
|
+
"DELETE FROM entries WHERE key IN (
|
|
146
|
+
SELECT key FROM (
|
|
147
|
+
SELECT key, SUM(length(CAST(key AS BLOB)) + length(value))
|
|
148
|
+
OVER (ORDER BY accessed DESC ROWS UNBOUNDED PRECEDING) AS used
|
|
149
|
+
FROM entries
|
|
150
|
+
) WHERE used > ?1
|
|
151
|
+
)",
|
|
152
|
+
[self.size_limit - size as i64],
|
|
153
|
+
)?;
|
|
154
|
+
transaction.execute(
|
|
155
|
+
"INSERT INTO entries(key, value, expires, accessed)
|
|
156
|
+
VALUES (?1, ?2, ?3, (SELECT COALESCE(MAX(accessed), 0) + 1 FROM entries))",
|
|
157
|
+
params![key, value, expire.map(|ttl| timestamp + ttl)],
|
|
158
|
+
)?;
|
|
159
|
+
}
|
|
160
|
+
transaction.commit()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fn delete(&self, py: Python<'_>, key: String) -> PyResult<()> {
|
|
166
|
+
py.detach(|| {
|
|
167
|
+
self.with_connection(|connection| {
|
|
168
|
+
connection.execute("DELETE FROM entries WHERE key = ?1", [key])?;
|
|
169
|
+
Ok(())
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn clear(&self, py: Python<'_>) -> PyResult<()> {
|
|
175
|
+
py.detach(|| {
|
|
176
|
+
self.with_connection(|connection| {
|
|
177
|
+
connection.execute("DELETE FROM entries", [])?;
|
|
178
|
+
Ok(())
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn close(&self, py: Python<'_>) -> PyResult<()> {
|
|
184
|
+
py.detach(|| {
|
|
185
|
+
let mut guard = self
|
|
186
|
+
.connection
|
|
187
|
+
.lock()
|
|
188
|
+
.map_err(|_| PyRuntimeError::new_err("Download cache lock poisoned"))?;
|
|
189
|
+
if let Some(connection) = guard.take() {
|
|
190
|
+
connection.close().map_err(|(connection, error)| {
|
|
191
|
+
*guard = Some(connection);
|
|
192
|
+
database_error(error)
|
|
193
|
+
})?;
|
|
194
|
+
}
|
|
195
|
+
Ok(())
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
|
|
|
24
24
|
use std::sync::{Mutex, OnceLock};
|
|
25
25
|
use std::time::{Duration, Instant};
|
|
26
26
|
|
|
27
|
-
use crate::http::{bearer_headers, do_request_light, status_to_pyerr};
|
|
27
|
+
use crate::http::{bearer_headers, do_request, do_request_light, status_to_pyerr};
|
|
28
28
|
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Typed JSON response structs (avoids Value allocations)
|
|
@@ -155,6 +155,14 @@ impl GcsCreds {
|
|
|
155
155
|
format!("{}/storage/v1/b/{}/o/{}", base, self.bucket, encoded)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
fn media_url(&self, object_path: &str, generation: Option<&str>) -> String {
|
|
159
|
+
let mut params = vec![("alt", "media")];
|
|
160
|
+
if let Some(generation) = generation {
|
|
161
|
+
params.push(("ifGenerationMatch", generation));
|
|
162
|
+
}
|
|
163
|
+
append_params(&self.object_url(object_path), ¶ms)
|
|
164
|
+
}
|
|
165
|
+
|
|
158
166
|
fn upload_base(&self) -> String {
|
|
159
167
|
let base = self.endpoint.trim_end_matches('/');
|
|
160
168
|
format!("{}/upload/storage/v1/b/{}/o", base, self.bucket)
|
|
@@ -343,13 +351,21 @@ impl CloudBackend for GcsCreds {
|
|
|
343
351
|
) -> impl std::future::Future<Output = Result<(reqwest::StatusCode, Bytes), String>> {
|
|
344
352
|
let extras = extra_headers.to_vec();
|
|
345
353
|
async move {
|
|
354
|
+
let generation = extras
|
|
355
|
+
.iter()
|
|
356
|
+
.find(|(name, _)| name == "x-asanypath-generation-match")
|
|
357
|
+
.map(|(_, value)| value.as_str());
|
|
346
358
|
let url = match method {
|
|
347
|
-
"GET" =>
|
|
359
|
+
"GET" => self.media_url(path, generation),
|
|
348
360
|
"DELETE" => self.object_url(path),
|
|
349
361
|
_ => self.object_url(path),
|
|
350
362
|
};
|
|
351
363
|
let mut headers = self.auth_headers().await;
|
|
352
|
-
headers.extend(
|
|
364
|
+
headers.extend(
|
|
365
|
+
extras
|
|
366
|
+
.into_iter()
|
|
367
|
+
.filter(|(name, _)| name != "x-asanypath-generation-match"),
|
|
368
|
+
);
|
|
353
369
|
do_request_light(method, &url, &headers, body, use_h2).await
|
|
354
370
|
}
|
|
355
371
|
}
|
|
@@ -930,6 +946,84 @@ pub fn gcs_put_batch<'py>(
|
|
|
930
946
|
})
|
|
931
947
|
}
|
|
932
948
|
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
// Resumable upload (orchestrated from Python): init a session, then PUT ranges
|
|
951
|
+
// ---------------------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
/// Begin a resumable upload session; returns the session URI (Location header).
|
|
954
|
+
#[pyfunction]
|
|
955
|
+
#[pyo3(signature = (endpoint, bucket, object, total, access_token=None, use_h2=None))]
|
|
956
|
+
pub fn gcs_start_resumable<'py>(
|
|
957
|
+
py: Python<'py>,
|
|
958
|
+
endpoint: String,
|
|
959
|
+
bucket: String,
|
|
960
|
+
object: String,
|
|
961
|
+
total: u64,
|
|
962
|
+
access_token: Option<String>,
|
|
963
|
+
use_h2: Option<bool>,
|
|
964
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
965
|
+
let h2 = use_h2.unwrap_or(false);
|
|
966
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
967
|
+
let creds = gcs_creds!(endpoint, bucket, access_token);
|
|
968
|
+
let url = append_params(
|
|
969
|
+
&creds.upload_base(),
|
|
970
|
+
&[("uploadType", "resumable"), ("name", &object)],
|
|
971
|
+
);
|
|
972
|
+
let mut headers = creds.auth_headers().await;
|
|
973
|
+
headers.push(("content-length".to_string(), "0".to_string()));
|
|
974
|
+
headers.push((
|
|
975
|
+
"x-upload-content-type".to_string(),
|
|
976
|
+
"application/octet-stream".to_string(),
|
|
977
|
+
));
|
|
978
|
+
headers.push(("x-upload-content-length".to_string(), total.to_string()));
|
|
979
|
+
let (status, body, resp_headers) = do_request("POST", &url, &headers, None, h2)
|
|
980
|
+
.await
|
|
981
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
982
|
+
if !status.is_success() {
|
|
983
|
+
return Err(status_to_pyerr(status, &creds.error_path(&object), &body));
|
|
984
|
+
}
|
|
985
|
+
resp_headers
|
|
986
|
+
.get("location")
|
|
987
|
+
.cloned()
|
|
988
|
+
.ok_or_else(|| PyRuntimeError::new_err("GCS resumable init: no Location header"))
|
|
989
|
+
})
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/// Upload one chunk to a resumable session. GCS completes when offset+len == total.
|
|
993
|
+
#[pyfunction]
|
|
994
|
+
#[pyo3(signature = (session_uri, data, offset, total, use_h2=None))]
|
|
995
|
+
pub fn gcs_upload_chunk<'py>(
|
|
996
|
+
py: Python<'py>,
|
|
997
|
+
session_uri: String,
|
|
998
|
+
data: Vec<u8>,
|
|
999
|
+
offset: u64,
|
|
1000
|
+
total: u64,
|
|
1001
|
+
use_h2: Option<bool>,
|
|
1002
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1003
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1004
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1005
|
+
let len = data.len() as u64;
|
|
1006
|
+
let end = offset + len - 1;
|
|
1007
|
+
let headers = vec![
|
|
1008
|
+
("content-length".to_string(), len.to_string()),
|
|
1009
|
+
(
|
|
1010
|
+
"content-range".to_string(),
|
|
1011
|
+
format!("bytes {offset}-{end}/{total}"),
|
|
1012
|
+
),
|
|
1013
|
+
];
|
|
1014
|
+
let (status, body, _h) =
|
|
1015
|
+
do_request("PUT", &session_uri, &headers, Some(Bytes::from(data)), h2)
|
|
1016
|
+
.await
|
|
1017
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1018
|
+
// 308 Resume Incomplete = intermediate chunk accepted; 2xx = final chunk done.
|
|
1019
|
+
if status.as_u16() == 308 || status.is_success() {
|
|
1020
|
+
Ok(())
|
|
1021
|
+
} else {
|
|
1022
|
+
Err(status_to_pyerr(status, "gs://resumable-chunk", &body))
|
|
1023
|
+
}
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
|
|
933
1027
|
/// Copy multiple objects concurrently (server-side). Pairs are `(src, dst)`.
|
|
934
1028
|
#[pyfunction]
|
|
935
1029
|
pub fn gcs_copy_batch<'py>(
|
|
@@ -1161,7 +1255,21 @@ fn secs_to_datetime(secs: u64) -> (i64, i64, i64, u64, u64, u64) {
|
|
|
1161
1255
|
|
|
1162
1256
|
#[cfg(test)]
|
|
1163
1257
|
mod adc_tests {
|
|
1164
|
-
use super::mint_adc_token;
|
|
1258
|
+
use super::{GcsCreds, mint_adc_token};
|
|
1259
|
+
|
|
1260
|
+
#[test]
|
|
1261
|
+
fn range_media_url_pins_generation() {
|
|
1262
|
+
let creds = GcsCreds::new(
|
|
1263
|
+
"https://storage.googleapis.com".into(),
|
|
1264
|
+
"bucket".into(),
|
|
1265
|
+
None,
|
|
1266
|
+
);
|
|
1267
|
+
let url = url::Url::parse(&creds.media_url("nested/file.bin", Some("123"))).unwrap();
|
|
1268
|
+
let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
|
|
1269
|
+
assert_eq!(query.get("alt").unwrap(), "media");
|
|
1270
|
+
assert_eq!(query.get("ifGenerationMatch").unwrap(), "123");
|
|
1271
|
+
assert!(url.path().ends_with("nested%2Ffile.bin"));
|
|
1272
|
+
}
|
|
1165
1273
|
|
|
1166
1274
|
#[test]
|
|
1167
1275
|
fn rejects_service_account_adc() {
|
|
@@ -158,6 +158,46 @@ pub async fn do_request_light(
|
|
|
158
158
|
.await
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/// Stream a request body from a file (bounded memory). An explicit
|
|
162
|
+
/// `content_length` frames the body with Content-Length instead of chunked
|
|
163
|
+
/// encoding; retries reopen the file so a consumed stream can be replayed.
|
|
164
|
+
pub async fn do_request_stream_file(
|
|
165
|
+
method: &str,
|
|
166
|
+
url: &str,
|
|
167
|
+
headers: &[(String, String)],
|
|
168
|
+
file_path: &str,
|
|
169
|
+
content_length: u64,
|
|
170
|
+
use_h2: bool,
|
|
171
|
+
) -> Result<(StatusCode, Bytes), String> {
|
|
172
|
+
let method_parsed = method.parse::<Method>().map_err(|e| e.to_string())?;
|
|
173
|
+
let client = get_client(use_h2);
|
|
174
|
+
let headers = headers.to_vec();
|
|
175
|
+
|
|
176
|
+
(async || {
|
|
177
|
+
let file = tokio::fs::File::open(file_path)
|
|
178
|
+
.await
|
|
179
|
+
.map_err(|e| e.to_string())?;
|
|
180
|
+
let stream = tokio_util::io::ReaderStream::new(file);
|
|
181
|
+
let mut req = client.request(method_parsed.clone(), url);
|
|
182
|
+
for (k, v) in &headers {
|
|
183
|
+
req = req.header(k.as_str(), v.as_str());
|
|
184
|
+
}
|
|
185
|
+
req = req
|
|
186
|
+
.header("content-length", content_length.to_string())
|
|
187
|
+
.body(reqwest::Body::wrap_stream(stream));
|
|
188
|
+
let resp = req.send().await.map_err(|e| e.to_string())?;
|
|
189
|
+
let status = resp.status();
|
|
190
|
+
if is_retryable_status(status) {
|
|
191
|
+
return Err(format!("HTTP {} (retryable)", status.as_u16()));
|
|
192
|
+
}
|
|
193
|
+
let resp_body = resp.bytes().await.map_err(|e| e.to_string())?;
|
|
194
|
+
Ok((status, resp_body))
|
|
195
|
+
})
|
|
196
|
+
.retry(retry_backoff())
|
|
197
|
+
.when(|e: &String| is_retryable(e))
|
|
198
|
+
.await
|
|
199
|
+
}
|
|
200
|
+
|
|
161
201
|
// ---------------------------------------------------------------------------
|
|
162
202
|
// Helpers
|
|
163
203
|
// ---------------------------------------------------------------------------
|
|
@@ -14,6 +14,7 @@ use pyo3::prelude::*;
|
|
|
14
14
|
|
|
15
15
|
#[macro_use]
|
|
16
16
|
pub mod backend;
|
|
17
|
+
pub mod cache;
|
|
17
18
|
pub mod http;
|
|
18
19
|
pub mod xml;
|
|
19
20
|
|
|
@@ -29,6 +30,7 @@ pub mod ssh;
|
|
|
29
30
|
|
|
30
31
|
#[pymodule]
|
|
31
32
|
fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
33
|
+
m.add_class::<cache::DownloadCache>()?;
|
|
32
34
|
// S3
|
|
33
35
|
m.add_function(wrap_pyfunction!(s3::s3_is_dir, m)?)?;
|
|
34
36
|
m.add_function(wrap_pyfunction!(s3::s3_get_acl, m)?)?;
|
|
@@ -40,6 +42,10 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
40
42
|
m.add_function(wrap_pyfunction!(s3::s3_get_batch, m)?)?;
|
|
41
43
|
m.add_function(wrap_pyfunction!(s3::s3_head_batch, m)?)?;
|
|
42
44
|
m.add_function(wrap_pyfunction!(s3::s3_put_batch, m)?)?;
|
|
45
|
+
m.add_function(wrap_pyfunction!(s3::s3_create_multipart, m)?)?;
|
|
46
|
+
m.add_function(wrap_pyfunction!(s3::s3_upload_part, m)?)?;
|
|
47
|
+
m.add_function(wrap_pyfunction!(s3::s3_complete_multipart, m)?)?;
|
|
48
|
+
m.add_function(wrap_pyfunction!(s3::s3_abort_multipart, m)?)?;
|
|
43
49
|
m.add_function(wrap_pyfunction!(s3::s3_delete_batch, m)?)?;
|
|
44
50
|
m.add_function(wrap_pyfunction!(s3::s3_exists_batch, m)?)?;
|
|
45
51
|
m.add_function(wrap_pyfunction!(s3::s3_is_dir_batch, m)?)?;
|
|
@@ -56,6 +62,8 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
56
62
|
m.add_function(wrap_pyfunction!(gcs::gcs_get_batch, m)?)?;
|
|
57
63
|
m.add_function(wrap_pyfunction!(gcs::gcs_head_batch, m)?)?;
|
|
58
64
|
m.add_function(wrap_pyfunction!(gcs::gcs_put_batch, m)?)?;
|
|
65
|
+
m.add_function(wrap_pyfunction!(gcs::gcs_start_resumable, m)?)?;
|
|
66
|
+
m.add_function(wrap_pyfunction!(gcs::gcs_upload_chunk, m)?)?;
|
|
59
67
|
m.add_function(wrap_pyfunction!(gcs::gcs_delete_batch, m)?)?;
|
|
60
68
|
m.add_function(wrap_pyfunction!(gcs::gcs_exists_batch, m)?)?;
|
|
61
69
|
m.add_function(wrap_pyfunction!(gcs::gcs_is_dir_batch, m)?)?;
|
|
@@ -71,6 +79,8 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
71
79
|
m.add_function(wrap_pyfunction!(azure::az_get_batch, m)?)?;
|
|
72
80
|
m.add_function(wrap_pyfunction!(azure::az_head_batch, m)?)?;
|
|
73
81
|
m.add_function(wrap_pyfunction!(azure::az_put_batch, m)?)?;
|
|
82
|
+
m.add_function(wrap_pyfunction!(azure::az_put_block, m)?)?;
|
|
83
|
+
m.add_function(wrap_pyfunction!(azure::az_put_block_list, m)?)?;
|
|
74
84
|
m.add_function(wrap_pyfunction!(azure::az_delete_batch, m)?)?;
|
|
75
85
|
m.add_function(wrap_pyfunction!(azure::az_exists_batch, m)?)?;
|
|
76
86
|
m.add_function(wrap_pyfunction!(azure::az_is_dir_batch, m)?)?;
|
|
@@ -83,6 +93,7 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
83
93
|
// Artifactory batch
|
|
84
94
|
m.add_function(wrap_pyfunction!(artifactory::art_get_batch, m)?)?;
|
|
85
95
|
m.add_function(wrap_pyfunction!(artifactory::art_put_batch, m)?)?;
|
|
96
|
+
m.add_function(wrap_pyfunction!(artifactory::art_put_stream, m)?)?;
|
|
86
97
|
m.add_function(wrap_pyfunction!(artifactory::art_delete_batch, m)?)?;
|
|
87
98
|
m.add_function(wrap_pyfunction!(artifactory::art_exists_batch, m)?)?;
|
|
88
99
|
m.add_function(wrap_pyfunction!(artifactory::art_list_batch, m)?)?;
|
|
@@ -103,6 +114,7 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
103
114
|
m.add_function(wrap_pyfunction!(ssh::ssh_read, m)?)?;
|
|
104
115
|
m.add_function(wrap_pyfunction!(ssh::ssh_read_range, m)?)?;
|
|
105
116
|
m.add_function(wrap_pyfunction!(ssh::ssh_write, m)?)?;
|
|
117
|
+
m.add_function(wrap_pyfunction!(ssh::ssh_write_chunk, m)?)?;
|
|
106
118
|
m.add_function(wrap_pyfunction!(ssh::ssh_exists, m)?)?;
|
|
107
119
|
m.add_function(wrap_pyfunction!(ssh::ssh_stat, m)?)?;
|
|
108
120
|
m.add_function(wrap_pyfunction!(ssh::ssh_lstat, m)?)?;
|
|
@@ -56,8 +56,11 @@ struct S3AwsConfig {
|
|
|
56
56
|
|
|
57
57
|
static S3_AWS_CONFIGS: OnceLock<AsyncMutex<HashMap<String, S3AwsConfig>>> = OnceLock::new();
|
|
58
58
|
|
|
59
|
-
async fn s3_aws_config(
|
|
60
|
-
|
|
59
|
+
async fn s3_aws_config(
|
|
60
|
+
profile: Option<String>,
|
|
61
|
+
region_override: Option<String>,
|
|
62
|
+
) -> Result<S3AwsConfig, String> {
|
|
63
|
+
let profile = profile.or_else(|| std::env::var("AWS_PROFILE").ok());
|
|
61
64
|
let cache_key = format!(
|
|
62
65
|
"{}|{}",
|
|
63
66
|
profile.as_deref().unwrap_or_default(),
|
|
@@ -133,9 +136,10 @@ pub fn s3_get_credentials<'py>(
|
|
|
133
136
|
access_key: Option<String>,
|
|
134
137
|
secret_key: Option<String>,
|
|
135
138
|
session_token: Option<String>,
|
|
139
|
+
profile: Option<String>,
|
|
136
140
|
) -> PyResult<Bound<'py, PyAny>> {
|
|
137
141
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
138
|
-
let config = s3_aws_config(region_override)
|
|
142
|
+
let config = s3_aws_config(profile, region_override)
|
|
139
143
|
.await
|
|
140
144
|
.map_err(PyRuntimeError::new_err)?;
|
|
141
145
|
let resolved = match (access_key, secret_key) {
|
|
@@ -1065,6 +1069,229 @@ pub fn s3_put_batch<'py>(
|
|
|
1065
1069
|
})
|
|
1066
1070
|
}
|
|
1067
1071
|
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
// Multipart upload primitives (sequencing/concurrency orchestrated from Python)
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
/// Extract the `<UploadId>` text from an InitiateMultipartUpload response.
|
|
1077
|
+
fn parse_upload_id(body: &[u8]) -> Result<String, PyErr> {
|
|
1078
|
+
let mut reader = Reader::from_reader(body);
|
|
1079
|
+
reader.config_mut().trim_text(true);
|
|
1080
|
+
let mut buf = Vec::new();
|
|
1081
|
+
let mut in_id = false;
|
|
1082
|
+
let mut text = String::new();
|
|
1083
|
+
loop {
|
|
1084
|
+
match reader.read_event_into(&mut buf) {
|
|
1085
|
+
Ok(Event::Start(e)) => {
|
|
1086
|
+
let name = e.name();
|
|
1087
|
+
if local_name(name.as_ref()) == "UploadId" {
|
|
1088
|
+
in_id = true;
|
|
1089
|
+
text.clear();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
Ok(Event::Text(e)) if in_id => text.push_str(&e.xml10_content()),
|
|
1093
|
+
Ok(Event::End(_)) if in_id => return Ok(text.clone()),
|
|
1094
|
+
Ok(Event::Eof) | Err(_) => break,
|
|
1095
|
+
_ => {}
|
|
1096
|
+
}
|
|
1097
|
+
buf.clear();
|
|
1098
|
+
}
|
|
1099
|
+
Err(PyRuntimeError::new_err(
|
|
1100
|
+
"S3 CreateMultipartUpload: no UploadId in response",
|
|
1101
|
+
))
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/// Initiate a multipart upload; returns the UploadId.
|
|
1105
|
+
#[pyfunction]
|
|
1106
|
+
#[pyo3(signature = (endpoint, bucket, key, region, access_key, secret_key, session_token=None, options_json=None, use_h2=None))]
|
|
1107
|
+
pub fn s3_create_multipart<'py>(
|
|
1108
|
+
py: Python<'py>,
|
|
1109
|
+
endpoint: String,
|
|
1110
|
+
bucket: String,
|
|
1111
|
+
key: String,
|
|
1112
|
+
region: String,
|
|
1113
|
+
access_key: String,
|
|
1114
|
+
secret_key: String,
|
|
1115
|
+
session_token: Option<String>,
|
|
1116
|
+
options_json: Option<String>,
|
|
1117
|
+
use_h2: Option<bool>,
|
|
1118
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1119
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1120
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1121
|
+
let creds = s3_creds!(
|
|
1122
|
+
endpoint,
|
|
1123
|
+
bucket,
|
|
1124
|
+
region,
|
|
1125
|
+
access_key,
|
|
1126
|
+
secret_key,
|
|
1127
|
+
session_token
|
|
1128
|
+
);
|
|
1129
|
+
let options: crate::backend::UploadOptions =
|
|
1130
|
+
sonic_rs::from_str(&options_json.unwrap_or_else(|| "{}".to_string()))
|
|
1131
|
+
.map_err(|e| PyValueError::new_err(format!("invalid backend_options: {e}")))?;
|
|
1132
|
+
crate::backend::require_empty_provider(&options)?;
|
|
1133
|
+
let mut extra = crate::backend::user_headers(&options)?;
|
|
1134
|
+
extra.push(("content-length".to_string(), "0".to_string()));
|
|
1135
|
+
let s3_path = format!("/{key}");
|
|
1136
|
+
let query = vec![("uploads".to_string(), String::new())];
|
|
1137
|
+
let (status, body, _h) = do_s3_request(&creds, "POST", &s3_path, &query, None, h2, &extra)
|
|
1138
|
+
.await
|
|
1139
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1140
|
+
if !status.is_success() {
|
|
1141
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1142
|
+
}
|
|
1143
|
+
parse_upload_id(&body)
|
|
1144
|
+
})
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/// Upload one part; returns its ETag (used to complete the upload).
|
|
1148
|
+
#[pyfunction]
|
|
1149
|
+
#[pyo3(signature = (endpoint, bucket, key, upload_id, part_number, data, region, access_key, secret_key, session_token=None, use_h2=None))]
|
|
1150
|
+
pub fn s3_upload_part<'py>(
|
|
1151
|
+
py: Python<'py>,
|
|
1152
|
+
endpoint: String,
|
|
1153
|
+
bucket: String,
|
|
1154
|
+
key: String,
|
|
1155
|
+
upload_id: String,
|
|
1156
|
+
part_number: u32,
|
|
1157
|
+
data: Vec<u8>,
|
|
1158
|
+
region: String,
|
|
1159
|
+
access_key: String,
|
|
1160
|
+
secret_key: String,
|
|
1161
|
+
session_token: Option<String>,
|
|
1162
|
+
use_h2: Option<bool>,
|
|
1163
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1164
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1165
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1166
|
+
let creds = s3_creds!(
|
|
1167
|
+
endpoint,
|
|
1168
|
+
bucket,
|
|
1169
|
+
region,
|
|
1170
|
+
access_key,
|
|
1171
|
+
secret_key,
|
|
1172
|
+
session_token
|
|
1173
|
+
);
|
|
1174
|
+
let s3_path = format!("/{key}");
|
|
1175
|
+
let query = vec![
|
|
1176
|
+
("partNumber".to_string(), part_number.to_string()),
|
|
1177
|
+
("uploadId".to_string(), upload_id),
|
|
1178
|
+
];
|
|
1179
|
+
let (status, body, headers) = do_s3_request(
|
|
1180
|
+
&creds,
|
|
1181
|
+
"PUT",
|
|
1182
|
+
&s3_path,
|
|
1183
|
+
&query,
|
|
1184
|
+
Some(Bytes::from(data)),
|
|
1185
|
+
h2,
|
|
1186
|
+
&[],
|
|
1187
|
+
)
|
|
1188
|
+
.await
|
|
1189
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1190
|
+
if !status.is_success() {
|
|
1191
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1192
|
+
}
|
|
1193
|
+
Ok(headers.get("etag").cloned().unwrap_or_default())
|
|
1194
|
+
})
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/// Complete a multipart upload from the collected `(part_number, etag)` pairs.
|
|
1198
|
+
#[pyfunction]
|
|
1199
|
+
#[pyo3(signature = (endpoint, bucket, key, upload_id, parts, region, access_key, secret_key, session_token=None, use_h2=None))]
|
|
1200
|
+
pub fn s3_complete_multipart<'py>(
|
|
1201
|
+
py: Python<'py>,
|
|
1202
|
+
endpoint: String,
|
|
1203
|
+
bucket: String,
|
|
1204
|
+
key: String,
|
|
1205
|
+
upload_id: String,
|
|
1206
|
+
parts: Vec<(u32, String)>,
|
|
1207
|
+
region: String,
|
|
1208
|
+
access_key: String,
|
|
1209
|
+
secret_key: String,
|
|
1210
|
+
session_token: Option<String>,
|
|
1211
|
+
use_h2: Option<bool>,
|
|
1212
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1213
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1214
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1215
|
+
let creds = s3_creds!(
|
|
1216
|
+
endpoint,
|
|
1217
|
+
bucket,
|
|
1218
|
+
region,
|
|
1219
|
+
access_key,
|
|
1220
|
+
secret_key,
|
|
1221
|
+
session_token
|
|
1222
|
+
);
|
|
1223
|
+
let mut xml = String::from("<CompleteMultipartUpload>");
|
|
1224
|
+
for (n, etag) in &parts {
|
|
1225
|
+
xml.push_str(&format!(
|
|
1226
|
+
"<Part><PartNumber>{n}</PartNumber><ETag>{etag}</ETag></Part>"
|
|
1227
|
+
));
|
|
1228
|
+
}
|
|
1229
|
+
xml.push_str("</CompleteMultipartUpload>");
|
|
1230
|
+
let s3_path = format!("/{key}");
|
|
1231
|
+
let query = vec![("uploadId".to_string(), upload_id)];
|
|
1232
|
+
let extra = vec![("content-type".to_string(), "application/xml".to_string())];
|
|
1233
|
+
let (status, body, _h) = do_s3_request(
|
|
1234
|
+
&creds,
|
|
1235
|
+
"POST",
|
|
1236
|
+
&s3_path,
|
|
1237
|
+
&query,
|
|
1238
|
+
Some(Bytes::from(xml.into_bytes())),
|
|
1239
|
+
h2,
|
|
1240
|
+
&extra,
|
|
1241
|
+
)
|
|
1242
|
+
.await
|
|
1243
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1244
|
+
if !status.is_success() {
|
|
1245
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1246
|
+
}
|
|
1247
|
+
// S3 may return 200 OK with an <Error> body when completion fails.
|
|
1248
|
+
if body.windows(7).any(|w| w == b"<Error>") {
|
|
1249
|
+
return Err(PyRuntimeError::new_err(format!(
|
|
1250
|
+
"S3 CompleteMultipartUpload failed: {}",
|
|
1251
|
+
String::from_utf8_lossy(&body)
|
|
1252
|
+
)));
|
|
1253
|
+
}
|
|
1254
|
+
Ok(())
|
|
1255
|
+
})
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/// Abort a multipart upload, discarding any uploaded parts.
|
|
1259
|
+
#[pyfunction]
|
|
1260
|
+
#[pyo3(signature = (endpoint, bucket, key, upload_id, region, access_key, secret_key, session_token=None, use_h2=None))]
|
|
1261
|
+
pub fn s3_abort_multipart<'py>(
|
|
1262
|
+
py: Python<'py>,
|
|
1263
|
+
endpoint: String,
|
|
1264
|
+
bucket: String,
|
|
1265
|
+
key: String,
|
|
1266
|
+
upload_id: String,
|
|
1267
|
+
region: String,
|
|
1268
|
+
access_key: String,
|
|
1269
|
+
secret_key: String,
|
|
1270
|
+
session_token: Option<String>,
|
|
1271
|
+
use_h2: Option<bool>,
|
|
1272
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1273
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1274
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1275
|
+
let creds = s3_creds!(
|
|
1276
|
+
endpoint,
|
|
1277
|
+
bucket,
|
|
1278
|
+
region,
|
|
1279
|
+
access_key,
|
|
1280
|
+
secret_key,
|
|
1281
|
+
session_token
|
|
1282
|
+
);
|
|
1283
|
+
let s3_path = format!("/{key}");
|
|
1284
|
+
let query = vec![("uploadId".to_string(), upload_id)];
|
|
1285
|
+
let (status, body, _h) = do_s3_request(&creds, "DELETE", &s3_path, &query, None, h2, &[])
|
|
1286
|
+
.await
|
|
1287
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1288
|
+
if !status.is_success() {
|
|
1289
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1290
|
+
}
|
|
1291
|
+
Ok(())
|
|
1292
|
+
})
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1068
1295
|
/// Copy multiple objects concurrently (server-side). Pairs are `(src_key, dst_key)`.
|
|
1069
1296
|
#[pyfunction]
|
|
1070
1297
|
pub fn s3_copy_batch<'py>(
|
|
@@ -426,6 +426,54 @@ pub fn ssh_write<'py>(
|
|
|
426
426
|
})
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
/// Write one chunk of a streamed upload. The first chunk (`truncate=true`)
|
|
430
|
+
/// resets the file; later chunks open with APPEND so no offset tracking is
|
|
431
|
+
/// needed. Returns the byte count written.
|
|
432
|
+
#[pyfunction]
|
|
433
|
+
#[allow(clippy::too_many_arguments)]
|
|
434
|
+
pub fn ssh_write_chunk<'py>(
|
|
435
|
+
py: Python<'py>,
|
|
436
|
+
path: String,
|
|
437
|
+
data: Vec<u8>,
|
|
438
|
+
truncate: bool,
|
|
439
|
+
host: String,
|
|
440
|
+
port: u16,
|
|
441
|
+
user: String,
|
|
442
|
+
password: Option<String>,
|
|
443
|
+
key_path: Option<String>,
|
|
444
|
+
key_passphrase: Option<String>,
|
|
445
|
+
use_agent: bool,
|
|
446
|
+
strict_host_key: bool,
|
|
447
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
448
|
+
let conn = conn!(
|
|
449
|
+
host,
|
|
450
|
+
port,
|
|
451
|
+
user,
|
|
452
|
+
password,
|
|
453
|
+
key_path,
|
|
454
|
+
key_passphrase,
|
|
455
|
+
use_agent,
|
|
456
|
+
strict_host_key
|
|
457
|
+
);
|
|
458
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
459
|
+
let session = session_for(&conn).await.map_err(PyRuntimeError::new_err)?;
|
|
460
|
+
let n = data.len();
|
|
461
|
+
let flags = if truncate {
|
|
462
|
+
OpenFlags::CREATE | OpenFlags::WRITE | OpenFlags::TRUNCATE
|
|
463
|
+
} else {
|
|
464
|
+
OpenFlags::CREATE | OpenFlags::WRITE | OpenFlags::APPEND
|
|
465
|
+
};
|
|
466
|
+
let mut file = session
|
|
467
|
+
.sftp
|
|
468
|
+
.open_with_flags(&path, flags)
|
|
469
|
+
.await
|
|
470
|
+
.map_err(err)?;
|
|
471
|
+
file.write_all(&data).await.map_err(err)?;
|
|
472
|
+
file.shutdown().await.map_err(err)?;
|
|
473
|
+
Ok(n)
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
429
477
|
/// Check whether a remote path exists.
|
|
430
478
|
#[pyfunction]
|
|
431
479
|
#[allow(clippy::too_many_arguments)]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|