ap-client 0.6.1.dev12__tar.gz → 0.7.0__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.
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/PKG-INFO +4 -4
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/cli.py +19 -0
- ap_client-0.7.0/ap_client/dataset_name.py +104 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/dfs_commands.py +5 -2
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/fs_commands.py +177 -16
- ap_client-0.7.0/ap_client/fs_storage.py +146 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/fs_transfer.py +53 -11
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/instance_commands.py +2 -2
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/instance_options.py +4 -9
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/irepo_sdk.py +5 -3
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/ossutil_io.py +43 -19
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/pyproject.toml +4 -4
- ap_client-0.6.1.dev12/ap_client/dataset_name.py +0 -53
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/__init__.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/akpool_commands.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/api.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/config.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/dataset_commands.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/dataset_register.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/exporter.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/managed_ak.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/manifest_view.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/ossutil_hash.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/profile_commands.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/repo_read_guard.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/share_api.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/split_publish.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/tbb.py +0 -0
- {ap_client-0.6.1.dev12 → ap_client-0.7.0}/ap_client/waiter.py +0 -0
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ap-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Agent Platform API Client & CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: irepo-artifact==0.2.
|
|
6
|
+
Requires-Dist: irepo-artifact==0.2.4
|
|
7
7
|
Requires-Dist: pyyaml>=6.0
|
|
8
8
|
Requires-Dist: requests>=2.28.0
|
|
9
9
|
Requires-Dist: rich>=13.0.0
|
|
10
10
|
Requires-Dist: typer>=0.9.0
|
|
11
11
|
Requires-Dist: websockets>=13.0
|
|
12
12
|
Provides-Extra: all
|
|
13
|
-
Requires-Dist: instance-repo[oss]<2.0.0,>=1.4.
|
|
13
|
+
Requires-Dist: instance-repo[oss]<2.0.0,>=1.4.0; extra == 'all'
|
|
14
14
|
Provides-Extra: dataset
|
|
15
|
-
Requires-Dist: instance-repo[oss]<2.0.0,>=1.4.
|
|
15
|
+
Requires-Dist: instance-repo[oss]<2.0.0,>=1.4.0; extra == 'dataset'
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
|
|
18
18
|
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.
|
|
@@ -955,6 +955,25 @@ def _print_job_get_plain(job: dict) -> None:
|
|
|
955
955
|
]
|
|
956
956
|
)
|
|
957
957
|
|
|
958
|
+
bindings = job.get("ak_bindings") or []
|
|
959
|
+
rows = [
|
|
960
|
+
{**parameter, "ak_id": binding["ak_id"]}
|
|
961
|
+
for binding in bindings
|
|
962
|
+
for parameter in binding.get("parameters", [])
|
|
963
|
+
]
|
|
964
|
+
if "ak_bindings.parameters" in (job.get("restricted_fields") or []):
|
|
965
|
+
typer.echo("\nManaged AK parameters: content access required")
|
|
966
|
+
elif rows:
|
|
967
|
+
typer.echo("\nManaged AK parameters")
|
|
968
|
+
for row in rows:
|
|
969
|
+
typer.echo(f" {row['parameter']}: {row['value']} -> {row['ak_id']}")
|
|
970
|
+
if any("parameters" not in binding for binding in bindings):
|
|
971
|
+
typer.echo(" Some historical parameter mappings are unavailable")
|
|
972
|
+
elif response_ak_ids(job):
|
|
973
|
+
typer.echo("\nManaged AK parameters: historical mapping unavailable")
|
|
974
|
+
elif "ak_bindings" in job:
|
|
975
|
+
typer.echo("\nManaged AK parameters\n No managed AK used")
|
|
976
|
+
|
|
958
977
|
meta_pairs = [
|
|
959
978
|
("meta_model", job.get("meta_model")),
|
|
960
979
|
("meta_job_type", job.get("meta_job_type")),
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""DatasetName 解析 Adapter:唯一入口委托给已安装的 InstanceRepo SDK。
|
|
2
|
+
|
|
3
|
+
AP CLI 不维护第三套 dataset 名解析:``instance_repo.paths.DatasetName`` 是客户端
|
|
4
|
+
路径契约的唯一事实源(单段 ``A`` / 两段 ``A/B`` / 多段树形 ``A/B/C``,与
|
|
5
|
+
apiserver 的 namespace guard 及 conformance 契约对齐)。SDK 缺失或过旧(没有
|
|
6
|
+
``DatasetName.parse`` 能力)时,给出明确的升级提示,不静默降级、不本地重写解析规则。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DatasetNameError",
|
|
17
|
+
"DatasetNameUpgradeRequired",
|
|
18
|
+
"parse_dataset_name",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
#: SDK 缺失/无 DatasetName 能力时的升级提示(与 irepo_sdk 的 SdkMissing 同范式)。
|
|
22
|
+
_UPGRADE_HINT = (
|
|
23
|
+
"The installed instance-repo SDK does not provide DatasetName parsing "
|
|
24
|
+
"(single-segment L1 dataset names need it).\n"
|
|
25
|
+
"Install it with: pip install 'ap-client[dataset]' --upgrade"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: 多段(3+)名能力的首个 SDK 版本。旧 SDK(如 1.4.0)有 parse 但只认 1-2 段,
|
|
29
|
+
#: 对多段名会抛"one or two canonical segments"——此时应提示升级而非报名称非法。
|
|
30
|
+
_MULTI_LEVEL_SDK_VERSION = (1, 4, 1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sdk_supports_multi_level() -> bool:
|
|
34
|
+
"""已安装 instance-repo 是否支持多段数据集名(>= 1.4.1)。版本不可读时保守视为不支持。"""
|
|
35
|
+
try:
|
|
36
|
+
from importlib.metadata import version
|
|
37
|
+
|
|
38
|
+
installed = version("instance-repo")
|
|
39
|
+
except Exception: # noqa: BLE001 - 元数据缺失与旧 SDK 同等对待
|
|
40
|
+
return False
|
|
41
|
+
match = re.match(r"(\d+)\.(\d+)\.(\d+)", str(installed))
|
|
42
|
+
if not match:
|
|
43
|
+
return False
|
|
44
|
+
return tuple(int(part) for part in match.groups()) >= _MULTI_LEVEL_SDK_VERSION
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_structurally_valid_multi_level(value: str) -> bool:
|
|
48
|
+
"""值是否为"新 SDK 会接受"的 3+ 段名——即每个段都合法。
|
|
49
|
+
|
|
50
|
+
旧 SDK 对非法名(空段 a//b、非法字符 a*b)与多段名报同一文案,不能只按
|
|
51
|
+
段数判断:升级 SDK 也修不好空段/非法字符,必须保留原始校验错误。
|
|
52
|
+
这里保守复刻 SDK 的非法段规则作为 gate,只放行结构上合法的多段名。
|
|
53
|
+
"""
|
|
54
|
+
parts = value.split("/")
|
|
55
|
+
if len(parts) < 3 or any(not part for part in parts):
|
|
56
|
+
return False
|
|
57
|
+
for part in parts:
|
|
58
|
+
if (
|
|
59
|
+
part in {".", ".."}
|
|
60
|
+
or "\\" in part
|
|
61
|
+
or "*" in part
|
|
62
|
+
or "?" in part
|
|
63
|
+
or any(unicodedata.category(char) == "Cc" for char in part)
|
|
64
|
+
):
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DatasetNameUpgradeRequired(RuntimeError):
|
|
70
|
+
"""已安装的 instance-repo SDK 缺少 DatasetName 能力,必须升级。"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DatasetNameError(ValueError):
|
|
74
|
+
"""Dataset 名不符合 IR DatasetName 契约(单段或两段、无非法段)。"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_dataset_name(value: str) -> Any:
|
|
78
|
+
"""Parse ``value`` through the installed IR ``DatasetName`` capability.
|
|
79
|
+
|
|
80
|
+
Returns the SDK ``DatasetName`` (``canonical`` / ``l1`` / ``l2`` /
|
|
81
|
+
``segments`` / ``leaf``)。Raises :class:`DatasetNameUpgradeRequired` when the
|
|
82
|
+
installed SDK lacks the capability (包括只认 1-2 段的旧 SDK 拒绝多段名);
|
|
83
|
+
raises :class:`DatasetNameError` when the value is not a valid name.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
from instance_repo.paths import DatasetName
|
|
87
|
+
except ImportError as exc:
|
|
88
|
+
raise DatasetNameUpgradeRequired(_UPGRADE_HINT) from exc
|
|
89
|
+
parse = getattr(DatasetName, "parse", None)
|
|
90
|
+
if not callable(parse):
|
|
91
|
+
raise DatasetNameUpgradeRequired(_UPGRADE_HINT)
|
|
92
|
+
try:
|
|
93
|
+
return parse(value)
|
|
94
|
+
except Exception as exc: # noqa: BLE001 - SDK 校验失败统一转成 CLI 契约
|
|
95
|
+
# 旧 SDK(1.4.0 及更早)只认 1-2 段:结构上合法的多段名被拒时给出
|
|
96
|
+
# 升级提示。非法名(空段/非法字符)无论新旧 SDK 都拒绝,保留原始
|
|
97
|
+
# 校验错误为 DatasetNameError——升级修不好这类名称。
|
|
98
|
+
if (
|
|
99
|
+
isinstance(value, str)
|
|
100
|
+
and _is_structurally_valid_multi_level(value)
|
|
101
|
+
and not _sdk_supports_multi_level()
|
|
102
|
+
):
|
|
103
|
+
raise DatasetNameUpgradeRequired(_UPGRADE_HINT) from exc
|
|
104
|
+
raise DatasetNameError(str(exc)) from exc
|
|
@@ -272,13 +272,16 @@ def _fail(exc: Exception) -> None:
|
|
|
272
272
|
if isinstance(exc, SdkMissing):
|
|
273
273
|
typer.echo(str(exc), err=True)
|
|
274
274
|
raise typer.Exit(2) from exc
|
|
275
|
-
|
|
275
|
+
input_errors = (OssUsageError, ConfigurationError, typer.BadParameter)
|
|
276
|
+
message = (
|
|
277
|
+
str(exc) if isinstance(exc, (ConfigurationError, typer.BadParameter)) else safe_error(exc)
|
|
278
|
+
)
|
|
276
279
|
typer.echo("error: " + _display(message), err=True)
|
|
277
280
|
if getattr(exc, "biz_code", None):
|
|
278
281
|
typer.echo(f"hint: Check the Dataset access grant (API code {exc.biz_code})", err=True)
|
|
279
282
|
elif getattr(exc, "code", None) == "AccessDenied":
|
|
280
283
|
typer.echo("hint: Check the Dataset reader/writer grant for this operation", err=True)
|
|
281
|
-
raise typer.Exit(2 if isinstance(exc,
|
|
284
|
+
raise typer.Exit(2 if isinstance(exc, input_errors) else 1) from exc
|
|
282
285
|
|
|
283
286
|
|
|
284
287
|
@dfs_app.command("ls")
|
|
@@ -39,6 +39,8 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import inspect
|
|
41
41
|
import os
|
|
42
|
+
import sys
|
|
43
|
+
from datetime import datetime, timezone
|
|
42
44
|
from pathlib import Path
|
|
43
45
|
from typing import Any, Optional
|
|
44
46
|
|
|
@@ -270,6 +272,32 @@ def _user_data_prefix(uid: str) -> str:
|
|
|
270
272
|
return f"swe/user-data/users/{uid}/"
|
|
271
273
|
|
|
272
274
|
|
|
275
|
+
def _existing_policy(on_exist: str, overwrite: bool, skip_existing: bool):
|
|
276
|
+
policy = _check_choice(on_exist, ("prompt", "overwrite", "skip", "error"), "--on-exist")
|
|
277
|
+
if overwrite and skip_existing or (policy != "prompt" and (overwrite or skip_existing)):
|
|
278
|
+
raise _fail(
|
|
279
|
+
"Conflicting existing-file options",
|
|
280
|
+
"Choose one of --on-exist, --overwrite, --skip-existing",
|
|
281
|
+
2,
|
|
282
|
+
)
|
|
283
|
+
return "overwrite" if overwrite else "skip" if skip_existing else policy
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _replace_existing(path: str, policy: str) -> bool:
|
|
287
|
+
if policy == "overwrite":
|
|
288
|
+
return True
|
|
289
|
+
if policy == "skip":
|
|
290
|
+
return False
|
|
291
|
+
if policy == "error" or not all(
|
|
292
|
+
stream.isatty() for stream in (sys.stdin, sys.stdout, sys.stderr)
|
|
293
|
+
):
|
|
294
|
+
raise FileExistsError(
|
|
295
|
+
f"Destination already exists: {path}. Use --overwrite or --skip-existing; "
|
|
296
|
+
"interactive confirmation requires a terminal."
|
|
297
|
+
)
|
|
298
|
+
return typer.confirm(f"Overwrite {path}?", default=False, err=True)
|
|
299
|
+
|
|
300
|
+
|
|
273
301
|
# ---------------------------------------------------------------------------
|
|
274
302
|
# ap fs ls / get / put
|
|
275
303
|
# ---------------------------------------------------------------------------
|
|
@@ -277,28 +305,116 @@ def _user_data_prefix(uid: str) -> str:
|
|
|
277
305
|
|
|
278
306
|
@fs_app.command("ls")
|
|
279
307
|
def fs_ls(
|
|
308
|
+
path: Optional[str] = typer.Argument(None, help="Directory to list (default: space root)"),
|
|
280
309
|
directory: Optional[str] = typer.Option(None, "--dir", help="Sub-directory prefix"),
|
|
310
|
+
recursive: bool = typer.Option(False, "--recursive", "-R", help="List all descendants"),
|
|
311
|
+
all_entries: bool = typer.Option(
|
|
312
|
+
False, "--all", "-a", help="Include names starting with a dot"
|
|
313
|
+
),
|
|
314
|
+
reverse: bool = typer.Option(False, "--reverse", "-r", help="Reverse name sort order"),
|
|
315
|
+
long: bool = typer.Option(
|
|
316
|
+
False, "--long", "-l", help="Show type, size in bytes and modified time"
|
|
317
|
+
),
|
|
318
|
+
human_readable: bool = typer.Option(
|
|
319
|
+
False, "--human-readable", "-h", help="Use readable sizes with -l"
|
|
320
|
+
),
|
|
321
|
+
sort: str = typer.Option(
|
|
322
|
+
"name", "--sort", help="Sort by name/size/time (size and time: largest/newest first)"
|
|
323
|
+
),
|
|
324
|
+
directories_only: bool = typer.Option(False, "--dirs-only", help="Show directories only"),
|
|
325
|
+
files_only: bool = typer.Option(False, "--files-only", help="Show files only"),
|
|
326
|
+
limit: Optional[int] = typer.Option(
|
|
327
|
+
None, "--limit", min=1, help="Maximum number of displayed entries"
|
|
328
|
+
),
|
|
281
329
|
user: Optional[str] = typer.Option(None, "--user", help=_USER_HELP),
|
|
282
330
|
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
283
331
|
):
|
|
284
|
-
"""List
|
|
332
|
+
"""List one directory level; use -R to include descendants."""
|
|
333
|
+
from .fs_storage import UserDataFiles
|
|
285
334
|
from .irepo_sdk import sdk_error_exit
|
|
286
335
|
|
|
287
336
|
output_format = _fmt(output_format)
|
|
337
|
+
sort = _check_choice(sort, ("name", "size", "time"), "--sort")
|
|
338
|
+
if path is not None and directory is not None:
|
|
339
|
+
raise _fail("Specify a path or --dir, not both", "Example: ap fs ls probe", 2)
|
|
340
|
+
if directories_only and files_only:
|
|
341
|
+
raise _fail("--dirs-only conflicts with --files-only", "Choose one type filter", 2)
|
|
342
|
+
directory = path if path is not None else directory or ""
|
|
288
343
|
repo = _load_repo_or_exit()
|
|
289
344
|
uid = _resolve_uid(repo, user)
|
|
290
345
|
try:
|
|
291
|
-
|
|
346
|
+
rows = UserDataFiles(repo).list(uid, directory, recursive=recursive)
|
|
292
347
|
except Exception as exc: # noqa: BLE001
|
|
293
348
|
sdk_error_exit(exc, what=f"user storage {uid}", stage="list")
|
|
294
349
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
350
|
+
def visible(row):
|
|
351
|
+
return (
|
|
352
|
+
row.get("explicit", False)
|
|
353
|
+
or all_entries
|
|
354
|
+
or not any(part.startswith(".") for part in row["name"].split("/"))
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
rows = [
|
|
358
|
+
row
|
|
359
|
+
for row in rows
|
|
360
|
+
if visible(row)
|
|
361
|
+
and (not directories_only or row["type"] == "directory")
|
|
362
|
+
and (not files_only or row["type"] == "file")
|
|
363
|
+
]
|
|
364
|
+
rows.sort(key=lambda row: row["path"])
|
|
365
|
+
if sort != "name":
|
|
366
|
+
field = "size" if sort == "size" else "modified"
|
|
367
|
+
rows.sort(key=lambda row: row[field] or 0, reverse=True)
|
|
368
|
+
if reverse:
|
|
369
|
+
rows.reverse()
|
|
370
|
+
if limit is not None:
|
|
371
|
+
rows = rows[:limit]
|
|
300
372
|
payload = {"user": uid, "dir": directory or "", "files": rows}
|
|
301
|
-
|
|
373
|
+
display_rows = []
|
|
374
|
+
for row in rows:
|
|
375
|
+
shown = dict(row)
|
|
376
|
+
size = row.get("size")
|
|
377
|
+
if human_readable and size is not None:
|
|
378
|
+
value = float(size)
|
|
379
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
380
|
+
if value < 1024 or unit == "TiB":
|
|
381
|
+
shown["size"] = f"{value:g} {unit}"
|
|
382
|
+
break
|
|
383
|
+
value /= 1024
|
|
384
|
+
if row.get("modified") is not None:
|
|
385
|
+
shown["modified"] = datetime.fromtimestamp(row["modified"], tz=timezone.utc).strftime(
|
|
386
|
+
"%Y-%m-%d %H:%M:%SZ"
|
|
387
|
+
)
|
|
388
|
+
display_rows.append(shown)
|
|
389
|
+
columns = (
|
|
390
|
+
(("name", "PATH"), ("type", "TYPE"), ("size", "BYTES"), ("modified", "MODIFIED"))
|
|
391
|
+
if long
|
|
392
|
+
else (("name", "PATH"),)
|
|
393
|
+
)
|
|
394
|
+
_emit(payload, display_rows, columns, output_format)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@fs_app.command("mkdir")
|
|
398
|
+
def fs_mkdir(
|
|
399
|
+
path: str = typer.Argument(..., help="Directory path inside the user storage space"),
|
|
400
|
+
parents: bool = typer.Option(
|
|
401
|
+
False, "--parents", "-p", help="Create missing parents; accept existing directories"
|
|
402
|
+
),
|
|
403
|
+
user: Optional[str] = typer.Option(None, "--user", help=_USER_HELP),
|
|
404
|
+
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
405
|
+
):
|
|
406
|
+
"""Create an empty directory using an OSS directory marker."""
|
|
407
|
+
from .fs_storage import UserDataFiles
|
|
408
|
+
from .irepo_sdk import sdk_error_exit
|
|
409
|
+
|
|
410
|
+
output_format = _fmt(output_format)
|
|
411
|
+
repo = _load_repo_or_exit()
|
|
412
|
+
uid = _resolve_uid(repo, user)
|
|
413
|
+
try:
|
|
414
|
+
result = UserDataFiles(repo).mkdir(uid, path, parents=parents)
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
sdk_error_exit(exc, what=f"user storage {uid} {path}", stage="mkdir")
|
|
417
|
+
_emit_detail({"user": uid, **result}, output_format)
|
|
302
418
|
|
|
303
419
|
|
|
304
420
|
@fs_app.command("get")
|
|
@@ -309,6 +425,15 @@ def fs_get(
|
|
|
309
425
|
target: Optional[str] = typer.Option(
|
|
310
426
|
None, "--target", help="Local target path (default: the remote file name in CWD)"
|
|
311
427
|
),
|
|
428
|
+
overwrite: bool = typer.Option(
|
|
429
|
+
False, "--overwrite", "-f", help="Replace an existing destination"
|
|
430
|
+
),
|
|
431
|
+
skip_existing: bool = typer.Option(
|
|
432
|
+
False, "--skip-existing", "-n", help="Keep existing destinations"
|
|
433
|
+
),
|
|
434
|
+
on_exist: str = typer.Option(
|
|
435
|
+
"prompt", "--on-exist", help="Existing destination: prompt/overwrite/skip/error"
|
|
436
|
+
),
|
|
312
437
|
concurrency: int = typer.Option(
|
|
313
438
|
8, "--concurrency", "-j", min=1, max=64, help="Maximum concurrent transfer requests"
|
|
314
439
|
),
|
|
@@ -324,23 +449,41 @@ def fs_get(
|
|
|
324
449
|
from .irepo_sdk import sdk_error_exit
|
|
325
450
|
|
|
326
451
|
output_format = _fmt(output_format)
|
|
452
|
+
policy = _existing_policy(on_exist, overwrite, skip_existing)
|
|
327
453
|
repo = _load_repo_or_exit()
|
|
328
454
|
uid = _resolve_uid(repo, user)
|
|
329
455
|
remote = _remote_subpath(filepath, directory)
|
|
330
456
|
local = Path(target) if target else Path(Path(remote).name)
|
|
331
|
-
if local.is_dir():
|
|
457
|
+
if local.is_dir() or (target and target.endswith("/")):
|
|
332
458
|
local = local / Path(remote).name
|
|
459
|
+
skipped = False
|
|
333
460
|
try:
|
|
461
|
+
if local.is_symlink():
|
|
462
|
+
raise ValueError("Download target must not be a symbolic link")
|
|
463
|
+
exists = local.exists()
|
|
464
|
+
if exists and not local.is_file():
|
|
465
|
+
raise ValueError("Download target must be a regular file")
|
|
466
|
+
skipped = exists and not _replace_existing(str(local), policy)
|
|
334
467
|
if local.parent and str(local.parent) not in ("", "."):
|
|
335
468
|
local.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
-
written =
|
|
337
|
-
|
|
469
|
+
written = (
|
|
470
|
+
str(local)
|
|
471
|
+
if skipped
|
|
472
|
+
else UserDataTransfer(repo, concurrency=concurrency, progress=progress).get(
|
|
473
|
+
uid, remote, local, overwrite=exists or policy == "overwrite"
|
|
474
|
+
)
|
|
338
475
|
)
|
|
339
476
|
except Exception as exc: # noqa: BLE001
|
|
340
477
|
sdk_error_exit(exc, what=f"user storage {uid} {remote}", stage="download")
|
|
341
478
|
|
|
342
479
|
_emit_detail(
|
|
343
|
-
{
|
|
480
|
+
{
|
|
481
|
+
"user": uid,
|
|
482
|
+
"remote": remote,
|
|
483
|
+
"local": str(written or local),
|
|
484
|
+
"downloaded": 0 if skipped else 1,
|
|
485
|
+
"skipped": int(skipped),
|
|
486
|
+
},
|
|
344
487
|
output_format,
|
|
345
488
|
)
|
|
346
489
|
|
|
@@ -353,7 +496,15 @@ def fs_put(
|
|
|
353
496
|
source: Optional[str] = typer.Option(
|
|
354
497
|
None, "--source", help="Local source path (default: <filepath> relative to CWD)"
|
|
355
498
|
),
|
|
356
|
-
overwrite: bool = typer.Option(
|
|
499
|
+
overwrite: bool = typer.Option(
|
|
500
|
+
False, "--overwrite", "-f", help="Allow overwriting remote objects"
|
|
501
|
+
),
|
|
502
|
+
skip_existing: bool = typer.Option(
|
|
503
|
+
False, "--skip-existing", "-n", help="Keep existing remote objects"
|
|
504
|
+
),
|
|
505
|
+
on_exist: str = typer.Option(
|
|
506
|
+
"prompt", "--on-exist", help="Existing destination: prompt/overwrite/skip/error"
|
|
507
|
+
),
|
|
357
508
|
concurrency: int = typer.Option(
|
|
358
509
|
8, "--concurrency", "-j", min=1, max=64, help="Maximum concurrent transfer requests"
|
|
359
510
|
),
|
|
@@ -369,6 +520,7 @@ def fs_put(
|
|
|
369
520
|
from .irepo_sdk import sdk_error_exit
|
|
370
521
|
|
|
371
522
|
output_format = _fmt(output_format)
|
|
523
|
+
policy = _existing_policy(on_exist, overwrite, skip_existing)
|
|
372
524
|
local = Path(source or filepath)
|
|
373
525
|
if not local.exists():
|
|
374
526
|
raise _fail(
|
|
@@ -381,21 +533,30 @@ def fs_put(
|
|
|
381
533
|
uid = _resolve_uid(repo, user)
|
|
382
534
|
remote = _remote_subpath(filepath, directory)
|
|
383
535
|
is_dir = local.is_dir()
|
|
536
|
+
if filepath.endswith("/") and not is_dir:
|
|
537
|
+
remote = remote.rstrip("/") + "/" + local.name
|
|
384
538
|
try:
|
|
385
539
|
outcome: Any = UserDataTransfer(repo, concurrency=concurrency, progress=progress).put(
|
|
386
|
-
uid,
|
|
540
|
+
uid,
|
|
541
|
+
local,
|
|
542
|
+
remote,
|
|
543
|
+
overwrite=policy == "overwrite",
|
|
544
|
+
on_conflict=lambda key: _replace_existing(key, policy),
|
|
387
545
|
)
|
|
388
546
|
except Exception as exc: # noqa: BLE001
|
|
389
547
|
sdk_error_exit(exc, what=f"user storage {uid} {remote}", stage="upload")
|
|
390
548
|
|
|
391
|
-
|
|
549
|
+
results = outcome if isinstance(outcome, list) else [outcome]
|
|
550
|
+
skipped = sum(isinstance(item, dict) and item.get("skipped", False) for item in results)
|
|
392
551
|
_emit_detail(
|
|
393
552
|
{
|
|
394
553
|
"user": uid,
|
|
395
554
|
"remote": remote,
|
|
396
555
|
"local": str(local),
|
|
397
556
|
"kind": "directory" if is_dir else "file",
|
|
398
|
-
"objects":
|
|
557
|
+
"objects": len(results),
|
|
558
|
+
"uploaded": len(results) - skipped,
|
|
559
|
+
"skipped": skipped,
|
|
399
560
|
},
|
|
400
561
|
output_format,
|
|
401
562
|
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Directory operations using the SDK's scoped OSS authorization and routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .fs_transfer import UserDataTransfer, _scope_denied
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UserDataFiles(UserDataTransfer):
|
|
9
|
+
def list(self, uid: str, path: str = "", *, recursive: bool = False):
|
|
10
|
+
from instance_repo._routing import _OperationIntent
|
|
11
|
+
|
|
12
|
+
root = self.repo._user_data_prefix(uid)
|
|
13
|
+
prefix = root if path in ("", ".", "/") else self._key(uid, path) + "/"
|
|
14
|
+
|
|
15
|
+
def listing(bucket):
|
|
16
|
+
entries = {}
|
|
17
|
+
found = False
|
|
18
|
+
marker = ""
|
|
19
|
+
while True:
|
|
20
|
+
page = bucket.list_objects(
|
|
21
|
+
prefix=prefix, delimiter="" if recursive else "/", marker=marker, max_keys=1000
|
|
22
|
+
)
|
|
23
|
+
found = found or bool(page.object_list or page.prefix_list)
|
|
24
|
+
for obj in page.object_list:
|
|
25
|
+
if obj.key == prefix:
|
|
26
|
+
continue
|
|
27
|
+
kind = "directory" if obj.key.endswith("/") else "file"
|
|
28
|
+
entries[obj.key] = dict(
|
|
29
|
+
path=obj.key[len(root) :],
|
|
30
|
+
name=obj.key[len(prefix) :],
|
|
31
|
+
key=obj.key,
|
|
32
|
+
type=kind,
|
|
33
|
+
size=obj.size,
|
|
34
|
+
modified=obj.last_modified,
|
|
35
|
+
)
|
|
36
|
+
if recursive:
|
|
37
|
+
# OSS may have no marker object for a directory. Rebuild
|
|
38
|
+
# its ancestors so -R --dirs-only includes these too.
|
|
39
|
+
parts = obj.key[len(prefix) :].split("/")
|
|
40
|
+
for depth in range(1, len(parts)):
|
|
41
|
+
name = "/".join(parts[:depth]) + "/"
|
|
42
|
+
key = prefix + name
|
|
43
|
+
entries.setdefault(
|
|
44
|
+
key,
|
|
45
|
+
dict(
|
|
46
|
+
path=key[len(root) :],
|
|
47
|
+
name=name,
|
|
48
|
+
key=key,
|
|
49
|
+
type="directory",
|
|
50
|
+
size=None,
|
|
51
|
+
modified=None,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
for common in page.prefix_list:
|
|
55
|
+
entries[common] = dict(
|
|
56
|
+
path=common[len(root) :],
|
|
57
|
+
name=common[len(prefix) :],
|
|
58
|
+
key=common,
|
|
59
|
+
type="directory",
|
|
60
|
+
size=None,
|
|
61
|
+
modified=None,
|
|
62
|
+
)
|
|
63
|
+
if not page.is_truncated:
|
|
64
|
+
break
|
|
65
|
+
if not page.next_marker or page.next_marker == marker:
|
|
66
|
+
raise ValueError("OSS listing did not advance its pagination marker")
|
|
67
|
+
marker = page.next_marker
|
|
68
|
+
return list(entries.values()), found
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
rows, found = self.content._execute_oss(
|
|
72
|
+
_OperationIntent.NON_DOWNLOAD, prefix, None, listing
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if prefix == root or not _scope_denied(exc):
|
|
76
|
+
raise
|
|
77
|
+
# A file-only grant need not permit listing the same-name directory.
|
|
78
|
+
rows, found = [], False
|
|
79
|
+
if found or prefix == root:
|
|
80
|
+
return rows
|
|
81
|
+
key = prefix.rstrip("/")
|
|
82
|
+
|
|
83
|
+
def file_entry(bucket):
|
|
84
|
+
if not bucket.object_exists(key):
|
|
85
|
+
raise FileNotFoundError(f"No such file or directory: {path}")
|
|
86
|
+
meta = bucket.head_object(key)
|
|
87
|
+
return [
|
|
88
|
+
dict(
|
|
89
|
+
path=key[len(root) :],
|
|
90
|
+
name=key.rsplit("/", 1)[-1],
|
|
91
|
+
key=key,
|
|
92
|
+
type="file",
|
|
93
|
+
size=meta.content_length,
|
|
94
|
+
modified=meta.last_modified,
|
|
95
|
+
explicit=True,
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
if path.endswith("/"):
|
|
100
|
+
raise FileNotFoundError(f"No such directory: {path}")
|
|
101
|
+
return self.content._execute_oss(_OperationIntent.NON_DOWNLOAD, key, None, file_entry)
|
|
102
|
+
|
|
103
|
+
def mkdir(self, uid: str, path: str, *, parents: bool = False):
|
|
104
|
+
from instance_repo._routing import _OperationIntent
|
|
105
|
+
|
|
106
|
+
clean = self.repo._clean_user_data_subpath(path)
|
|
107
|
+
segments = clean.split("/")
|
|
108
|
+
paths = ["/".join(segments[:i]) for i in range(1, len(segments) + 1)]
|
|
109
|
+
|
|
110
|
+
def exists(key):
|
|
111
|
+
return self.content._execute_oss(
|
|
112
|
+
_OperationIntent.NON_DOWNLOAD, key, None, lambda bucket: bucket.object_exists(key)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def directory_exists(key):
|
|
116
|
+
return self.content._execute_oss(
|
|
117
|
+
_OperationIntent.NON_DOWNLOAD,
|
|
118
|
+
key,
|
|
119
|
+
None,
|
|
120
|
+
lambda bucket: bool(bucket.list_objects(prefix=key, max_keys=1).object_list),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Check all ancestors before creating any marker.
|
|
124
|
+
present = []
|
|
125
|
+
for part in paths:
|
|
126
|
+
key = self._key(uid, part)
|
|
127
|
+
if exists(key):
|
|
128
|
+
raise FileExistsError(f"A file already exists at {part}")
|
|
129
|
+
present.append(directory_exists(key + "/"))
|
|
130
|
+
if present[-1] and not parents:
|
|
131
|
+
raise FileExistsError(f"Directory already exists: {clean}; use --parents to keep it")
|
|
132
|
+
if not parents and not all(present[:-1]):
|
|
133
|
+
raise FileNotFoundError("Parent directory does not exist; use --parents/-p")
|
|
134
|
+
created = []
|
|
135
|
+
for part, found in zip(paths, present):
|
|
136
|
+
if found:
|
|
137
|
+
continue
|
|
138
|
+
key = self._key(uid, part) + "/"
|
|
139
|
+
|
|
140
|
+
def create(bucket, key=key):
|
|
141
|
+
if not bucket.object_exists(key):
|
|
142
|
+
bucket.put_object(key, b"", headers={"x-oss-forbid-overwrite": "true"})
|
|
143
|
+
|
|
144
|
+
self.content._execute_oss(_OperationIntent.NON_DOWNLOAD, key, None, create)
|
|
145
|
+
created.append(part)
|
|
146
|
+
return {"path": clean, "created": created}
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import tempfile
|
|
7
7
|
import threading
|
|
8
8
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
9
|
+
from dataclasses import replace
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
from rich.console import Console
|
|
@@ -24,6 +25,25 @@ from .ossutil_io import OssOperationError, _WriteBucket
|
|
|
24
25
|
PART_SIZE = 8 * 1024 * 1024
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
def _visual_task(task):
|
|
29
|
+
# Rich treats total=0 as 0%. Count processed files for zero-byte work,
|
|
30
|
+
# while retaining actual bytes for DownloadColumn and speed calculation.
|
|
31
|
+
if task.total == 0:
|
|
32
|
+
count = task.fields["files_total"]
|
|
33
|
+
return replace(task, total=count or 1, completed=task.fields["files_done"] if count else 1)
|
|
34
|
+
return task
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _TransferBar(BarColumn):
|
|
38
|
+
def render(self, task):
|
|
39
|
+
return super().render(_visual_task(task))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _TransferPercent(TaskProgressColumn):
|
|
43
|
+
def render(self, task):
|
|
44
|
+
return super().render(_visual_task(task))
|
|
45
|
+
|
|
46
|
+
|
|
27
47
|
def _scope_denied(exc: Exception) -> bool:
|
|
28
48
|
"""Only explicit scope refusals allow a more specific authorization request."""
|
|
29
49
|
seen = set()
|
|
@@ -43,12 +63,14 @@ class TransferProgress:
|
|
|
43
63
|
console = Console(stderr=True, markup=False, highlight=False)
|
|
44
64
|
self.display = Progress(
|
|
45
65
|
TextColumn("{task.description}"),
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
_TransferBar(),
|
|
67
|
+
_TransferPercent(),
|
|
48
68
|
DownloadColumn(),
|
|
49
69
|
TransferSpeedColumn(),
|
|
50
70
|
TimeRemainingColumn(),
|
|
51
|
-
TextColumn(
|
|
71
|
+
TextColumn(
|
|
72
|
+
"files {task.fields[files_done]}/{task.fields[files_total]} skipped {task.fields[skipped]}"
|
|
73
|
+
),
|
|
52
74
|
console=console,
|
|
53
75
|
disable=not (console.is_terminal if enabled is None else enabled),
|
|
54
76
|
)
|
|
@@ -57,6 +79,7 @@ class TransferProgress:
|
|
|
57
79
|
self.sizes: dict[str, int] = {}
|
|
58
80
|
self.done: dict[str, int] = {}
|
|
59
81
|
self.files_done = 0
|
|
82
|
+
self.files_skipped = 0
|
|
60
83
|
self.total_bytes = 0
|
|
61
84
|
self.completed_bytes = 0
|
|
62
85
|
|
|
@@ -69,6 +92,7 @@ class TransferProgress:
|
|
|
69
92
|
total=None if unknown else self.total_bytes,
|
|
70
93
|
files_done=0,
|
|
71
94
|
files_total=len(sizes),
|
|
95
|
+
skipped=0,
|
|
72
96
|
)
|
|
73
97
|
return self
|
|
74
98
|
|
|
@@ -100,6 +124,7 @@ class TransferProgress:
|
|
|
100
124
|
with self.lock:
|
|
101
125
|
self.known_total = True
|
|
102
126
|
if skipped:
|
|
127
|
+
self.files_skipped += 1
|
|
103
128
|
# Skipped bytes were not transferred; do not inflate the speed.
|
|
104
129
|
self.total_bytes -= self.sizes[key]
|
|
105
130
|
self.sizes[key] = 0
|
|
@@ -115,6 +140,7 @@ class TransferProgress:
|
|
|
115
140
|
completed=self.completed_bytes,
|
|
116
141
|
total=self.total_bytes if self.known_total else None,
|
|
117
142
|
files_done=self.files_done,
|
|
143
|
+
skipped=self.files_skipped,
|
|
118
144
|
)
|
|
119
145
|
|
|
120
146
|
|
|
@@ -135,17 +161,23 @@ class UserDataTransfer:
|
|
|
135
161
|
raise ValueError("uid must not be empty")
|
|
136
162
|
return self.repo._user_data_prefix(uid) + self.repo._clean_user_data_subpath(path)
|
|
137
163
|
|
|
138
|
-
def put(
|
|
164
|
+
def put(
|
|
165
|
+
self, uid: str, source: Path, remote: str, *, overwrite: bool = False, on_conflict=None
|
|
166
|
+
):
|
|
139
167
|
from instance_repo._routing import _OperationIntent
|
|
140
168
|
|
|
141
169
|
source = Path(source)
|
|
142
170
|
remote = self.repo._clean_user_data_subpath(remote)
|
|
143
171
|
base = self._key(uid, remote)
|
|
144
172
|
directory = source.is_dir()
|
|
173
|
+
if source.is_symlink():
|
|
174
|
+
raise ValueError("Symbolic link upload sources are not supported")
|
|
145
175
|
files = []
|
|
146
176
|
if directory:
|
|
147
177
|
for root, dirs, names in os.walk(source):
|
|
148
178
|
dirs.sort()
|
|
179
|
+
if any((Path(root) / name).is_symlink() for name in dirs + names):
|
|
180
|
+
raise ValueError("Symbolic links inside an upload directory are not supported")
|
|
149
181
|
for name in sorted(names):
|
|
150
182
|
local = Path(root) / name
|
|
151
183
|
key = self._key(
|
|
@@ -161,27 +193,33 @@ class UserDataTransfer:
|
|
|
161
193
|
part_threads = max(1, self.concurrency // workers)
|
|
162
194
|
report = TransferProgress("Uploading", self.progress).start(sizes)
|
|
163
195
|
directory_scope = base.rstrip("/") + "/" if directory else None
|
|
196
|
+
conflict_lock = threading.Lock()
|
|
164
197
|
|
|
165
198
|
def transfer(item):
|
|
166
199
|
local, key = item
|
|
167
200
|
write_started = False
|
|
201
|
+
allowed_overwrite = overwrite
|
|
168
202
|
# Keep the SDK's resume records across failures and later invocations.
|
|
169
203
|
store = self.oss.ResumableStore()
|
|
170
204
|
|
|
171
205
|
def upload(bucket):
|
|
172
|
-
nonlocal write_started
|
|
206
|
+
nonlocal write_started, allowed_overwrite
|
|
173
207
|
if not overwrite and not write_started and bucket.object_exists(key):
|
|
174
|
-
|
|
208
|
+
with conflict_lock:
|
|
209
|
+
# Serialize prompts even when multiple workers find collisions.
|
|
210
|
+
allowed_overwrite = bool(on_conflict(key)) if on_conflict else False
|
|
211
|
+
if not allowed_overwrite:
|
|
212
|
+
return {"key": key, "skipped": True}
|
|
175
213
|
write_started = True
|
|
176
214
|
self.oss.resumable_upload(
|
|
177
|
-
_WriteBucket(bucket,
|
|
215
|
+
_WriteBucket(bucket, allowed_overwrite),
|
|
178
216
|
key,
|
|
179
217
|
str(local),
|
|
180
218
|
store=store,
|
|
181
219
|
num_threads=part_threads,
|
|
182
220
|
part_size=PART_SIZE,
|
|
183
221
|
multipart_threshold=PART_SIZE,
|
|
184
|
-
headers={} if
|
|
222
|
+
headers={} if allowed_overwrite else {"x-oss-forbid-overwrite": "true"},
|
|
185
223
|
progress_callback=report.callback(key),
|
|
186
224
|
)
|
|
187
225
|
return {"key": key, "skipped": False}
|
|
@@ -251,12 +289,12 @@ class UserDataTransfer:
|
|
|
251
289
|
raise
|
|
252
290
|
return outcomes
|
|
253
291
|
|
|
254
|
-
def get(self, uid: str, remote: str, target: Path) -> str:
|
|
292
|
+
def get(self, uid: str, remote: str, target: Path, *, overwrite: bool = True) -> str:
|
|
255
293
|
from instance_repo._routing import _OperationIntent
|
|
256
294
|
|
|
257
295
|
key = self._key(uid, remote)
|
|
258
296
|
target = Path(target)
|
|
259
|
-
if target.exists() and not target.is_file():
|
|
297
|
+
if target.is_symlink() or (target.exists() and not target.is_file()):
|
|
260
298
|
raise ValueError("Download target must be a regular file")
|
|
261
299
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
262
300
|
report = TransferProgress("Downloading", self.progress).start({key: 0}, unknown=True)
|
|
@@ -282,6 +320,10 @@ class UserDataTransfer:
|
|
|
282
320
|
|
|
283
321
|
self.content._execute_oss(_OperationIntent.DOWNLOAD, key, None, download)
|
|
284
322
|
partial.chmod(0o600)
|
|
285
|
-
|
|
323
|
+
if overwrite:
|
|
324
|
+
os.replace(partial, target)
|
|
325
|
+
else:
|
|
326
|
+
# Atomically refuse a destination created since the CLI preflight.
|
|
327
|
+
os.link(partial, target)
|
|
286
328
|
report.finish(key)
|
|
287
329
|
return str(target)
|
|
@@ -709,7 +709,7 @@ def instance_push(
|
|
|
709
709
|
prune: bool = typer.Option(
|
|
710
710
|
False,
|
|
711
711
|
"--prune",
|
|
712
|
-
help="
|
|
712
|
+
help="Disabled: SDK safe pruning is unavailable; delete obsolete metadata explicitly",
|
|
713
713
|
),
|
|
714
714
|
yes: bool = typer.Option(
|
|
715
715
|
False,
|
|
@@ -1018,7 +1018,7 @@ def instance_pull(
|
|
|
1018
1018
|
if private_endpoint_reachable is False:
|
|
1019
1019
|
raise _fail(
|
|
1020
1020
|
f"Failed to pull instance {instance}: the private OSS endpoint is unreachable from this network",
|
|
1021
|
-
"instance pull does not support public download",
|
|
1021
|
+
"instance pull does not support public download; run inside an AP dev cluster / ROCK VPC",
|
|
1022
1022
|
)
|
|
1023
1023
|
|
|
1024
1024
|
# SDK 的 swe/custom 下载会先往输出目录写 <id>.json 与 .probe 中间文件,再由
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
6
|
import json
|
|
7
|
-
from importlib import metadata
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from typing import Any
|
|
10
9
|
|
|
@@ -75,14 +74,10 @@ def upload_hashes(records: list[dict], supplied: dict[str, str], skip: bool) ->
|
|
|
75
74
|
|
|
76
75
|
|
|
77
76
|
def require_working_prune() -> None:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if version in {"1.3.5.dev1", "1.3.5.dev2"}:
|
|
83
|
-
raise ValueError(
|
|
84
|
-
f"instance-repo {version} cannot complete --prune; upgrade to a fixed SDK before uploading"
|
|
85
|
-
)
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"--prune is disabled before uploading: instance-repo does not support safe pruning. "
|
|
79
|
+
"Upload without --prune; review and delete obsolete instance metadata explicitly."
|
|
80
|
+
)
|
|
86
81
|
|
|
87
82
|
|
|
88
83
|
def progress_callback(completed: int, total: int, result: Any) -> None:
|
|
@@ -946,7 +946,7 @@ def load_repo(
|
|
|
946
946
|
if request_timeout is not None:
|
|
947
947
|
transport.timeout = request_timeout
|
|
948
948
|
kwargs["transport"] = transport
|
|
949
|
-
|
|
949
|
+
if request_timeout is not None and "transport" in supported and "transport" not in kwargs:
|
|
950
950
|
# Inject a pre-configured Transport so the custom timeout is effective
|
|
951
951
|
# from the very first HTTP call inside Repo.__init__ / _bind_dataset_profile.
|
|
952
952
|
try:
|
|
@@ -1161,7 +1161,7 @@ _BIZ_CODE_RULES: dict[int, tuple[str, int]] = {
|
|
|
1161
1161
|
# 字符串 ``code`` → (hint, 退出码)。SDK 的 ``InstanceRepoError.code``。
|
|
1162
1162
|
_CODE_RULES: dict[str, tuple[str, int]] = {
|
|
1163
1163
|
"E_PRIVATE_NETWORK_REQUIRED": (
|
|
1164
|
-
"This operation requires private-network access; run inside an
|
|
1164
|
+
"This operation requires private-network access; run inside an AP dev cluster / ROCK VPC",
|
|
1165
1165
|
1,
|
|
1166
1166
|
),
|
|
1167
1167
|
"E_DIGEST_MISMATCH": ("Content digest check failed; pull or repack the instance again", 1),
|
|
@@ -1195,7 +1195,9 @@ _CODE_RULES: dict[str, tuple[str, int]] = {
|
|
|
1195
1195
|
}
|
|
1196
1196
|
|
|
1197
1197
|
#: 无法归类时的兜底文案。
|
|
1198
|
-
_FALLBACK_HINT =
|
|
1198
|
+
_FALLBACK_HINT = (
|
|
1199
|
+
"Rerun with -v/--verbose for the full traceback; contact the Agent Platform on-call if needed"
|
|
1200
|
+
)
|
|
1199
1201
|
|
|
1200
1202
|
# 数据面寻址字段:``/apis/v1/repo-config`` 在当前 apiserver 里不存在(设计 §8.3),SDK 的
|
|
1201
1203
|
# 自动发现是 best-effort、构造期不抛错,真正用到某字段时才由 ``bootstrap.require`` 抛
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import stat
|
|
10
11
|
import tempfile
|
|
11
12
|
import time
|
|
12
13
|
import unicodedata
|
|
@@ -300,9 +301,9 @@ class OssFiles:
|
|
|
300
301
|
|
|
301
302
|
凭据请求携带 ``dataset_name``:普通调用方在控制面按它走 dataset RBAC(旧 prod
|
|
302
303
|
构建也要求显式上下文)。ans:// 路径是 dataset 相对的,dataset 名就是最长前缀
|
|
303
|
-
(
|
|
304
|
+
(任意深度的最长已注册前缀优先);由于 ``A``(L1)与 ``A/B`` 可能同时是合法布局,
|
|
304
305
|
这里以**注册表为准**:候选按"最长优先"查注册,第一个命中者胜出;都未注册时退回
|
|
305
|
-
|
|
306
|
+
原有最多两段上下文(系统身份的 raw 路径与服务端的 list-only 发现都会校验
|
|
306
307
|
claimed 命名空间,写操作仍由服务端拒绝)。结果按相对路径缓存,一次命令内多次
|
|
307
308
|
签发只查一次。
|
|
308
309
|
"""
|
|
@@ -322,18 +323,16 @@ class OssFiles:
|
|
|
322
323
|
segments = [segment for segment in relative.split("/") if segment]
|
|
323
324
|
if not segments:
|
|
324
325
|
return None
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
326
|
+
# Validate each segment with the SDK. Older SDKs only parse one or two
|
|
327
|
+
# levels at a time; that limit must not truncate registry lookup paths.
|
|
328
328
|
valid: list[str] = []
|
|
329
|
-
for
|
|
329
|
+
for index, segment in enumerate(segments):
|
|
330
330
|
try:
|
|
331
|
-
parse_dataset_name(
|
|
331
|
+
parse_dataset_name(segment)
|
|
332
332
|
except DatasetNameError:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
valid.append(candidate)
|
|
333
|
+
break
|
|
334
|
+
valid.append("/".join(segments[: index + 1]))
|
|
335
|
+
valid.reverse()
|
|
337
336
|
if not valid:
|
|
338
337
|
return None
|
|
339
338
|
for candidate in valid:
|
|
@@ -344,8 +343,9 @@ class OssFiles:
|
|
|
344
343
|
raise
|
|
345
344
|
continue
|
|
346
345
|
return candidate
|
|
347
|
-
#
|
|
348
|
-
|
|
346
|
+
# 未注册路径的 list-only 发现与写操作仍由服务端授权。
|
|
347
|
+
# Preserve the legacy raw-OSS discovery namespace when none is registered.
|
|
348
|
+
return valid[-2] if len(valid) >= 2 else valid[0]
|
|
349
349
|
|
|
350
350
|
def _credentials(self, scope: str, permission: str, list_only: bool) -> dict:
|
|
351
351
|
kwargs: dict[str, Any] = {}
|
|
@@ -559,7 +559,10 @@ class OssFiles:
|
|
|
559
559
|
def cat(self, remote: RemotePath, output: Any) -> None:
|
|
560
560
|
result = self.bucket(remote.key, "reader", private_only=True).get_object(remote.key)
|
|
561
561
|
try:
|
|
562
|
-
|
|
562
|
+
from .ossutil_hash import _stored_content_stream
|
|
563
|
+
|
|
564
|
+
stream = _stored_content_stream(result)
|
|
565
|
+
while data := stream.read(1024 * 1024):
|
|
563
566
|
output.write(data)
|
|
564
567
|
finally:
|
|
565
568
|
result.resp.response.close()
|
|
@@ -654,11 +657,25 @@ class OssFiles:
|
|
|
654
657
|
if target.exists() and not overwrite:
|
|
655
658
|
raise OssOperationError(f"Local target already exists: {target}; use --force")
|
|
656
659
|
suffix = hashlib.sha256((remote.bucket + remote.key).encode()).hexdigest()[:16]
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
+
# oss2 creates random .tmp-* siblings, so protect their directory
|
|
661
|
+
# throughout the download, including interruptions and retries.
|
|
662
|
+
staging = target.with_name(f".ap-{target.name}-{suffix}.download")
|
|
663
|
+
staging.mkdir(mode=0o700, exist_ok=True)
|
|
664
|
+
directory_fd = os.open(
|
|
665
|
+
staging, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
|
|
666
|
+
)
|
|
667
|
+
try:
|
|
668
|
+
if os.fstat(directory_fd).st_uid != os.getuid():
|
|
669
|
+
raise OssOperationError("Download staging directory must belong to this user")
|
|
670
|
+
os.fchmod(directory_fd, 0o700)
|
|
671
|
+
finally:
|
|
672
|
+
os.close(directory_fd)
|
|
673
|
+
partial = staging / "content.part"
|
|
674
|
+
fd = os.open(partial, os.O_CREAT | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
675
|
+
try:
|
|
676
|
+
os.fchmod(fd, 0o600)
|
|
677
|
+
finally:
|
|
660
678
|
os.close(fd)
|
|
661
|
-
path.chmod(0o600)
|
|
662
679
|
if self.byte_range is not None:
|
|
663
680
|
result = bucket.get_object(
|
|
664
681
|
remote.key,
|
|
@@ -682,13 +699,20 @@ class OssFiles:
|
|
|
682
699
|
part_size=self.part_size,
|
|
683
700
|
progress_callback=self._progress(remote.path, progress),
|
|
684
701
|
)
|
|
702
|
+
fd = os.open(partial, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
703
|
+
try:
|
|
704
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
705
|
+
raise OssOperationError("Download staging content must be a regular file")
|
|
706
|
+
os.fchmod(fd, 0o600)
|
|
707
|
+
finally:
|
|
708
|
+
os.close(fd)
|
|
685
709
|
self._check_local_path(target)
|
|
686
710
|
if overwrite:
|
|
687
711
|
os.replace(partial, target)
|
|
688
712
|
else:
|
|
689
713
|
os.link(partial, target)
|
|
690
714
|
partial.unlink()
|
|
691
|
-
|
|
715
|
+
staging.rmdir()
|
|
692
716
|
|
|
693
717
|
@staticmethod
|
|
694
718
|
def _check_local_path(path: Path) -> None:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ap-client"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
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"
|
|
@@ -14,15 +14,15 @@ dependencies = [
|
|
|
14
14
|
"rich>=13.0.0",
|
|
15
15
|
"PyYAML>=6.0",
|
|
16
16
|
"websockets>=13.0",
|
|
17
|
-
"irepo-artifact==0.2.
|
|
17
|
+
"irepo-artifact==0.2.4",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
21
|
dataset = [
|
|
22
|
-
"instance-repo[oss]>=1.4.
|
|
22
|
+
"instance-repo[oss]>=1.4.0,<2.0.0",
|
|
23
23
|
]
|
|
24
24
|
all = [
|
|
25
|
-
"instance-repo[oss]>=1.4.
|
|
25
|
+
"instance-repo[oss]>=1.4.0,<2.0.0",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[project.scripts]
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
"""DatasetName 解析 Adapter:唯一入口委托给已安装的 InstanceRepo SDK。
|
|
2
|
-
|
|
3
|
-
AP CLI 不维护第三套 dataset 名解析:``instance_repo.paths.DatasetName`` 是客户端
|
|
4
|
-
路径契约的唯一事实源(单段 ``A`` / 两段 ``A/B`` / 多段树形 ``A/B/C``,与
|
|
5
|
-
apiserver 的 namespace guard 及 conformance 契约对齐)。SDK 缺失或过旧(没有
|
|
6
|
-
``DatasetName.parse`` 能力)时,给出明确的升级提示,不静默降级、不本地重写解析规则。
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
__all__ = [
|
|
14
|
-
"DatasetNameError",
|
|
15
|
-
"DatasetNameUpgradeRequired",
|
|
16
|
-
"parse_dataset_name",
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
#: SDK 缺失/无 DatasetName 能力时的升级提示(与 irepo_sdk 的 SdkMissing 同范式)。
|
|
20
|
-
_UPGRADE_HINT = (
|
|
21
|
-
"The installed instance-repo SDK does not provide DatasetName parsing "
|
|
22
|
-
"(single-segment L1 dataset names need it).\n"
|
|
23
|
-
"Install it with: pip install 'ap-client[dataset]' --upgrade"
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class DatasetNameUpgradeRequired(RuntimeError):
|
|
28
|
-
"""已安装的 instance-repo SDK 缺少 DatasetName 能力,必须升级。"""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class DatasetNameError(ValueError):
|
|
32
|
-
"""Dataset 名不符合 IR DatasetName 契约(单段或两段、无非法段)。"""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def parse_dataset_name(value: str) -> Any:
|
|
36
|
-
"""Parse ``value`` through the installed IR ``DatasetName`` capability.
|
|
37
|
-
|
|
38
|
-
Returns the SDK ``DatasetName`` (``canonical`` / ``l1`` / ``l2`` /
|
|
39
|
-
``segments`` / ``leaf``)。Raises :class:`DatasetNameUpgradeRequired` when the
|
|
40
|
-
installed SDK lacks the capability; raises :class:`DatasetNameError` when
|
|
41
|
-
the value is not a valid L1 or L1/L2 name.
|
|
42
|
-
"""
|
|
43
|
-
try:
|
|
44
|
-
from instance_repo.paths import DatasetName
|
|
45
|
-
except ImportError as exc:
|
|
46
|
-
raise DatasetNameUpgradeRequired(_UPGRADE_HINT) from exc
|
|
47
|
-
parse = getattr(DatasetName, "parse", None)
|
|
48
|
-
if not callable(parse):
|
|
49
|
-
raise DatasetNameUpgradeRequired(_UPGRADE_HINT)
|
|
50
|
-
try:
|
|
51
|
-
return parse(value)
|
|
52
|
-
except Exception as exc: # noqa: BLE001 - SDK 校验失败统一转成 CLI 契约
|
|
53
|
-
raise DatasetNameError(str(exc)) from exc
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|