bitfab 0.64.4 → 0.64.5

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.
package/cloudReplay.py CHANGED
@@ -34,6 +34,7 @@ REPLAY_COMMAND_ENV = "BITFAB_REPLAY_COMMAND"
34
34
  SDK_LANGUAGE_ENV = "BITFAB_SDK_LANGUAGE"
35
35
  CHECK_COMMAND_ENV = "BITFAB_REPLAY_CHECK"
36
36
  REQUEST_VERSION = 3
37
+ OLDEST_DISPATCHED_VERSION = 2
37
38
  RESULT_LINE = "bitfab-replay-result "
38
39
  RESULT_CHUNK = 4000
39
40
  OUTPUT_BEGIN = "bitfab-replay-output-begin"
@@ -87,14 +88,20 @@ ENVIRONMENT_SETTING = re.compile(
87
88
  PUSH_TRIGGER = re.compile(
88
89
  r"^[ \t]*\"?(?:on\"?[ \t]*:.*\bpush\b|push\"?[ \t]*:)", re.MULTILINE
89
90
  )
90
- DECLARED_INPUTS = re.compile(r"^[ \t]*\"?execution_id\"?[ \t]*:", re.MULTILINE)
91
+ DISPATCH_INPUTS = ("execution_id", "request")
92
+ BRANCH_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
91
93
  FOLLOW_FLAGS = (
92
94
  "--cloud-status",
93
95
  "--cloud-watch",
94
96
  "--cloud-cancel",
95
97
  "--cloud-cleanup",
96
98
  )
97
- CLOUD_VALUE_FLAGS = (*FOLLOW_FLAGS, "--cloud-request-id", "--cloud-timeout")
99
+ CLOUD_VALUE_FLAGS = (
100
+ *FOLLOW_FLAGS,
101
+ "--cloud-request-id",
102
+ "--cloud-timeout",
103
+ "--cloud-ref",
104
+ )
98
105
  CLOUD_SWITCHES = ("--cloud", "--cloud-preview", "--cloud-detach", "--cloud-full-output")
99
106
  RENAMED_FLAGS = {
100
107
  "--cloud-dry-run": "--cloud-preview",
@@ -144,6 +151,23 @@ Options added by --cloud:
144
151
  GitHub job log holds.
145
152
  --cloud-timeout MIN Stop the replay after MIN minutes (1..7200).
146
153
  --cloud-request-id ID Recover a submission whose response was lost.
154
+ --cloud-ref BRANCH Replay BRANCH as it is on GitHub, with no snapshot of your
155
+ working tree, and record the experiment on BRANCH. Meant for
156
+ CI jobs, such as a nightly replay of dev. Needs only GitHub
157
+ access that can run Actions, and a workflow on BRANCH that
158
+ declares a request input.
159
+
160
+ A workflow can also dispatch bitfab-replay.yml itself on any branch other than a
161
+ bitfab-replay/ snapshot branch, passing the replay as JSON in the request input:
162
+
163
+ {"args": ["--registry", "scripts/replay.ts", "classify", "--dataset-ids", "UUID"],
164
+ "cwd": "web", "timeoutMinutes": 60}
165
+
166
+ args are the replay options without --cloud. cwd is the directory to replay from,
167
+ relative to the repository root, and defaults to the root. timeoutMinutes and
168
+ fullOutput (true) match --cloud-timeout and --cloud-full-output. version and id are
169
+ optional: version names the request format (3 now, 2 still accepted), and id is the
170
+ execution UUID, which must match an execution_id input when the workflow has one.
147
171
 
148
172
  Follow a replay by the execution UUID it prints:
149
173
  --cloud-watch ID | --cloud-status ID | --cloud-cancel ID | --cloud-cleanup ID
@@ -611,6 +635,16 @@ def parse(argv):
611
635
  timeout.isdigit() and 1 <= int(timeout) <= 7200
612
636
  ):
613
637
  raise ValueError("--cloud-timeout must be 1..7200 minutes")
638
+ branch = cloud.get("--cloud-ref")
639
+ if branch is not None and (
640
+ not BRANCH_NAME.fullmatch(branch)
641
+ or branch.startswith(PREFIX)
642
+ or ".." in branch
643
+ or branch.endswith((".", "/", ".lock"))
644
+ ):
645
+ raise ValueError(
646
+ f"--cloud-ref takes a branch name such as dev, other than a {PREFIX} snapshot branch"
647
+ )
614
648
  operation = "submit"
615
649
  execution_id = cloud.get("--cloud-request-id") or str(uuid.uuid4())
616
650
  if not UUID.fullmatch(execution_id):
@@ -797,6 +831,8 @@ def execution_lock(directory, execution_id):
797
831
 
798
832
 
799
833
  def find_run(record):
834
+ if record.get("snapshot") is False:
835
+ return find_branch_run(record)
800
836
  query = urlencode(
801
837
  {"event": "workflow_dispatch", "branch": record["branch"], "per_page": 100}
802
838
  )
@@ -815,6 +851,31 @@ def find_run(record):
815
851
  return matches[0] if matches else None
816
852
 
817
853
 
854
+ def find_branch_run(record):
855
+ query = urlencode(
856
+ {
857
+ "event": "workflow_dispatch",
858
+ "branch": record["branch"],
859
+ "created": ">=" + record["dispatchedAt"],
860
+ "per_page": 100,
861
+ }
862
+ )
863
+ runs = api(
864
+ record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
865
+ )
866
+ matches = [
867
+ run
868
+ for run in runs["workflow_runs"]
869
+ if run["head_branch"] == record["branch"]
870
+ and run.get("created_at", "") >= record["dispatchedAt"]
871
+ ]
872
+ if len(matches) > 1:
873
+ raise ValueError(
874
+ f"Several runs were dispatched on {record['branch']} at about the same time, so this execution cannot tell which is its own; find it in the Actions tab"
875
+ )
876
+ return matches[0] if matches else None
877
+
878
+
818
879
  def status(record, *, fetch_result=True):
819
880
  if record["state"] == "prepared":
820
881
  return record
@@ -826,6 +887,8 @@ def status(record, *, fetch_result=True):
826
887
  if run is None:
827
888
  record["state"] = "dispatch_unknown"
828
889
  return record
890
+ if record.get("snapshot") is False and run["head_branch"] == record["branch"]:
891
+ record["sha"] = run["head_sha"]
829
892
  if run["head_sha"] != record["sha"] or run["head_branch"] != record["branch"]:
830
893
  raise ValueError("GitHub run does not match the recorded execution")
831
894
  record.update(
@@ -955,6 +1018,9 @@ def print_outcome(found):
955
1018
  def cleanup(root, record):
956
1019
  if record.get("cleaned"):
957
1020
  return
1021
+ if record.get("snapshot") is False:
1022
+ record["cleaned"] = True
1023
+ return
958
1024
  if record.get("state") not in ("completed", "prepared"):
959
1025
  raise ValueError(
960
1026
  "Cleanup requires a confirmed completed GitHub run; cancel and wait first"
@@ -983,7 +1049,7 @@ def http_status(error):
983
1049
  return f" (HTTP {status})" if status else ""
984
1050
 
985
1051
 
986
- def preflight(repo, workflow):
1052
+ def preflight(repo, workflow, *, pushes=True):
987
1053
  access = github_access()
988
1054
  try:
989
1055
  details = api(repo, "")
@@ -991,7 +1057,7 @@ def preflight(repo, workflow):
991
1057
  raise ValueError(
992
1058
  f"GitHub access from {access['source']} cannot read {repo}{http_status(error)}; use an account or token with write access to it"
993
1059
  ) from error
994
- if (details or {}).get("permissions", {}).get("push") is False:
1060
+ if pushes and (details or {}).get("permissions", {}).get("push") is False:
995
1061
  raise ValueError(
996
1062
  f"GitHub access from {access['source']} cannot push to {repo}; use an account or token with write access to it"
997
1063
  )
@@ -1072,21 +1138,43 @@ def submit(root, repo, workflow, parsed, path):
1072
1138
  ) from error
1073
1139
  record["state"] = "dispatch_unknown"
1074
1140
  save(path, record)
1075
- payload = {"ref": record["branch"]}
1076
- if DECLARED_INPUTS.search(
1141
+ declared = declared_inputs(
1077
1142
  within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
1078
- ):
1079
- payload["inputs"] = {"execution_id": execution_id, "request": "-"}
1143
+ )
1144
+ return dispatch(record, {"execution_id": execution_id, "request": "-"}, declared)
1145
+
1146
+
1147
+ def declared_inputs(text):
1148
+ return {
1149
+ name
1150
+ for name in DISPATCH_INPUTS
1151
+ if re.search(rf"^[ \t]*\"?{name}\"?[ \t]*:", text, re.MULTILINE)
1152
+ }
1153
+
1154
+
1155
+ def dispatch(record, inputs, declared, *, run_details=False):
1156
+ payload = {"ref": record["branch"]}
1157
+ sent = {name: value for name, value in inputs.items() if name in declared}
1158
+ if sent:
1159
+ payload["inputs"] = sent
1160
+ path = f"actions/workflows/{record['workflow']}/dispatches"
1080
1161
  try:
1081
- response = api(
1082
- repo,
1083
- f"actions/workflows/{workflow}/dispatches",
1084
- method="POST",
1085
- payload=payload,
1086
- )
1162
+ try:
1163
+ response = api(
1164
+ record["repository"],
1165
+ path,
1166
+ method="POST",
1167
+ payload={**payload, "return_run_details": True}
1168
+ if run_details
1169
+ else payload,
1170
+ )
1171
+ except RuntimeError as error:
1172
+ if not run_details or getattr(error, "http_status", None) != 422:
1173
+ raise
1174
+ response = api(record["repository"], path, method="POST", payload=payload)
1087
1175
  except RuntimeError as error:
1088
1176
  raise ValueError(
1089
- f"Dispatching {workflow} on {record['branch']} failed{http_status(error)}; the GitHub account needs write access to Actions, and the registered workflow must accept workflow_dispatch. Check the Actions tab, then resume with --cloud-watch {execution_id}"
1177
+ f"Dispatching {record['workflow']} on {record['branch']} failed{http_status(error)}; the GitHub account needs write access to Actions, and the registered workflow must accept workflow_dispatch. Check the Actions tab, then resume with --cloud-watch {record['id']}"
1090
1178
  ) from error
1091
1179
  if isinstance(response, dict) and response.get("workflow_run_id"):
1092
1180
  record["runId"] = response["workflow_run_id"]
@@ -1095,6 +1183,84 @@ def submit(root, repo, workflow, parsed, path):
1095
1183
  return record
1096
1184
 
1097
1185
 
1186
+ def branch_head(repo, branch):
1187
+ try:
1188
+ found = api(repo, f"git/ref/heads/{branch}")
1189
+ except RuntimeError as error:
1190
+ if getattr(error, "http_status", None) == 404:
1191
+ raise ValueError(f"{repo} has no branch {branch}") from error
1192
+ raise
1193
+ sha = ((found or {}).get("object") or {}).get("sha", "")
1194
+ if not SHA.fullmatch(sha):
1195
+ raise ValueError(f"GitHub gave no commit for branch {branch}")
1196
+ return sha
1197
+
1198
+
1199
+ def branch_workflow(repo, workflow, sha):
1200
+ path = f"{WORKFLOW_DIRECTORY}/{workflow}"
1201
+ try:
1202
+ found = api(repo, f"contents/{path}?ref={sha}")
1203
+ except RuntimeError as error:
1204
+ raise ValueError(
1205
+ f"Could not read {path} at {sha[:12]}{http_status(error)}; the branch needs the workflow"
1206
+ ) from error
1207
+ return base64.b64decode((found or {}).get("content", "")).decode(errors="replace")
1208
+
1209
+
1210
+ def submit_as_is(root, repo, workflow, parsed, path):
1211
+ execution_id = parsed["id"]
1212
+ branch = parsed["cloud"]["--cloud-ref"]
1213
+ request, _ = replay_request(root, parsed)
1214
+ sha = branch_head(repo, branch)
1215
+ if "--cloud-preview" in parsed["switches"]:
1216
+ return {
1217
+ "preview": True,
1218
+ "repository": repo,
1219
+ "branch": branch,
1220
+ "sha": sha,
1221
+ "request": request,
1222
+ }
1223
+ if path.exists():
1224
+ record = json.loads(path.read_text())
1225
+ if (
1226
+ record["request"] != request
1227
+ or record["repository"].lower() != repo.lower()
1228
+ or record["branch"] != branch
1229
+ ):
1230
+ raise ValueError("Execution ID already belongs to a different request")
1231
+ status(record, fetch_result=False)
1232
+ return record
1233
+ preflight(repo, workflow, pushes=False)
1234
+ declared = declared_inputs(branch_workflow(repo, workflow, sha))
1235
+ if "request" not in declared:
1236
+ raise ValueError(
1237
+ f"{WORKFLOW_DIRECTORY}/{workflow} on {branch} declares no request input, so it cannot take a replay; add request (type: string, required: false) under on.workflow_dispatch.inputs and land it on {branch}"
1238
+ )
1239
+ record = {
1240
+ "id": execution_id,
1241
+ "repository": repo,
1242
+ "workflow": workflow,
1243
+ "branch": branch,
1244
+ "snapshot": False,
1245
+ "request": request,
1246
+ "state": "dispatch_unknown",
1247
+ "sha": sha,
1248
+ "dispatchedAt": time.strftime(
1249
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 5)
1250
+ ),
1251
+ }
1252
+ save(path, record)
1253
+ progress(
1254
+ f"Replaying {branch} at {sha[:12]} as it is on GitHub, without local changes. Execution {execution_id}; resume with --cloud-watch {execution_id}"
1255
+ )
1256
+ return dispatch(
1257
+ record,
1258
+ {"execution_id": execution_id, "request": json.dumps(request)},
1259
+ declared,
1260
+ run_details=True,
1261
+ )
1262
+
1263
+
1098
1264
  def report_steps(record, shown):
1099
1265
  job = replay_job(record)
1100
1266
  for step in (job or {}).get("steps", []):
@@ -1140,7 +1306,8 @@ def run_cli(argv):
1140
1306
  path = directory / f"{execution_id}.json"
1141
1307
  with execution_lock(directory, execution_id):
1142
1308
  if operation == "submit":
1143
- record = submit(root, repo, workflow, parsed, path)
1309
+ submitter = submit_as_is if "--cloud-ref" in parsed["cloud"] else submit
1310
+ record = submitter(root, repo, workflow, parsed, path)
1144
1311
  if record.get("preview"):
1145
1312
  return record, None
1146
1313
  else:
@@ -1216,14 +1383,86 @@ def snapshot_request(root, commit):
1216
1383
  return request
1217
1384
 
1218
1385
 
1386
+ def dispatched_inputs():
1387
+ path = os.environ.get("GITHUB_EVENT_PATH")
1388
+ if not path or not Path(path).is_file():
1389
+ return {}
1390
+ inputs = json.loads(Path(path).read_text()).get("inputs")
1391
+ return inputs if isinstance(inputs, dict) else {}
1392
+
1393
+
1394
+ def decode_request(text):
1395
+ try:
1396
+ return json.loads(text)
1397
+ except ValueError:
1398
+ pass
1399
+ try:
1400
+ return json.loads(base64.b64decode(text, validate=True))
1401
+ except ValueError as error:
1402
+ raise ValueError(
1403
+ "The request input is neither JSON nor base64-encoded JSON"
1404
+ ) from error
1405
+
1406
+
1407
+ def dispatched_request():
1408
+ branch = os.environ.get("GITHUB_REF_NAME", "")
1409
+ inputs = dispatched_inputs()
1410
+ text = str(inputs.get("request") or "").strip()
1411
+ if text in ("", "-"):
1412
+ raise ValueError(
1413
+ f"A replay of {branch} takes its request from the workflow_dispatch request input, and this run has none; start replays with bitfab-replay --cloud, or --cloud --cloud-ref {branch} to replay the branch as it is"
1414
+ )
1415
+ request = decode_request(text)
1416
+ if not isinstance(request, dict):
1417
+ raise ValueError("The request input must be a JSON object")
1418
+ version = request.get("version", REQUEST_VERSION)
1419
+ if not isinstance(version, int) or version < OLDEST_DISPATCHED_VERSION:
1420
+ raise ValueError(
1421
+ f"Request version {version!r} is not supported; send version {REQUEST_VERSION}, or leave version out"
1422
+ )
1423
+ if version > REQUEST_VERSION:
1424
+ raise ValueError(
1425
+ f"This replay was submitted by a newer SDK than the one {branch} installs; the request is version {version} and this SDK reads up to {REQUEST_VERSION}"
1426
+ )
1427
+ execution_id = request.get("id") or inputs.get("execution_id") or str(uuid.uuid4())
1428
+ if not isinstance(execution_id, str) or not UUID.fullmatch(execution_id):
1429
+ raise ValueError("The request id must be a UUID")
1430
+ if inputs.get("execution_id") and inputs["execution_id"] != execution_id:
1431
+ raise ValueError("The request id does not match the execution_id input")
1432
+ args = request.get("args")
1433
+ validate_arguments(args)
1434
+ if request.get("check") is True and "--dry-run" not in args:
1435
+ args = [*args, "--dry-run"]
1436
+ normalized = {
1437
+ "version": REQUEST_VERSION,
1438
+ "id": execution_id,
1439
+ "cwd": request.get("cwd") or ".",
1440
+ "args": args,
1441
+ }
1442
+ timeout = request.get("timeoutMinutes")
1443
+ if timeout is not None:
1444
+ if not isinstance(timeout, int) or not 1 <= timeout <= 7200:
1445
+ raise ValueError("timeoutMinutes must be 1..7200")
1446
+ normalized["timeoutMinutes"] = timeout
1447
+ if request.get("fullOutput") is True:
1448
+ normalized["fullOutput"] = True
1449
+ return normalized
1450
+
1451
+
1452
+ def runner_request(root, commit):
1453
+ if os.environ.get("GITHUB_REF_NAME", "").startswith(PREFIX):
1454
+ return snapshot_request(root, commit)
1455
+ return dispatched_request()
1456
+
1457
+
1219
1458
  def execute():
1220
1459
  if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
1221
1460
  raise ValueError("Submit a new replay instead of rerunning an Actions job")
1222
1461
  root = root_directory()
1223
1462
  commit = os.environ["GITHUB_SHA"]
1224
1463
  if git(root, "rev-parse", "HEAD") != commit:
1225
- raise ValueError("Runner checkout does not match the dispatched snapshot SHA")
1226
- request = snapshot_request(root, commit)
1464
+ raise ValueError("Runner checkout does not match the dispatched commit")
1465
+ request = runner_request(root, commit)
1227
1466
  directory = within(root, request["cwd"])
1228
1467
  check_secrets(root)
1229
1468
  names = {option_name(value) for value in request["args"]}
@@ -1828,7 +2067,11 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
1828
2067
  ]
1829
2068
  return {
1830
2069
  "name": "Bitfab replay",
1831
- "on": "workflow_dispatch",
2070
+ "on": {
2071
+ "workflow_dispatch": {
2072
+ "inputs": {"request": {"type": "string", "required": False}}
2073
+ }
2074
+ },
1832
2075
  "permissions": {"contents": "read"},
1833
2076
  "jobs": {"replay": job},
1834
2077
  }
@@ -181,7 +181,7 @@ function encodeRequestBody(body) {
181
181
  }
182
182
 
183
183
  // src/version.generated.ts
184
- var __version__ = "0.64.4";
184
+ var __version__ = "0.64.5";
185
185
  var __packageName__ = "bitfab";
186
186
 
187
187
  // src/errors.ts
@@ -2500,4 +2500,4 @@ export {
2500
2500
  HttpClient,
2501
2501
  experimentIdWireFields
2502
2502
  };
2503
- //# sourceMappingURL=chunk-XASGXGQN.js.map
2503
+ //# sourceMappingURL=chunk-4IRNG6KB.js.map