asanypath-native 2.1.1__tar.gz → 2.3.0__tar.gz

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