asanypath 2.0.0__py3-none-any.whl
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/__init__.py +81 -0
- asanypath/__init__.pyi +62 -0
- asanypath/_credstore.py +102 -0
- asanypath/_transfer.py +216 -0
- asanypath/_version.py +24 -0
- asanypath/artifactory.py +366 -0
- asanypath/asanypath.py +556 -0
- asanypath/asanypath.pyi +225 -0
- asanypath/azure.py +274 -0
- asanypath/batcher.py +206 -0
- asanypath/cli.py +2566 -0
- asanypath/cloud.py +1042 -0
- asanypath/common.py +355 -0
- asanypath/exceptions.py +43 -0
- asanypath/ftp.py +543 -0
- asanypath/gcs.py +253 -0
- asanypath/http.py +516 -0
- asanypath/local.py +817 -0
- asanypath/options.py +57 -0
- asanypath/s3.py +277 -0
- asanypath/ssh.py +776 -0
- asanypath/sync.py +220 -0
- asanypath/unsupported.py +202 -0
- asanypath-2.0.0.dist-info/METADATA +591 -0
- asanypath-2.0.0.dist-info/RECORD +28 -0
- asanypath-2.0.0.dist-info/WHEEL +4 -0
- asanypath-2.0.0.dist-info/entry_points.txt +2 -0
- asanypath-2.0.0.dist-info/licenses/LICENSE +21 -0
asanypath/options.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
"""Operation-scoped configuration for backend requests."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BackendOptions(msgspec.Struct, frozen=True, kw_only=True):
|
|
18
|
+
"""Provider-native options attached to one backend operation.
|
|
19
|
+
|
|
20
|
+
``headers`` and ``query`` contain request values. ``provider`` contains
|
|
21
|
+
JSON-compatible object-resource fields for backends that support them (GCS).
|
|
22
|
+
Backend-owned authentication and protocol-critical request values cannot be
|
|
23
|
+
overridden.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
headers: Mapping[str, str] = msgspec.field(default_factory=dict)
|
|
27
|
+
query: Mapping[str, str] = msgspec.field(default_factory=dict)
|
|
28
|
+
provider: Mapping[str, Any] = msgspec.field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
AccessAction = Literal["read", "write", "execute"]
|
|
32
|
+
PolicyPrincipal = str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AccessGrant(msgspec.Struct, frozen=True):
|
|
36
|
+
"""Actions granted to one normalized policy principal."""
|
|
37
|
+
|
|
38
|
+
principal: PolicyPrincipal
|
|
39
|
+
actions: frozenset[AccessAction]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AccessPolicy(msgspec.Struct, frozen=True, kw_only=True):
|
|
43
|
+
"""Resource access policy represented by normalized grants."""
|
|
44
|
+
|
|
45
|
+
owner: str | None = None
|
|
46
|
+
group: str | None = None
|
|
47
|
+
grants: tuple[AccessGrant, ...] = ()
|
|
48
|
+
provider: Mapping[str, Any] = msgspec.field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AccessPolicyPatch(msgspec.Struct, frozen=True, kw_only=True):
|
|
52
|
+
"""Targeted changes to a resource access policy."""
|
|
53
|
+
|
|
54
|
+
owner: str | None = None
|
|
55
|
+
group: str | None = None
|
|
56
|
+
grants: tuple[AccessGrant, ...] = ()
|
|
57
|
+
provider: Mapping[str, Any] = msgspec.field(default_factory=dict)
|
asanypath/s3.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from configparser import ConfigParser
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from os import getenv
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
|
|
16
|
+
import msgspec
|
|
17
|
+
|
|
18
|
+
from asanypath.cloud import CloudPathMixin
|
|
19
|
+
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
20
|
+
from asanypath_native import (
|
|
21
|
+
s3_copy_batch,
|
|
22
|
+
s3_delete_batch,
|
|
23
|
+
s3_exists_batch,
|
|
24
|
+
s3_get_acl,
|
|
25
|
+
s3_get_batch,
|
|
26
|
+
s3_head_batch,
|
|
27
|
+
s3_is_dir,
|
|
28
|
+
s3_is_dir_batch,
|
|
29
|
+
s3_list_batch,
|
|
30
|
+
s3_list_buckets,
|
|
31
|
+
s3_presign,
|
|
32
|
+
s3_put_acl,
|
|
33
|
+
s3_put_batch,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class S3Path(CloudPathMixin):
|
|
38
|
+
protocol: str = "s3"
|
|
39
|
+
_supports_range_read: bool = True
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _batcher_key_fn(
|
|
43
|
+
endpoint: str,
|
|
44
|
+
bucket: str,
|
|
45
|
+
region: str,
|
|
46
|
+
access_key: str,
|
|
47
|
+
secret_key: str,
|
|
48
|
+
session_token: str | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
return f"{endpoint}|{bucket}|{region}|{access_key}"
|
|
51
|
+
|
|
52
|
+
_batcher_ops = {
|
|
53
|
+
"get": {"batch_fn": s3_get_batch, "items_key": "keys", "wrap": bytes},
|
|
54
|
+
"exists": {"batch_fn": s3_exists_batch, "items_key": "keys"},
|
|
55
|
+
"head": {"batch_fn": s3_head_batch, "items_key": "keys"},
|
|
56
|
+
"delete": {"batch_fn": s3_delete_batch, "items_key": "keys"},
|
|
57
|
+
"put": {"batch_fn": s3_put_batch},
|
|
58
|
+
"list": {"batch_fn": s3_list_batch, "items_key": "prefixes"},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_is_dir_batch_fn = staticmethod(s3_is_dir_batch)
|
|
62
|
+
_copy_batch_fn = staticmethod(s3_copy_batch)
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*args,
|
|
67
|
+
endpoint_url: str | None = None,
|
|
68
|
+
aws_region: str | None = None,
|
|
69
|
+
aws_access_key_id: str | None = None,
|
|
70
|
+
aws_secret_access_key: str | None = None,
|
|
71
|
+
aws_session_token: str | None = None,
|
|
72
|
+
**kwargs,
|
|
73
|
+
):
|
|
74
|
+
super().__init__(*args, **kwargs)
|
|
75
|
+
cfg = self.env_config
|
|
76
|
+
self._endpoint_url = endpoint_url or cfg.endpoint_url
|
|
77
|
+
self._region = aws_region or cfg.aws_region
|
|
78
|
+
self._access_key = aws_access_key_id or cfg.aws_access_key_id
|
|
79
|
+
self._secret_key = aws_secret_access_key or cfg.aws_secret_access_key
|
|
80
|
+
self._session_token = aws_session_token or cfg.aws_session_token
|
|
81
|
+
self._bucket = self._path._netloc
|
|
82
|
+
# Promote explicit params into class cache so derived instances
|
|
83
|
+
# (parent, /, with_name, etc.) inherit them.
|
|
84
|
+
if endpoint_url:
|
|
85
|
+
cfg.endpoint_url = endpoint_url
|
|
86
|
+
if aws_region:
|
|
87
|
+
cfg.aws_region = aws_region
|
|
88
|
+
if aws_access_key_id:
|
|
89
|
+
cfg.aws_access_key_id = aws_access_key_id
|
|
90
|
+
if aws_secret_access_key:
|
|
91
|
+
cfg.aws_secret_access_key = aws_secret_access_key
|
|
92
|
+
if aws_session_token:
|
|
93
|
+
cfg.aws_session_token = aws_session_token
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def _create_env_config(cls) -> SimpleNamespace:
|
|
97
|
+
"""Build config from credential files, then AWS env vars (env takes precedence)."""
|
|
98
|
+
profile = getenv("AWS_PROFILE", "default")
|
|
99
|
+
credsfile = str(
|
|
100
|
+
Path(getenv("AWS_SHARED_CREDENTIALS_FILE", "~/.aws/credentials")).expanduser()
|
|
101
|
+
)
|
|
102
|
+
cfgfile = str(Path(getenv("AWS_CONFIG_FILE", "~/.aws/config")).expanduser())
|
|
103
|
+
|
|
104
|
+
cfg = {
|
|
105
|
+
"endpoint_url": None,
|
|
106
|
+
"aws_region": "us-east-1",
|
|
107
|
+
"aws_access_key_id": None,
|
|
108
|
+
"aws_secret_access_key": None,
|
|
109
|
+
"aws_session_token": None,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Shared credentials/config files (lower precedence).
|
|
113
|
+
cp = ConfigParser()
|
|
114
|
+
if cp.read(filenames=credsfile):
|
|
115
|
+
if profile in cp:
|
|
116
|
+
cfg.update(cp[profile])
|
|
117
|
+
if cp.read(filenames=cfgfile):
|
|
118
|
+
section = profile if profile == "default" else f"profile {profile}"
|
|
119
|
+
if section in cp:
|
|
120
|
+
cfg.update(cp[section])
|
|
121
|
+
|
|
122
|
+
# Environment variables override the profile files.
|
|
123
|
+
env = {
|
|
124
|
+
"endpoint_url": getenv("AWS_ENDPOINT_URL"),
|
|
125
|
+
"aws_region": getenv("AWS_REGION"),
|
|
126
|
+
"aws_access_key_id": getenv("AWS_ACCESS_KEY_ID"),
|
|
127
|
+
"aws_secret_access_key": getenv("AWS_SECRET_ACCESS_KEY"),
|
|
128
|
+
"aws_session_token": getenv("AWS_SESSION_TOKEN"),
|
|
129
|
+
}
|
|
130
|
+
cfg.update({k: v for k, v in env.items() if v is not None})
|
|
131
|
+
|
|
132
|
+
return SimpleNamespace(**cfg)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# CloudPathMixin hooks
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def _item_path(self) -> str:
|
|
140
|
+
return self._path.path.strip("/")
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def _native_kwargs(self) -> dict:
|
|
144
|
+
return {
|
|
145
|
+
"endpoint": self._endpoint_url,
|
|
146
|
+
"bucket": self._bucket,
|
|
147
|
+
"region": self._region,
|
|
148
|
+
"access_key": self._access_key,
|
|
149
|
+
"secret_key": self._secret_key,
|
|
150
|
+
"session_token": self._session_token or None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Backend-specific operations
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
async def is_dir(self) -> bool:
|
|
158
|
+
"""Check if path is a directorish."""
|
|
159
|
+
return await s3_is_dir(prefix=self._item_path, **self._native_kwargs)
|
|
160
|
+
|
|
161
|
+
async def _list_containers(self) -> list[str] | None:
|
|
162
|
+
"""List buckets when at the service root (``s3://``); else None."""
|
|
163
|
+
if self._bucket:
|
|
164
|
+
return None
|
|
165
|
+
names = await s3_list_buckets(
|
|
166
|
+
endpoint=self._endpoint_url,
|
|
167
|
+
region=self._region,
|
|
168
|
+
access_key=self._access_key,
|
|
169
|
+
secret_key=self._secret_key,
|
|
170
|
+
session_token=self._session_token or None,
|
|
171
|
+
use_h2=False,
|
|
172
|
+
)
|
|
173
|
+
return [f"s3://{name}" for name in names]
|
|
174
|
+
|
|
175
|
+
async def stat(self, *, follow_symlinks: bool = True):
|
|
176
|
+
"""Return file metadata from a HEAD request."""
|
|
177
|
+
headers = await self._get_batcher().head(
|
|
178
|
+
item=self._item_path,
|
|
179
|
+
**self._native_kwargs,
|
|
180
|
+
)
|
|
181
|
+
size = int(headers.get("Content-Length", headers.get("content-length", 0)))
|
|
182
|
+
last_modified = headers.get("Last-Modified", headers.get("last-modified"))
|
|
183
|
+
mtime = None
|
|
184
|
+
if last_modified:
|
|
185
|
+
mtime = (
|
|
186
|
+
datetime.strptime(last_modified, "%a, %d %b %Y %H:%M:%S GMT")
|
|
187
|
+
.replace(tzinfo=timezone.utc)
|
|
188
|
+
.timestamp()
|
|
189
|
+
)
|
|
190
|
+
return SimpleNamespace(st_size=size, st_ctime=mtime, st_mtime=mtime)
|
|
191
|
+
|
|
192
|
+
async def checksums(self) -> dict[str, str]:
|
|
193
|
+
"""Return MD5 checksum from the S3 ETag header."""
|
|
194
|
+
headers = await self._get_batcher().head(
|
|
195
|
+
item=self._item_path,
|
|
196
|
+
**self._native_kwargs,
|
|
197
|
+
)
|
|
198
|
+
etag = headers.get("ETag", headers.get("etag", "")).strip('"')
|
|
199
|
+
return {"md5": etag}
|
|
200
|
+
|
|
201
|
+
async def get_access_policy(
|
|
202
|
+
self, *, backend_options: BackendOptions | None = None
|
|
203
|
+
) -> AccessPolicy:
|
|
204
|
+
"""Return the object ACL and its normalized read/write grants.
|
|
205
|
+
|
|
206
|
+
S3 ACL permissions beyond read/write remain available in ``provider``
|
|
207
|
+
because they do not have portable POSIX equivalents.
|
|
208
|
+
"""
|
|
209
|
+
data = await s3_get_acl(key=self._item_path, **self._native_kwargs)
|
|
210
|
+
if isinstance(data, str):
|
|
211
|
+
data = msgspec.json.decode(data)
|
|
212
|
+
if not isinstance(data, Mapping):
|
|
213
|
+
raise TypeError("native S3 ACL response must be a mapping")
|
|
214
|
+
grants = []
|
|
215
|
+
for grant in data.get("grants", []):
|
|
216
|
+
if not isinstance(grant, Mapping):
|
|
217
|
+
continue
|
|
218
|
+
permission = grant.get("permission")
|
|
219
|
+
principal = grant.get("principal")
|
|
220
|
+
if not isinstance(principal, str):
|
|
221
|
+
continue
|
|
222
|
+
actions = frozenset(
|
|
223
|
+
action
|
|
224
|
+
for action, allowed in (
|
|
225
|
+
("read", permission in {"READ", "FULL_CONTROL"}),
|
|
226
|
+
("write", permission in {"WRITE", "FULL_CONTROL"}),
|
|
227
|
+
)
|
|
228
|
+
if allowed
|
|
229
|
+
)
|
|
230
|
+
if actions:
|
|
231
|
+
grants.append(AccessGrant(principal, actions))
|
|
232
|
+
owner = data.get("owner")
|
|
233
|
+
return AccessPolicy(
|
|
234
|
+
owner=owner if isinstance(owner, str) else None,
|
|
235
|
+
grants=tuple(grants),
|
|
236
|
+
provider={"s3_acl": data},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def update_access_policy(
|
|
240
|
+
self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Replace the object ACL using a complete ``provider["s3_acl"]`` document."""
|
|
243
|
+
acl = policy_patch.provider.get("s3_acl")
|
|
244
|
+
if not isinstance(acl, Mapping):
|
|
245
|
+
raise ValueError("S3 ACL replacement requires provider['s3_acl']")
|
|
246
|
+
await s3_put_acl(
|
|
247
|
+
key=self._item_path,
|
|
248
|
+
acl_json=msgspec.json.encode(acl).decode(),
|
|
249
|
+
**self._native_kwargs,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def presign(self, *, expires: int = 3600, method: str = "GET") -> str:
|
|
253
|
+
"""Generate a presigned URL for this S3 object.
|
|
254
|
+
|
|
255
|
+
Uses AWS Signature V4 query-string presigning via native Rust implementation.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
expires: URL validity in seconds (default 3600, max 604800).
|
|
259
|
+
method: HTTP method the URL will be used for (GET, PUT, etc.).
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A presigned URL string.
|
|
263
|
+
"""
|
|
264
|
+
if expires < 1 or expires > 604800:
|
|
265
|
+
raise ValueError("expires must be between 1 and 604800 seconds")
|
|
266
|
+
|
|
267
|
+
return s3_presign(
|
|
268
|
+
endpoint=self._endpoint_url,
|
|
269
|
+
bucket=self._bucket,
|
|
270
|
+
key=self._item_path,
|
|
271
|
+
region=self._region,
|
|
272
|
+
access_key=self._access_key,
|
|
273
|
+
secret_key=self._secret_key,
|
|
274
|
+
session_token=self._session_token,
|
|
275
|
+
method=method,
|
|
276
|
+
expires=expires,
|
|
277
|
+
)
|