ap-client 0.7.0.dev9__tar.gz → 0.7.1.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.
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/PKG-INFO +1 -1
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/api.py +111 -1
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/cli.py +15 -15
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dataset_commands.py +13 -8
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dataset_register.py +1 -2
- ap_client-0.7.1.dev0/ap_client/profile_cascade_all.py +149 -0
- ap_client-0.7.1.dev0/ap_client/profile_cascade_cli.py +356 -0
- ap_client-0.7.1.dev0/ap_client/profile_cascade_display.py +279 -0
- ap_client-0.7.1.dev0/ap_client/profile_cascade_state.py +207 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/profile_commands.py +491 -48
- ap_client-0.7.1.dev0/ap_client/profile_diagnostics.py +117 -0
- ap_client-0.7.1.dev0/ap_client/profile_display.py +172 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/pyproject.toml +1 -1
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/__init__.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/akpool_commands.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/config.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dataset_name.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dfs_commands.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/exporter.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/fs_commands.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/fs_storage.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/fs_transfer.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/instance_commands.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/instance_options.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/irepo_sdk.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/managed_ak.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/manifest_view.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/ossutil_hash.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/ossutil_io.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/repo_read_guard.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/share_api.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/split_publish.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/tbb.py +0 -0
- {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/waiter.py +0 -0
|
@@ -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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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,16 +3236,6 @@ 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
|
|
|
@@ -3280,6 +3270,12 @@ def job_create(
|
|
|
3280
3270
|
# Apply per-instance resource settings from a Resource Profile
|
|
3281
3271
|
ap job create claw-eval-task --dataset claw-eval/v1 --resource-profile rp-123
|
|
3282
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).
|
|
3283
3279
|
"""
|
|
3284
3280
|
if transparency is not None and transparency not in {"whitebox", "blackbox"}:
|
|
3285
3281
|
raise typer.BadParameter("Choose whitebox or blackbox", param_hint="--transparency")
|
|
@@ -6684,6 +6680,10 @@ def main() -> None:
|
|
|
6684
6680
|
|
|
6685
6681
|
|
|
6686
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
|
|
6687
6687
|
if isinstance(exc.detail, dict) and exc.detail.get("code") == "blackbox_content_restricted":
|
|
6688
6688
|
return f"error: Blackbox content is hidden; Group Reader access is required. (request_id={exc.request_id})"
|
|
6689
6689
|
if str(exc.detail) != _NON_JSON_RESPONSE_DETAIL:
|
|
@@ -716,7 +716,9 @@ def _versioned_split(
|
|
|
716
716
|
f"Check the dataset name and permission: ap dataset get {dataset}",
|
|
717
717
|
) from exc
|
|
718
718
|
detail = _model_dict(record) if record is not None else None
|
|
719
|
-
return
|
|
719
|
+
return (
|
|
720
|
+
detail if detail is not None and _version_value(detail.get("version")) == version else None
|
|
721
|
+
)
|
|
720
722
|
|
|
721
723
|
|
|
722
724
|
def _versioned_splits(
|
|
@@ -1286,8 +1288,9 @@ def dataset_get(
|
|
|
1286
1288
|
|
|
1287
1289
|
def dataset_create(
|
|
1288
1290
|
dataset: str = typer.Argument(
|
|
1289
|
-
...,
|
|
1290
|
-
"
|
|
1291
|
+
...,
|
|
1292
|
+
help="Dataset name: L1, L1/L2, or a deeper path "
|
|
1293
|
+
"(e.g. my-bench, alibaba/my-bench, or gdpval/synthetic/my-bench)",
|
|
1291
1294
|
),
|
|
1292
1295
|
benchmark: str = typer.Option(..., "--benchmark", help="Benchmark name or source_id"),
|
|
1293
1296
|
description: Optional[str] = typer.Option(None, "--description", help="Dataset description"),
|
|
@@ -1946,9 +1949,7 @@ def dataset_split_get(
|
|
|
1946
1949
|
resolved_version = _version_value(version)
|
|
1947
1950
|
|
|
1948
1951
|
if resolved_version:
|
|
1949
|
-
record = _versioned_split(
|
|
1950
|
-
dataset, resolved_version, split, environment=environment
|
|
1951
|
-
)
|
|
1952
|
+
record = _versioned_split(dataset, resolved_version, split, environment=environment)
|
|
1952
1953
|
if record is not None:
|
|
1953
1954
|
payload = dict(record)
|
|
1954
1955
|
payload.setdefault("dataset", dataset)
|
|
@@ -2151,7 +2152,9 @@ def dataset_split_update(
|
|
|
2151
2152
|
", ".join(
|
|
2152
2153
|
_split_names_of(
|
|
2153
2154
|
_versioned_splits(
|
|
2154
|
-
dataset,
|
|
2155
|
+
dataset,
|
|
2156
|
+
resolved_version,
|
|
2157
|
+
stage="split update precheck",
|
|
2155
2158
|
environment=environment,
|
|
2156
2159
|
)
|
|
2157
2160
|
)
|
|
@@ -2171,7 +2174,9 @@ def dataset_split_update(
|
|
|
2171
2174
|
repo = _dataset_repo()
|
|
2172
2175
|
update_kwargs = _sdk_read_kwargs(repo.splits, "update", environment=env, metadata_model="")
|
|
2173
2176
|
try:
|
|
2174
|
-
repo.splits.update(
|
|
2177
|
+
repo.splits.update(
|
|
2178
|
+
dataset, split, version=resolved_version, run_type=resolved_run_type, **update_kwargs
|
|
2179
|
+
)
|
|
2175
2180
|
except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
|
|
2176
2181
|
raise _fail(
|
|
2177
2182
|
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
|