asanypath-native 2.1.1__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.1 → asanypath_native-2.2.0}/Cargo.lock +18 -1
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/Cargo.toml +4 -3
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/PKG-INFO +1 -1
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/asanypath_native.pyi +10 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/artifactory.rs +39 -1
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/azure.rs +91 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/gcs.rs +79 -1
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/http.rs +40 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/lib.rs +10 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/s3.rs +223 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/ssh.rs +48 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/pyproject.toml +0 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/backend.rs +0 -0
- {asanypath_native-2.1.1 → asanypath_native-2.2.0}/src/xml.rs +0 -0
- {asanypath_native-2.1.1 → 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
|
]
|
|
@@ -2694,6 +2695,7 @@ dependencies = [
|
|
|
2694
2695
|
"base64 0.22.1",
|
|
2695
2696
|
"bytes",
|
|
2696
2697
|
"futures-core",
|
|
2698
|
+
"futures-util",
|
|
2697
2699
|
"h2",
|
|
2698
2700
|
"http 1.5.0",
|
|
2699
2701
|
"http-body 1.1.0",
|
|
@@ -2714,12 +2716,14 @@ dependencies = [
|
|
|
2714
2716
|
"sync_wrapper",
|
|
2715
2717
|
"tokio",
|
|
2716
2718
|
"tokio-rustls",
|
|
2719
|
+
"tokio-util",
|
|
2717
2720
|
"tower",
|
|
2718
2721
|
"tower-http",
|
|
2719
2722
|
"tower-service",
|
|
2720
2723
|
"url",
|
|
2721
2724
|
"wasm-bindgen",
|
|
2722
2725
|
"wasm-bindgen-futures",
|
|
2726
|
+
"wasm-streams",
|
|
2723
2727
|
"web-sys",
|
|
2724
2728
|
"webpki-roots",
|
|
2725
2729
|
]
|
|
@@ -3884,6 +3888,19 @@ dependencies = [
|
|
|
3884
3888
|
"unicode-ident",
|
|
3885
3889
|
]
|
|
3886
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
|
+
|
|
3887
3904
|
[[package]]
|
|
3888
3905
|
name = "web-sys"
|
|
3889
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
|
|
|
@@ -11,6 +11,10 @@ from typing import Any
|
|
|
11
11
|
# -- S3 --
|
|
12
12
|
async def s3_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
13
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: ...
|
|
14
18
|
async def s3_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
15
19
|
async def s3_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
16
20
|
async def s3_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -42,6 +46,8 @@ def s3_presign(
|
|
|
42
46
|
# -- GCS --
|
|
43
47
|
async def gcs_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
44
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: ...
|
|
45
51
|
async def gcs_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
46
52
|
async def gcs_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
47
53
|
async def gcs_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -62,6 +68,8 @@ def gcs_presign(
|
|
|
62
68
|
# -- Azure --
|
|
63
69
|
async def az_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
64
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: ...
|
|
65
73
|
async def az_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
66
74
|
async def az_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
67
75
|
async def az_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -84,6 +92,7 @@ def az_presign(
|
|
|
84
92
|
# -- Artifactory --
|
|
85
93
|
async def art_get_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
86
94
|
async def art_put_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
95
|
+
async def art_put_stream(*args: Any, **kwargs: Any) -> Any: ...
|
|
87
96
|
async def art_copy_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
88
97
|
async def art_delete_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
89
98
|
async def art_exists_batch(*args: Any, **kwargs: Any) -> Any: ...
|
|
@@ -110,6 +119,7 @@ async def range_read(path: str, start: int, end: int, **kwargs: Any) -> bytes: .
|
|
|
110
119
|
async def ssh_read(*args: Any, **kwargs: Any) -> bytes: ...
|
|
111
120
|
async def ssh_read_range(*args: Any, **kwargs: Any) -> bytes: ...
|
|
112
121
|
async def ssh_write(*args: Any, **kwargs: Any) -> int: ...
|
|
122
|
+
async def ssh_write_chunk(*args: Any, **kwargs: Any) -> int: ...
|
|
113
123
|
async def ssh_exists(*args: Any, **kwargs: Any) -> bool: ...
|
|
114
124
|
async def ssh_stat(*args: Any, **kwargs: Any) -> Any: ...
|
|
115
125
|
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>,
|
|
@@ -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)?)?;
|
|
@@ -1065,6 +1065,229 @@ pub fn s3_put_batch<'py>(
|
|
|
1065
1065
|
})
|
|
1066
1066
|
}
|
|
1067
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
|
+
|
|
1068
1291
|
/// Copy multiple objects concurrently (server-side). Pairs are `(src_key, dst_key)`.
|
|
1069
1292
|
#[pyfunction]
|
|
1070
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
|