ap-client 0.6.0.dev2__tar.gz → 0.7.0.dev0__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 (21) hide show
  1. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/PKG-INFO +4 -4
  2. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/api.py +71 -4
  3. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/cli.py +132 -218
  4. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/exporter.py +33 -2
  5. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/managed_ak.py +44 -14
  6. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/profile_commands.py +109 -1
  7. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/pyproject.toml +4 -4
  8. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/__init__.py +0 -0
  9. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/akpool_commands.py +0 -0
  10. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/config.py +0 -0
  11. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/dataset_commands.py +0 -0
  12. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/dataset_register.py +0 -0
  13. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/fs_commands.py +0 -0
  14. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/instance_commands.py +0 -0
  15. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/irepo_sdk.py +0 -0
  16. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/manifest_view.py +0 -0
  17. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/repo_read_guard.py +0 -0
  18. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/share_api.py +0 -0
  19. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/split_publish.py +0 -0
  20. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/tbb.py +0 -0
  21. {ap_client-0.6.0.dev2 → ap_client-0.7.0.dev0}/ap_client/waiter.py +0 -0
@@ -1,18 +1,18 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.6.0.dev2
3
+ Version: 0.7.0.dev0
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: irepo-artifact==0.2.1
6
+ Requires-Dist: irepo-artifact==0.2.2
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.3.2; extra == 'all'
13
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.3.5; extra == 'all'
14
14
  Provides-Extra: dataset
15
- Requires-Dist: instance-repo[oss]<2.0.0,>=1.3.2; extra == 'dataset'
15
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.3.5; 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.
@@ -1127,6 +1127,8 @@ class APIClient:
1127
1127
  ak_ids: Optional[list[str]] = None,
1128
1128
  priority: Optional[str] = None,
1129
1129
  priority_weight: Optional[int] = None,
1130
+ transparency: Optional[Literal["whitebox", "blackbox"]] = None,
1131
+ idempotency_key: Optional[str] = None,
1130
1132
  ) -> dict:
1131
1133
  """Create a group with optional scheduling defaults for its Jobs.
1132
1134
 
@@ -1134,6 +1136,10 @@ class APIClient:
1134
1136
  """
1135
1137
  if priority is not None and priority not in {"high", "medium"}:
1136
1138
  raise ValueError("priority must be one of high/medium")
1139
+ if transparency is not None and transparency not in {"whitebox", "blackbox"}:
1140
+ raise ValueError("transparency must be whitebox or blackbox")
1141
+ if transparency == "blackbox" and not idempotency_key:
1142
+ raise ValueError("blackbox Group creation requires a reusable idempotency_key (UUID4)")
1137
1143
  if ak_ids is not None and not isinstance(ak_ids, list):
1138
1144
  raise ValueError("ak_ids must be a list")
1139
1145
  references = list(ak_ids or [])
@@ -1142,6 +1148,10 @@ class APIClient:
1142
1148
  raise ValueError("ak_ids entries must be nonempty strings")
1143
1149
  references = list(dict.fromkeys(references))
1144
1150
  body: dict = {}
1151
+ if transparency is not None:
1152
+ body["transparency"] = transparency
1153
+ if idempotency_key is not None:
1154
+ body["idempotency_key"] = idempotency_key
1145
1155
  if references:
1146
1156
  body["ak_ids"] = references
1147
1157
  if name:
@@ -1263,9 +1273,11 @@ class APIClient:
1263
1273
  ak_mode: str = "auto",
1264
1274
  instance_range: Optional[str] = None,
1265
1275
  profile: Optional[str] = None,
1276
+ transparency: Optional[Literal["whitebox", "blackbox"]] = None,
1266
1277
  ) -> dict:
1267
1278
  """Create a job."""
1268
1279
  body = self.build_create_job_body(
1280
+ transparency=transparency,
1269
1281
  template=template,
1270
1282
  params=params,
1271
1283
  params_list=params_list,
@@ -1377,8 +1389,11 @@ class APIClient:
1377
1389
  ak_mode: str = "auto",
1378
1390
  instance_range: Optional[str] = None,
1379
1391
  profile: Optional[str] = None,
1392
+ transparency: Optional[Literal["whitebox", "blackbox"]] = None,
1380
1393
  ) -> dict:
1381
1394
  """Build the /jobs request body for job creation."""
1395
+ if transparency is not None and transparency not in {"whitebox", "blackbox"}:
1396
+ raise ValueError("transparency must be whitebox or blackbox")
1382
1397
  if profile is not None and (profile_id is not None or profile_version is not None):
1383
1398
  raise ValueError("profile cannot be combined with profile_id or profile_version")
1384
1399
  _validate_credential_source_selection(
@@ -1503,8 +1518,23 @@ class APIClient:
1503
1518
  if experiment is not None:
1504
1519
  body["experiment"] = experiment
1505
1520
 
1521
+ if transparency is not None:
1522
+ body["transparency"] = transparency
1506
1523
  return body
1507
1524
 
1525
+ def get_job_access_contact(self, job_id: str) -> dict:
1526
+ """Discover the owner and grant contact without reading protected details."""
1527
+ result = self._get(f"/jobs/{quote(job_id, safe='')}", params={"access_contact": True})
1528
+ if (
1529
+ not isinstance(result, dict)
1530
+ or result.get("resource_type") != "job"
1531
+ or "authorization_contact" not in result
1532
+ ):
1533
+ raise ConfigurationError(
1534
+ "Server does not support access contact queries; upgrade Agent-Platform first"
1535
+ )
1536
+ return result
1537
+
1508
1538
  def get_job(
1509
1539
  self,
1510
1540
  job_id: str,
@@ -1630,6 +1660,19 @@ class APIClient:
1630
1660
 
1631
1661
  # ==================== Group operations ====================
1632
1662
 
1663
+ def get_group_access_contact(self, group_id: str) -> dict:
1664
+ """Discover the owner and grant contact without reading protected details."""
1665
+ result = self._get(f"/groups/{quote(group_id, safe='')}", params={"access_contact": True})
1666
+ if (
1667
+ not isinstance(result, dict)
1668
+ or result.get("resource_type") != "group"
1669
+ or "authorization_contact" not in result
1670
+ ):
1671
+ raise ConfigurationError(
1672
+ "Server does not support access contact queries; upgrade Agent-Platform first"
1673
+ )
1674
+ return result
1675
+
1633
1676
  def get_group(self, group_id: str, timeout: TimeoutType = None) -> dict:
1634
1677
  """Get group details."""
1635
1678
  return self._get(f"/groups/{quote(group_id)}", timeout=timeout)
@@ -3046,6 +3089,23 @@ class APIClient:
3046
3089
  return self._get(f"{self._PROFILES_BASE}/{quote(id_or_name)}/versions/{version}")
3047
3090
  return self._get(f"{self._PROFILES_BASE}/{quote(id_or_name)}")
3048
3091
 
3092
+ def update_profile_job_meta(self, profile_id: str, patch: dict) -> dict:
3093
+ """Patch live Job defaults without changing the Profile version.
3094
+
3095
+ Omitted fields stay unchanged. Set benchmark to None to clear it.
3096
+ Tags use {"append": [...], "remove": [...]} and are applied atomically.
3097
+ """
3098
+ return self._request_central(
3099
+ "PATCH", f"/profiles/{quote(profile_id, safe='')}/job-meta", json_body=patch
3100
+ )
3101
+
3102
+ def replace_profile_job_meta(self, profile_id: str, job_meta: dict) -> dict:
3103
+ """Replace all live Job defaults; {} clears benchmark and tags.
3104
+
3105
+ This does not change spec, Profile tags, version, verification or release.
3106
+ """
3107
+ return self._put_central(f"/profiles/{quote(profile_id, safe='')}/job-meta", job_meta)
3108
+
3049
3109
  def update_profile(
3050
3110
  self,
3051
3111
  profile_id: str,
@@ -3054,8 +3114,13 @@ class APIClient:
3054
3114
  description: str = "",
3055
3115
  account_pool: Optional[str] = None,
3056
3116
  metadata: Optional[dict] = None,
3117
+ reason: Optional[str] = None,
3057
3118
  ) -> dict:
3058
- """Update a submission profile, preserving an omitted default Pool."""
3119
+ """Update a submission profile, preserving an omitted default Pool.
3120
+
3121
+ reason describes this update; omission never reuses a previous reason.
3122
+ The server validates whether it is required and its 500-character limit.
3123
+ """
3059
3124
  body: dict = {"name": name, "spec": spec}
3060
3125
  if description:
3061
3126
  body["description"] = description
@@ -3063,6 +3128,8 @@ class APIClient:
3063
3128
  body["account_pool"] = account_pool
3064
3129
  if metadata is not None:
3065
3130
  body["metadata"] = metadata
3131
+ if reason is not None:
3132
+ body["reason"] = reason
3066
3133
  return self._put(f"{self._PROFILES_BASE}/{quote(profile_id)}", body)
3067
3134
 
3068
3135
  def delete_profile(self, profile_id: str) -> dict:
@@ -3085,8 +3152,8 @@ class APIClient:
3085
3152
  ) -> dict:
3086
3153
  """把某个 job/group 实例的访问权授予某 user/user_group(scope=resource 绑定)。
3087
3154
 
3088
- workspace_id = 该实例所属 queue_id;apiserver 按该 WorkSpace 的 workspace-admin/
3089
- 创建者/superuser 门控。鉴权走当前 API Key 解析出的调用者身份。
3155
+ workspace_id = 该实例所属 queue_id;Job/Group Owner、Group Admin 或所属
3156
+ WorkSpace 的管理员/创建者及平台管理员可授权。鉴权走当前 API Key 解析出的调用者身份。
3090
3157
  """
3091
3158
  body = {
3092
3159
  "scope": "resource",
@@ -3352,7 +3419,7 @@ def _response_error_detail(resp: requests.Response) -> Any:
3352
3419
  except Exception:
3353
3420
  return resp.text
3354
3421
  if isinstance(err_data, dict):
3355
- return err_data.get("detail", resp.text)
3422
+ return err_data.get("detail", err_data.get("message", resp.text))
3356
3423
  return err_data
3357
3424
 
3358
3425
 
@@ -17,9 +17,7 @@ from ap_client.akpool_commands import register as _register_akpool_commands
17
17
  from ap_client.api import (
18
18
  APIError,
19
19
  PaginationProtocolError,
20
- _is_sensitive_key,
21
20
  _merge_meta_tags,
22
- _redact_sensitive_data,
23
21
  set_cluster_override,
24
22
  set_verbose_override,
25
23
  )
@@ -207,146 +205,8 @@ def _job_create_progress():
207
205
  )
208
206
 
209
207
 
210
- def _shell_command(args: list[str]) -> str:
211
- return " ".join(shlex.quote(str(arg)) for arg in args)
212
-
213
-
214
- def _retry_argument_contains_secret(raw: str) -> bool:
215
- """Inspect display arguments without changing the submission payload."""
216
- try:
217
- value = json.loads(raw)
218
- except (json.JSONDecodeError, TypeError):
219
- value = raw
220
-
221
- def contains_secret(item: Any) -> bool:
222
- if isinstance(item, dict):
223
- return any(
224
- _is_sensitive_key(str(key)) or contains_secret(field) for key, field in item.items()
225
- )
226
- if isinstance(item, list):
227
- return any(contains_secret(field) for field in item)
228
- return _redact_sensitive_data(item) != item
229
-
230
- return contains_secret(value)
231
-
232
-
233
- def _build_job_create_retry_command(
234
- *,
235
- template: Optional[str],
236
- agenthub_ref: Optional[str],
237
- instance_id: Optional[str],
238
- dataset: Optional[str],
239
- instance_range: Optional[str],
240
- exclude_instance_ids: Optional[str],
241
- params: Optional[str],
242
- params_list_input: Optional[str],
243
- suite_name: Optional[str],
244
- group_id: Optional[str],
245
- tags: Optional[str],
246
- overrides: Optional[str],
247
- concurrency: Optional[int],
248
- batch_size: int,
249
- trials: Optional[int] = None,
250
- max_failure_retries: int = 0,
251
- wait: bool,
252
- wait_interval: float,
253
- wait_timeout: Optional[float],
254
- output_format: str,
255
- enable_otel_tracing: Optional[bool],
256
- runner: Optional[str],
257
- runner_options: Optional[str],
258
- model_base_url_collection: Optional[str],
259
- model_base_url_file: Optional[str],
260
- datasources: Optional[str],
261
- account_pool: Optional[str] = None,
262
- idempotency_key: str,
263
- enable_post_process: bool = False,
264
- group_post_process: Optional[str] = None,
265
- profile: Optional[str] = None,
266
- resource_profile_id: Optional[str] = None,
267
- timeout: Optional[float] = None,
268
- ak_id: Optional[str] = None,
269
- ak_mode: str = "auto",
270
- ) -> str:
271
- args = ["ap", "job", "create"]
272
- if template is not None:
273
- args.append(template)
274
- if ak_id is not None:
275
- args.extend(["--ak-id", ak_id])
276
- if ak_mode != "auto":
277
- args.extend(["--ak-mode", ak_mode])
278
- if profile is not None:
279
- args.extend(["--profile", profile])
280
- if resource_profile_id:
281
- args.extend(["--resource-profile", resource_profile_id])
282
- if agenthub_ref:
283
- args.extend(["--agenthub-ref", agenthub_ref])
284
- if instance_id:
285
- args.extend(["-i", instance_id])
286
- if dataset:
287
- args.extend(["--dataset", dataset])
288
- if instance_range:
289
- args.extend(["--range", instance_range])
290
- if exclude_instance_ids:
291
- args.extend(["--exclude-instance-ids", exclude_instance_ids])
292
- if params:
293
- args.extend(["-p", params])
294
- if params_list_input:
295
- args.extend(["--params-list", params_list_input])
296
- if suite_name:
297
- args.extend(["--suite-name", suite_name])
298
- if group_id:
299
- args.extend(["--group-id", group_id])
300
- if tags:
301
- args.extend(["--tags", tags])
302
- if overrides:
303
- args.extend(["--overrides", overrides])
304
- if concurrency is not None:
305
- args.extend(["--concurrency", str(concurrency)])
306
- if batch_size != 200:
307
- args.extend(["--batch-size", str(batch_size)])
308
- if trials is not None:
309
- args.extend(["--trials", str(trials)])
310
- if max_failure_retries:
311
- args.extend(["--max-failure-retries", str(max_failure_retries)])
312
- if wait:
313
- args.append("--wait")
314
- if wait_interval != 5.0:
315
- args.extend(["--wait-interval", f"{wait_interval:g}"])
316
- if wait_timeout is not None:
317
- args.extend(["--wait-timeout", f"{wait_timeout:g}"])
318
- if timeout is not None:
319
- args.extend(["--timeout", f"{timeout:g}"])
320
- if output_format != "plain":
321
- args.extend(["--format", output_format])
322
- if enable_otel_tracing is not None:
323
- args.extend(["--enable-otel-tracing", str(enable_otel_tracing).lower()])
324
- if runner:
325
- args.extend(["--runner", runner])
326
- if runner_options:
327
- args.extend(["--runner-options", runner_options])
328
- if model_base_url_collection:
329
- args.extend(["--model-base-url-collection", model_base_url_collection])
330
- if model_base_url_file:
331
- args.extend(["--model-base-url-file", model_base_url_file])
332
- if datasources:
333
- args.extend(["--datasources", datasources])
334
- if account_pool:
335
- args.extend(["--account-pool", account_pool])
336
- if group_post_process:
337
- args.extend(["--group-post-process", group_post_process])
338
- elif enable_post_process:
339
- args.append("--enable-post-process")
340
- if any(_retry_argument_contains_secret(str(arg)) for arg in args):
341
- # A redacted command would silently retry with different credentials.
342
- # Keep the original invocation private and print only the retry key.
343
- return (
344
- "Rerun your original command with "
345
- f"--idempotency-key {shlex.quote(idempotency_key)}. "
346
- "The command is omitted because it contains inline credentials."
347
- )
348
- args.extend(["--idempotency-key", idempotency_key])
349
- return _shell_command(args)
208
+ def _build_job_create_retry_command(*, idempotency_key: str) -> str:
209
+ return f"Rerun your original command with --idempotency-key {shlex.quote(idempotency_key)}"
350
210
 
351
211
 
352
212
  def _emit_idempotency_retry_hint(command: str) -> None:
@@ -1059,8 +919,26 @@ def _print_params_plain(params: Any, masked_params: list) -> None:
1059
919
  _print_key_values(rows, skip_empty=False)
1060
920
 
1061
921
 
922
+ def _print_content_notice(rows: list[dict]) -> None:
923
+ if any(
924
+ row.get("transparency") == "blackbox" and row.get("can_read_content") is False
925
+ for row in rows
926
+ ):
927
+ typer.echo("Blackbox content is hidden; Group Reader access is required.")
928
+
929
+
930
+ def _content_columns(rows: list[dict], columns: list[str]) -> list[str]:
931
+ restricted = {field for row in rows for field in (row.get("restricted_fields") or [])}
932
+ return [
933
+ field
934
+ for field in columns
935
+ if field not in restricted or any(row.get(field) is not None for row in rows)
936
+ ]
937
+
938
+
1062
939
  def _print_job_get_plain(job: dict) -> None:
1063
940
  typer.echo("Job")
941
+ _print_content_notice([job])
1064
942
  _print_key_values(
1065
943
  [
1066
944
  ("job_id", job.get("job_id")),
@@ -1333,6 +1211,8 @@ _DEFAULT_JOB_LIST_COLUMNS = [
1333
1211
 
1334
1212
  _JOB_LIST_AVAILABLE_COLUMNS = [
1335
1213
  *_DEFAULT_JOB_LIST_COLUMNS,
1214
+ "transparency",
1215
+ "can_read_content",
1336
1216
  "job_name",
1337
1217
  "group_id",
1338
1218
  "quota_id",
@@ -1494,6 +1374,8 @@ def _print_job_list_plain(
1494
1374
  )
1495
1375
  else:
1496
1376
  selected_columns = default_columns
1377
+ _print_content_notice(rows)
1378
+ selected_columns = _content_columns(rows, selected_columns)
1497
1379
  render = _print_rich_table if rich else _print_plain_table
1498
1380
  render(
1499
1381
  rows,
@@ -1549,6 +1431,8 @@ _GROUP_LIST_TABLE_MIN_COLUMNS = 3
1549
1431
 
1550
1432
  _GROUP_LIST_AVAILABLE_COLUMNS = [
1551
1433
  *_DEFAULT_GROUP_LIST_COLUMNS,
1434
+ "transparency",
1435
+ "can_read_content",
1552
1436
  "template",
1553
1437
  "scheduling_status",
1554
1438
  "profile",
@@ -1637,7 +1521,8 @@ def _prepare_group_list_rows(groups: list[dict]) -> list[dict]:
1637
1521
  "upstream_platform",
1638
1522
  "upstream_job_id",
1639
1523
  ):
1640
- row[key] = row.get(key) or "-"
1524
+ if key not in row.get("restricted_fields", []):
1525
+ row[key] = row.get(key) or "-"
1641
1526
  rows.append(row)
1642
1527
  return rows
1643
1528
 
@@ -1654,6 +1539,8 @@ def _print_group_list_plain(
1654
1539
  )
1655
1540
  else:
1656
1541
  selected_columns = _DEFAULT_GROUP_LIST_COLUMNS
1542
+ _print_content_notice(rows)
1543
+ selected_columns = _content_columns(rows, selected_columns)
1657
1544
  render = _print_rich_table if rich else _print_plain_table
1658
1545
  render(
1659
1546
  rows,
@@ -1688,6 +1575,7 @@ def _pick_available_columns(
1688
1575
 
1689
1576
 
1690
1577
  def _print_group_eval_plain(result: dict) -> None:
1578
+ _print_content_notice([result])
1691
1579
  header_parts: list[str] = []
1692
1580
  for key in ("group_id", "name"):
1693
1581
  if key in result:
@@ -1761,6 +1649,7 @@ def _print_group_eval_plain(result: dict) -> None:
1761
1649
  rows,
1762
1650
  [
1763
1651
  ("task_id", "task_id"),
1652
+ ("job_ids", "job_ids"),
1764
1653
  ("trials", "trials"),
1765
1654
  ("scored", "scored"),
1766
1655
  ("avg_score", "avg_score"),
@@ -3016,6 +2905,11 @@ def job_list(
3016
2905
 
3017
2906
  @job_app.command("create")
3018
2907
  def job_create(
2908
+ transparency: Optional[str] = typer.Option(
2909
+ None,
2910
+ "--transparency",
2911
+ help="whitebox or blackbox; omit to inherit the Group/Profile default",
2912
+ ),
3019
2913
  template: Optional[str] = typer.Argument(None, help="Template name; optional with --profile"),
3020
2914
  ak_id: Optional[str] = typer.Option(
3021
2915
  None, "--ak-id", help="AP-managed AK ID; requires server support"
@@ -3362,6 +3256,8 @@ def job_create(
3362
3256
  idempotent retries: the server rejects a same idempotency_key with a
3363
3257
  different requested group_id (409 idempotency_conflict).
3364
3258
  """
3259
+ if transparency is not None and transparency not in {"whitebox", "blackbox"}:
3260
+ raise typer.BadParameter("Choose whitebox or blackbox", param_hint="--transparency")
3365
3261
  output_format = _normalize_output_format(output_format)
3366
3262
  if not template and not profile:
3367
3263
  raise typer.BadParameter("TEMPLATE is required unless --profile is provided")
@@ -3384,6 +3280,7 @@ def job_create(
3384
3280
  )
3385
3281
  idempotency_enabled = (
3386
3282
  enable_idempotency
3283
+ or transparency == "blackbox"
3387
3284
  or idempotency_key is not None
3388
3285
  or profile is not None
3389
3286
  or implicit_resource_profile_idempotency
@@ -3535,6 +3432,7 @@ def job_create(
3535
3432
 
3536
3433
  # Build request body via create_job (reuse existing method with profile fields)
3537
3434
  body = client.build_create_job_body(
3435
+ **({"transparency": transparency} if transparency is not None else {}),
3538
3436
  template=template,
3539
3437
  params=params_dict or None,
3540
3438
  suite_name=suite_name,
@@ -3603,43 +3501,7 @@ def job_create(
3603
3501
 
3604
3502
  # Build retry command before submitting so it's available on failure
3605
3503
  profile_retry_command = (
3606
- _build_job_create_retry_command(
3607
- template=template,
3608
- agenthub_ref=agenthub_ref,
3609
- instance_id=instance_id,
3610
- dataset=dataset,
3611
- instance_range=instance_range,
3612
- exclude_instance_ids=None,
3613
- params=params,
3614
- params_list_input=None,
3615
- suite_name=suite_name,
3616
- group_id=group_id,
3617
- tags=tags,
3618
- overrides=overrides,
3619
- concurrency=concurrency,
3620
- batch_size=batch_size,
3621
- trials=trials,
3622
- max_failure_retries=max_failure_retries,
3623
- wait=wait,
3624
- wait_interval=wait_interval,
3625
- wait_timeout=wait_timeout,
3626
- output_format=output_format,
3627
- enable_otel_tracing=enable_otel_tracing,
3628
- runner=runner,
3629
- runner_options=runner_options,
3630
- model_base_url_collection=model_base_url_collection,
3631
- model_base_url_file=model_base_url_file,
3632
- datasources=datasources,
3633
- account_pool=account_pool,
3634
- enable_post_process=enable_post_process,
3635
- group_post_process=group_post_process,
3636
- idempotency_key=submission_idempotency_key,
3637
- profile=profile,
3638
- resource_profile_id=resource_profile_id,
3639
- **({"ak_id": ak_id} if ak_id is not None else {}),
3640
- **({"ak_mode": ak_mode} if ak_mode != "auto" else {}),
3641
- timeout=timeout,
3642
- )
3504
+ _build_job_create_retry_command(idempotency_key=submission_idempotency_key)
3643
3505
  if idempotency_enabled and submission_idempotency_key is not None
3644
3506
  else None
3645
3507
  )
@@ -3655,6 +3517,7 @@ def job_create(
3655
3517
  try:
3656
3518
  with console.status("[bold green]Submitting by profile..."):
3657
3519
  result = client.create_job(
3520
+ **({"transparency": transparency} if transparency is not None else {}),
3658
3521
  template=template,
3659
3522
  params=params_dict or None,
3660
3523
  suite_name=suite_name,
@@ -3866,41 +3729,7 @@ def job_create(
3866
3729
  model_base_url_collection_list = None
3867
3730
 
3868
3731
  retry_command = (
3869
- _build_job_create_retry_command(
3870
- template=template,
3871
- agenthub_ref=agenthub_ref,
3872
- instance_id=instance_id,
3873
- dataset=dataset,
3874
- instance_range=instance_range,
3875
- exclude_instance_ids=exclude_instance_ids,
3876
- params=params,
3877
- params_list_input=params_list_input,
3878
- suite_name=suite_name,
3879
- group_id=group_id,
3880
- tags=tags,
3881
- overrides=overrides,
3882
- concurrency=concurrency,
3883
- batch_size=batch_size,
3884
- max_failure_retries=max_failure_retries,
3885
- wait=wait,
3886
- wait_interval=wait_interval,
3887
- wait_timeout=wait_timeout,
3888
- output_format=output_format,
3889
- enable_otel_tracing=enable_otel_tracing,
3890
- runner=runner,
3891
- runner_options=runner_options,
3892
- model_base_url_collection=model_base_url_collection,
3893
- model_base_url_file=model_base_url_file,
3894
- datasources=datasources,
3895
- account_pool=account_pool,
3896
- enable_post_process=enable_post_process,
3897
- group_post_process=group_post_process,
3898
- idempotency_key=submission_idempotency_key,
3899
- resource_profile_id=resource_profile_id,
3900
- **({"ak_id": ak_id} if ak_id is not None else {}),
3901
- **({"ak_mode": ak_mode} if ak_mode != "auto" else {}),
3902
- timeout=timeout,
3903
- )
3732
+ _build_job_create_retry_command(idempotency_key=submission_idempotency_key)
3904
3733
  if not dry_run
3905
3734
  and not job_spec_flag
3906
3735
  and idempotency_enabled
@@ -4003,6 +3832,7 @@ def job_create(
4003
3832
 
4004
3833
  if job_spec_flag:
4005
3834
  payload = client.build_create_job_body(
3835
+ **({"transparency": transparency} if transparency is not None else {}),
4006
3836
  template=template,
4007
3837
  params_list=[dict(p) for p in params_list],
4008
3838
  dataset=dataset,
@@ -4046,6 +3876,7 @@ def job_create(
4046
3876
  batch = build_batch(start)
4047
3877
  payloads.append(
4048
3878
  client.build_create_job_body(
3879
+ **({"transparency": transparency} if transparency is not None else {}),
4049
3880
  template=template,
4050
3881
  params_list=batch,
4051
3882
  dataset=dataset,
@@ -4105,6 +3936,7 @@ def job_create(
4105
3936
  current_stage=f"batch {batch_index + 1}: {start + 1}-{batch_end}/{total_count}",
4106
3937
  )
4107
3938
  batch_result = client.create_job(
3939
+ **({"transparency": transparency} if transparency is not None else {}),
4108
3940
  template=template,
4109
3941
  params_list=batch,
4110
3942
  dataset=dataset,
@@ -4214,6 +4046,7 @@ def job_create(
4214
4046
  else:
4215
4047
  if job_spec_flag:
4216
4048
  payload = client.build_create_job_body(
4049
+ **({"transparency": transparency} if transparency is not None else {}),
4217
4050
  template=template,
4218
4051
  params=params_dict,
4219
4052
  dataset=dataset,
@@ -4251,6 +4084,7 @@ def job_create(
4251
4084
 
4252
4085
  if dry_run:
4253
4086
  payload = client.build_create_job_body(
4087
+ **({"transparency": transparency} if transparency is not None else {}),
4254
4088
  template=template,
4255
4089
  params=params_dict,
4256
4090
  dataset=dataset,
@@ -4300,6 +4134,7 @@ def job_create(
4300
4134
  current_stage="submitting",
4301
4135
  )
4302
4136
  result = client.create_job(
4137
+ **({"transparency": transparency} if transparency is not None else {}),
4303
4138
  template=template,
4304
4139
  params=params_dict,
4305
4140
  dataset=dataset,
@@ -4477,6 +4312,11 @@ def job_get(
4477
4312
  "--field",
4478
4313
  help="Print one field path, e.g. status, lifecycle.run_duration, params.model",
4479
4314
  ),
4315
+ owner: bool = typer.Option(
4316
+ False,
4317
+ "--owner",
4318
+ help="Show only the owner and authorization contact, without reading protected details",
4319
+ ),
4480
4320
  output_format: str = typer.Option(
4481
4321
  None,
4482
4322
  "--format",
@@ -4493,7 +4333,20 @@ def job_get(
4493
4333
  include_spec = include_spec or _field_path_requires_spec(field)
4494
4334
  if field == "job_spec_data.spec_yaml":
4495
4335
  spec_format = "yaml"
4336
+ if owner and share_token is not None:
4337
+ raise typer.BadParameter("--owner cannot be combined with --share-token")
4496
4338
  client = _read_client(share_token)
4339
+ if owner and (include_params or include_spec or field):
4340
+ raise typer.BadParameter(
4341
+ "--owner cannot be combined with --include-params, --include-spec or --field"
4342
+ )
4343
+ if owner:
4344
+ result = client.get_job_access_contact(job_id)
4345
+ if output_format in {"plain", "table"}:
4346
+ _print_detail(result)
4347
+ else:
4348
+ _print_formatted(result, output_format)
4349
+ return
4497
4350
  result = client.get_job(
4498
4351
  job_id,
4499
4352
  include_params=include_params,
@@ -4501,6 +4354,18 @@ def job_get(
4501
4354
  spec_format=spec_format,
4502
4355
  )
4503
4356
  if field:
4357
+ safe_job_name = (
4358
+ field == "job_name"
4359
+ and isinstance(result.get("job_id"), str)
4360
+ and bool(result["job_id"])
4361
+ and result.get("job_name") == result["job_id"]
4362
+ )
4363
+ if not safe_job_name and any(
4364
+ field == path or field.startswith(path + ".") or field.startswith(path + "[")
4365
+ for path in (result.get("restricted_fields") or [])
4366
+ ):
4367
+ _emit_error(f"field hidden: {field}; Group Reader access is required")
4368
+ raise typer.Exit(1)
4504
4369
  try:
4505
4370
  value = _extract_field(result, field)
4506
4371
  except ValueError as exc:
@@ -4729,15 +4594,21 @@ _RESOURCE_ROLE_IDS = {
4729
4594
  ("job", "reader"): "built-in:job-reader",
4730
4595
  ("job", "writer"): "built-in:job-writer",
4731
4596
  ("group", "reader"): "built-in:group-reader",
4597
+ ("group", "blackbox-reader"): "built-in:blackbox-reader",
4732
4598
  ("group", "writer"): "built-in:group-writer",
4733
4599
  ("group", "cloner"): "built-in:group-cloner",
4600
+ ("group", "admin"): "built-in:group-admin",
4734
4601
  }
4735
4602
 
4736
4603
 
4737
4604
  def _resource_role_id(resource: str, role: str) -> str:
4738
4605
  key = (resource, (role or "").strip().lower())
4739
4606
  if key not in _RESOURCE_ROLE_IDS:
4740
- choices = "reader, writer, cloner" if resource == "group" else "reader, writer"
4607
+ choices = (
4608
+ "reader, writer, cloner, blackbox-reader, admin"
4609
+ if resource == "group"
4610
+ else "reader, writer"
4611
+ )
4741
4612
  raise typer.BadParameter(f"role must be one of: {choices}")
4742
4613
  return _RESOURCE_ROLE_IDS[key]
4743
4614
 
@@ -5439,6 +5310,14 @@ def checkpoint_get(
5439
5310
 
5440
5311
  @group_app.command("create")
5441
5312
  def group_create(
5313
+ transparency: Optional[str] = typer.Option(
5314
+ None,
5315
+ "--transparency",
5316
+ help="whitebox or blackbox; omit to inherit the Group/Profile default",
5317
+ ),
5318
+ idempotency_key: Optional[str] = typer.Option(
5319
+ None, "--idempotency-key", help="Reuse this key when retrying blackbox Group creation"
5320
+ ),
5442
5321
  ak_id: Optional[list[str]] = typer.Option(
5443
5322
  None, "--ak-id", help="Managed AK reference (repeatable) to associate with the Group"
5444
5323
  ),
@@ -5591,6 +5470,8 @@ def group_create(
5591
5470
  ),
5592
5471
  ):
5593
5472
  """Create a Group."""
5473
+ if transparency is not None and transparency not in {"whitebox", "blackbox"}:
5474
+ raise typer.BadParameter("Choose whitebox or blackbox", param_hint="--transparency")
5594
5475
  output_format = _normalize_output_format(output_format)
5595
5476
  if priority is not None and priority not in {"high", "medium"}:
5596
5477
  raise typer.BadParameter("--priority must be one of high/medium")
@@ -5599,6 +5480,8 @@ def group_create(
5599
5480
  validate_ak_id(reference)
5600
5481
  except ValueError as exc:
5601
5482
  raise typer.BadParameter(str(exc), param_hint="--ak-id") from None
5483
+ if transparency == "blackbox" and idempotency_key is None:
5484
+ idempotency_key = str(uuid.uuid4())
5602
5485
  client = get_client()
5603
5486
  eval_config_dict = json.loads(eval_config) if eval_config else None
5604
5487
  group_post_process_dict = _parse_json_object(group_post_process, "--group-post-process")
@@ -5656,6 +5539,8 @@ def group_create(
5656
5539
 
5657
5540
  try:
5658
5541
  result = client.create_group(
5542
+ **({"transparency": transparency} if transparency is not None else {}),
5543
+ **({"idempotency_key": idempotency_key} if idempotency_key is not None else {}),
5659
5544
  ak_ids=ak_id,
5660
5545
  name=name,
5661
5546
  tags=group_tags,
@@ -5678,7 +5563,17 @@ def group_create(
5678
5563
  **({"priority": priority} if priority is not None else {}),
5679
5564
  **({"priority_weight": priority_weight} if priority_weight is not None else {}),
5680
5565
  )
5566
+ except APIError:
5567
+ if idempotency_key is not None:
5568
+ _emit_idempotency_retry_hint(
5569
+ f"Rerun your original command with --idempotency-key {shlex.quote(idempotency_key)}"
5570
+ )
5571
+ raise
5681
5572
  except ManagedAKProtocolError as exc:
5573
+ if idempotency_key is not None:
5574
+ _emit_idempotency_retry_hint(
5575
+ _build_job_create_retry_command(idempotency_key=idempotency_key)
5576
+ )
5682
5577
  _emit_error(str(exc))
5683
5578
  raise typer.Exit(1) from None
5684
5579
  if output_format == "plain":
@@ -5815,6 +5710,11 @@ def group_get(
5815
5710
  share_token: Optional[str] = typer.Option(
5816
5711
  None, "--share-token", help="Read with a share token"
5817
5712
  ),
5713
+ owner: bool = typer.Option(
5714
+ False,
5715
+ "--owner",
5716
+ help="Show only the owner and authorization contact, without reading protected details",
5717
+ ),
5818
5718
  output_format: str = typer.Option(
5819
5719
  None,
5820
5720
  "--format",
@@ -5823,7 +5723,16 @@ def group_get(
5823
5723
  ):
5824
5724
  """Show Group details."""
5825
5725
  output_format = _normalize_output_format(output_format, keep_table=True)
5726
+ if owner and share_token is not None:
5727
+ raise typer.BadParameter("--owner cannot be combined with --share-token")
5826
5728
  client = _read_client(share_token, group_id if share_token else None)
5729
+ if owner:
5730
+ result = client.get_group_access_contact(group_id)
5731
+ if output_format in {"plain", "table"}:
5732
+ _print_detail(result)
5733
+ else:
5734
+ _print_formatted(result, output_format)
5735
+ return
5827
5736
  result = client.get_group(group_id)
5828
5737
  if output_format == "table":
5829
5738
  _print_detail(result)
@@ -6029,6 +5938,7 @@ def group_clone(
6029
5938
  ("source_group_id", preview.get("source_group_id")),
6030
5939
  ("target_cluster", preview.get("target_cluster")),
6031
5940
  ("target_name", (preview.get("target_group") or {}).get("name")),
5941
+ ("transparency", (preview.get("target_group") or {}).get("transparency")),
6032
5942
  ("target_queue", (preview.get("target_group") or {}).get("queue_id")),
6033
5943
  ("jobs", preview.get("source_job_count")),
6034
5944
  ("target_jobs", preview.get("target_job_count")),
@@ -6553,7 +6463,7 @@ def group_grant(
6553
6463
  role: str = typer.Option(
6554
6464
  "reader",
6555
6465
  "--role",
6556
- help="reader (view), cloner (view+clone), or writer (view+cancel/update)",
6466
+ help="reader (view), blackbox-reader (protected Group content), cloner (view+clone), writer (view+cancel/update), or admin (view+clone+update+grant)",
6557
6467
  ),
6558
6468
  ):
6559
6469
  """Grant a user/user-group access to a specific group (RBAC v2 instance delegation)."""
@@ -6567,7 +6477,9 @@ def group_revoke(
6567
6477
  user_group: Optional[str] = typer.Option(
6568
6478
  None, "--user-group", help="Revoke from this user_group_id"
6569
6479
  ),
6570
- role: str = typer.Option("reader", "--role", help="reader, cloner, or writer"),
6480
+ role: str = typer.Option(
6481
+ "reader", "--role", help="reader, blackbox-reader, cloner, writer, or admin"
6482
+ ),
6571
6483
  ):
6572
6484
  """Revoke a user/user-group's instance access to a specific group."""
6573
6485
  _revoke_instance_access("group", group_id, role, user, user_group)
@@ -6747,6 +6659,8 @@ def main() -> None:
6747
6659
 
6748
6660
 
6749
6661
  def _format_api_error(exc: APIError) -> str:
6662
+ if isinstance(exc.detail, dict) and exc.detail.get("code") == "blackbox_content_restricted":
6663
+ return f"error: Blackbox content is hidden; Group Reader access is required. (request_id={exc.request_id})"
6750
6664
  if str(exc.detail) != _NON_JSON_RESPONSE_DETAIL:
6751
6665
  return f"error: {exc}"
6752
6666
 
@@ -23,7 +23,7 @@ import requests
23
23
  if TYPE_CHECKING:
24
24
  from irepo_artifact import ArtifactClient, DownloadResult
25
25
 
26
- from .api import APIClient
26
+ from .api import APIClient, APIError
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
@@ -429,8 +429,9 @@ def _export_job_into_dir(
429
429
  extract_artifacts=extract_artifacts,
430
430
  skip_no_artifact=skip_no_artifact,
431
431
  )
432
- from irepo_artifact import ArtifactError, ArtifactNotFound
432
+ from irepo_artifact import AccessDenied, ArtifactError, ArtifactNotFound
433
433
 
434
+ job_data = job or {}
434
435
  current_stage = "job metadata"
435
436
  selective = bool(include_files or exclude_files)
436
437
 
@@ -480,6 +481,11 @@ def _export_job_into_dir(
480
481
  except ArtifactError as exc:
481
482
  outcome = error = exc
482
483
 
484
+ # A denied refresh can retain old metadata. Never publish or delete
485
+ # existing export files on an authorization failure.
486
+ if isinstance(error, AccessDenied):
487
+ raise error
488
+
483
489
  artifact = outcome.artifacts_response
484
490
  manifest = outcome.manifest_response
485
491
  missing_manifest = (
@@ -580,6 +586,31 @@ def _export_job_into_dir(
580
586
  except Exception as exc:
581
587
  if result_callback is not None:
582
588
  result_callback(job_id, "failed", None)
589
+
590
+ if isinstance(exc, AccessDenied):
591
+ if (
592
+ job_data.get("transparency") == "blackbox"
593
+ and job_data.get("can_read_content") is False
594
+ and getattr(exc, "_request_status", None) == 403
595
+ ):
596
+ raise APIError(
597
+ 403,
598
+ {
599
+ "code": "blackbox_content_restricted",
600
+ "message": "Group Reader access is required",
601
+ },
602
+ None,
603
+ None,
604
+ ) from exc
605
+ raise RuntimeError(
606
+ f"Access denied while exporting job {job_id}; check your login/API key and resource permissions."
607
+ ) from exc
608
+ if (
609
+ isinstance(exc, APIError)
610
+ and isinstance(exc.detail, dict)
611
+ and exc.detail.get("code") == "blackbox_content_restricted"
612
+ ):
613
+ raise
583
614
  raise RuntimeError(
584
615
  f"Failed to export job {job_id} at stage '{current_stage}': {exc}"
585
616
  ) from exc
@@ -21,7 +21,6 @@ def validate_ak_selection(ak_id: Any, params: Any, params_list: Any, overrides:
21
21
  if isinstance(params_list, list):
22
22
  candidates.extend(params_list)
23
23
  for value in candidates:
24
- parameter_ak_ids(value)
25
24
  if (
26
25
  isinstance(value, dict)
27
26
  and {"ak_id", "ak_selection_source", "user_ak_id", "key_id"} & value.keys()
@@ -31,13 +30,8 @@ def validate_ak_selection(ak_id: Any, params: Any, params_list: Any, overrides:
31
30
  )
32
31
 
33
32
 
34
- def parameter_ak_ids(value: Any) -> set[str]:
35
- """Find explicit parameter references without resolving credential plaintext."""
36
- return parameter_ak_usage(value)[0]
37
-
38
-
39
33
  def parameter_ak_usage(value: Any) -> tuple[set[str], set[tuple[str, str]]]:
40
- """Return explicit IDs and every requested (selection source, tag) pair."""
34
+ """Collect references for receipt checks; the server validates their syntax."""
41
35
  if isinstance(value, dict):
42
36
  usages = [parameter_ak_usage(v) for v in value.values()]
43
37
  return set().union(*(ids for ids, _ in usages)), set().union(
@@ -49,6 +43,8 @@ def parameter_ak_usage(value: Any) -> tuple[set[str], set[tuple[str, str]]]:
49
43
  *(selected for _, selected in usages)
50
44
  )
51
45
  if isinstance(value, str) and "$akpool" in value:
46
+ # Normalize only for receipt recognition; the request keeps the original value.
47
+ value = re.sub(r"^\$akpool:[ \t]+", "$akpool:", value)
52
48
  match = re.fullmatch(r"\$akpool:([A-Za-z0-9][A-Za-z0-9_.-]{0,127})", value)
53
49
  if match:
54
50
  return {match[1]}, set()
@@ -64,8 +60,14 @@ def parameter_ak_usage(value: Any) -> tuple[set[str], set[tuple[str, str]]]:
64
60
  c in ",@#.$" or c.isspace() or unicodedata.category(c) == "Cc" for c in tag
65
61
  )
66
62
  ):
67
- return set(), {("personal_default" if match[2] == "me" else "queue_default", tag)}
68
- raise ValueError("Use a complete $akpool:<ak-id> parameter value or selector")
63
+ return set(), {
64
+ (
65
+ "personal_default" if match[2] == "me" else "queue_default",
66
+ "" if match[2] == "me" and tag == "default" else tag,
67
+ )
68
+ }
69
+ # Keep the capability check for future syntax without echoing arbitrary values.
70
+ return {"$akpool"}, set()
69
71
  return set(), set()
70
72
 
71
73
 
@@ -90,6 +92,20 @@ def _version_one(value: Any) -> bool:
90
92
  return type(value) in (int, float) and value == 1
91
93
 
92
94
 
95
+ def _personal_default_alias(selectors: set[tuple[str, str]]) -> set[tuple[str, str]]:
96
+ """Accept the pre-alias spelling of the personal default on reused Jobs.
97
+
98
+ ``$akpool:#default@me`` is now the untagged personal default, but Jobs frozen by an
99
+ older version stored ``selection_tag="default"``. An idempotent resubmission returns
100
+ that frozen binding verbatim, so the alias must acknowledge the legacy tag as well.
101
+ """
102
+ expanded = set(selectors)
103
+ for source, tag in selectors:
104
+ if source == "personal_default" and tag in {"", "default"}:
105
+ expanded.add((source, "default" if tag == "" else ""))
106
+ return expanded
107
+
108
+
93
109
  def require_parameter_ak_ack(
94
110
  response: Any, ak_ids: set[str], selectors: set[tuple[str, str]] | None = None
95
111
  ) -> None:
@@ -102,6 +118,7 @@ def require_parameter_ak_ack(
102
118
  if selectors:
103
119
  if not _version_one(response.get("ak_selection_version")):
104
120
  raise ManagedAKProtocolError("$akpool selector", response)
121
+ reused_bindings: set[tuple[str, str]] = set()
105
122
  if response.get("dry_run") is True:
106
123
  preview = response.get("preview_job")
107
124
  if not response_ak_ids(preview):
@@ -113,18 +130,31 @@ def require_parameter_ak_ack(
113
130
  jobs = response.get("jobs")
114
131
  if not isinstance(jobs, list):
115
132
  raise ManagedAKProtocolError("$akpool selector", response)
133
+ job_list = [job for job in jobs if isinstance(job, dict)] if jobs else []
134
+ aggregate = _confirmed_selectors(response.get("ak_bindings"))
116
135
  confirmed = (
136
+ set().union(*(_confirmed_selectors(job.get("ak_bindings")) for job in job_list))
137
+ if job_list
138
+ else aggregate
139
+ )
140
+ # Only a reused Job may answer with the legacy tag: a fresh submission must
141
+ # bind the alias as the untagged personal default. Large Profile receipts omit
142
+ # per-Job details and sample the persisted bindings at the top level, so those
143
+ # aggregated bindings are frozen evidence too: a fresh alias binding is always
144
+ # untagged, which leaves "default" only on Jobs frozen before the alias existed.
145
+ reused_bindings = (
117
146
  set().union(
118
147
  *(
119
148
  _confirmed_selectors(job.get("ak_bindings"))
120
- for job in jobs
121
- if isinstance(job, dict)
149
+ for job in job_list
150
+ if job.get("reused") is True
122
151
  )
123
152
  )
124
- if jobs
125
- else _confirmed_selectors(response.get("ak_bindings"))
153
+ if job_list
154
+ else aggregate
126
155
  )
127
- if not selectors <= confirmed:
156
+ accepted = confirmed | _personal_default_alias(reused_bindings)
157
+ if not selectors <= accepted:
128
158
  raise ManagedAKProtocolError("$akpool selector", response)
129
159
 
130
160
 
@@ -15,6 +15,7 @@ from ap_client.api import APIError # noqa: F401
15
15
  from ap_client.config import normalize_output_format
16
16
  from rich import print
17
17
  from rich.table import Table
18
+ from rich.text import Text
18
19
 
19
20
 
20
21
  def _import_yaml():
@@ -153,6 +154,23 @@ def _fmt_dt(value: Optional[str]) -> str:
153
154
  return dt.strftime("%Y-%m-%d %H:%M:%S")
154
155
 
155
156
 
157
+ def _print_job_meta_plain(job_meta: dict) -> None:
158
+ benchmark = job_meta.get("benchmark") or "(inherit from spec)"
159
+ tags = ", ".join(job_meta.get("tags") or []) or "(none)"
160
+ typer.echo(f" Job benchmark default: {benchmark}")
161
+ typer.echo(f" Job tag defaults: {tags}")
162
+
163
+
164
+ def _print_job_meta_result(result: dict, output_format: str) -> None:
165
+ if output_format != "plain":
166
+ _print_formatted(result, output_format)
167
+ return
168
+ typer.echo("Profile Job defaults saved. Profile version is unchanged.")
169
+ payload = result.get("data", result)
170
+ if isinstance(payload, dict) and isinstance(payload.get("job_meta"), dict):
171
+ _print_job_meta_plain(payload["job_meta"])
172
+
173
+
156
174
  def _print_profile_plain(profile: dict) -> None:
157
175
  """Pretty-print a single profile in plain format."""
158
176
  pid = profile.get("profile_id") or profile.get("id", "-")
@@ -185,6 +203,10 @@ def _print_profile_plain(profile: dict) -> None:
185
203
  )
186
204
  print(f" [bold]Created:[/] {_fmt_dt(profile.get('created_at'))}")
187
205
  print(f" [bold]Updated:[/] {_fmt_dt(profile.get('updated_at'))}")
206
+ if profile.get("update_reason"):
207
+ typer.echo(f" Update reason: {profile['update_reason']}")
208
+ if isinstance(profile.get("job_meta"), dict):
209
+ _print_job_meta_plain(profile["job_meta"])
188
210
 
189
211
  spec = profile.get("spec")
190
212
  if not isinstance(spec, dict):
@@ -277,6 +299,7 @@ def _print_versions_plain(data: dict, profile_id: str) -> None:
277
299
  table.add_column("VERIFIED")
278
300
  table.add_column("RELEASED")
279
301
  table.add_column("GROUP")
302
+ table.add_column("REASON")
280
303
 
281
304
  for v in items:
282
305
  ver = v.get("verification") or {}
@@ -288,6 +311,7 @@ def _print_versions_plain(data: dict, profile_id: str) -> None:
288
311
  "true" if ver else "-",
289
312
  "true" if v.get("release") else "-",
290
313
  ver.get("group_id") or "-",
314
+ Text(v.get("update_reason") or "-"),
291
315
  )
292
316
  print(table)
293
317
 
@@ -383,6 +407,80 @@ def profile_set_tags(
383
407
  _print_formatted(result, output_format)
384
408
 
385
409
 
410
+ @profile_app.command("patch-job-meta")
411
+ def profile_patch_job_meta(
412
+ identifier: str = typer.Argument(..., help="Profile ID or name"),
413
+ benchmark: Optional[str] = typer.Option(
414
+ None, "--benchmark", help="Set the Job benchmark default; an empty value clears it"
415
+ ),
416
+ clear_benchmark: bool = typer.Option(
417
+ False, "--clear-benchmark", help="Clear the live benchmark default and inherit from spec"
418
+ ),
419
+ append_tags: Optional[list[str]] = typer.Option(
420
+ None, "--append-tag", help="Append a Job default tag (repeatable)"
421
+ ),
422
+ remove_tags: Optional[list[str]] = typer.Option(
423
+ None, "--remove-tag", help="Remove an exact Job default tag (repeatable)"
424
+ ),
425
+ output_format: str = typer.Option(
426
+ None, "--format", help="Output format: plain/table/json/yaml (default: AP_FORMAT or plain)"
427
+ ),
428
+ ):
429
+ """Patch Job defaults without changing the Profile version.
430
+
431
+ Omitted fields stay unchanged. Remove and append tags in one request to
432
+ replace a keyed tag. Profile tags and spec are not changed.
433
+ """
434
+ output_format = _normalize_output_format(output_format)
435
+ if benchmark is not None and clear_benchmark:
436
+ raise typer.BadParameter("--benchmark and --clear-benchmark are mutually exclusive")
437
+ patch: dict = {}
438
+ if clear_benchmark or benchmark is not None:
439
+ patch["benchmark"] = None if clear_benchmark else benchmark
440
+ delta: dict = {}
441
+ if append_tags:
442
+ delta["append"] = append_tags
443
+ if remove_tags:
444
+ delta["remove"] = remove_tags
445
+ if delta:
446
+ patch["tags"] = delta
447
+ if not patch:
448
+ raise typer.BadParameter(
449
+ "provide --benchmark, --clear-benchmark, --append-tag or --remove-tag"
450
+ )
451
+ result = get_client().update_profile_job_meta(identifier, patch)
452
+ _print_job_meta_result(result, output_format)
453
+
454
+
455
+ @profile_app.command("update-job-meta")
456
+ def profile_update_job_meta(
457
+ identifier: str = typer.Argument(..., help="Profile ID or name"),
458
+ job_meta_json: str = typer.Option(
459
+ ..., "--json", help="Complete job_meta JSON object; '{}' clears all live defaults"
460
+ ),
461
+ output_format: str = typer.Option(
462
+ None, "--format", help="Output format: plain/table/json/yaml (default: AP_FORMAT or plain)"
463
+ ),
464
+ ):
465
+ """Replace all Job defaults without changing the Profile version.
466
+
467
+ Supply the full job_meta object using --json. Omitted fields are cleared;
468
+ '{}' clears all live defaults. Use patch-job-meta to change selected fields.
469
+ Profile tags and spec are not changed, and --reason is not required.
470
+ """
471
+ output_format = _normalize_output_format(output_format)
472
+ try:
473
+ job_meta = json.loads(job_meta_json)
474
+ # Reject non-JSON constants such as NaN before constructing a request.
475
+ json.dumps(job_meta, allow_nan=False)
476
+ except ValueError as exc:
477
+ raise typer.BadParameter(f"Invalid JSON: {exc}", param_hint="--json") from exc
478
+ if not isinstance(job_meta, dict):
479
+ raise typer.BadParameter("must be a JSON object", param_hint="--json")
480
+ result = get_client().replace_profile_job_meta(identifier, job_meta)
481
+ _print_job_meta_result(result, output_format)
482
+
483
+
386
484
  @profile_app.command("list")
387
485
  def profile_list(
388
486
  keyword: Optional[str] = typer.Option(None, "--keyword", "-k", help="Filter by name keyword"),
@@ -468,6 +566,9 @@ def profile_update(
468
566
  None, "--spec", "-s", help="New inline spec as JSON string"
469
567
  ),
470
568
  description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
569
+ reason: str = typer.Option(
570
+ ..., "--reason", help="Required reason for this update (1-500 characters after trimming)"
571
+ ),
471
572
  account_pool: Optional[str] = typer.Option(
472
573
  None,
473
574
  "--account-pool",
@@ -488,9 +589,15 @@ def profile_update(
488
589
 
489
590
  Renaming a profile is intentionally not supported via the CLI — the
490
591
  profile name is treated as a stable handle. Provide either --file or
491
- --spec to replace the spec; --description is optional.
592
+ --spec to replace the spec. --reason is required and must contain 1-500
593
+ characters after trimming. A previous version's reason is never reused.
492
594
  """
493
595
  output_format = _normalize_output_format(output_format)
596
+ reason = reason.strip()
597
+ if not reason:
598
+ raise typer.BadParameter("must not be empty or whitespace", param_hint="--reason")
599
+ if len(reason) > 500:
600
+ raise typer.BadParameter("must be at most 500 characters", param_hint="--reason")
494
601
  spec = _load_spec(file, spec_inline)
495
602
  metadata = _load_metadata(metadata_inline)
496
603
 
@@ -511,6 +618,7 @@ def profile_update(
511
618
  description=final_description,
512
619
  account_pool=final_account_pool,
513
620
  metadata=final_metadata,
621
+ reason=reason,
514
622
  )
515
623
  if output_format == "plain":
516
624
  print("[green]Profile updated:[/]")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ap-client"
7
- version = "0.6.0.dev2"
7
+ version = "0.7.0.dev0"
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.1",
17
+ "irepo-artifact==0.2.2",
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
21
  dataset = [
22
- "instance-repo[oss]>=1.3.2,<2.0.0",
22
+ "instance-repo[oss]>=1.3.5,<2.0.0",
23
23
  ]
24
24
  all = [
25
- "instance-repo[oss]>=1.3.2,<2.0.0",
25
+ "instance-repo[oss]>=1.3.5,<2.0.0",
26
26
  ]
27
27
 
28
28
  [project.scripts]