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