asanypath-native 2.1.0__tar.gz → 2.2.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.0 → asanypath_native-2.2.0}/Cargo.lock +24 -8
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/Cargo.toml +4 -3
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/PKG-INFO +1 -1
- asanypath_native-2.2.0/asanypath_native.pyi +135 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/artifactory.rs +39 -1
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/azure.rs +91 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/gcs.rs +79 -1
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/http.rs +40 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/lib.rs +10 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/s3.rs +270 -10
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/ssh.rs +48 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/pyproject.toml +0 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/backend.rs +0 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.0}/src/xml.rs +0 -0
- {asanypath_native-2.1.0 → asanypath_native-2.2.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.2.0"
|
|
100
100
|
dependencies = [
|
|
101
101
|
"anyhow",
|
|
102
102
|
"aws-config",
|
|
@@ -122,6 +122,7 @@ dependencies = [
|
|
|
122
122
|
"sonic-rs",
|
|
123
123
|
"ssh2-config",
|
|
124
124
|
"tokio",
|
|
125
|
+
"tokio-util",
|
|
125
126
|
"url",
|
|
126
127
|
"urlencoding",
|
|
127
128
|
]
|
|
@@ -311,9 +312,9 @@ dependencies = [
|
|
|
311
312
|
|
|
312
313
|
[[package]]
|
|
313
314
|
name = "aws-sigv4"
|
|
314
|
-
version = "1.
|
|
315
|
+
version = "1.6.0"
|
|
315
316
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
316
|
-
checksum = "
|
|
317
|
+
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
|
|
317
318
|
dependencies = [
|
|
318
319
|
"aws-credential-types",
|
|
319
320
|
"aws-smithy-http",
|
|
@@ -448,15 +449,14 @@ dependencies = [
|
|
|
448
449
|
|
|
449
450
|
[[package]]
|
|
450
451
|
name = "aws-smithy-runtime-api"
|
|
451
|
-
version = "1.
|
|
452
|
+
version = "1.19.0"
|
|
452
453
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
453
|
-
checksum = "
|
|
454
|
+
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
|
|
454
455
|
dependencies = [
|
|
455
456
|
"aws-smithy-async",
|
|
456
457
|
"aws-smithy-runtime-api-macros",
|
|
457
458
|
"aws-smithy-types",
|
|
458
459
|
"bytes",
|
|
459
|
-
"http 0.2.12",
|
|
460
460
|
"http 1.5.0",
|
|
461
461
|
"pin-project-lite",
|
|
462
462
|
"tokio",
|
|
@@ -488,9 +488,9 @@ dependencies = [
|
|
|
488
488
|
|
|
489
489
|
[[package]]
|
|
490
490
|
name = "aws-smithy-types"
|
|
491
|
-
version = "1.
|
|
491
|
+
version = "1.8.1"
|
|
492
492
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
493
|
-
checksum = "
|
|
493
|
+
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
|
|
494
494
|
dependencies = [
|
|
495
495
|
"base64-simd",
|
|
496
496
|
"bytes",
|
|
@@ -2695,6 +2695,7 @@ dependencies = [
|
|
|
2695
2695
|
"base64 0.22.1",
|
|
2696
2696
|
"bytes",
|
|
2697
2697
|
"futures-core",
|
|
2698
|
+
"futures-util",
|
|
2698
2699
|
"h2",
|
|
2699
2700
|
"http 1.5.0",
|
|
2700
2701
|
"http-body 1.1.0",
|
|
@@ -2715,12 +2716,14 @@ dependencies = [
|
|
|
2715
2716
|
"sync_wrapper",
|
|
2716
2717
|
"tokio",
|
|
2717
2718
|
"tokio-rustls",
|
|
2719
|
+
"tokio-util",
|
|
2718
2720
|
"tower",
|
|
2719
2721
|
"tower-http",
|
|
2720
2722
|
"tower-service",
|
|
2721
2723
|
"url",
|
|
2722
2724
|
"wasm-bindgen",
|
|
2723
2725
|
"wasm-bindgen-futures",
|
|
2726
|
+
"wasm-streams",
|
|
2724
2727
|
"web-sys",
|
|
2725
2728
|
"webpki-roots",
|
|
2726
2729
|
]
|
|
@@ -3885,6 +3888,19 @@ dependencies = [
|
|
|
3885
3888
|
"unicode-ident",
|
|
3886
3889
|
]
|
|
3887
3890
|
|
|
3891
|
+
[[package]]
|
|
3892
|
+
name = "wasm-streams"
|
|
3893
|
+
version = "0.4.2"
|
|
3894
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3895
|
+
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
|
3896
|
+
dependencies = [
|
|
3897
|
+
"futures-util",
|
|
3898
|
+
"js-sys",
|
|
3899
|
+
"wasm-bindgen",
|
|
3900
|
+
"wasm-bindgen-futures",
|
|
3901
|
+
"web-sys",
|
|
3902
|
+
]
|
|
3903
|
+
|
|
3888
3904
|
[[package]]
|
|
3889
3905
|
name = "web-sys"
|
|
3890
3906
|
version = "0.3.105"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "asanypath-native"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.2.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,6 +34,7 @@ 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"] }
|
|
39
40
|
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
"""Type stub for the Rust-native asanypath_native extension module."""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# -- S3 --
|
|
12
|
+
async def s3_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
13
|
+
async def s3_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
14
|
+
async def s3_create_multipart(*args: Any, **kwargs: Any) -> Any: ...
|
|
15
|
+
async def s3_upload_part(*args: Any, **kwargs: Any) -> Any: ...
|
|
16
|
+
async def s3_complete_multipart(*args: Any, **kwargs: Any) -> Any: ...
|
|
17
|
+
async def s3_abort_multipart(*args: Any, **kwargs: Any) -> Any: ...
|
|
18
|
+
async def s3_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
19
|
+
async def s3_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
20
|
+
async def s3_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
21
|
+
async def s3_list_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
22
|
+
async def s3_head_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
23
|
+
async def s3_is_dir(*args: Any, **kwargs: Any) -> Any: ...
|
|
24
|
+
async def s3_is_dir_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
25
|
+
async def s3_list_buckets(*args: Any, **kwargs: Any) -> Any: ...
|
|
26
|
+
async def s3_get_acl(*args: Any, **kwargs: Any) -> Any: ...
|
|
27
|
+
async def s3_put_acl(*args: Any, **kwargs: Any) -> Any: ...
|
|
28
|
+
async def s3_get_credentials(
|
|
29
|
+
region_override: str | None,
|
|
30
|
+
access_key: str | None,
|
|
31
|
+
secret_key: str | None,
|
|
32
|
+
session_token: str | None,
|
|
33
|
+
) -> tuple[str, str, str | None, str, str | None]: ...
|
|
34
|
+
def s3_presign(
|
|
35
|
+
endpoint: str,
|
|
36
|
+
bucket: str,
|
|
37
|
+
key: str,
|
|
38
|
+
region: str,
|
|
39
|
+
access_key: str,
|
|
40
|
+
secret_key: str,
|
|
41
|
+
session_token: str | None = None,
|
|
42
|
+
method: str | None = None,
|
|
43
|
+
expires: int | None = None,
|
|
44
|
+
) -> str: ...
|
|
45
|
+
|
|
46
|
+
# -- GCS --
|
|
47
|
+
async def gcs_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
48
|
+
async def gcs_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
49
|
+
async def gcs_start_resumable(*args: Any, **kwargs: Any) -> Any: ...
|
|
50
|
+
async def gcs_upload_chunk(*args: Any, **kwargs: Any) -> Any: ...
|
|
51
|
+
async def gcs_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
52
|
+
async def gcs_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
53
|
+
async def gcs_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
54
|
+
async def gcs_list_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
55
|
+
async def gcs_head(*args: Any, **kwargs: Any) -> Any: ...
|
|
56
|
+
async def gcs_head_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
57
|
+
async def gcs_is_dir(*args: Any, **kwargs: Any) -> Any: ...
|
|
58
|
+
async def gcs_is_dir_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
59
|
+
async def gcs_list_buckets(*args: Any, **kwargs: Any) -> Any: ...
|
|
60
|
+
def gcs_presign(
|
|
61
|
+
bucket: str,
|
|
62
|
+
object_path: str,
|
|
63
|
+
service_account_json: str,
|
|
64
|
+
method: str | None = None,
|
|
65
|
+
expires: int | None = None,
|
|
66
|
+
) -> str: ...
|
|
67
|
+
|
|
68
|
+
# -- Azure --
|
|
69
|
+
async def az_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
70
|
+
async def az_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
71
|
+
async def az_put_block(*args: Any, **kwargs: Any) -> Any: ...
|
|
72
|
+
async def az_put_block_list(*args: Any, **kwargs: Any) -> Any: ...
|
|
73
|
+
async def az_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
74
|
+
async def az_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
75
|
+
async def az_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
76
|
+
async def az_list_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
77
|
+
async def az_head_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
78
|
+
async def az_is_dir(*args: Any, **kwargs: Any) -> Any: ...
|
|
79
|
+
async def az_is_dir_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
80
|
+
async def az_list_containers(*args: Any, **kwargs: Any) -> Any: ...
|
|
81
|
+
def az_presign(
|
|
82
|
+
endpoint: str,
|
|
83
|
+
container: str,
|
|
84
|
+
blob_path: str,
|
|
85
|
+
account_name: str,
|
|
86
|
+
account_key: str | None = None,
|
|
87
|
+
sas_token: str | None = None,
|
|
88
|
+
method: str | None = None,
|
|
89
|
+
expires: int | None = None,
|
|
90
|
+
) -> str: ...
|
|
91
|
+
|
|
92
|
+
# -- Artifactory --
|
|
93
|
+
async def art_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
94
|
+
async def art_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
95
|
+
async def art_put_stream(*args: Any, **kwargs: Any) -> Any: ...
|
|
96
|
+
async def art_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
97
|
+
async def art_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
98
|
+
async def art_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
99
|
+
async def art_list_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
100
|
+
async def art_list_repos(*args: Any, **kwargs: Any) -> Any: ...
|
|
101
|
+
async def art_storage_info(*args: Any, **kwargs: Any) -> Any: ...
|
|
102
|
+
|
|
103
|
+
# -- HTTP --
|
|
104
|
+
async def http_get(*args: Any, **kwargs: Any) -> Any: ...
|
|
105
|
+
async def http_put(*args: Any, **kwargs: Any) -> Any: ...
|
|
106
|
+
async def http_post(*args: Any, **kwargs: Any) -> Any: ...
|
|
107
|
+
async def http_delete(*args: Any, **kwargs: Any) -> Any: ...
|
|
108
|
+
async def http_head(*args: Any, **kwargs: Any) -> Any: ...
|
|
109
|
+
async def http_patch(*args: Any, **kwargs: Any) -> Any: ...
|
|
110
|
+
async def http_options(*args: Any, **kwargs: Any) -> Any: ...
|
|
111
|
+
async def http_exists(*args: Any, **kwargs: Any) -> Any: ...
|
|
112
|
+
async def http_scrape_links(*args: Any, **kwargs: Any) -> Any: ...
|
|
113
|
+
async def http_request(*args: Any, **kwargs: Any) -> Any: ...
|
|
114
|
+
|
|
115
|
+
# -- Unified --
|
|
116
|
+
async def range_read(path: str, start: int, end: int, **kwargs: Any) -> bytes: ...
|
|
117
|
+
|
|
118
|
+
# -- SSH / SFTP --
|
|
119
|
+
async def ssh_read(*args: Any, **kwargs: Any) -> bytes: ...
|
|
120
|
+
async def ssh_read_range(*args: Any, **kwargs: Any) -> bytes: ...
|
|
121
|
+
async def ssh_write(*args: Any, **kwargs: Any) -> int: ...
|
|
122
|
+
async def ssh_write_chunk(*args: Any, **kwargs: Any) -> int: ...
|
|
123
|
+
async def ssh_exists(*args: Any, **kwargs: Any) -> bool: ...
|
|
124
|
+
async def ssh_stat(*args: Any, **kwargs: Any) -> Any: ...
|
|
125
|
+
async def ssh_lstat(*args: Any, **kwargs: Any) -> Any: ...
|
|
126
|
+
async def ssh_setstat(*args: Any, **kwargs: Any) -> None: ...
|
|
127
|
+
async def ssh_list(*args: Any, **kwargs: Any) -> Any: ...
|
|
128
|
+
async def ssh_mkdir(*args: Any, **kwargs: Any) -> None: ...
|
|
129
|
+
async def ssh_rmdir(*args: Any, **kwargs: Any) -> None: ...
|
|
130
|
+
async def ssh_unlink(*args: Any, **kwargs: Any) -> None: ...
|
|
131
|
+
async def ssh_rename(*args: Any, **kwargs: Any) -> None: ...
|
|
132
|
+
def ssh_resolve_config(
|
|
133
|
+
host: str, config_paths: list[str]
|
|
134
|
+
) -> tuple[str | None, int | None, str | None, str | None]: ...
|
|
135
|
+
def ssh_disconnect_all() -> None: ...
|
|
@@ -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>,
|
|
@@ -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)
|
|
@@ -930,6 +930,84 @@ pub fn gcs_put_batch<'py>(
|
|
|
930
930
|
})
|
|
931
931
|
}
|
|
932
932
|
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
// Resumable upload (orchestrated from Python): init a session, then PUT ranges
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
/// Begin a resumable upload session; returns the session URI (Location header).
|
|
938
|
+
#[pyfunction]
|
|
939
|
+
#[pyo3(signature = (endpoint, bucket, object, total, access_token=None, use_h2=None))]
|
|
940
|
+
pub fn gcs_start_resumable<'py>(
|
|
941
|
+
py: Python<'py>,
|
|
942
|
+
endpoint: String,
|
|
943
|
+
bucket: String,
|
|
944
|
+
object: String,
|
|
945
|
+
total: u64,
|
|
946
|
+
access_token: Option<String>,
|
|
947
|
+
use_h2: Option<bool>,
|
|
948
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
949
|
+
let h2 = use_h2.unwrap_or(false);
|
|
950
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
951
|
+
let creds = gcs_creds!(endpoint, bucket, access_token);
|
|
952
|
+
let url = append_params(
|
|
953
|
+
&creds.upload_base(),
|
|
954
|
+
&[("uploadType", "resumable"), ("name", &object)],
|
|
955
|
+
);
|
|
956
|
+
let mut headers = creds.auth_headers().await;
|
|
957
|
+
headers.push(("content-length".to_string(), "0".to_string()));
|
|
958
|
+
headers.push((
|
|
959
|
+
"x-upload-content-type".to_string(),
|
|
960
|
+
"application/octet-stream".to_string(),
|
|
961
|
+
));
|
|
962
|
+
headers.push(("x-upload-content-length".to_string(), total.to_string()));
|
|
963
|
+
let (status, body, resp_headers) = do_request("POST", &url, &headers, None, h2)
|
|
964
|
+
.await
|
|
965
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
966
|
+
if !status.is_success() {
|
|
967
|
+
return Err(status_to_pyerr(status, &creds.error_path(&object), &body));
|
|
968
|
+
}
|
|
969
|
+
resp_headers
|
|
970
|
+
.get("location")
|
|
971
|
+
.cloned()
|
|
972
|
+
.ok_or_else(|| PyRuntimeError::new_err("GCS resumable init: no Location header"))
|
|
973
|
+
})
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/// Upload one chunk to a resumable session. GCS completes when offset+len == total.
|
|
977
|
+
#[pyfunction]
|
|
978
|
+
#[pyo3(signature = (session_uri, data, offset, total, use_h2=None))]
|
|
979
|
+
pub fn gcs_upload_chunk<'py>(
|
|
980
|
+
py: Python<'py>,
|
|
981
|
+
session_uri: String,
|
|
982
|
+
data: Vec<u8>,
|
|
983
|
+
offset: u64,
|
|
984
|
+
total: u64,
|
|
985
|
+
use_h2: Option<bool>,
|
|
986
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
987
|
+
let h2 = use_h2.unwrap_or(false);
|
|
988
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
989
|
+
let len = data.len() as u64;
|
|
990
|
+
let end = offset + len - 1;
|
|
991
|
+
let headers = vec![
|
|
992
|
+
("content-length".to_string(), len.to_string()),
|
|
993
|
+
(
|
|
994
|
+
"content-range".to_string(),
|
|
995
|
+
format!("bytes {offset}-{end}/{total}"),
|
|
996
|
+
),
|
|
997
|
+
];
|
|
998
|
+
let (status, body, _h) =
|
|
999
|
+
do_request("PUT", &session_uri, &headers, Some(Bytes::from(data)), h2)
|
|
1000
|
+
.await
|
|
1001
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1002
|
+
// 308 Resume Incomplete = intermediate chunk accepted; 2xx = final chunk done.
|
|
1003
|
+
if status.as_u16() == 308 || status.is_success() {
|
|
1004
|
+
Ok(())
|
|
1005
|
+
} else {
|
|
1006
|
+
Err(status_to_pyerr(status, "gs://resumable-chunk", &body))
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
933
1011
|
/// Copy multiple objects concurrently (server-side). Pairs are `(src, dst)`.
|
|
934
1012
|
#[pyfunction]
|
|
935
1013
|
pub fn gcs_copy_batch<'py>(
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +40,10 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
40
40
|
m.add_function(wrap_pyfunction!(s3::s3_get_batch, m)?)?;
|
|
41
41
|
m.add_function(wrap_pyfunction!(s3::s3_head_batch, m)?)?;
|
|
42
42
|
m.add_function(wrap_pyfunction!(s3::s3_put_batch, m)?)?;
|
|
43
|
+
m.add_function(wrap_pyfunction!(s3::s3_create_multipart, m)?)?;
|
|
44
|
+
m.add_function(wrap_pyfunction!(s3::s3_upload_part, m)?)?;
|
|
45
|
+
m.add_function(wrap_pyfunction!(s3::s3_complete_multipart, m)?)?;
|
|
46
|
+
m.add_function(wrap_pyfunction!(s3::s3_abort_multipart, m)?)?;
|
|
43
47
|
m.add_function(wrap_pyfunction!(s3::s3_delete_batch, m)?)?;
|
|
44
48
|
m.add_function(wrap_pyfunction!(s3::s3_exists_batch, m)?)?;
|
|
45
49
|
m.add_function(wrap_pyfunction!(s3::s3_is_dir_batch, m)?)?;
|
|
@@ -56,6 +60,8 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
56
60
|
m.add_function(wrap_pyfunction!(gcs::gcs_get_batch, m)?)?;
|
|
57
61
|
m.add_function(wrap_pyfunction!(gcs::gcs_head_batch, m)?)?;
|
|
58
62
|
m.add_function(wrap_pyfunction!(gcs::gcs_put_batch, m)?)?;
|
|
63
|
+
m.add_function(wrap_pyfunction!(gcs::gcs_start_resumable, m)?)?;
|
|
64
|
+
m.add_function(wrap_pyfunction!(gcs::gcs_upload_chunk, m)?)?;
|
|
59
65
|
m.add_function(wrap_pyfunction!(gcs::gcs_delete_batch, m)?)?;
|
|
60
66
|
m.add_function(wrap_pyfunction!(gcs::gcs_exists_batch, m)?)?;
|
|
61
67
|
m.add_function(wrap_pyfunction!(gcs::gcs_is_dir_batch, m)?)?;
|
|
@@ -71,6 +77,8 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
71
77
|
m.add_function(wrap_pyfunction!(azure::az_get_batch, m)?)?;
|
|
72
78
|
m.add_function(wrap_pyfunction!(azure::az_head_batch, m)?)?;
|
|
73
79
|
m.add_function(wrap_pyfunction!(azure::az_put_batch, m)?)?;
|
|
80
|
+
m.add_function(wrap_pyfunction!(azure::az_put_block, m)?)?;
|
|
81
|
+
m.add_function(wrap_pyfunction!(azure::az_put_block_list, m)?)?;
|
|
74
82
|
m.add_function(wrap_pyfunction!(azure::az_delete_batch, m)?)?;
|
|
75
83
|
m.add_function(wrap_pyfunction!(azure::az_exists_batch, m)?)?;
|
|
76
84
|
m.add_function(wrap_pyfunction!(azure::az_is_dir_batch, m)?)?;
|
|
@@ -83,6 +91,7 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
83
91
|
// Artifactory batch
|
|
84
92
|
m.add_function(wrap_pyfunction!(artifactory::art_get_batch, m)?)?;
|
|
85
93
|
m.add_function(wrap_pyfunction!(artifactory::art_put_batch, m)?)?;
|
|
94
|
+
m.add_function(wrap_pyfunction!(artifactory::art_put_stream, m)?)?;
|
|
86
95
|
m.add_function(wrap_pyfunction!(artifactory::art_delete_batch, m)?)?;
|
|
87
96
|
m.add_function(wrap_pyfunction!(artifactory::art_exists_batch, m)?)?;
|
|
88
97
|
m.add_function(wrap_pyfunction!(artifactory::art_list_batch, m)?)?;
|
|
@@ -103,6 +112,7 @@ fn asanypath_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
103
112
|
m.add_function(wrap_pyfunction!(ssh::ssh_read, m)?)?;
|
|
104
113
|
m.add_function(wrap_pyfunction!(ssh::ssh_read_range, m)?)?;
|
|
105
114
|
m.add_function(wrap_pyfunction!(ssh::ssh_write, m)?)?;
|
|
115
|
+
m.add_function(wrap_pyfunction!(ssh::ssh_write_chunk, m)?)?;
|
|
106
116
|
m.add_function(wrap_pyfunction!(ssh::ssh_exists, m)?)?;
|
|
107
117
|
m.add_function(wrap_pyfunction!(ssh::ssh_stat, m)?)?;
|
|
108
118
|
m.add_function(wrap_pyfunction!(ssh::ssh_lstat, m)?)?;
|
|
@@ -11,6 +11,9 @@ use std::collections::HashMap;
|
|
|
11
11
|
use std::sync::OnceLock;
|
|
12
12
|
use std::time::SystemTime;
|
|
13
13
|
|
|
14
|
+
use aws_config::environment::region::EnvironmentVariableRegionProvider;
|
|
15
|
+
use aws_config::meta::region::RegionProviderChain;
|
|
16
|
+
use aws_config::profile::region::ProfileFileRegionProvider;
|
|
14
17
|
use aws_config::{BehaviorVersion, Region};
|
|
15
18
|
use aws_credential_types::Credentials;
|
|
16
19
|
use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
|
|
@@ -66,20 +69,54 @@ async fn s3_aws_config(region_override: Option<String>) -> Result<S3AwsConfig, S
|
|
|
66
69
|
return Ok(config.clone());
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// Resolve region WITHOUT IMDS: explicit override > AWS_REGION env > profile.
|
|
73
|
+
// The loader must then receive a *concrete* region: an unset region makes the
|
|
74
|
+
// credentials chain probe EC2 IMDS during load() (a ~3s timeout off-EC2), which
|
|
75
|
+
// is pointless when a custom endpoint_url is configured (S3-compatible stores).
|
|
76
|
+
let region_no_imds: Option<Region> = match ®ion_override {
|
|
77
|
+
Some(region) => Some(Region::new(region.clone())),
|
|
78
|
+
None => {
|
|
79
|
+
RegionProviderChain::first_try(EnvironmentVariableRegionProvider::new())
|
|
80
|
+
.or_else(ProfileFileRegionProvider::default())
|
|
81
|
+
.region()
|
|
82
|
+
.await
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// First load with a concrete placeholder region (no IMDS) to read endpoint_url.
|
|
87
|
+
let probe_region = region_no_imds
|
|
88
|
+
.clone()
|
|
89
|
+
.unwrap_or_else(|| Region::new("us-east-1"));
|
|
90
|
+
let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(probe_region);
|
|
91
|
+
if let Some(profile) = &profile {
|
|
71
92
|
loader = loader.profile_name(profile);
|
|
72
93
|
}
|
|
73
|
-
if let Some(region) = region_override {
|
|
74
|
-
loader = loader.region(Region::new(region));
|
|
75
|
-
}
|
|
76
94
|
let sdk_config = loader.load().await;
|
|
95
|
+
let endpoint_url = sdk_config.endpoint_url().map(str::to_string);
|
|
96
|
+
|
|
97
|
+
// Preserve IMDS: only a genuine real-AWS target (no endpoint, no configured
|
|
98
|
+
// region) consults the IMDS region provider; then reload so the credentials
|
|
99
|
+
// chain is built against the IMDS-resolved region.
|
|
100
|
+
let (region, sdk_config) = if let Some(region) = region_no_imds {
|
|
101
|
+
(region.as_ref().to_string(), sdk_config)
|
|
102
|
+
} else if endpoint_url.is_some() {
|
|
103
|
+
("us-east-1".to_string(), sdk_config)
|
|
104
|
+
} else {
|
|
105
|
+
match RegionProviderChain::default_provider().region().await {
|
|
106
|
+
Some(imds_region) => {
|
|
107
|
+
let mut loader =
|
|
108
|
+
aws_config::defaults(BehaviorVersion::latest()).region(imds_region.clone());
|
|
109
|
+
if let Some(profile) = &profile {
|
|
110
|
+
loader = loader.profile_name(profile);
|
|
111
|
+
}
|
|
112
|
+
(imds_region.as_ref().to_string(), loader.load().await)
|
|
113
|
+
}
|
|
114
|
+
None => ("us-east-1".to_string(), sdk_config),
|
|
115
|
+
}
|
|
116
|
+
};
|
|
77
117
|
let config = S3AwsConfig {
|
|
78
|
-
region
|
|
79
|
-
|
|
80
|
-
.map(|region| region.as_ref().to_string())
|
|
81
|
-
.unwrap_or_else(|| "us-east-1".to_string()),
|
|
82
|
-
endpoint_url: sdk_config.endpoint_url().map(str::to_string),
|
|
118
|
+
region,
|
|
119
|
+
endpoint_url,
|
|
83
120
|
credentials_provider: sdk_config
|
|
84
121
|
.credentials_provider()
|
|
85
122
|
.ok_or_else(|| "AWS credential provider chain is unavailable".to_string())?,
|
|
@@ -1028,6 +1065,229 @@ pub fn s3_put_batch<'py>(
|
|
|
1028
1065
|
})
|
|
1029
1066
|
}
|
|
1030
1067
|
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1069
|
+
// Multipart upload primitives (sequencing/concurrency orchestrated from Python)
|
|
1070
|
+
// ---------------------------------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
/// Extract the `<UploadId>` text from an InitiateMultipartUpload response.
|
|
1073
|
+
fn parse_upload_id(body: &[u8]) -> Result<String, PyErr> {
|
|
1074
|
+
let mut reader = Reader::from_reader(body);
|
|
1075
|
+
reader.config_mut().trim_text(true);
|
|
1076
|
+
let mut buf = Vec::new();
|
|
1077
|
+
let mut in_id = false;
|
|
1078
|
+
let mut text = String::new();
|
|
1079
|
+
loop {
|
|
1080
|
+
match reader.read_event_into(&mut buf) {
|
|
1081
|
+
Ok(Event::Start(e)) => {
|
|
1082
|
+
let name = e.name();
|
|
1083
|
+
if local_name(name.as_ref()) == "UploadId" {
|
|
1084
|
+
in_id = true;
|
|
1085
|
+
text.clear();
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
Ok(Event::Text(e)) if in_id => text.push_str(&e.xml10_content()),
|
|
1089
|
+
Ok(Event::End(_)) if in_id => return Ok(text.clone()),
|
|
1090
|
+
Ok(Event::Eof) | Err(_) => break,
|
|
1091
|
+
_ => {}
|
|
1092
|
+
}
|
|
1093
|
+
buf.clear();
|
|
1094
|
+
}
|
|
1095
|
+
Err(PyRuntimeError::new_err(
|
|
1096
|
+
"S3 CreateMultipartUpload: no UploadId in response",
|
|
1097
|
+
))
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/// Initiate a multipart upload; returns the UploadId.
|
|
1101
|
+
#[pyfunction]
|
|
1102
|
+
#[pyo3(signature = (endpoint, bucket, key, region, access_key, secret_key, session_token=None, options_json=None, use_h2=None))]
|
|
1103
|
+
pub fn s3_create_multipart<'py>(
|
|
1104
|
+
py: Python<'py>,
|
|
1105
|
+
endpoint: String,
|
|
1106
|
+
bucket: String,
|
|
1107
|
+
key: String,
|
|
1108
|
+
region: String,
|
|
1109
|
+
access_key: String,
|
|
1110
|
+
secret_key: String,
|
|
1111
|
+
session_token: Option<String>,
|
|
1112
|
+
options_json: Option<String>,
|
|
1113
|
+
use_h2: Option<bool>,
|
|
1114
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1115
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1116
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1117
|
+
let creds = s3_creds!(
|
|
1118
|
+
endpoint,
|
|
1119
|
+
bucket,
|
|
1120
|
+
region,
|
|
1121
|
+
access_key,
|
|
1122
|
+
secret_key,
|
|
1123
|
+
session_token
|
|
1124
|
+
);
|
|
1125
|
+
let options: crate::backend::UploadOptions =
|
|
1126
|
+
sonic_rs::from_str(&options_json.unwrap_or_else(|| "{}".to_string()))
|
|
1127
|
+
.map_err(|e| PyValueError::new_err(format!("invalid backend_options: {e}")))?;
|
|
1128
|
+
crate::backend::require_empty_provider(&options)?;
|
|
1129
|
+
let mut extra = crate::backend::user_headers(&options)?;
|
|
1130
|
+
extra.push(("content-length".to_string(), "0".to_string()));
|
|
1131
|
+
let s3_path = format!("/{key}");
|
|
1132
|
+
let query = vec![("uploads".to_string(), String::new())];
|
|
1133
|
+
let (status, body, _h) = do_s3_request(&creds, "POST", &s3_path, &query, None, h2, &extra)
|
|
1134
|
+
.await
|
|
1135
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1136
|
+
if !status.is_success() {
|
|
1137
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1138
|
+
}
|
|
1139
|
+
parse_upload_id(&body)
|
|
1140
|
+
})
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/// Upload one part; returns its ETag (used to complete the upload).
|
|
1144
|
+
#[pyfunction]
|
|
1145
|
+
#[pyo3(signature = (endpoint, bucket, key, upload_id, part_number, data, region, access_key, secret_key, session_token=None, use_h2=None))]
|
|
1146
|
+
pub fn s3_upload_part<'py>(
|
|
1147
|
+
py: Python<'py>,
|
|
1148
|
+
endpoint: String,
|
|
1149
|
+
bucket: String,
|
|
1150
|
+
key: String,
|
|
1151
|
+
upload_id: String,
|
|
1152
|
+
part_number: u32,
|
|
1153
|
+
data: Vec<u8>,
|
|
1154
|
+
region: String,
|
|
1155
|
+
access_key: String,
|
|
1156
|
+
secret_key: String,
|
|
1157
|
+
session_token: Option<String>,
|
|
1158
|
+
use_h2: Option<bool>,
|
|
1159
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1160
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1161
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1162
|
+
let creds = s3_creds!(
|
|
1163
|
+
endpoint,
|
|
1164
|
+
bucket,
|
|
1165
|
+
region,
|
|
1166
|
+
access_key,
|
|
1167
|
+
secret_key,
|
|
1168
|
+
session_token
|
|
1169
|
+
);
|
|
1170
|
+
let s3_path = format!("/{key}");
|
|
1171
|
+
let query = vec![
|
|
1172
|
+
("partNumber".to_string(), part_number.to_string()),
|
|
1173
|
+
("uploadId".to_string(), upload_id),
|
|
1174
|
+
];
|
|
1175
|
+
let (status, body, headers) = do_s3_request(
|
|
1176
|
+
&creds,
|
|
1177
|
+
"PUT",
|
|
1178
|
+
&s3_path,
|
|
1179
|
+
&query,
|
|
1180
|
+
Some(Bytes::from(data)),
|
|
1181
|
+
h2,
|
|
1182
|
+
&[],
|
|
1183
|
+
)
|
|
1184
|
+
.await
|
|
1185
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1186
|
+
if !status.is_success() {
|
|
1187
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1188
|
+
}
|
|
1189
|
+
Ok(headers.get("etag").cloned().unwrap_or_default())
|
|
1190
|
+
})
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/// Complete a multipart upload from the collected `(part_number, etag)` pairs.
|
|
1194
|
+
#[pyfunction]
|
|
1195
|
+
#[pyo3(signature = (endpoint, bucket, key, upload_id, parts, region, access_key, secret_key, session_token=None, use_h2=None))]
|
|
1196
|
+
pub fn s3_complete_multipart<'py>(
|
|
1197
|
+
py: Python<'py>,
|
|
1198
|
+
endpoint: String,
|
|
1199
|
+
bucket: String,
|
|
1200
|
+
key: String,
|
|
1201
|
+
upload_id: String,
|
|
1202
|
+
parts: Vec<(u32, String)>,
|
|
1203
|
+
region: String,
|
|
1204
|
+
access_key: String,
|
|
1205
|
+
secret_key: String,
|
|
1206
|
+
session_token: Option<String>,
|
|
1207
|
+
use_h2: Option<bool>,
|
|
1208
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1209
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1210
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1211
|
+
let creds = s3_creds!(
|
|
1212
|
+
endpoint,
|
|
1213
|
+
bucket,
|
|
1214
|
+
region,
|
|
1215
|
+
access_key,
|
|
1216
|
+
secret_key,
|
|
1217
|
+
session_token
|
|
1218
|
+
);
|
|
1219
|
+
let mut xml = String::from("<CompleteMultipartUpload>");
|
|
1220
|
+
for (n, etag) in &parts {
|
|
1221
|
+
xml.push_str(&format!(
|
|
1222
|
+
"<Part><PartNumber>{n}</PartNumber><ETag>{etag}</ETag></Part>"
|
|
1223
|
+
));
|
|
1224
|
+
}
|
|
1225
|
+
xml.push_str("</CompleteMultipartUpload>");
|
|
1226
|
+
let s3_path = format!("/{key}");
|
|
1227
|
+
let query = vec![("uploadId".to_string(), upload_id)];
|
|
1228
|
+
let extra = vec![("content-type".to_string(), "application/xml".to_string())];
|
|
1229
|
+
let (status, body, _h) = do_s3_request(
|
|
1230
|
+
&creds,
|
|
1231
|
+
"POST",
|
|
1232
|
+
&s3_path,
|
|
1233
|
+
&query,
|
|
1234
|
+
Some(Bytes::from(xml.into_bytes())),
|
|
1235
|
+
h2,
|
|
1236
|
+
&extra,
|
|
1237
|
+
)
|
|
1238
|
+
.await
|
|
1239
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1240
|
+
if !status.is_success() {
|
|
1241
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1242
|
+
}
|
|
1243
|
+
// S3 may return 200 OK with an <Error> body when completion fails.
|
|
1244
|
+
if body.windows(7).any(|w| w == b"<Error>") {
|
|
1245
|
+
return Err(PyRuntimeError::new_err(format!(
|
|
1246
|
+
"S3 CompleteMultipartUpload failed: {}",
|
|
1247
|
+
String::from_utf8_lossy(&body)
|
|
1248
|
+
)));
|
|
1249
|
+
}
|
|
1250
|
+
Ok(())
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/// Abort a multipart upload, discarding any uploaded parts.
|
|
1255
|
+
#[pyfunction]
|
|
1256
|
+
#[pyo3(signature = (endpoint, bucket, key, upload_id, region, access_key, secret_key, session_token=None, use_h2=None))]
|
|
1257
|
+
pub fn s3_abort_multipart<'py>(
|
|
1258
|
+
py: Python<'py>,
|
|
1259
|
+
endpoint: String,
|
|
1260
|
+
bucket: String,
|
|
1261
|
+
key: String,
|
|
1262
|
+
upload_id: String,
|
|
1263
|
+
region: String,
|
|
1264
|
+
access_key: String,
|
|
1265
|
+
secret_key: String,
|
|
1266
|
+
session_token: Option<String>,
|
|
1267
|
+
use_h2: Option<bool>,
|
|
1268
|
+
) -> PyResult<Bound<'py, PyAny>> {
|
|
1269
|
+
let h2 = use_h2.unwrap_or(false);
|
|
1270
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
1271
|
+
let creds = s3_creds!(
|
|
1272
|
+
endpoint,
|
|
1273
|
+
bucket,
|
|
1274
|
+
region,
|
|
1275
|
+
access_key,
|
|
1276
|
+
secret_key,
|
|
1277
|
+
session_token
|
|
1278
|
+
);
|
|
1279
|
+
let s3_path = format!("/{key}");
|
|
1280
|
+
let query = vec![("uploadId".to_string(), upload_id)];
|
|
1281
|
+
let (status, body, _h) = do_s3_request(&creds, "DELETE", &s3_path, &query, None, h2, &[])
|
|
1282
|
+
.await
|
|
1283
|
+
.map_err(PyRuntimeError::new_err)?;
|
|
1284
|
+
if !status.is_success() {
|
|
1285
|
+
return Err(status_to_pyerr(status, &creds.error_path(&key), &body));
|
|
1286
|
+
}
|
|
1287
|
+
Ok(())
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1031
1291
|
/// Copy multiple objects concurrently (server-side). Pairs are `(src_key, dst_key)`.
|
|
1032
1292
|
#[pyfunction]
|
|
1033
1293
|
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
|
|
File without changes
|