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.
@@ -96,7 +96,7 @@ dependencies = [
96
96
 
97
97
  [[package]]
98
98
  name = "asanypath-native"
99
- version = "2.1.0"
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.5.3"
315
+ version = "1.6.0"
315
316
  source = "registry+https://github.com/rust-lang/crates.io-index"
316
- checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70"
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.16.2"
452
+ version = "1.19.0"
452
453
  source = "registry+https://github.com/rust-lang/crates.io-index"
453
- checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8"
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.6.4"
491
+ version = "1.8.1"
492
492
  source = "registry+https://github.com/rust-lang/crates.io-index"
493
- checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd"
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.1.0"
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.0
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
@@ -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(&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)?)?;
@@ -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
- let mut loader = aws_config::defaults(BehaviorVersion::latest());
70
- if let Some(profile) = profile {
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 &region_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: sdk_config
79
- .region()
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)]