ap-client 0.6.1.dev9__tar.gz → 0.6.1.dev11__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.dev9 → ap_client-0.6.1.dev11}/PKG-INFO +1 -1
  2. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/fs_commands.py +25 -7
  3. ap_client-0.6.1.dev11/ap_client/fs_transfer.py +245 -0
  4. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/irepo_sdk.py +39 -1
  5. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/pyproject.toml +1 -1
  6. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/__init__.py +0 -0
  7. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/akpool_commands.py +0 -0
  8. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/api.py +0 -0
  9. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/cli.py +0 -0
  10. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/config.py +0 -0
  11. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/dataset_commands.py +0 -0
  12. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/dataset_name.py +0 -0
  13. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/dataset_register.py +0 -0
  14. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/dfs_commands.py +0 -0
  15. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/exporter.py +0 -0
  16. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/instance_commands.py +0 -0
  17. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/instance_options.py +0 -0
  18. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/managed_ak.py +0 -0
  19. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/manifest_view.py +0 -0
  20. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/ossutil_hash.py +0 -0
  21. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/ossutil_io.py +0 -0
  22. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/profile_commands.py +0 -0
  23. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/repo_read_guard.py +0 -0
  24. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/share_api.py +0 -0
  25. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/split_publish.py +0 -0
  26. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/ap_client/tbb.py +0 -0
  27. {ap_client-0.6.1.dev9 → ap_client-0.6.1.dev11}/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.dev9
3
+ Version: 0.6.1.dev11
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,245 @@
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
+ class TransferProgress:
28
+ def __init__(self, description: str, enabled: bool | None):
29
+ console = Console(stderr=True, markup=False, highlight=False)
30
+ self.display = Progress(
31
+ TextColumn("{task.description}"),
32
+ BarColumn(),
33
+ TaskProgressColumn(),
34
+ DownloadColumn(),
35
+ TransferSpeedColumn(),
36
+ TimeRemainingColumn(),
37
+ TextColumn("files {task.fields[files_done]}/{task.fields[files_total]}"),
38
+ console=console,
39
+ disable=not (console.is_terminal if enabled is None else enabled),
40
+ )
41
+ self.description = description
42
+ self.lock = threading.Lock()
43
+ self.sizes: dict[str, int] = {}
44
+ self.done: dict[str, int] = {}
45
+ self.files_done = 0
46
+ self.total_bytes = 0
47
+ self.completed_bytes = 0
48
+
49
+ def start(self, sizes: dict[str, int], *, unknown: bool = False):
50
+ self.sizes = dict(sizes)
51
+ self.total_bytes = sum(sizes.values())
52
+ self.known_total = not unknown
53
+ self.task = self.display.add_task(
54
+ self.description,
55
+ total=None if unknown else self.total_bytes,
56
+ files_done=0,
57
+ files_total=len(sizes),
58
+ )
59
+ return self
60
+
61
+ def __enter__(self):
62
+ self.display.start()
63
+ return self
64
+
65
+ def __exit__(self, kind, value, traceback):
66
+ if kind is not None:
67
+ self.display.update(self.task, description=self.description + " failed")
68
+ self.display.stop()
69
+
70
+ def callback(self, key: str):
71
+ def update(consumed, total):
72
+ with self.lock:
73
+ size = max(0, int(total or 0))
74
+ self.known_total = True
75
+ self.total_bytes += size - self.sizes[key]
76
+ self.sizes[key] = size
77
+ # Multipart callbacks and retries can arrive out of order.
78
+ old = self.done.get(key, 0)
79
+ self.done[key] = min(size, max(old, consumed))
80
+ self.completed_bytes += self.done[key] - old
81
+ self._render()
82
+
83
+ return update
84
+
85
+ def finish(self, key: str, *, skipped: bool = False):
86
+ with self.lock:
87
+ self.known_total = True
88
+ if skipped:
89
+ # Skipped bytes were not transferred; do not inflate the speed.
90
+ self.total_bytes -= self.sizes[key]
91
+ self.sizes[key] = 0
92
+ old = self.done.get(key, 0)
93
+ self.done[key] = self.sizes[key]
94
+ self.completed_bytes += self.done[key] - old
95
+ self.files_done += 1
96
+ self._render()
97
+
98
+ def _render(self):
99
+ self.display.update(
100
+ self.task,
101
+ completed=self.completed_bytes,
102
+ total=self.total_bytes if self.known_total else None,
103
+ files_done=self.files_done,
104
+ )
105
+
106
+
107
+ class UserDataTransfer:
108
+ def __init__(self, repo, *, concurrency: int = 8, progress: bool | None = None):
109
+ if not 1 <= concurrency <= 64:
110
+ raise ValueError("concurrency must be between 1 and 64")
111
+ self.repo, self.concurrency, self.progress = repo, concurrency, progress
112
+ self.content = getattr(repo, "_content", None)
113
+ if not callable(getattr(self.content, "_execute_oss", None)):
114
+ raise OssOperationError("User storage transfer requires a compatible instance-repo SDK")
115
+ from instance_repo.store.oss import _import_oss2
116
+
117
+ self.oss = _import_oss2()
118
+
119
+ def _key(self, uid: str, path: str) -> str:
120
+ if not str(uid).strip():
121
+ raise ValueError("uid must not be empty")
122
+ return self.repo._user_data_prefix(uid) + self.repo._clean_user_data_subpath(path)
123
+
124
+ def put(self, uid: str, source: Path, remote: str, *, overwrite: bool = False):
125
+ from instance_repo._routing import _OperationIntent
126
+
127
+ source = Path(source)
128
+ base = self._key(uid, remote)
129
+ directory = source.is_dir()
130
+ files = []
131
+ if directory:
132
+ for root, dirs, names in os.walk(source):
133
+ dirs.sort()
134
+ for name in sorted(names):
135
+ local = Path(root) / name
136
+ key = self._key(
137
+ uid, remote.rstrip("/") + "/" + local.relative_to(source).as_posix()
138
+ )
139
+ files.append((local, key))
140
+ else:
141
+ files.append((source, base))
142
+ if any(not local.is_file() for local, _ in files):
143
+ raise ValueError("Upload sources must be regular files")
144
+ sizes = {key: local.stat().st_size for local, key in files}
145
+ workers = min(self.concurrency, max(1, len(files)))
146
+ part_threads = max(1, self.concurrency // workers)
147
+ report = TransferProgress("Uploading", self.progress).start(sizes)
148
+
149
+ def transfer(item):
150
+ local, key = item
151
+ write_started = False
152
+ # Keep the SDK's resume records across failures and later invocations.
153
+ store = self.oss.ResumableStore()
154
+
155
+ def upload(bucket):
156
+ nonlocal write_started
157
+ if not overwrite and not write_started and bucket.object_exists(key):
158
+ return {"key": key, "skipped": True}
159
+ write_started = True
160
+ self.oss.resumable_upload(
161
+ _WriteBucket(bucket, overwrite),
162
+ key,
163
+ str(local),
164
+ store=store,
165
+ num_threads=part_threads,
166
+ part_size=PART_SIZE,
167
+ multipart_threshold=PART_SIZE,
168
+ headers={} if overwrite else {"x-oss-forbid-overwrite": "true"},
169
+ progress_callback=report.callback(key),
170
+ )
171
+ return {"key": key, "skipped": False}
172
+
173
+ # Exact object scope retains subdirectory/file ACL behavior.
174
+ result = self.content._execute_oss(_OperationIntent.NON_DOWNLOAD, key, None, upload)
175
+ report.finish(key, skipped=result["skipped"])
176
+ return result
177
+
178
+ with report:
179
+ if not files:
180
+ return []
181
+ if workers == 1:
182
+ outcomes = [transfer(item) for item in files]
183
+ else:
184
+ outcomes = self._parallel(files, transfer, workers)
185
+ return outcomes if directory else outcomes[0]
186
+
187
+ @staticmethod
188
+ def _parallel(files, transfer, workers):
189
+ outcomes = [None] * len(files)
190
+ items = iter(enumerate(files))
191
+ with ThreadPoolExecutor(max_workers=workers) as pool:
192
+ pending = {
193
+ pool.submit(transfer, item): index
194
+ for index, item in [next(items) for _ in range(workers)]
195
+ }
196
+ try:
197
+ while pending:
198
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
199
+ for future in done:
200
+ outcomes[pending.pop(future)] = future.result()
201
+ for _ in done:
202
+ entry = next(items, None)
203
+ if entry is not None:
204
+ index, item = entry
205
+ pending[pool.submit(transfer, item)] = index
206
+ except BaseException:
207
+ for future in pending:
208
+ future.cancel()
209
+ raise
210
+ return outcomes
211
+
212
+ def get(self, uid: str, remote: str, target: Path) -> str:
213
+ from instance_repo._routing import _OperationIntent
214
+
215
+ key = self._key(uid, remote)
216
+ target = Path(target)
217
+ if target.exists() and not target.is_file():
218
+ raise ValueError("Download target must be a regular file")
219
+ target.parent.mkdir(parents=True, exist_ok=True)
220
+ report = TransferProgress("Downloading", self.progress).start({key: 0}, unknown=True)
221
+ # Keep an existing destination intact until every range has completed.
222
+ with (
223
+ report,
224
+ tempfile.TemporaryDirectory(prefix=".ap-fs-get-", dir=target.parent) as scratch,
225
+ ):
226
+ partial = Path(scratch) / "data"
227
+ store = self.oss.ResumableDownloadStore(root=scratch)
228
+
229
+ def download(bucket):
230
+ self.oss.resumable_download(
231
+ bucket,
232
+ key,
233
+ str(partial),
234
+ store=store,
235
+ num_threads=self.concurrency,
236
+ part_size=PART_SIZE,
237
+ multiget_threshold=PART_SIZE,
238
+ progress_callback=report.callback(key),
239
+ )
240
+
241
+ self.content._execute_oss(_OperationIntent.DOWNLOAD, key, None, download)
242
+ partial.chmod(0o600)
243
+ os.replace(partial, target)
244
+ report.finish(key)
245
+ return str(target)
@@ -955,7 +955,9 @@ def load_repo(
955
955
  _Transport = None
956
956
  if _Transport is not None:
957
957
  kwargs["transport"] = _Transport(
958
- cfg.base_url, cfg.token_key or "", env=api_env,
958
+ cfg.base_url,
959
+ cfg.token_key or "",
960
+ env=api_env,
959
961
  timeout=request_timeout,
960
962
  )
961
963
 
@@ -968,9 +970,45 @@ def load_repo(
968
970
  repo = _Repo(**kwargs)
969
971
  _bind_dataset_profile(repo)
970
972
  _install_oss_probe_cache(repo, verbose=cfg.verbose)
973
+ _install_user_data_directory_listing(repo)
971
974
  return repo
972
975
 
973
976
 
977
+ def _install_user_data_directory_listing(repo: Any) -> bool:
978
+ """Keep the directory boundary lost by instance-repo 1.4.0 list_user_data.
979
+
980
+ Use the SDK's path validation and content store so STS authorization,
981
+ endpoint selection and pagination retain their normal behavior. Normalize
982
+ here until the SDK dependency includes its directory-prefix fix.
983
+ """
984
+ content = getattr(repo, "_content", None)
985
+ prefix_for = getattr(repo, "_user_data_prefix", None)
986
+ clean = getattr(repo, "_clean_user_data_subpath", None)
987
+ if not all(
988
+ callable(fn)
989
+ for fn in (
990
+ getattr(repo, "list_user_data", None),
991
+ prefix_for,
992
+ clean,
993
+ getattr(content, "list_objects", None),
994
+ )
995
+ ):
996
+ return False
997
+
998
+ def list_user_data(uid: str, subpath: str = "") -> list[str]:
999
+ from instance_repo.errors import SchemaError
1000
+
1001
+ if not str(uid).strip():
1002
+ raise SchemaError("uid must not be empty")
1003
+ prefix = prefix_for(uid)
1004
+ if str(subpath).strip():
1005
+ prefix += clean(subpath) + "/"
1006
+ return content.list_objects(prefix)
1007
+
1008
+ repo.list_user_data = list_user_data
1009
+ return True
1010
+
1011
+
974
1012
  def _bind_dataset_profile(repo: Any) -> None:
975
1013
  """Supply the configured context missing from SDK 1.3.5 dataset discovery.
976
1014
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ap-client"
7
- version = "0.6.1.dev9"
7
+ version = "0.6.1.dev11"
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"