ap-client 0.7.0.dev11__tar.gz → 0.7.1.dev1__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 (34) hide show
  1. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/PKG-INFO +1 -1
  2. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/api.py +111 -1
  3. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/cli.py +44 -35
  4. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/dataset_commands.py +33 -12
  5. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/dataset_register.py +1 -2
  6. ap_client-0.7.1.dev1/ap_client/profile_cascade_all.py +149 -0
  7. ap_client-0.7.1.dev1/ap_client/profile_cascade_cli.py +356 -0
  8. ap_client-0.7.1.dev1/ap_client/profile_cascade_display.py +279 -0
  9. ap_client-0.7.1.dev1/ap_client/profile_cascade_state.py +207 -0
  10. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/profile_commands.py +491 -48
  11. ap_client-0.7.1.dev1/ap_client/profile_diagnostics.py +117 -0
  12. ap_client-0.7.1.dev1/ap_client/profile_display.py +172 -0
  13. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/pyproject.toml +1 -1
  14. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/__init__.py +0 -0
  15. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/akpool_commands.py +0 -0
  16. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/config.py +0 -0
  17. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/dataset_name.py +0 -0
  18. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/dfs_commands.py +0 -0
  19. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/exporter.py +0 -0
  20. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/fs_commands.py +0 -0
  21. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/fs_storage.py +0 -0
  22. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/fs_transfer.py +0 -0
  23. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/instance_commands.py +0 -0
  24. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/instance_options.py +0 -0
  25. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/irepo_sdk.py +0 -0
  26. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/managed_ak.py +0 -0
  27. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/manifest_view.py +0 -0
  28. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/ossutil_hash.py +0 -0
  29. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/ossutil_io.py +0 -0
  30. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/repo_read_guard.py +0 -0
  31. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/share_api.py +0 -0
  32. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/split_publish.py +0 -0
  33. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/tbb.py +0 -0
  34. {ap_client-0.7.0.dev11 → ap_client-0.7.1.dev1}/ap_client/waiter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.7.0.dev11
3
+ Version: 0.7.1.dev1
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: irepo-artifact==0.2.4
@@ -2963,6 +2963,8 @@ class APIClient:
2963
2963
  account_pool: Optional[str] = None,
2964
2964
  metadata: Optional[dict] = None,
2965
2965
  tags: Optional[list[str]] = None,
2966
+ parents: Optional[list[str | dict]] = None,
2967
+ auto_follow_parents: Optional[list[str]] = None,
2966
2968
  ) -> dict:
2967
2969
  """Create a new submission profile."""
2968
2970
  body: dict = {"name": name, "spec": spec}
@@ -2974,6 +2976,10 @@ class APIClient:
2974
2976
  body["metadata"] = metadata
2975
2977
  if tags is not None:
2976
2978
  body["tags"] = tags
2979
+ if parents is not None:
2980
+ body["parents"] = parents
2981
+ if auto_follow_parents is not None:
2982
+ body["auto_follow_parents"] = auto_follow_parents
2977
2983
  return self._post(self._PROFILES_BASE, body)
2978
2984
 
2979
2985
  def list_profiles(
@@ -3115,6 +3121,9 @@ class APIClient:
3115
3121
  account_pool: Optional[str] = None,
3116
3122
  metadata: Optional[dict] = None,
3117
3123
  reason: Optional[str] = None,
3124
+ version: Optional[int] = None,
3125
+ parents: Optional[list[str | dict]] = None,
3126
+ auto_follow_parents: Optional[list[str]] = None,
3118
3127
  ) -> dict:
3119
3128
  """Update a submission profile, preserving an omitted default Pool.
3120
3129
 
@@ -3130,7 +3139,108 @@ class APIClient:
3130
3139
  body["metadata"] = metadata
3131
3140
  if reason is not None:
3132
3141
  body["reason"] = reason
3133
- return self._put(f"{self._PROFILES_BASE}/{quote(profile_id)}", body)
3142
+ if version is not None:
3143
+ if type(version) is not int or version < 1:
3144
+ raise ValueError("version must be a positive integer")
3145
+ body["version"] = version
3146
+ if parents is not None:
3147
+ body["parents"] = parents
3148
+ if auto_follow_parents is not None:
3149
+ body["auto_follow_parents"] = auto_follow_parents
3150
+ return self._put(f"{self._PROFILES_BASE}/{quote(profile_id, safe='')}", body)
3151
+
3152
+ def patch_profile(self, profile_id: str, fields: dict) -> dict:
3153
+ """Patch own definition; callers should supply their original version."""
3154
+ return self._request(
3155
+ "PATCH", f"{self._PROFILES_BASE}/{quote(profile_id, safe='')}", json_body=fields
3156
+ )
3157
+
3158
+ def resolve_profile(self, identifier: str, *, version: Optional[int] = None) -> dict:
3159
+ return self._get(
3160
+ f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/resolved",
3161
+ params={"version": version} if version is not None else None,
3162
+ )
3163
+
3164
+ def preview_profile(self, draft: dict) -> dict:
3165
+ return self._post(f"{self._PROFILES_BASE}/resolve", draft)
3166
+
3167
+ def profile_children(
3168
+ self,
3169
+ identifier: str,
3170
+ *,
3171
+ page: int = 1,
3172
+ page_size: int = 20,
3173
+ target_version: Optional[int] = None,
3174
+ eligible_only: bool = False,
3175
+ current_only: bool = False,
3176
+ ) -> dict:
3177
+ params = {"page": page, "page_size": page_size}
3178
+ if target_version is not None:
3179
+ params["target_version"] = target_version
3180
+ if eligible_only:
3181
+ params["eligible_only"] = "true"
3182
+ if current_only:
3183
+ params["current_only"] = "true"
3184
+ return self._get(
3185
+ f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/children",
3186
+ params=params,
3187
+ )
3188
+
3189
+ def profile_diff(
3190
+ self,
3191
+ identifier: str,
3192
+ from_version: Optional[int] = None,
3193
+ to_version: Optional[int] = None,
3194
+ view: str = "own",
3195
+ *,
3196
+ compare_to: Optional[str] = None,
3197
+ ) -> dict:
3198
+ """Compare versions of one Profile, or two Profiles (omitted versions use current)."""
3199
+ params = {"from": from_version, "to": to_version, "view": view}
3200
+ if compare_to is not None:
3201
+ params["compare_to"] = compare_to
3202
+ result = self._get(
3203
+ f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/diff",
3204
+ params=params,
3205
+ )
3206
+ if compare_to is not None:
3207
+ # Older servers ignore unknown query fields. Do not present their
3208
+ # single-Profile response as a successful cross-Profile comparison.
3209
+ for field in ("from_profile", "to_profile"):
3210
+ ref = result.get(field) if isinstance(result, dict) else None
3211
+ if (
3212
+ not isinstance(ref, dict)
3213
+ or not isinstance(ref.get("profile_id"), str)
3214
+ or not ref["profile_id"].startswith("sp-")
3215
+ or type(ref.get("version")) is not int
3216
+ or ref["version"] < 1
3217
+ ):
3218
+ raise APIError(
3219
+ 200,
3220
+ "Server did not confirm cross-Profile diff support; upgrade the server before comparing different Profiles",
3221
+ None,
3222
+ None,
3223
+ )
3224
+ return result
3225
+
3226
+ def cascade_profiles(self, identifier: str, request: dict) -> dict:
3227
+ return self._post(f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/cascades", request)
3228
+
3229
+ def get_profile_cascade(self, operation_id: str) -> dict:
3230
+ return self._get(f"/apis/v1/profile-cascades/{quote(operation_id, safe='')}")
3231
+
3232
+ def profile_cascade_history(
3233
+ self, profile_id: str, *, page: int = 1, page_size: int = 20
3234
+ ) -> dict:
3235
+ return self._get(
3236
+ f"{self._PROFILES_BASE}/{quote(profile_id, safe='')}/cascades",
3237
+ params={"page": page, "page_size": page_size},
3238
+ )
3239
+
3240
+ def retry_profile_cascade(self, operation_id: str, items: list[dict]) -> dict:
3241
+ return self._post(
3242
+ f"/apis/v1/profile-cascades/{quote(operation_id, safe='')}/retry", {"items": items}
3243
+ )
3134
3244
 
3135
3245
  def delete_profile(self, profile_id: str) -> dict:
3136
3246
  """Delete a submission profile."""
@@ -3003,22 +3003,22 @@ def job_create(
3003
3003
  meta_model: Optional[str] = typer.Option(
3004
3004
  None,
3005
3005
  "--meta-model",
3006
- help="Target model or training project this job serves; not the called model or Judge. Values: ap meta-tag get meta-model --format json",
3006
+ help="Meta model tag; omit to leave unset",
3007
3007
  ),
3008
3008
  meta_job_type: Optional[str] = typer.Option(
3009
3009
  None,
3010
3010
  "--meta-job-type",
3011
- help="Business purpose of this job. Values: ap meta-tag get meta-job-type --format json",
3011
+ help="Meta job type tag: Eval-Eval (default) or Others",
3012
3012
  ),
3013
3013
  meta_teacher_direction: Optional[str] = typer.Option(
3014
3014
  None,
3015
3015
  "--meta-teacher-direction",
3016
- help="Teacher direction this job serves; verify any dataset-derived value. Values: ap meta-tag get meta-teacher-direction --format json",
3016
+ help="Meta teacher direction tag; omit to derive from dataset_series; options: ap meta-teacher-direction list",
3017
3017
  ),
3018
3018
  meta_train_stage: Optional[str] = typer.Option(
3019
3019
  None,
3020
3020
  "--meta-train-stage",
3021
- help="Training stage this job serves; verify any dataset-derived value. Values: ap meta-tag get meta-train-stage --format json",
3021
+ help="Meta train stage tag; omit to derive from dataset run_type; options: ap meta-train-stage list",
3022
3022
  ),
3023
3023
  overrides: Optional[str] = typer.Option(None, "--overrides", help="Override config (JSON)"),
3024
3024
  concurrency: Optional[int] = typer.Option(
@@ -3190,7 +3190,7 @@ def job_create(
3190
3190
  benchmark: Optional[str] = typer.Option(
3191
3191
  None,
3192
3192
  "--benchmark",
3193
- help="Prefer the registered benchmark source ID for this run; name is also accepted. Find IDs: ap benchmark list --format json; inspect by name: ap benchmark get NAME --format json",
3193
+ help="Benchmark name from the AP registry (see 'ap benchmark list')",
3194
3194
  ),
3195
3195
  checkpoint_id: Optional[str] = typer.Option(
3196
3196
  None,
@@ -3236,41 +3236,46 @@ def job_create(
3236
3236
  ):
3237
3237
  """Submit a job.
3238
3238
 
3239
- For agents:
3240
- \b
3241
- Before submitting, you MUST ensure these five parameters are explicitly specified:
3242
- benchmark, meta-model, meta-job-type, meta-train-stage, meta-teacher-direction.
3243
- Keep specified values. Fill missing parameters using available Job, Group,
3244
- Profile, Dataset, Template, or other relevant information.
3245
- If a missing value cannot be inferred, use default for benchmark
3246
- or Others for the corresponding meta parameter.
3247
- Do not ask the user for these values.
3248
-
3249
3239
  Model configuration:
3250
3240
  Supply template parameters through --params/-p; select a managed credential with --ak-id.
3251
3241
 
3252
3242
  Examples:
3253
- Use catalog/context-selected values for the metadata variables below.
3254
-
3255
- \b
3256
- # Profile submission (PROFILE_REF is ID:vN)
3257
- ap job create --profile "$PROFILE_REF" -i "$INSTANCE_ID" \\
3258
- --benchmark "$BENCHMARK_ID" \\
3259
- --meta-model "$META_MODEL" \\
3260
- --meta-job-type "$META_JOB_TYPE" \\
3261
- --meta-train-stage "$META_TRAIN_STAGE" \\
3262
- --meta-teacher-direction "$META_TEACHER_DIRECTION"
3263
-
3264
- \b
3265
- # Batch jobs from a Dataset (first 3 instances)
3266
- ap job create claw-eval-task --dataset "$DATASET_PATH" --range 0,3 \\
3267
- -p '{"model":"qwen-max"}' \\
3268
- --benchmark "$BENCHMARK_ID" \\
3269
- --meta-model "$META_MODEL" \\
3270
- --meta-job-type "$META_JOB_TYPE" \\
3271
- --meta-train-stage "$META_TRAIN_STAGE" \\
3272
- --meta-teacher-direction "$META_TEACHER_DIRECTION"
3243
+ # Single job - full parameters
3244
+ ap job create claw-eval-task -p '{"task_id":"T02_email_triage","model":"qwen-max"}'
3245
+
3246
+ # Single job - instance_id + shared params
3247
+ ap job create claw-eval-task -i T02_email_triage -p '{"model":"qwen-max"}'
3248
+
3249
+ # Batch jobs - multiple instance_ids + shared params
3250
+ ap job create claw-eval-task -i T02_email_triage,T03zh_calendar_scheduling,T04_calendar_scheduling -p '{"model":"qwen-max"}'
3273
3251
 
3252
+ # Batch jobs - dataset + shared params
3253
+ ap job create claw-eval-task --dataset claw-eval/claw-eval/v1@0 -p '{"model":"qwen-max"}'
3254
+
3255
+ # Batch jobs - dataset subset with exclusions
3256
+ ap job create claw-eval-task --dataset claw-eval/claw-eval/v1@0 --range 0,10,2 --exclude-instance-ids T03zh_calendar_scheduling
3257
+
3258
+ # Batch jobs from a params list(inline JSON) with shared base params (-p merged into each item)
3259
+ ap job create claw-eval-task --params-list '[{"task_id":"T02_email_triage"},{"task_id":"T03zh_calendar_scheduling"}]' -p '{"model":"qwen-max"}'
3260
+
3261
+ # Batch jobs from a params list file (also supports -p for shared base params)
3262
+ ap job create claw-eval-task --params-list params.json -p '{"model":"qwen-max"}'
3263
+
3264
+ # Debug mode - keep pod alive for interactive debugging (Pod Login)
3265
+ ap job create stress-test -p '{"duration":60}' --debug
3266
+
3267
+ # Debug mode with custom keep-alive duration
3268
+ ap job create stress-test -p '{"duration":60}' --debug --debug-keep-alive-seconds 7200
3269
+
3270
+ # Apply per-instance resource settings from a Resource Profile
3271
+ ap job create claw-eval-task --dataset claw-eval/v1 --resource-profile rp-123
3272
+
3273
+ Note: benchmark+ckpt metadata (--benchmark/--checkpoint-*/--experiment) on a
3274
+ standalone single job needs no client-side group handling — the server
3275
+ auto-creates a Group (auto_created_benchmark_group) to persist the
3276
+ Group-level fields. Generating a group_id client-side would break
3277
+ idempotent retries: the server rejects a same idempotency_key with a
3278
+ different requested group_id (409 idempotency_conflict).
3274
3279
  """
3275
3280
  if transparency is not None and transparency not in {"whitebox", "blackbox"}:
3276
3281
  raise typer.BadParameter("Choose whitebox or blackbox", param_hint="--transparency")
@@ -6675,6 +6680,10 @@ def main() -> None:
6675
6680
 
6676
6681
 
6677
6682
  def _format_api_error(exc: APIError) -> str:
6683
+ from ap_client.profile_diagnostics import format_profile_error
6684
+
6685
+ if profile_error := format_profile_error(exc):
6686
+ return profile_error
6678
6687
  if isinstance(exc.detail, dict) and exc.detail.get("code") == "blackbox_content_restricted":
6679
6688
  return f"error: Blackbox content is hidden; Group Reader access is required. (request_id={exc.request_id})"
6680
6689
  if str(exc.detail) != _NON_JSON_RESPONSE_DETAIL:
@@ -695,20 +695,31 @@ def _unversioned_split(
695
695
 
696
696
 
697
697
  def _versioned_split(
698
- dataset: str, version: str, split: str, *, environment: Optional[str] = None
698
+ dataset: str,
699
+ version: str,
700
+ split: str,
701
+ *,
702
+ environment: Optional[str] = None,
703
+ oss_fallback: bool = True,
699
704
  ) -> Optional[dict]:
700
705
  """取指定 version 里的单个 split 记录;不存在返回 ``None``(不当作错误)。
701
706
 
702
707
  与 ``_unversioned_split`` 同源,走 recordsvc 的 ``splits.get``(SDK 自带
703
- version_first 内嵌投影的 legacy 回退)。
708
+ version_first 内嵌投影的 legacy 回退)。``split get`` 保持默认 OSS 发现回退;
709
+ ``split create`` 的幂等检查须传 ``oss_fallback=False``:dataset 刚创建或
710
+ 尚未上传时 OSS prefix 不存在,``_discover_split`` 会把"split 不存在"误报为
711
+ 查询失败,404 → legacy 回退 → None 即可判定"不存在 → 走 create"。
704
712
  """
705
713
  from .irepo_sdk import _reason
706
714
 
707
715
  env = _environment(environment)
708
716
  repo = _dataset_repo()
717
+ read_kwargs = _read_env_kwargs(repo, repo.splits, "get", env)
718
+ if not oss_fallback:
719
+ read_kwargs["fallback_to_oss"] = False
709
720
  try:
710
721
  record = repo.splits.get(
711
- dataset, split, version=version, **_read_env_kwargs(repo, repo.splits, "get", env)
722
+ dataset, split, version=version, **read_kwargs
712
723
  )
713
724
  except Exception as exc: # noqa: BLE001
714
725
  raise _fail(
@@ -716,7 +727,9 @@ def _versioned_split(
716
727
  f"Check the dataset name and permission: ap dataset get {dataset}",
717
728
  ) from exc
718
729
  detail = _model_dict(record) if record is not None else None
719
- return detail if detail is not None and _version_value(detail.get("version")) == version else None
730
+ return (
731
+ detail if detail is not None and _version_value(detail.get("version")) == version else None
732
+ )
720
733
 
721
734
 
722
735
  def _versioned_splits(
@@ -1286,8 +1299,9 @@ def dataset_get(
1286
1299
 
1287
1300
  def dataset_create(
1288
1301
  dataset: str = typer.Argument(
1289
- ..., help="Dataset name: L1, L1/L2, or a deeper path "
1290
- "(e.g. my-bench, alibaba/my-bench, or gdpval/synthetic/my-bench)"
1302
+ ...,
1303
+ help="Dataset name: L1, L1/L2, or a deeper path "
1304
+ "(e.g. my-bench, alibaba/my-bench, or gdpval/synthetic/my-bench)",
1291
1305
  ),
1292
1306
  benchmark: str = typer.Option(..., "--benchmark", help="Benchmark name or source_id"),
1293
1307
  description: Optional[str] = typer.Option(None, "--description", help="Dataset description"),
@@ -1946,9 +1960,7 @@ def dataset_split_get(
1946
1960
  resolved_version = _version_value(version)
1947
1961
 
1948
1962
  if resolved_version:
1949
- record = _versioned_split(
1950
- dataset, resolved_version, split, environment=environment
1951
- )
1963
+ record = _versioned_split(dataset, resolved_version, split, environment=environment)
1952
1964
  if record is not None:
1953
1965
  payload = dict(record)
1954
1966
  payload.setdefault("dataset", dataset)
@@ -2026,7 +2038,12 @@ def dataset_split_create(
2026
2038
  # 契约对齐——旧 Version PATCH(splits 全量替换 / split_run_types)已被服务端
2027
2039
  # 拒绝("split-first Version group updates are not supported")。
2028
2040
  env = _environment(environment)
2029
- existing = _versioned_split(dataset, resolved_version, split, environment=environment)
2041
+ # 幂等预检禁用 OSS 发现回退:dataset 刚创建或尚未上传时 OSS prefix 不存在,
2042
+ # _discover_split 会把"split 不存在"误报为查询失败("not found in OSS");
2043
+ # recordsvc 404 → legacy 回退 → None 即判定"不存在 → 走 create"。
2044
+ existing = _versioned_split(
2045
+ dataset, resolved_version, split, environment=environment, oss_fallback=False
2046
+ )
2030
2047
  if existing is not None:
2031
2048
  # 幂等:已存在同名 split(设计 §4.3)。
2032
2049
  payload = dict(existing)
@@ -2151,7 +2168,9 @@ def dataset_split_update(
2151
2168
  ", ".join(
2152
2169
  _split_names_of(
2153
2170
  _versioned_splits(
2154
- dataset, resolved_version, stage="split update precheck",
2171
+ dataset,
2172
+ resolved_version,
2173
+ stage="split update precheck",
2155
2174
  environment=environment,
2156
2175
  )
2157
2176
  )
@@ -2171,7 +2190,9 @@ def dataset_split_update(
2171
2190
  repo = _dataset_repo()
2172
2191
  update_kwargs = _sdk_read_kwargs(repo.splits, "update", environment=env, metadata_model="")
2173
2192
  try:
2174
- repo.splits.update(dataset, split, version=resolved_version, run_type=resolved_run_type, **update_kwargs)
2193
+ repo.splits.update(
2194
+ dataset, split, version=resolved_version, run_type=resolved_run_type, **update_kwargs
2195
+ )
2175
2196
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
2176
2197
  raise _fail(
2177
2198
  f"Failed to update split {split} of dataset {dataset} version {resolved_version}: {_reason(exc)}",
@@ -121,8 +121,7 @@ def _create_owned_dataset(repo: Any, dataset: str, benchmark_id: str) -> dict:
121
121
  raise datasets._fail(
122
122
  f"Failed to create dataset {dataset}: the name must be one or more "
123
123
  "canonical path segments",
124
- "Use a dataset name such as my-bench, your-team/my-bench, "
125
- "or gdpval/synthetic/my-bench",
124
+ "Use a dataset name such as my-bench, your-team/my-bench, or gdpval/synthetic/my-bench",
126
125
  exit_code=2,
127
126
  )
128
127
 
@@ -0,0 +1,149 @@
1
+ """Bounded all-Child discovery and replayable client-side cascade batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from copy import deepcopy
7
+
8
+ from ap_client.api import APIClient
9
+
10
+ BATCH_SIZE = 100
11
+
12
+
13
+ def preview_all(client: APIClient, identifier: str, target_version: int) -> dict:
14
+ batches: list[dict] = []
15
+ after = end = ""
16
+ base_id = None
17
+ seen: set[str] = set()
18
+ while True:
19
+ body = {"dry_run": True, "all": True, "target_version": target_version}
20
+ if after:
21
+ body.update(after=after, selection_end=end)
22
+ value = client.cascade_profiles(base_id or identifier, body)
23
+ if (
24
+ value.get("all") is not True
25
+ or value.get("dry_run") is not True
26
+ or not value.get("base_profile_id")
27
+ or value.get("target_version") != target_version
28
+ or (base_id and value["base_profile_id"] != base_id)
29
+ ):
30
+ raise ValueError(
31
+ "Invalid all-Children preview response; update the server and preview again"
32
+ )
33
+ base_id = value["base_profile_id"]
34
+ next_end = value.get("selection_end", "")
35
+ if after and next_end != end:
36
+ raise ValueError("The all-Children selection boundary changed; preview again")
37
+ end = next_end
38
+ items = value.get("items")
39
+ if not isinstance(items, list) or len(items) > BATCH_SIZE:
40
+ raise ValueError("Invalid preview batch size")
41
+ ids = [item.get("child_id") for item in items]
42
+ if (
43
+ any(not isinstance(i, str) or not i or i <= after or (end and i > end) for i in ids)
44
+ or ids != sorted(ids)
45
+ or len(set(ids)) != len(ids)
46
+ or seen.intersection(ids)
47
+ ):
48
+ raise ValueError("Invalid preview cursor or duplicate Child")
49
+ seen.update(ids)
50
+ if items:
51
+ batches.append(value)
52
+ cursor = value.get("next_cursor", "")
53
+ if not cursor:
54
+ break
55
+ if not ids or cursor != ids[-1] or cursor <= after or cursor >= end:
56
+ raise ValueError("Invalid preview cursor")
57
+ after = cursor
58
+ return {
59
+ "dry_run": True,
60
+ "all": True,
61
+ "base_profile_id": base_id,
62
+ "target_version": target_version,
63
+ "selection_end": end,
64
+ "batches": batches,
65
+ }
66
+
67
+
68
+ def execution_targets(
69
+ items: list[dict], base_id: str, target: int, seen: set[str]
70
+ ) -> tuple[list[dict], int]:
71
+ """Use the same selection rules for manual and all-Child execution."""
72
+ if not isinstance(items, list) or len(items) > BATCH_SIZE:
73
+ raise ValueError("Each preview batch must have at most 100 Children")
74
+ targets, excluded = [], 0
75
+ for item in items:
76
+ if not isinstance(item, dict):
77
+ raise ValueError("Invalid Child in saved preview")
78
+ child_id = item.get("child_id")
79
+ if not isinstance(child_id, str) or not child_id.startswith("sp-") or child_id in seen:
80
+ raise ValueError("Invalid or duplicate Child in saved preview")
81
+ seen.add(child_id)
82
+ if item.get("valid") is not True:
83
+ excluded += 1
84
+ continue
85
+ baseline = item.get("target")
86
+ if not isinstance(baseline, dict) or set(baseline) != {
87
+ "child_id",
88
+ "expected_child_version",
89
+ "expected_parent",
90
+ }:
91
+ raise ValueError(
92
+ "Every valid Child requires a complete target; upgrade the server and preview again"
93
+ )
94
+ parent, version = baseline.get("expected_parent"), baseline.get("expected_child_version")
95
+ if (
96
+ baseline.get("child_id") != child_id
97
+ or type(version) is not int
98
+ or version < 1
99
+ or not isinstance(parent, dict)
100
+ or set(parent) != {"profile_id", "version"}
101
+ or parent.get("profile_id") != base_id
102
+ or type(parent.get("version")) is not int
103
+ or parent["version"] < 1
104
+ ):
105
+ raise ValueError("Every valid Child requires a complete preview baseline")
106
+ if parent["version"] >= target:
107
+ excluded += 1
108
+ continue
109
+ # Send the server's target unchanged, independent of display filters.
110
+ targets.append(deepcopy(baseline))
111
+ return targets, excluded
112
+
113
+
114
+ def validate_preview_identity(preview: dict) -> tuple[str, int]:
115
+ if not isinstance(preview, dict) or preview.get("dry_run") is not True:
116
+ raise ValueError("Expected a saved dry-run preview")
117
+ base_id, target = preview.get("base_profile_id"), preview.get("target_version")
118
+ if (
119
+ not isinstance(base_id, str)
120
+ or not base_id.startswith("sp-")
121
+ or type(target) is not int
122
+ or target < 1
123
+ ):
124
+ raise ValueError("Invalid saved preview identity")
125
+ return base_id, target
126
+
127
+
128
+ def execution_batches(preview: dict, root_key: str) -> tuple[list[dict], int]:
129
+ """Validate the whole saved plan before any writes; each batch gets a stable UUID."""
130
+ root = uuid.UUID(root_key)
131
+ base_id, target = validate_preview_identity(preview)
132
+ if preview.get("all") is not True or not isinstance(preview.get("batches"), list):
133
+ raise ValueError("Expected an --all preview file")
134
+ requests, seen, excluded = [], set(), 0
135
+ for index, batch in enumerate(preview["batches"]):
136
+ if not isinstance(batch, dict) or validate_preview_identity(batch) != (base_id, target):
137
+ raise ValueError("Preview batches must use the same fixed Base and target")
138
+ targets, skipped = execution_targets(batch.get("items"), base_id, target, seen)
139
+ excluded += skipped
140
+ if targets:
141
+ requests.append(
142
+ {
143
+ "dry_run": False,
144
+ "request_key": str(uuid.uuid5(root, f"profile-cascade-batch:{index}")),
145
+ "target_version": target,
146
+ "targets": targets,
147
+ }
148
+ )
149
+ return requests, excluded