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.
@@ -96,7 +96,7 @@ dependencies = [
96
96
 
97
97
  [[package]]
98
98
  name = "asanypath-native"
99
- version = "2.1.1"
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.1.1"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asanypath-native
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: Native cloud acceleration for asanypath (Rust/tokio/reqwest)
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -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(&params));
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(&params));
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)]