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/artifactory.py
ADDED
|
@@ -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
|
+
)
|