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.
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/Cargo.lock +95 -1
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/Cargo.toml +2 -1
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/PKG-INFO +1 -1
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/asanypath_native.pyi +11 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/azure.rs +6 -4
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/backend.rs +33 -9
- asanypath_native-2.3.1/src/cache.rs +198 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/gcs.rs +44 -8
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/lib.rs +2 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/s3.rs +10 -5
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/pyproject.toml +0 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/artifactory.rs +0 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/http.rs +0 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/ssh.rs +0 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/src/xml.rs +0 -0
- {asanypath_native-2.2.0 → asanypath_native-2.3.1}/uv.lock +0 -0
|
@@ -96,7 +96,7 @@ dependencies = [
|
|
|
96
96
|
|
|
97
97
|
[[package]]
|
|
98
98
|
name = "asanypath-native"
|
|
99
|
-
version = "2.
|
|
99
|
+
version = "2.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.
|
|
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"
|
|
@@ -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:
|
|
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(¶ms));
|
|
979
|
-
let (status, body) =
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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, &
|
|
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
|
|
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), ¶ms)
|
|
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" =>
|
|
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(
|
|
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:
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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(
|
|
60
|
-
|
|
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:
|
|
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::
|
|
1185
|
+
Some(Bytes::from_owner(data)),
|
|
1181
1186
|
h2,
|
|
1182
1187
|
&[],
|
|
1183
1188
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|