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/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
+ )