ap-client 0.6.1.dev10__tar.gz → 0.6.1.dev12__tar.gz

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.
Files changed (27) hide show
  1. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/PKG-INFO +1 -1
  2. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/fs_commands.py +25 -7
  3. ap_client-0.6.1.dev12/ap_client/fs_transfer.py +287 -0
  4. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/pyproject.toml +1 -1
  5. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/__init__.py +0 -0
  6. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/akpool_commands.py +0 -0
  7. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/api.py +0 -0
  8. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/cli.py +0 -0
  9. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/config.py +0 -0
  10. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/dataset_commands.py +0 -0
  11. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/dataset_name.py +0 -0
  12. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/dataset_register.py +0 -0
  13. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/dfs_commands.py +0 -0
  14. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/exporter.py +0 -0
  15. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/instance_commands.py +0 -0
  16. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/instance_options.py +0 -0
  17. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/irepo_sdk.py +0 -0
  18. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/managed_ak.py +0 -0
  19. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/manifest_view.py +0 -0
  20. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/ossutil_hash.py +0 -0
  21. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/ossutil_io.py +0 -0
  22. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/profile_commands.py +0 -0
  23. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/repo_read_guard.py +0 -0
  24. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/share_api.py +0 -0
  25. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/split_publish.py +0 -0
  26. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/tbb.py +0 -0
  27. {ap_client-0.6.1.dev10 → ap_client-0.6.1.dev12}/ap_client/waiter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.6.1.dev10
3
+ Version: 0.6.1.dev12
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: irepo-artifact==0.2.2
@@ -3,8 +3,7 @@
3
3
  命令与后端映射:
4
4
 
5
5
  * ``fs ls`` → ``repo.list_user_data(uid, subpath=...)``
6
- * ``fs get`` → ``repo.download_user_data(uid, remote_subpath, local_path)``
7
- * ``fs put`` → ``repo.upload_user_data`` / ``repo.upload_user_data_dir``
6
+ * ``fs get/put`` → CLI 并发传输层,沿用 SDK 精确对象 STS 与网络路由
8
7
  * ``fs access ls`` → ``repo.list_user_data_acl(uid, subpath=...)``
9
8
  * ``fs access grant`` → ``repo.grant_user_data(..., subpath=...)``
10
9
  * ``fs access revoke`` → ``repo.revoke_user_data(..., subpath=...)``
@@ -310,9 +309,18 @@ def fs_get(
310
309
  target: Optional[str] = typer.Option(
311
310
  None, "--target", help="Local target path (default: the remote file name in CWD)"
312
311
  ),
312
+ concurrency: int = typer.Option(
313
+ 8, "--concurrency", "-j", min=1, max=64, help="Maximum concurrent transfer requests"
314
+ ),
315
+ progress: Optional[bool] = typer.Option(
316
+ None,
317
+ "--progress/--no-progress",
318
+ help="Show transfer progress on stderr (default: terminal only)",
319
+ ),
313
320
  output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
314
321
  ):
315
322
  """Download one file from a user storage space."""
323
+ from .fs_transfer import UserDataTransfer
316
324
  from .irepo_sdk import sdk_error_exit
317
325
 
318
326
  output_format = _fmt(output_format)
@@ -325,7 +333,9 @@ def fs_get(
325
333
  try:
326
334
  if local.parent and str(local.parent) not in ("", "."):
327
335
  local.parent.mkdir(parents=True, exist_ok=True)
328
- written = repo.download_user_data(uid, remote, str(local))
336
+ written = UserDataTransfer(repo, concurrency=concurrency, progress=progress).get(
337
+ uid, remote, local
338
+ )
329
339
  except Exception as exc: # noqa: BLE001
330
340
  sdk_error_exit(exc, what=f"user storage {uid} {remote}", stage="download")
331
341
 
@@ -344,9 +354,18 @@ def fs_put(
344
354
  None, "--source", help="Local source path (default: <filepath> relative to CWD)"
345
355
  ),
346
356
  overwrite: bool = typer.Option(False, "--overwrite", help="Allow overwriting remote objects"),
357
+ concurrency: int = typer.Option(
358
+ 8, "--concurrency", "-j", min=1, max=64, help="Maximum concurrent transfer requests"
359
+ ),
360
+ progress: Optional[bool] = typer.Option(
361
+ None,
362
+ "--progress/--no-progress",
363
+ help="Show transfer progress on stderr (default: terminal only)",
364
+ ),
347
365
  output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
348
366
  ):
349
367
  """Upload a local file (or directory) into a user storage space."""
368
+ from .fs_transfer import UserDataTransfer
350
369
  from .irepo_sdk import sdk_error_exit
351
370
 
352
371
  output_format = _fmt(output_format)
@@ -363,10 +382,9 @@ def fs_put(
363
382
  remote = _remote_subpath(filepath, directory)
364
383
  is_dir = local.is_dir()
365
384
  try:
366
- if is_dir:
367
- outcome: Any = repo.upload_user_data_dir(uid, str(local), remote, overwrite=overwrite)
368
- else:
369
- outcome = repo.upload_user_data(uid, str(local), remote, overwrite=overwrite)
385
+ outcome: Any = UserDataTransfer(repo, concurrency=concurrency, progress=progress).put(
386
+ uid, local, remote, overwrite=overwrite
387
+ )
370
388
  except Exception as exc: # noqa: BLE001
371
389
  sdk_error_exit(exc, what=f"user storage {uid} {remote}", stage="upload")
372
390
 
@@ -0,0 +1,287 @@
1
+ """Bounded user-storage transfers through the SDK's authorization and routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ import threading
8
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.progress import (
13
+ BarColumn,
14
+ DownloadColumn,
15
+ Progress,
16
+ TaskProgressColumn,
17
+ TextColumn,
18
+ TimeRemainingColumn,
19
+ TransferSpeedColumn,
20
+ )
21
+
22
+ from .ossutil_io import OssOperationError, _WriteBucket
23
+
24
+ PART_SIZE = 8 * 1024 * 1024
25
+
26
+
27
+ def _scope_denied(exc: Exception) -> bool:
28
+ """Only explicit scope refusals allow a more specific authorization request."""
29
+ seen = set()
30
+ cause = exc
31
+ while cause is not None and id(cause) not in seen:
32
+ seen.add(id(cause))
33
+ if getattr(cause, "biz_code", None) == 94023: # user-data has no grant
34
+ return True
35
+ if getattr(cause, "status", None) == 403 and getattr(cause, "code", None) == "AccessDenied":
36
+ return True
37
+ cause = cause.__cause__
38
+ return False
39
+
40
+
41
+ class TransferProgress:
42
+ def __init__(self, description: str, enabled: bool | None):
43
+ console = Console(stderr=True, markup=False, highlight=False)
44
+ self.display = Progress(
45
+ TextColumn("{task.description}"),
46
+ BarColumn(),
47
+ TaskProgressColumn(),
48
+ DownloadColumn(),
49
+ TransferSpeedColumn(),
50
+ TimeRemainingColumn(),
51
+ TextColumn("files {task.fields[files_done]}/{task.fields[files_total]}"),
52
+ console=console,
53
+ disable=not (console.is_terminal if enabled is None else enabled),
54
+ )
55
+ self.description = description
56
+ self.lock = threading.Lock()
57
+ self.sizes: dict[str, int] = {}
58
+ self.done: dict[str, int] = {}
59
+ self.files_done = 0
60
+ self.total_bytes = 0
61
+ self.completed_bytes = 0
62
+
63
+ def start(self, sizes: dict[str, int], *, unknown: bool = False):
64
+ self.sizes = dict(sizes)
65
+ self.total_bytes = sum(sizes.values())
66
+ self.known_total = not unknown
67
+ self.task = self.display.add_task(
68
+ self.description,
69
+ total=None if unknown else self.total_bytes,
70
+ files_done=0,
71
+ files_total=len(sizes),
72
+ )
73
+ return self
74
+
75
+ def __enter__(self):
76
+ self.display.start()
77
+ return self
78
+
79
+ def __exit__(self, kind, value, traceback):
80
+ if kind is not None:
81
+ self.display.update(self.task, description=self.description + " failed")
82
+ self.display.stop()
83
+
84
+ def callback(self, key: str):
85
+ def update(consumed, total):
86
+ with self.lock:
87
+ size = max(0, int(total or 0))
88
+ self.known_total = True
89
+ self.total_bytes += size - self.sizes[key]
90
+ self.sizes[key] = size
91
+ # Multipart callbacks and retries can arrive out of order.
92
+ old = self.done.get(key, 0)
93
+ self.done[key] = min(size, max(old, consumed))
94
+ self.completed_bytes += self.done[key] - old
95
+ self._render()
96
+
97
+ return update
98
+
99
+ def finish(self, key: str, *, skipped: bool = False):
100
+ with self.lock:
101
+ self.known_total = True
102
+ if skipped:
103
+ # Skipped bytes were not transferred; do not inflate the speed.
104
+ self.total_bytes -= self.sizes[key]
105
+ self.sizes[key] = 0
106
+ old = self.done.get(key, 0)
107
+ self.done[key] = self.sizes[key]
108
+ self.completed_bytes += self.done[key] - old
109
+ self.files_done += 1
110
+ self._render()
111
+
112
+ def _render(self):
113
+ self.display.update(
114
+ self.task,
115
+ completed=self.completed_bytes,
116
+ total=self.total_bytes if self.known_total else None,
117
+ files_done=self.files_done,
118
+ )
119
+
120
+
121
+ class UserDataTransfer:
122
+ def __init__(self, repo, *, concurrency: int = 8, progress: bool | None = None):
123
+ if not 1 <= concurrency <= 64:
124
+ raise ValueError("concurrency must be between 1 and 64")
125
+ self.repo, self.concurrency, self.progress = repo, concurrency, progress
126
+ self.content = getattr(repo, "_content", None)
127
+ if not callable(getattr(self.content, "_execute_oss", None)):
128
+ raise OssOperationError("User storage transfer requires a compatible instance-repo SDK")
129
+ from instance_repo.store.oss import _import_oss2
130
+
131
+ self.oss = _import_oss2()
132
+
133
+ def _key(self, uid: str, path: str) -> str:
134
+ if not str(uid).strip():
135
+ raise ValueError("uid must not be empty")
136
+ return self.repo._user_data_prefix(uid) + self.repo._clean_user_data_subpath(path)
137
+
138
+ def put(self, uid: str, source: Path, remote: str, *, overwrite: bool = False):
139
+ from instance_repo._routing import _OperationIntent
140
+
141
+ source = Path(source)
142
+ remote = self.repo._clean_user_data_subpath(remote)
143
+ base = self._key(uid, remote)
144
+ directory = source.is_dir()
145
+ files = []
146
+ if directory:
147
+ for root, dirs, names in os.walk(source):
148
+ dirs.sort()
149
+ for name in sorted(names):
150
+ local = Path(root) / name
151
+ key = self._key(
152
+ uid, remote.rstrip("/") + "/" + local.relative_to(source).as_posix()
153
+ )
154
+ files.append((local, key))
155
+ else:
156
+ files.append((source, base))
157
+ if any(not local.is_file() for local, _ in files):
158
+ raise ValueError("Upload sources must be regular files")
159
+ sizes = {key: local.stat().st_size for local, key in files}
160
+ workers = min(self.concurrency, max(1, len(files)))
161
+ part_threads = max(1, self.concurrency // workers)
162
+ report = TransferProgress("Uploading", self.progress).start(sizes)
163
+ directory_scope = base.rstrip("/") + "/" if directory else None
164
+
165
+ def transfer(item):
166
+ local, key = item
167
+ write_started = False
168
+ # Keep the SDK's resume records across failures and later invocations.
169
+ store = self.oss.ResumableStore()
170
+
171
+ def upload(bucket):
172
+ nonlocal write_started
173
+ if not overwrite and not write_started and bucket.object_exists(key):
174
+ return {"key": key, "skipped": True}
175
+ write_started = True
176
+ self.oss.resumable_upload(
177
+ _WriteBucket(bucket, overwrite),
178
+ key,
179
+ str(local),
180
+ store=store,
181
+ num_threads=part_threads,
182
+ part_size=PART_SIZE,
183
+ multipart_threshold=PART_SIZE,
184
+ headers={} if overwrite else {"x-oss-forbid-overwrite": "true"},
185
+ progress_callback=report.callback(key),
186
+ )
187
+ return {"key": key, "skipped": False}
188
+
189
+ scope = directory_scope or key
190
+ try:
191
+ result = self.content._execute_oss(
192
+ _OperationIntent.NON_DOWNLOAD, scope, None, upload
193
+ )
194
+ except Exception as exc:
195
+ if scope == key or not _scope_denied(exc):
196
+ raise
197
+ # A directory token may deny a reader subtree that contains a
198
+ # more specific writer grant. Re-authorize only this object;
199
+ # never ignore a Deny or request a broader ancestor token.
200
+ result = self.content._execute_oss(_OperationIntent.NON_DOWNLOAD, key, None, upload)
201
+ report.finish(key, skipped=result["skipped"])
202
+ return result
203
+
204
+ with report:
205
+ if not files:
206
+ return []
207
+ if directory_scope is not None:
208
+ try:
209
+ # Prime the SDK's thread-safe, expiry-aware credential cache.
210
+ # The server includes descendant reader Deny rules in this
211
+ # directory policy. Never reconstruct that policy locally.
212
+ credentials = self.content._credentials(directory_scope, None)
213
+ except Exception as exc:
214
+ if not _scope_denied(exc):
215
+ raise
216
+ # File-only grants remain usable without a parent grant.
217
+ directory_scope = None
218
+ else:
219
+ if credentials.get("prefix") != directory_scope:
220
+ raise OssOperationError(
221
+ "Directory credential scope differs from the requested directory"
222
+ )
223
+ if workers == 1:
224
+ outcomes = [transfer(item) for item in files]
225
+ else:
226
+ outcomes = self._parallel(files, transfer, workers)
227
+ return outcomes if directory else outcomes[0]
228
+
229
+ @staticmethod
230
+ def _parallel(files, transfer, workers):
231
+ outcomes = [None] * len(files)
232
+ items = iter(enumerate(files))
233
+ with ThreadPoolExecutor(max_workers=workers) as pool:
234
+ pending = {
235
+ pool.submit(transfer, item): index
236
+ for index, item in [next(items) for _ in range(workers)]
237
+ }
238
+ try:
239
+ while pending:
240
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
241
+ for future in done:
242
+ outcomes[pending.pop(future)] = future.result()
243
+ for _ in done:
244
+ entry = next(items, None)
245
+ if entry is not None:
246
+ index, item = entry
247
+ pending[pool.submit(transfer, item)] = index
248
+ except BaseException:
249
+ for future in pending:
250
+ future.cancel()
251
+ raise
252
+ return outcomes
253
+
254
+ def get(self, uid: str, remote: str, target: Path) -> str:
255
+ from instance_repo._routing import _OperationIntent
256
+
257
+ key = self._key(uid, remote)
258
+ target = Path(target)
259
+ if target.exists() and not target.is_file():
260
+ raise ValueError("Download target must be a regular file")
261
+ target.parent.mkdir(parents=True, exist_ok=True)
262
+ report = TransferProgress("Downloading", self.progress).start({key: 0}, unknown=True)
263
+ # Keep an existing destination intact until every range has completed.
264
+ with (
265
+ report,
266
+ tempfile.TemporaryDirectory(prefix=".ap-fs-get-", dir=target.parent) as scratch,
267
+ ):
268
+ partial = Path(scratch) / "data"
269
+ store = self.oss.ResumableDownloadStore(root=scratch)
270
+
271
+ def download(bucket):
272
+ self.oss.resumable_download(
273
+ bucket,
274
+ key,
275
+ str(partial),
276
+ store=store,
277
+ num_threads=self.concurrency,
278
+ part_size=PART_SIZE,
279
+ multiget_threshold=PART_SIZE,
280
+ progress_callback=report.callback(key),
281
+ )
282
+
283
+ self.content._execute_oss(_OperationIntent.DOWNLOAD, key, None, download)
284
+ partial.chmod(0o600)
285
+ os.replace(partial, target)
286
+ report.finish(key)
287
+ return str(target)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ap-client"
7
- version = "0.6.1.dev10"
7
+ version = "0.6.1.dev12"
8
8
  description = "Agent Platform API Client & CLI"
9
9
  readme = { text = "A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.", content-type = "text/markdown" }
10
10
  requires-python = ">=3.10"