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.
@@ -0,0 +1,366 @@
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
+ """JFrog Artifactory path implementation with Bearer token authentication."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import AsyncIterator, Mapping
13
+ from datetime import datetime, timezone
14
+ from os import getenv
15
+ from pathlib import Path
16
+ from types import SimpleNamespace
17
+ from typing import TYPE_CHECKING, Literal
18
+
19
+ import msgspec
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Self
23
+
24
+ from asanypath.cloud import CloudPathMixin
25
+ from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
26
+ from asanypath_native import (
27
+ art_copy_batch,
28
+ art_delete_batch,
29
+ art_exists_batch,
30
+ art_get_batch,
31
+ art_get_permission_target,
32
+ art_list_batch,
33
+ art_list_repos,
34
+ art_put_batch,
35
+ art_put_permission_target,
36
+ art_storage_info,
37
+ )
38
+
39
+
40
+ def _jfrog_conf_token(base_url: str) -> str | None:
41
+ """Return an access token from the JFrog CLI config matching ``base_url``.
42
+
43
+ Reads ``$JFROG_CLI_HOME`` (default ``~/.jfrog``), preferring a server whose
44
+ configured URL contains the target host, else the default/first server.
45
+ """
46
+ root = Path(getenv("JFROG_CLI_HOME") or (Path.home() / ".jfrog"))
47
+ host = base_url.split("/")[0].lower() if base_url else ""
48
+ for name in ("jfrog-cli.conf.v7", "jfrog-cli.conf.v6", "jfrog-cli.conf.v5", "jfrog-cli.conf"):
49
+ try:
50
+ data = json.loads((root / name).read_text())
51
+ except (OSError, ValueError):
52
+ continue
53
+ servers = data.get("servers") if isinstance(data, dict) else None
54
+ if not servers:
55
+ continue
56
+ match = next(
57
+ (
58
+ s
59
+ for s in servers
60
+ if host and host in f"{s.get('url', '')}{s.get('artifactoryUrl', '')}".lower()
61
+ ),
62
+ None,
63
+ )
64
+ if match is None:
65
+ match = next((s for s in servers if s.get("isDefault")), servers[0])
66
+ if token := match.get("accessToken"):
67
+ return token
68
+ return None
69
+
70
+
71
+ class ArtifactoryPath(CloudPathMixin):
72
+ """Async path implementation for JFrog Artifactory with Bearer token auth.
73
+
74
+ Extends HTTPSPath to add authentication via Authorization: Bearer <token> header
75
+ for authenticated requests to Artifactory servers.
76
+
77
+ Example:
78
+ ```python
79
+ import asyncio
80
+ from asanypath import AsAnyPath
81
+
82
+ async def main():
83
+ # Using direct URL with token parameter
84
+ path = AsAnyPath("art://artifactory.example.com/artifactory/generic/file.txt",
85
+ token="your-token")
86
+
87
+ # Or using environment variable ARTIFACTORY_IDENTITY_TOKEN
88
+ path = AsAnyPath("art://artifactory.example.com/artifactory/generic/file.txt")
89
+
90
+ content = await path.read_bytes()
91
+ await path.write_bytes(b"new content")
92
+ ```
93
+ """
94
+
95
+ protocol: str = "art"
96
+ _supports_range_read: bool = True
97
+ _copy_batch_fn = staticmethod(art_copy_batch)
98
+
99
+ @staticmethod
100
+ def _batcher_key_fn(base_url: str, token: str, **_kw) -> str:
101
+ return f"art|{base_url}|{token[:8]}"
102
+
103
+ _batcher_ops = {
104
+ "get": {"batch_fn": art_get_batch, "items_key": "paths", "wrap": bytes},
105
+ "exists": {"batch_fn": art_exists_batch, "items_key": "paths"},
106
+ "delete": {"batch_fn": art_delete_batch, "items_key": "paths"},
107
+ "list": {"batch_fn": art_list_batch, "items_key": "paths"},
108
+ "put": {"batch_fn": art_put_batch, "unpack_put": True},
109
+ }
110
+
111
+ def __init__(self, *args, token: str | None = None, **kwargs):
112
+ """Initialize ArtifactoryPath with optional Bearer token.
113
+
114
+ Args:
115
+ *args: Path arguments passed to parent HTTPSPath.
116
+ token: Bearer token for authentication. If not provided, will attempt to read
117
+ from ARTIFACTORY_IDENTITY_TOKEN environment variable.
118
+ **kwargs: Additional keyword arguments passed to parent HTTPSPath.
119
+
120
+ Raises:
121
+ ValueError: If token is not provided and ARTIFACTORY_IDENTITY_TOKEN env var is not set.
122
+ """
123
+ super().__init__(*args, **kwargs)
124
+ # Pre-compute base_url and repo_path for native dispatch
125
+ raw = str(self)[len("art://") :] # "host/artifactory/repo/path"
126
+ idx = raw.find("/artifactory/")
127
+ if idx >= 0:
128
+ self._base_url = raw[: idx + len("/artifactory")]
129
+ self._repo_path = raw[idx + len("/artifactory/") :]
130
+ else:
131
+ self._base_url = raw.split("/")[0]
132
+ self._repo_path = "/".join(raw.split("/")[1:])
133
+
134
+ from asanypath import _credstore
135
+
136
+ cred_key = f"art:{self._base_url}"
137
+ self._token = token or self.env_config.token
138
+ if not self._token:
139
+ self._token = _credstore.load(cred_key)
140
+ if not self._token:
141
+ self._token = _jfrog_conf_token(self._base_url)
142
+ if not self._token:
143
+ raise ValueError(
144
+ "Artifactory token required via 'token' parameter, "
145
+ "ARTIFACTORY_IDENTITY_TOKEN environment variable, the JFrog CLI config "
146
+ "(~/.jfrog/jfrog-cli.conf), or a previously cached token (see keyring)."
147
+ )
148
+ # Promote the resolved token into the class-level cache so derived
149
+ # instances (parent, /, iterdir children, ...) inherit it without
150
+ # re-hitting the keyring. Persist only user-supplied tokens.
151
+ self.env_config.token = self._token
152
+ if token:
153
+ _credstore.save(cred_key, token)
154
+ # Cached folder hint — set by iterdir() to avoid redundant API calls
155
+ self._is_folder: bool | None = None
156
+ self._cached_info: dict | None = None
157
+
158
+ @classmethod
159
+ def _create_env_config(cls) -> SimpleNamespace:
160
+ """Build env config from Artifactory environment variables."""
161
+ return SimpleNamespace(
162
+ token=getenv("ARTIFACTORY_IDENTITY_TOKEN"),
163
+ )
164
+
165
+ # ------------------------------------------------------------------
166
+ # CloudPathMixin hooks
167
+ # ------------------------------------------------------------------
168
+
169
+ @property
170
+ def _item_path(self) -> str:
171
+ return self._repo_path
172
+
173
+ @property
174
+ def _native_kwargs(self) -> dict:
175
+ return {
176
+ "base_url": self._base_url,
177
+ "token": self._token,
178
+ }
179
+
180
+ # ------------------------------------------------------------------
181
+ # Backend-specific operations
182
+ # ------------------------------------------------------------------
183
+
184
+ async def _storage_info(self) -> dict:
185
+ """Fetch item metadata from the Artifactory storage API (cached).
186
+
187
+ Timestamps are parsed to epoch floats at ingestion so consumers
188
+ never deal with raw strings.
189
+ """
190
+ if self._cached_info is None:
191
+ raw = await art_storage_info(path=self._repo_path, **self._native_kwargs)
192
+ info: dict = {}
193
+ if "size" in raw:
194
+ try:
195
+ info["st_size"] = int(raw["size"])
196
+ except (ValueError, TypeError):
197
+ info["st_size"] = 0
198
+ if "created" in raw:
199
+ info["st_ctime"] = self._parse_art_ts(raw["created"])
200
+ if "lastModified" in raw:
201
+ info["st_mtime"] = self._parse_art_ts(raw["lastModified"])
202
+ if "is_dir" in raw:
203
+ info["children"] = [] # marker; iterdir uses art_list
204
+ checksums = {}
205
+ for k, v in raw.items():
206
+ if k.startswith("checksum_"):
207
+ checksums[k[len("checksum_") :]] = v
208
+ if checksums:
209
+ info["checksums"] = checksums
210
+ self._cached_info = info
211
+ return self._cached_info
212
+
213
+ async def is_dir(self) -> bool:
214
+ if self._is_folder is not None:
215
+ return self._is_folder
216
+ try:
217
+ info = await self._storage_info()
218
+ self._is_folder = "children" in info
219
+ return self._is_folder
220
+ except (FileNotFoundError, PermissionError):
221
+ return False
222
+
223
+ async def is_file(self) -> bool:
224
+ if self._is_folder is not None:
225
+ return not self._is_folder
226
+ try:
227
+ info = await self._storage_info()
228
+ self._is_folder = "children" in info
229
+ return not self._is_folder
230
+ except (FileNotFoundError, PermissionError):
231
+ return False
232
+
233
+ async def iterdir(self) -> AsyncIterator[Self]:
234
+ containers = await self._list_containers()
235
+ if containers is not None:
236
+ for uri in containers:
237
+ child = type(self)(uri)
238
+ child._token = self._token
239
+ yield child
240
+ return
241
+ batcher = self._get_batcher()
242
+ results = await batcher.list(
243
+ item=self._repo_path,
244
+ base_url=self._base_url,
245
+ token=self._token,
246
+ )
247
+ base = str(self).rstrip("/")
248
+ for uri, is_folder in results:
249
+ child_name = uri.rsplit("/", 1)[-1]
250
+ child_path = type(self)(f"{base}/{child_name}")
251
+ child_path._token = self._token
252
+ child_path._is_folder = is_folder
253
+ yield child_path
254
+
255
+ async def _list_containers(self) -> list[str] | None:
256
+ """List repositories when at the server root (``art://<host>/artifactory/``).
257
+
258
+ Returns None once a repository is selected, so normal object listing
259
+ proceeds. Handles both the canonical ``host/artifactory`` base and the
260
+ shorthand parse where the trailing ``artifactory`` lands in the repo
261
+ slot.
262
+ """
263
+ base = self._base_url
264
+ repo = self._repo_path.strip("/")
265
+ if repo == "artifactory":
266
+ base = f"{base}/artifactory"
267
+ repo = ""
268
+ if repo:
269
+ return None
270
+ if not base or base == "artifactory":
271
+ return None
272
+ names = await art_list_repos(base_url=base, token=self._token, use_h2=False)
273
+ return [f"art://{base}/{name}" for name in names]
274
+
275
+ @staticmethod
276
+ def _parse_art_ts(ts: str | None) -> float | None:
277
+ """Parse an Artifactory ISO-8601 timestamp to a POSIX float."""
278
+ if not ts:
279
+ return None
280
+ # Normalize "2019-10-16T14:57:25.000+0000" → "2019-10-16T14:57:25.000+00:00"
281
+ if len(ts) > 5 and ts[-5] in "+-" and ":" not in ts[-5:]:
282
+ ts = ts[:-2] + ":" + ts[-2:]
283
+ # fromisoformat() doesn't support "Z" until Python 3.11
284
+ if ts.endswith("Z"):
285
+ ts = ts[:-1] + "+00:00"
286
+ try:
287
+ return datetime.fromisoformat(ts).astimezone(timezone.utc).timestamp()
288
+ except ValueError:
289
+ return None
290
+
291
+ async def stat(self, *, follow_symlinks: bool = True) -> SimpleNamespace:
292
+ info = await self._storage_info()
293
+ return SimpleNamespace(
294
+ st_size=info.get("st_size", 0),
295
+ st_ctime=info.get("st_ctime"),
296
+ st_mtime=info.get("st_mtime"),
297
+ )
298
+
299
+ async def checksums(self) -> dict[str, str]:
300
+ """Return checksums (md5, sha1, sha256) from the Artifactory storage API."""
301
+ info = await self._storage_info()
302
+ return info.get("checksums", {})
303
+
304
+ @staticmethod
305
+ def _permission_target_name(backend_options: BackendOptions | None) -> str:
306
+ if backend_options is None:
307
+ raise ValueError("Artifactory policy operations require provider['permission_target']")
308
+ name = backend_options.provider.get("permission_target")
309
+ if not isinstance(name, str) or not name:
310
+ raise ValueError("Artifactory policy operations require provider['permission_target']")
311
+ return name
312
+
313
+ async def get_access_policy(
314
+ self, *, backend_options: BackendOptions | None = None
315
+ ) -> AccessPolicy:
316
+ """Return the explicitly selected Artifactory permission target."""
317
+ name = self._permission_target_name(backend_options)
318
+ data = await art_get_permission_target(name=name, **self._native_kwargs)
319
+ if isinstance(data, str):
320
+ data = msgspec.json.decode(data)
321
+ if not isinstance(data, Mapping):
322
+ raise TypeError("native Artifactory permission target response must be a mapping")
323
+ grants = []
324
+ principals = data.get("principals")
325
+ if isinstance(principals, Mapping):
326
+ for kind in ("users", "groups"):
327
+ entries = principals.get(kind)
328
+ if not isinstance(entries, Mapping):
329
+ continue
330
+ for principal, permissions in entries.items():
331
+ if not isinstance(principal, str) or not isinstance(permissions, list):
332
+ continue
333
+ actions: frozenset[Literal["read", "write"]] = frozenset(
334
+ action
335
+ for action, permission in (("read", "r"), ("write", "w"))
336
+ if permission in permissions
337
+ )
338
+ if actions:
339
+ grants.append(AccessGrant(f"{kind[:-1]}:{principal}", actions))
340
+ return AccessPolicy(
341
+ grants=tuple(grants),
342
+ provider={"artifactory_permission_target": data},
343
+ )
344
+
345
+ async def update_access_policy(
346
+ self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
347
+ ) -> None:
348
+ """Replace the explicitly selected Artifactory permission target."""
349
+ name = self._permission_target_name(backend_options)
350
+ target = policy_patch.provider.get("artifactory_permission_target")
351
+ if not isinstance(target, Mapping):
352
+ raise ValueError(
353
+ "Artifactory policy replacement requires provider['artifactory_permission_target']"
354
+ )
355
+ await art_put_permission_target(
356
+ name=name,
357
+ target_json=msgspec.json.encode(target).decode(),
358
+ **self._native_kwargs,
359
+ )
360
+
361
+ async def presign(self, *, expires: int = 3600, method: str = "GET") -> str:
362
+ """Presigned URLs are not supported for Artifactory."""
363
+ raise NotImplementedError(
364
+ "Artifactory does not support client-side presigned URL generation. "
365
+ "Use the Artifactory 'signed URL' REST API if your server supports it."
366
+ )