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/gcs.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
"""Google Cloud Storage path implementation (HTTP-style adapter).
|
|
8
|
+
|
|
9
|
+
Uses the GCS JSON API:
|
|
10
|
+
https://cloud.google.com/storage/docs/json_api/v1
|
|
11
|
+
|
|
12
|
+
Credentials: an explicit ``access_token`` or ``GCS_ACCESS_TOKEN`` is used when
|
|
13
|
+
present. Otherwise the native layer falls back to Application Default
|
|
14
|
+
Credentials (``GOOGLE_APPLICATION_CREDENTIALS`` or the well-known file written
|
|
15
|
+
by ``gcloud auth application-default login``), minting and caching an access
|
|
16
|
+
token. ADC is skipped when ``STORAGE_EMULATOR_HOST`` is set.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from os import getenv
|
|
24
|
+
from types import SimpleNamespace
|
|
25
|
+
|
|
26
|
+
import msgspec
|
|
27
|
+
|
|
28
|
+
from asanypath.cloud import CloudPathMixin
|
|
29
|
+
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
30
|
+
from asanypath_native import (
|
|
31
|
+
gcs_copy_batch,
|
|
32
|
+
gcs_delete_batch,
|
|
33
|
+
gcs_exists_batch,
|
|
34
|
+
gcs_get_acl,
|
|
35
|
+
gcs_get_batch,
|
|
36
|
+
gcs_head,
|
|
37
|
+
gcs_head_batch,
|
|
38
|
+
gcs_is_dir,
|
|
39
|
+
gcs_is_dir_batch,
|
|
40
|
+
gcs_list_batch,
|
|
41
|
+
gcs_list_buckets,
|
|
42
|
+
gcs_presign,
|
|
43
|
+
gcs_put_acl,
|
|
44
|
+
gcs_put_batch,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
GCS_API_BASE = "https://storage.googleapis.com"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GCSPath(CloudPathMixin):
|
|
51
|
+
"""Async path implementation for Google Cloud Storage.
|
|
52
|
+
|
|
53
|
+
Uses the GCS JSON API for object operations.
|
|
54
|
+
Supports: exists, is_file, is_dir, iterdir, read/write, unlink, touch, rename, stat.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
```python
|
|
58
|
+
path = GCSPath("gs://bucket/object/path.txt",
|
|
59
|
+
endpoint_url="http://localhost:4443")
|
|
60
|
+
content = await path.read_bytes()
|
|
61
|
+
```
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
protocol: str = "gs"
|
|
65
|
+
_supports_range_read: bool = True
|
|
66
|
+
_is_dir_batch_fn = staticmethod(gcs_is_dir_batch)
|
|
67
|
+
_copy_batch_fn = staticmethod(gcs_copy_batch)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _batcher_key_fn(endpoint: str, bucket: str, access_token: str | None = None) -> str:
|
|
71
|
+
return f"{endpoint}|{bucket}|{access_token or ''}"
|
|
72
|
+
|
|
73
|
+
_batcher_ops = {
|
|
74
|
+
"get": {"batch_fn": gcs_get_batch, "items_key": "object_paths", "wrap": bytes},
|
|
75
|
+
"exists": {"batch_fn": gcs_exists_batch, "items_key": "object_paths"},
|
|
76
|
+
"head": {"batch_fn": gcs_head_batch, "items_key": "object_paths"},
|
|
77
|
+
"delete": {"batch_fn": gcs_delete_batch, "items_key": "object_paths"},
|
|
78
|
+
"put": {"batch_fn": gcs_put_batch},
|
|
79
|
+
"list": {"batch_fn": gcs_list_batch, "items_key": "prefixes"},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*args,
|
|
85
|
+
endpoint_url: str | None = None,
|
|
86
|
+
project_id: str | None = None,
|
|
87
|
+
credentials_path: str | None = None,
|
|
88
|
+
access_token: str | None = None,
|
|
89
|
+
**kwargs,
|
|
90
|
+
):
|
|
91
|
+
super().__init__(*args, **kwargs)
|
|
92
|
+
cfg = self.env_config
|
|
93
|
+
self._project_id = project_id or cfg.project_id
|
|
94
|
+
self._credentials_path = credentials_path or cfg.credentials_path
|
|
95
|
+
self._access_token = access_token or cfg.access_token
|
|
96
|
+
self._endpoint_url = endpoint_url or cfg.endpoint_url
|
|
97
|
+
# Promote explicit params into class cache so derived instances
|
|
98
|
+
# (parent, /, with_name, etc.) inherit them.
|
|
99
|
+
if project_id:
|
|
100
|
+
cfg.project_id = project_id
|
|
101
|
+
if credentials_path:
|
|
102
|
+
cfg.credentials_path = credentials_path
|
|
103
|
+
if access_token:
|
|
104
|
+
cfg.access_token = access_token
|
|
105
|
+
if endpoint_url:
|
|
106
|
+
cfg.endpoint_url = endpoint_url
|
|
107
|
+
|
|
108
|
+
# URL format: gs://bucket/object/path
|
|
109
|
+
self._bucket = self._path.host or ""
|
|
110
|
+
self._object_path = self._path.path.lstrip("/")
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def _create_env_config(cls) -> SimpleNamespace: # pragma: no cover
|
|
114
|
+
"""Build env config from GCP environment variables."""
|
|
115
|
+
# Only called if cfg_instance is None; hard to trigger in unit tests.
|
|
116
|
+
# Implicitly tested through integration tests with cloud paths.
|
|
117
|
+
return SimpleNamespace(
|
|
118
|
+
project_id=getenv("GCP_PROJECT") or getenv("GOOGLE_CLOUD_PROJECT"),
|
|
119
|
+
credentials_path=getenv("GOOGLE_APPLICATION_CREDENTIALS"),
|
|
120
|
+
access_token=getenv("GCS_ACCESS_TOKEN"),
|
|
121
|
+
endpoint_url=getenv("STORAGE_EMULATOR_HOST", GCS_API_BASE),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# CloudPathMixin hooks
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def _item_path(self) -> str:
|
|
130
|
+
return self._object_path
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def _native_kwargs(self) -> dict:
|
|
134
|
+
return {
|
|
135
|
+
"endpoint": self._endpoint_url,
|
|
136
|
+
"bucket": self._bucket,
|
|
137
|
+
"access_token": self._access_token,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Backend-specific operations
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
async def is_dir(self) -> bool:
|
|
145
|
+
"""Check if path is a directory prefix (has objects with this prefix)."""
|
|
146
|
+
return await gcs_is_dir(prefix=self._object_path, **self._native_kwargs)
|
|
147
|
+
|
|
148
|
+
async def _list_containers(self) -> list[str] | None:
|
|
149
|
+
"""List buckets when at the service root (``gs://``); else None."""
|
|
150
|
+
if self._bucket:
|
|
151
|
+
return None
|
|
152
|
+
names = await gcs_list_buckets(
|
|
153
|
+
endpoint=self._endpoint_url,
|
|
154
|
+
project=self._project_id or "",
|
|
155
|
+
access_token=self._access_token,
|
|
156
|
+
use_h2=False,
|
|
157
|
+
)
|
|
158
|
+
return [f"gs://{name}" for name in names]
|
|
159
|
+
|
|
160
|
+
async def stat(self, *, follow_symlinks: bool = True) -> object:
|
|
161
|
+
"""Get object metadata."""
|
|
162
|
+
from types import SimpleNamespace
|
|
163
|
+
|
|
164
|
+
data = await gcs_head(object_path=self._object_path, **self._native_kwargs)
|
|
165
|
+
size = int(data.get("size", 0))
|
|
166
|
+
updated = data.get("updated")
|
|
167
|
+
mtime = None
|
|
168
|
+
if updated:
|
|
169
|
+
mtime = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp()
|
|
170
|
+
return SimpleNamespace(st_size=size, st_ctime=mtime, st_mtime=mtime)
|
|
171
|
+
|
|
172
|
+
async def checksums(self) -> dict[str, str]:
|
|
173
|
+
"""Return checksums from object metadata."""
|
|
174
|
+
data = await gcs_head(object_path=self._object_path, **self._native_kwargs)
|
|
175
|
+
result = {}
|
|
176
|
+
if "md5Hash" in data:
|
|
177
|
+
result["md5"] = data["md5Hash"]
|
|
178
|
+
if "crc32c" in data:
|
|
179
|
+
result["crc32c"] = data["crc32c"]
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
async def get_access_policy(
|
|
183
|
+
self, *, backend_options: BackendOptions | None = None
|
|
184
|
+
) -> AccessPolicy:
|
|
185
|
+
"""Return the object ACL and its normalized read/write grants."""
|
|
186
|
+
data = await gcs_get_acl(object_path=self._object_path, **self._native_kwargs)
|
|
187
|
+
if isinstance(data, str):
|
|
188
|
+
data = msgspec.json.decode(data)
|
|
189
|
+
if not isinstance(data, Mapping):
|
|
190
|
+
raise TypeError("native GCS ACL response must be a mapping")
|
|
191
|
+
grants = []
|
|
192
|
+
for grant in data.get("grants", []):
|
|
193
|
+
if not isinstance(grant, Mapping):
|
|
194
|
+
continue
|
|
195
|
+
principal = grant.get("principal")
|
|
196
|
+
permission = grant.get("permission")
|
|
197
|
+
if not isinstance(principal, str) or permission not in {"READER", "OWNER"}:
|
|
198
|
+
continue
|
|
199
|
+
actions = frozenset({"read", "write"} if permission == "OWNER" else {"read"})
|
|
200
|
+
grants.append(AccessGrant(principal, actions))
|
|
201
|
+
owner = data.get("owner")
|
|
202
|
+
return AccessPolicy(
|
|
203
|
+
owner=owner if isinstance(owner, str) else None,
|
|
204
|
+
grants=tuple(grants),
|
|
205
|
+
provider={"gcs_acl": data},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def update_access_policy(
|
|
209
|
+
self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Replace the object ACL using a complete ``provider["gcs_acl"]`` document."""
|
|
212
|
+
acl = policy_patch.provider.get("gcs_acl")
|
|
213
|
+
if not isinstance(acl, Mapping):
|
|
214
|
+
raise ValueError("GCS ACL replacement requires provider['gcs_acl']")
|
|
215
|
+
await gcs_put_acl(
|
|
216
|
+
object_path=self._object_path,
|
|
217
|
+
acl_json=msgspec.json.encode(acl).decode(),
|
|
218
|
+
**self._native_kwargs,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def presign(self, *, expires: int = 3600, method: str = "GET") -> str:
|
|
222
|
+
"""Generate a V4 signed URL for this GCS object.
|
|
223
|
+
|
|
224
|
+
Requires a service account JSON key file (via credentials_path or
|
|
225
|
+
GOOGLE_APPLICATION_CREDENTIALS).
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
expires: URL validity in seconds (default 3600, max 604800).
|
|
229
|
+
method: HTTP method the URL will be used for.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
A presigned URL string.
|
|
233
|
+
"""
|
|
234
|
+
if expires < 1 or expires > 604800: # pragma: no cover
|
|
235
|
+
raise ValueError("expires must be between 1 and 604800 seconds")
|
|
236
|
+
|
|
237
|
+
if not self._credentials_path: # pragma: no cover
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"presign requires credentials_path "
|
|
240
|
+
"(GOOGLE_APPLICATION_CREDENTIALS) pointing to a service account JSON key"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
from pathlib import Path
|
|
244
|
+
|
|
245
|
+
sa_json = Path(self._credentials_path).read_text()
|
|
246
|
+
|
|
247
|
+
return gcs_presign(
|
|
248
|
+
bucket=self._bucket,
|
|
249
|
+
object_path=self._object_path,
|
|
250
|
+
service_account_json=sa_json,
|
|
251
|
+
method=method,
|
|
252
|
+
expires=expires,
|
|
253
|
+
)
|