asanypath-native 2.2.0__tar.gz → 2.3.1__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.2.0"
99
+ version = "2.3.1"
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",
@@ -1197,6 +1198,18 @@ dependencies = [
1197
1198
  "windows-sys 0.61.2",
1198
1199
  ]
1199
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
+
1200
1213
  [[package]]
1201
1214
  name = "fastrand"
1202
1215
  version = "2.5.0"
@@ -1254,6 +1267,12 @@ version = "1.0.7"
1254
1267
  source = "registry+https://github.com/rust-lang/crates.io-index"
1255
1268
  checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1256
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
+
1257
1276
  [[package]]
1258
1277
  name = "form_urlencoded"
1259
1278
  version = "1.2.2"
@@ -1493,11 +1512,32 @@ version = "0.14.5"
1493
1512
  source = "registry+https://github.com/rust-lang/crates.io-index"
1494
1513
  checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1495
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
+
1496
1524
  [[package]]
1497
1525
  name = "hashbrown"
1498
1526
  version = "0.17.1"
1499
1527
  source = "registry+https://github.com/rust-lang/crates.io-index"
1500
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
+ ]
1501
1541
 
1502
1542
  [[package]]
1503
1543
  name = "heck"
@@ -1895,6 +1935,17 @@ dependencies = [
1895
1935
  "libc",
1896
1936
  ]
1897
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
+
1898
1949
  [[package]]
1899
1950
  name = "litemap"
1900
1951
  version = "0.8.3"
@@ -2800,6 +2851,31 @@ dependencies = [
2800
2851
  "zeroize",
2801
2852
  ]
2802
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
+
2803
2879
  [[package]]
2804
2880
  name = "russh"
2805
2881
  version = "0.63.3"
@@ -3363,6 +3439,18 @@ version = "0.1.0"
3363
3439
  source = "registry+https://github.com/rust-lang/crates.io-index"
3364
3440
  checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
3365
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
+
3366
3454
  [[package]]
3367
3455
  name = "ssh-cipher"
3368
3456
  version = "0.3.0"
@@ -3797,6 +3885,12 @@ dependencies = [
3797
3885
  "wasm-bindgen",
3798
3886
  ]
3799
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
+
3800
3894
  [[package]]
3801
3895
  name = "version_check"
3802
3896
  version = "0.9.5"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "asanypath-native"
3
- version = "2.2.0"
3
+ version = "2.3.1"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  publish = false
@@ -37,6 +37,7 @@ russh-sftp = "3"
37
37
  tokio-util = { version = "0.7", features = ["io"] }
38
38
  anyhow = "1"
39
39
  ssh2-config = { version = "0.8", default-features = false, features = ["nolog"] }
40
+ rusqlite = { version = "0.40", features = ["bundled"] }
40
41
 
41
42
  [profile.release]
42
43
  strip = "symbols"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asanypath-native
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: Native cloud acceleration for asanypath (Rust/tokio/reqwest)
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -8,6 +8,16 @@
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: ...
@@ -30,6 +40,7 @@ async def s3_get_credentials(
30
40
  access_key: str | None,
31
41
  secret_key: str | None,
32
42
  session_token: str | None,
43
+ profile: str | None = None,
33
44
  ) -> tuple[str, str, str | None, str, str | None]: ...
34
45
  def s3_presign(
35
46
  endpoint: str,
@@ -16,6 +16,7 @@ use futures::future::join_all;
16
16
  use hmac::{Hmac, KeyInit, Mac};
17
17
  use pyo3::exceptions::{PyRuntimeError, PyValueError};
18
18
  use pyo3::prelude::*;
19
+ use pyo3::pybacked::PyBackedBytes;
19
20
  use quick_xml::Reader;
20
21
  use quick_xml::events::Event;
21
22
  use serde::{Deserialize, Serialize};
@@ -956,7 +957,7 @@ pub fn az_put_block<'py>(
956
957
  container: String,
957
958
  blob: String,
958
959
  block_id: String,
959
- data: Vec<u8>,
960
+ data: PyBackedBytes,
960
961
  account_name: String,
961
962
  account_key: Option<String>,
962
963
  sas_token: Option<String>,
@@ -976,9 +977,10 @@ pub fn az_put_block<'py>(
976
977
  let mut headers = vec![("content-length".to_string(), data.len().to_string())];
977
978
  let params = [("blockid", block_id.as_str()), ("comp", "block")];
978
979
  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)?;
980
+ let (status, body) =
981
+ do_request_light("PUT", &url, &headers, Some(Bytes::from_owner(data)), h2)
982
+ .await
983
+ .map_err(PyRuntimeError::new_err)?;
982
984
  if !status.is_success() {
983
985
  return Err(status_to_pyerr(status, &creds.error_path(&blob), &body));
984
986
  }
@@ -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
+ }
@@ -18,6 +18,7 @@ use futures::future::join_all;
18
18
  use pyo3::exceptions::PyRuntimeError;
19
19
  use pyo3::exceptions::PyValueError;
20
20
  use pyo3::prelude::*;
21
+ use pyo3::pybacked::PyBackedBytes;
21
22
  use serde::{Deserialize, Serialize};
22
23
  use sonic_rs::JsonValueTrait;
23
24
  use std::collections::{HashMap, HashSet};
@@ -155,6 +156,14 @@ impl GcsCreds {
155
156
  format!("{}/storage/v1/b/{}/o/{}", base, self.bucket, encoded)
156
157
  }
157
158
 
159
+ fn media_url(&self, object_path: &str, generation: Option<&str>) -> String {
160
+ let mut params = vec![("alt", "media")];
161
+ if let Some(generation) = generation {
162
+ params.push(("ifGenerationMatch", generation));
163
+ }
164
+ append_params(&self.object_url(object_path), &params)
165
+ }
166
+
158
167
  fn upload_base(&self) -> String {
159
168
  let base = self.endpoint.trim_end_matches('/');
160
169
  format!("{}/upload/storage/v1/b/{}/o", base, self.bucket)
@@ -343,13 +352,21 @@ impl CloudBackend for GcsCreds {
343
352
  ) -> impl std::future::Future<Output = Result<(reqwest::StatusCode, Bytes), String>> {
344
353
  let extras = extra_headers.to_vec();
345
354
  async move {
355
+ let generation = extras
356
+ .iter()
357
+ .find(|(name, _)| name == "x-asanypath-generation-match")
358
+ .map(|(_, value)| value.as_str());
346
359
  let url = match method {
347
- "GET" => append_params(&self.object_url(path), &[("alt", "media")]),
360
+ "GET" => self.media_url(path, generation),
348
361
  "DELETE" => self.object_url(path),
349
362
  _ => self.object_url(path),
350
363
  };
351
364
  let mut headers = self.auth_headers().await;
352
- headers.extend(extras);
365
+ headers.extend(
366
+ extras
367
+ .into_iter()
368
+ .filter(|(name, _)| name != "x-asanypath-generation-match"),
369
+ );
353
370
  do_request_light(method, &url, &headers, body, use_h2).await
354
371
  }
355
372
  }
@@ -979,7 +996,7 @@ pub fn gcs_start_resumable<'py>(
979
996
  pub fn gcs_upload_chunk<'py>(
980
997
  py: Python<'py>,
981
998
  session_uri: String,
982
- data: Vec<u8>,
999
+ data: PyBackedBytes,
983
1000
  offset: u64,
984
1001
  total: u64,
985
1002
  use_h2: Option<bool>,
@@ -995,10 +1012,15 @@ pub fn gcs_upload_chunk<'py>(
995
1012
  format!("bytes {offset}-{end}/{total}"),
996
1013
  ),
997
1014
  ];
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)?;
1015
+ let (status, body, _h) = do_request(
1016
+ "PUT",
1017
+ &session_uri,
1018
+ &headers,
1019
+ Some(Bytes::from_owner(data)),
1020
+ h2,
1021
+ )
1022
+ .await
1023
+ .map_err(PyRuntimeError::new_err)?;
1002
1024
  // 308 Resume Incomplete = intermediate chunk accepted; 2xx = final chunk done.
1003
1025
  if status.as_u16() == 308 || status.is_success() {
1004
1026
  Ok(())
@@ -1239,7 +1261,21 @@ fn secs_to_datetime(secs: u64) -> (i64, i64, i64, u64, u64, u64) {
1239
1261
 
1240
1262
  #[cfg(test)]
1241
1263
  mod adc_tests {
1242
- use super::mint_adc_token;
1264
+ use super::{GcsCreds, mint_adc_token};
1265
+
1266
+ #[test]
1267
+ fn range_media_url_pins_generation() {
1268
+ let creds = GcsCreds::new(
1269
+ "https://storage.googleapis.com".into(),
1270
+ "bucket".into(),
1271
+ None,
1272
+ );
1273
+ let url = url::Url::parse(&creds.media_url("nested/file.bin", Some("123"))).unwrap();
1274
+ let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1275
+ assert_eq!(query.get("alt").unwrap(), "media");
1276
+ assert_eq!(query.get("ifGenerationMatch").unwrap(), "123");
1277
+ assert!(url.path().ends_with("nested%2Ffile.bin"));
1278
+ }
1243
1279
 
1244
1280
  #[test]
1245
1281
  fn rejects_service_account_adc() {
@@ -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)?)?;
@@ -25,6 +25,7 @@ use bytes::Bytes;
25
25
  use pyo3::exceptions::PyRuntimeError;
26
26
  use pyo3::exceptions::PyValueError;
27
27
  use pyo3::prelude::*;
28
+ use pyo3::pybacked::PyBackedBytes;
28
29
  use quick_xml::Reader;
29
30
  use quick_xml::{XmlVersion, events::Event};
30
31
  use serde::{Deserialize, Serialize};
@@ -56,8 +57,11 @@ struct S3AwsConfig {
56
57
 
57
58
  static S3_AWS_CONFIGS: OnceLock<AsyncMutex<HashMap<String, S3AwsConfig>>> = OnceLock::new();
58
59
 
59
- async fn s3_aws_config(region_override: Option<String>) -> Result<S3AwsConfig, String> {
60
- let profile = std::env::var("AWS_PROFILE").ok();
60
+ async fn s3_aws_config(
61
+ profile: Option<String>,
62
+ region_override: Option<String>,
63
+ ) -> Result<S3AwsConfig, String> {
64
+ let profile = profile.or_else(|| std::env::var("AWS_PROFILE").ok());
61
65
  let cache_key = format!(
62
66
  "{}|{}",
63
67
  profile.as_deref().unwrap_or_default(),
@@ -133,9 +137,10 @@ pub fn s3_get_credentials<'py>(
133
137
  access_key: Option<String>,
134
138
  secret_key: Option<String>,
135
139
  session_token: Option<String>,
140
+ profile: Option<String>,
136
141
  ) -> PyResult<Bound<'py, PyAny>> {
137
142
  pyo3_async_runtimes::tokio::future_into_py(py, async move {
138
- let config = s3_aws_config(region_override)
143
+ let config = s3_aws_config(profile, region_override)
139
144
  .await
140
145
  .map_err(PyRuntimeError::new_err)?;
141
146
  let resolved = match (access_key, secret_key) {
@@ -1150,7 +1155,7 @@ pub fn s3_upload_part<'py>(
1150
1155
  key: String,
1151
1156
  upload_id: String,
1152
1157
  part_number: u32,
1153
- data: Vec<u8>,
1158
+ data: PyBackedBytes,
1154
1159
  region: String,
1155
1160
  access_key: String,
1156
1161
  secret_key: String,
@@ -1177,7 +1182,7 @@ pub fn s3_upload_part<'py>(
1177
1182
  "PUT",
1178
1183
  &s3_path,
1179
1184
  &query,
1180
- Some(Bytes::from(data)),
1185
+ Some(Bytes::from_owner(data)),
1181
1186
  h2,
1182
1187
  &[],
1183
1188
  )