bitfab 0.65.0 → 0.65.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa27d322488c12e530f14a5f85a7f9978208edf332ab91f7bf156f4ccb7b0b25
4
- data.tar.gz: f8cadf7e8693aa155c254b7866b41f1781d5de4493dad8b0aad6643d285469a6
3
+ metadata.gz: 1ccbb4e0e912ec35fe388c4cdb2225c4fa3c4f5e020e87c7d52f81a3358907da
4
+ data.tar.gz: 4e674dc9c1fb2e1c2ef6f5d5b1cf21146904a0d85989d7e41f25ce6c0b04c740
5
5
  SHA512:
6
- metadata.gz: b274fec88de7c05f84977a60cc146290e89e2e8778fec9a498967304cc2c46a7cb65efa27f65210ec76ba1a2b58dbd3738e4f969d6ac126a9c3a87b25f80c30c
7
- data.tar.gz: 8ddf0c559dc1dd33139497e18acf48a065017a59ea05b81e708ee9d3aa4a8eae1b202fb1b5243e7027131b27bcaedd5a2c3065df2fa964793c388609f199e3ad
6
+ metadata.gz: a5f9344e7be229c578f2fb875de96aa11f49487b24775eec15955f32d18ab4552067842af6b4a8f12041f6140ee21bafbd3983630d0929e00a35236fb427e95f
7
+ data.tar.gz: 15bd45c45d9f6700edd7b96bbc792236441581763097307d13d4a14a76d0d8d733e5444c1586c9a24f6c990a8575e70851c37a80ff10b4f153bb84bb554e00c2
@@ -116,6 +116,8 @@ PUSH_TRIGGER = re.compile(
116
116
  r"^[ \t]*\"?(?:on\"?[ \t]*:.*\bpush\b|push\"?[ \t]*:)", re.MULTILINE
117
117
  )
118
118
  DISPATCH_INPUTS = ("execution_id", "request", KEY_INPUT)
119
+ PUSH_SUBJECT = "Bitfab replay started on push"
120
+ PUSH_CONDITION = f"github.event_name != 'push' || !startsWith(github.ref_name, '{PREFIX}') || startsWith(github.event.head_commit.message, '{PUSH_SUBJECT}')"
119
121
  BRANCH_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
120
122
  ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
121
123
  RESERVED_ENV_PREFIXES = ("GITHUB_", "RUNNER_", "ACTIONS_", "BITFAB_REPLAY_")
@@ -254,7 +256,8 @@ Set up once:
254
256
  [--runs-on LABEL] [--secret-prefix PREFIX] [--check COMMAND]
255
257
  Writes .github/workflows/bitfab-replay.yml, the only file cloud replay keeps
256
258
  in the repository, and lists what is left to do. On an existing workflow it
257
- adds only the lines a newer SDK needs and lists what it changed.
259
+ adds only the lines a newer SDK needs, such as the push trigger that lets a
260
+ replay run without GitHub access, and lists what it changed.
258
261
  --cloud-update [same options] [--remove-old-key-secrets]
259
262
  The same command as --cloud-init, for bringing an existing setup up to date.
260
263
  It also lists BITFAB_REPLAY_KEY_* secrets an older SDK left in the
@@ -266,6 +269,16 @@ Set up once:
266
269
  GitHub access comes from the GitHub CLI when it is logged in, otherwise from GH_TOKEN
267
270
  or GITHUB_TOKEN, otherwise from the github.com credential git already stores. Without
268
271
  the GitHub CLI, --cloud-secrets lists the secrets to create by hand and where.
272
+
273
+ Without GitHub access, or with access that cannot read or push to the repository, a
274
+ replay still runs when git can push the snapshot branch and the workflow also starts
275
+ on a push to a bitfab-replay/ branch, as one written or updated by --cloud-init does.
276
+ This command then warns, prints where to watch the run, and returns once the branch
277
+ is pushed, with no updates and exit code 0. The run uses the workflow's BITFAB_API_KEY
278
+ secret, and --cloud-full-output and --cloud-allow-file are refused, since only GitHub
279
+ access can confirm the repository is private. Delete the snapshot branch once the run
280
+ finishes. With access, a replay starts through the GitHub API as before, and its
281
+ snapshot commit asks GitHub not to start any workflow on the push.
269
282
  """
270
283
 
271
284
 
@@ -281,6 +294,10 @@ class DispatchRejected(ValueError):
281
294
  self.record = record
282
295
 
283
296
 
297
+ class GithubUnreachable(ValueError):
298
+ pass
299
+
300
+
284
301
  class KeepRedirect(urllib.request.HTTPRedirectHandler):
285
302
  def redirect_request(self, *args, **kwargs):
286
303
  return None
@@ -939,7 +956,15 @@ def sensitive(path):
939
956
  )
940
957
 
941
958
 
942
- def snapshot(root, workflow, files, request, *, preview=False, allowed=()):
959
+ def snapshot_subject(execution_id, on_push):
960
+ if on_push:
961
+ return f"{PUSH_SUBJECT} {execution_id}"
962
+ return f"Bitfab replay {execution_id} [skip actions]"
963
+
964
+
965
+ def snapshot(
966
+ root, workflow, files, request, *, preview=False, allowed=(), on_push=False
967
+ ):
943
968
  if git(root, "ls-files", "-u"):
944
969
  raise ValueError("Resolve merge conflicts before snapshotting")
945
970
  head = git(root, "rev-parse", "HEAD")
@@ -1014,7 +1039,7 @@ def snapshot(root, workflow, files, request, *, preview=False, allowed=()):
1014
1039
  tree,
1015
1040
  "-p",
1016
1041
  head,
1017
- input=f"Bitfab replay {request['id']}\n\n{json.dumps(request)}\n",
1042
+ input=f"{snapshot_subject(request['id'], on_push)}\n\n{json.dumps(request)}\n",
1018
1043
  )
1019
1044
  return {**summary, "sha": sha}
1020
1045
 
@@ -1050,9 +1075,8 @@ def execution_lock(directory, execution_id):
1050
1075
  def find_run(record):
1051
1076
  if record.get("snapshot") is False:
1052
1077
  return find_branch_run(record)
1053
- query = urlencode(
1054
- {"event": "workflow_dispatch", "branch": record["branch"], "per_page": 100}
1055
- )
1078
+ event = "push" if record.get("startedOnPush") else "workflow_dispatch"
1079
+ query = urlencode({"event": event, "branch": record["branch"], "per_page": 100})
1056
1080
  runs = api(
1057
1081
  record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
1058
1082
  )
@@ -1274,15 +1298,19 @@ def http_status(error):
1274
1298
 
1275
1299
 
1276
1300
  def preflight(repo, workflow, *, pushes=True, full_output=False, allows_files=False):
1277
- access = github_access()
1301
+ try:
1302
+ access = github_access()
1303
+ except ValueError as error:
1304
+ raise GithubUnreachable(str(error)) from error
1278
1305
  try:
1279
1306
  details = api(repo, "")
1280
1307
  except RuntimeError as error:
1281
- raise ValueError(
1308
+ refused = getattr(error, "http_status", None) in (401, 403, 404)
1309
+ raise (GithubUnreachable if refused else ValueError)(
1282
1310
  f"GitHub access from {access['source']} cannot read {repo}{http_status(error)}; use an account or token with write access to it"
1283
1311
  ) from error
1284
1312
  if pushes and (details or {}).get("permissions", {}).get("push") is False:
1285
- raise ValueError(
1313
+ raise GithubUnreachable(
1286
1314
  f"GitHub access from {access['source']} cannot push to {repo}; use an account or token with write access to it"
1287
1315
  )
1288
1316
  if full_output and (details or {}).get("private") is False:
@@ -1344,6 +1372,12 @@ def submit(root, repo, workflow, parsed, path):
1344
1372
  record = json.loads(path.read_text())
1345
1373
  if record["request"] != request or record["repository"].lower() != repo.lower():
1346
1374
  raise ValueError("Execution ID already belongs to a different request")
1375
+ if record.get("startedOnPush") and record["state"] != "prepared":
1376
+ try:
1377
+ github_access()
1378
+ except ValueError as error:
1379
+ record["unfollowed"] = str(error)
1380
+ return record
1347
1381
  status(record, fetch_result=False)
1348
1382
  if record["state"] == "prepared":
1349
1383
  raise ValueError(
@@ -1351,17 +1385,24 @@ def submit(root, repo, workflow, parsed, path):
1351
1385
  )
1352
1386
  refuse_rejected(record)
1353
1387
  return record
1354
- preflight(
1355
- repo,
1356
- workflow,
1357
- full_output=request.get("fullOutput") is True,
1358
- allows_files=bool(allowed),
1359
- )
1388
+ unfollowed = None
1389
+ try:
1390
+ preflight(
1391
+ repo,
1392
+ workflow,
1393
+ full_output=request.get("fullOutput") is True,
1394
+ allows_files=bool(allowed),
1395
+ )
1396
+ except GithubUnreachable as error:
1397
+ unfollowed = str(error)
1398
+ refuse_unfollowed(root, repo, workflow, request, allowed, unfollowed)
1360
1399
  if dry_run(request["args"]):
1361
1400
  progress(
1362
1401
  "--dry-run dispatches a GitHub run that checks secrets and resolves the traces without replaying; it receives the workflow's secrets"
1363
1402
  )
1364
- source = snapshot(root, workflow, files, request, allowed=allowed)
1403
+ source = snapshot(
1404
+ root, workflow, files, request, allowed=allowed, on_push=bool(unfollowed)
1405
+ )
1365
1406
  describe_snapshot(source)
1366
1407
  record = {
1367
1408
  "id": execution_id,
@@ -1370,6 +1411,7 @@ def submit(root, repo, workflow, parsed, path):
1370
1411
  "branch": PREFIX + execution_id,
1371
1412
  "request": request,
1372
1413
  "state": "prepared",
1414
+ **({"startedOnPush": True} if unfollowed else {}),
1373
1415
  **source,
1374
1416
  }
1375
1417
  save(path, record)
@@ -1389,6 +1431,9 @@ def submit(root, repo, workflow, parsed, path):
1389
1431
  ) from error
1390
1432
  record["state"] = "dispatch_unknown"
1391
1433
  save(path, record)
1434
+ if unfollowed:
1435
+ record["unfollowed"] = unfollowed
1436
+ return record
1392
1437
  text = within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
1393
1438
  declared = declared_inputs(text)
1394
1439
  key_inputs = issue_key_token(root, record, text)
@@ -1400,6 +1445,62 @@ def submit(root, repo, workflow, parsed, path):
1400
1445
  )
1401
1446
 
1402
1447
 
1448
+ def push_trigger(lines):
1449
+ for index, line in enumerate(lines):
1450
+ if re.fullmatch(r'"?on"?[ \t]*:[ \t]*(#.*)?', line):
1451
+ end = block_end(lines, index, 0)
1452
+ step = child_indent(lines, index, end, " ")
1453
+ row = next(
1454
+ (
1455
+ row
1456
+ for row in range(index + 1, end)
1457
+ if re.fullmatch(rf'{step}"?push"?[ \t]*:.*', lines[row])
1458
+ ),
1459
+ None,
1460
+ )
1461
+ if row is None:
1462
+ return None
1463
+ return lines[row : block_end(lines, row, len(step))]
1464
+ if re.match(r'"?on"?[ \t]*:', line):
1465
+ return [line] if re.search(r"\bpush\b", line) else None
1466
+ return None
1467
+
1468
+
1469
+ def starts_on_push(text):
1470
+ trigger = push_trigger(text.splitlines()) or []
1471
+ return any(PREFIX in line for line in trigger) and not any(
1472
+ "branches-ignore" in line for line in trigger
1473
+ )
1474
+
1475
+
1476
+ def refuse_unfollowed(root, repo, workflow, request, allowed, reason):
1477
+ path = f"{WORKFLOW_DIRECTORY}/{workflow}"
1478
+ if not starts_on_push(within(root, path).read_text()):
1479
+ raise ValueError(
1480
+ f"{reason}. Without it a replay can still run, with no updates here, once {path} also starts on a push to a {PREFIX} branch: run bitfab-replay --cloud-update and commit the workflow it updates"
1481
+ )
1482
+ if request.get("fullOutput") is True:
1483
+ raise ValueError(
1484
+ f"--cloud-full-output writes every trace's input and output into the Actions job log, so it needs GitHub access that confirms {repo} is private: {reason}"
1485
+ )
1486
+ if allowed:
1487
+ raise ValueError(
1488
+ f"{ALLOW_FLAG} uploads a credential-like file to a snapshot branch, so it needs GitHub access that confirms {repo} is private: {reason}"
1489
+ )
1490
+
1491
+
1492
+ def report_unfollowed(record, reason):
1493
+ progress(
1494
+ f"Warning: the replay started on GitHub, but this command gets no updates on it: {reason}"
1495
+ )
1496
+ progress(
1497
+ f"It runs with the workflow's BITFAB_API_KEY secret, since replaying as you needs GitHub access. Watch it at https://github.com/{record['repository']}/actions/workflows/{record['workflow']}; its experiment appears in Bitfab when it finishes"
1498
+ )
1499
+ progress(
1500
+ f"Once it finishes, delete its snapshot branch with git push origin --delete {record['branch']}, or with --cloud-cleanup {record['id']} from a machine with GitHub access"
1501
+ )
1502
+
1503
+
1403
1504
  def local_client(root, request):
1404
1505
  try:
1405
1506
  output = command(
@@ -1736,6 +1837,11 @@ def run_cli(argv):
1736
1837
  raise
1737
1838
  if record.get("preview"):
1738
1839
  return record, None
1840
+ reason = record.pop("unfollowed", None)
1841
+ if reason:
1842
+ report_unfollowed(record, reason)
1843
+ save(path, record)
1844
+ return record, None
1739
1845
  else:
1740
1846
  if not path.exists():
1741
1847
  raise ValueError(
@@ -2791,7 +2897,7 @@ def push_triggers(root, workflow):
2791
2897
 
2792
2898
 
2793
2899
  def workflow_document(steps, run, directory, env, *, runs_on, environment):
2794
- job = {"runs-on": runs_on}
2900
+ job = {"if": PUSH_CONDITION, "runs-on": runs_on}
2795
2901
  if environment:
2796
2902
  job["environment"] = environment
2797
2903
  job["steps"] = [
@@ -2816,7 +2922,8 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
2816
2922
  "request": {"type": "string", "required": False},
2817
2923
  KEY_INPUT: {"type": "string", "required": False},
2818
2924
  }
2819
- }
2925
+ },
2926
+ "push": {"branches": [PREFIX + "**"]},
2820
2927
  },
2821
2928
  "permissions": {"contents": "read", "id-token": "write"},
2822
2929
  "jobs": {"replay": job},
@@ -2830,10 +2937,18 @@ def indentation(line):
2830
2937
  def block_end(lines, start, indent):
2831
2938
  end = start + 1
2832
2939
  while end < len(lines) and (
2833
- not lines[end].strip() or indentation(lines[end]) > indent
2940
+ not lines[end].strip()
2941
+ or lines[end].lstrip().startswith("#")
2942
+ or indentation(lines[end]) > indent
2834
2943
  ):
2835
2944
  end += 1
2836
- while end > start + 1 and not lines[end - 1].strip():
2945
+ while end > start + 1 and (
2946
+ not lines[end - 1].strip()
2947
+ or (
2948
+ lines[end - 1].lstrip().startswith("#")
2949
+ and indentation(lines[end - 1]) <= indent
2950
+ )
2951
+ ):
2837
2952
  end -= 1
2838
2953
  return end
2839
2954
 
@@ -2938,19 +3053,67 @@ def add_id_token_permission(lines):
2938
3053
  if not re.fullmatch(r'"?permissions"?[ \t]*:[ \t]*(#.*)?', line):
2939
3054
  continue
2940
3055
  end = block_end(lines, index, 0)
2941
- child = next(
2942
- (
2943
- lines[row][: indentation(lines[row])]
2944
- for row in range(index + 1, end)
2945
- if lines[row].strip()
2946
- ),
2947
- " ",
2948
- )
2949
- lines.insert(end, f"{child}id-token: write")
3056
+ lines.insert(end, f"{child_indent(lines, index, end, ' ')}id-token: write")
2950
3057
  return True
2951
3058
  return False
2952
3059
 
2953
3060
 
3061
+ def holds_yaml(line):
3062
+ return bool(line.strip()) and not line.lstrip().startswith("#")
3063
+
3064
+
3065
+ def child_indent(lines, index, end, fallback):
3066
+ return next(
3067
+ (
3068
+ lines[row][: indentation(lines[row])]
3069
+ for row in range(index + 1, end)
3070
+ if holds_yaml(lines[row])
3071
+ ),
3072
+ fallback,
3073
+ )
3074
+
3075
+
3076
+ def add_push_trigger(lines):
3077
+ for index, line in enumerate(lines):
3078
+ if not re.fullmatch(r'"?on"?[ \t]*:[ \t]*(#.*)?', line):
3079
+ continue
3080
+ end = block_end(lines, index, 0)
3081
+ step = child_indent(lines, index, end, " ")
3082
+ lines[end:end] = [
3083
+ f"{step}push:",
3084
+ f"{step * 2}branches:",
3085
+ f"{step * 3}- {PREFIX}**",
3086
+ ]
3087
+ return True
3088
+ return False
3089
+
3090
+
3091
+ def add_push_condition(lines):
3092
+ jobs = next(
3093
+ (
3094
+ index
3095
+ for index, line in enumerate(lines)
3096
+ if re.fullmatch(r'"?jobs"?[ \t]*:[ \t]*(#.*)?', line)
3097
+ ),
3098
+ None,
3099
+ )
3100
+ if jobs is None:
3101
+ return False
3102
+ end = block_end(lines, jobs, 0)
3103
+ job = next((row for row in range(jobs + 1, end) if holds_yaml(lines[row])), None)
3104
+ if job is None or not lines[job].rstrip().endswith(":"):
3105
+ return False
3106
+ job_end = block_end(lines, job, indentation(lines[job]))
3107
+ step = child_indent(lines, job, job_end, " " * (indentation(lines[job]) + 2))
3108
+ if any(
3109
+ re.fullmatch(rf'{step}"?if"?[ \t]*:.*', lines[row])
3110
+ for row in range(job + 1, job_end)
3111
+ ):
3112
+ return False
3113
+ lines.insert(job + 1, f"{step}if: {json.dumps(PUSH_CONDITION)}")
3114
+ return True
3115
+
3116
+
2954
3117
  def secret_scope(environment):
2955
3118
  return f"environments/{environment}/" if environment else "actions/"
2956
3119
 
@@ -3041,6 +3204,20 @@ def update_workflow(root, path, *, remove_old_key_secrets=False):
3041
3204
  by_hand.append(
3042
3205
  f"{name} (type: string, required: false) under on.workflow_dispatch.inputs"
3043
3206
  )
3207
+ if not starts_on_push(text):
3208
+ if push_trigger(lines) is None and add_push_trigger(lines):
3209
+ updated.append(
3210
+ f"Started the workflow on a push to a {PREFIX} branch too, so a replay runs without GitHub access, with no updates on it"
3211
+ )
3212
+ else:
3213
+ by_hand.append(f"{PREFIX}** in on.push.branches")
3214
+ if push_trigger(lines) is not None and PUSH_SUBJECT not in text:
3215
+ if add_push_condition(lines):
3216
+ updated.append(
3217
+ "Skipped the push run of a replay started through the GitHub API, so each replay runs once"
3218
+ )
3219
+ else:
3220
+ by_hand.append(f"if: {json.dumps(PUSH_CONDITION)} on the replay job")
3044
3221
  if not re.search(r'^"?run-name"?[ \t]*:', text, re.MULTILINE):
3045
3222
  add_run_name(lines)
3046
3223
  updated.append("Named each run after its execution")
@@ -3095,7 +3272,7 @@ def update_workflow(root, path, *, remove_old_key_secrets=False):
3095
3272
  def initialize(argv, name="--cloud-init"):
3096
3273
  parser = argparse.ArgumentParser(
3097
3274
  prog=f"bitfab-replay {name}",
3098
- description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository, and list what is left to do. It detects the runtime, package manager, install command, and replay command from the directory you run it in. On an existing workflow it only adds what a newer SDK needs (the execution_id and key_id inputs, the run name, and the id-token: write permission), removes the BITFAB_REPLAY_BITFAB_API_KEY line older SDKs wrote, lists what it changed, and lists the BITFAB_REPLAY_KEY_* secrets older SDKs left in the repository; edit it directly for anything else.",
3275
+ description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository, and list what is left to do. It detects the runtime, package manager, install command, and replay command from the directory you run it in. On an existing workflow it only adds what a newer SDK needs (the execution_id and key_id inputs, the push trigger and the condition that skips a duplicate push run, the run name, and the id-token: write permission), removes the BITFAB_REPLAY_BITFAB_API_KEY line older SDKs wrote, lists what it changed, and lists the BITFAB_REPLAY_KEY_* secrets older SDKs left in the repository; edit it directly for anything else.",
3099
3276
  )
3100
3277
  parser.add_argument(
3101
3278
  "--secret",
@@ -17,6 +17,9 @@ module Bitfab
17
17
  # trace function. Experiments replay against it and its graders score its
18
18
  # members. Every method returns the parsed JSON response as a Hash with
19
19
  # string keys matching the HTTP API ("traceCount", "addedTraceIds", ...).
20
+ # Every dataset summary carries "golden", true when the dataset is golden
21
+ # for the whole organization and listed first in the Bitfab app, and false
22
+ # otherwise, including when an older server leaves the field out.
20
23
  class Datasets
21
24
  DEFAULT_RERUN_TIMEOUT_SECONDS = 90
22
25
  RERUN_POLL_INTERVAL_SECONDS = 1.0
@@ -29,11 +32,14 @@ module Bitfab
29
32
 
30
33
  # Create a dataset, or update the one already named this way under the
31
34
  # same trace function. The result's "created" reports which happened. A
32
- # nil description leaves an existing description untouched.
33
- def save(trace_function_key:, name:, description: nil)
35
+ # nil description leaves an existing description untouched. golden: true
36
+ # marks the dataset as golden for the whole organization so the Bitfab app
37
+ # lists it first, false unmarks it, and nil leaves it as it is.
38
+ def save(trace_function_key:, name:, description: nil, golden: nil)
34
39
  payload = {"traceFunctionKey" => trace_function_key, "name" => name}
35
40
  payload["description"] = description unless description.nil?
36
- @http_client.request("/api/sdk/datasets", payload)
41
+ payload["golden"] = golden unless golden.nil?
42
+ with_golden_default(@http_client.request("/api/sdk/datasets", payload))
37
43
  end
38
44
 
39
45
  # List datasets, scoped to one trace function when given and
@@ -44,7 +50,7 @@ module Bitfab
44
50
  datasets = []
45
51
  loop do
46
52
  result = @http_client.get("/api/sdk/datasets?#{URI.encode_www_form(query)}")
47
- datasets.concat(result["datasets"])
53
+ datasets.concat(result["datasets"].map { |summary| summary_with_golden_default(summary) })
48
54
  cursor = result["nextCursor"]
49
55
  return datasets if cursor.nil?
50
56
 
@@ -55,22 +61,24 @@ module Bitfab
55
61
  # Fetch one dataset by id. A dataset outside this organization raises
56
62
  # Net::HTTPError with a 404.
57
63
  def get(dataset_id)
58
- @http_client.get(dataset_path(dataset_id))["dataset"]
64
+ summary_with_golden_default(@http_client.get(dataset_path(dataset_id))["dataset"])
59
65
  end
60
66
 
61
67
  # Rename the dataset or change its description, and return the updated
62
68
  # dataset. It keeps its id, traces, and graders. A nil argument leaves that
63
- # field untouched, and an empty description clears it. Raises ArgumentError
64
- # when both are nil. A blank name, or one another dataset under the same
69
+ # field untouched, and an empty description clears it. golden: true marks
70
+ # the dataset as golden for the whole organization and false unmarks it.
71
+ # Raises ArgumentError when name, description, and golden are all nil. A blank name, or one another dataset under the same
65
72
  # trace function already uses, raises Net::HTTPError with a 400. A dataset
66
73
  # outside this organization raises Net::HTTPError with a 404.
67
- def update(dataset_id, name: nil, description: nil)
68
- raise ArgumentError, "Provide name and/or description to update." if name.nil? && description.nil?
74
+ def update(dataset_id, name: nil, description: nil, golden: nil)
75
+ raise ArgumentError, "Provide name, description, and/or golden to update." if name.nil? && description.nil? && golden.nil?
69
76
 
70
77
  payload = {}
71
78
  payload["name"] = name unless name.nil?
72
79
  payload["description"] = description unless description.nil?
73
- @http_client.request(dataset_path(dataset_id), payload, method: "PATCH")["dataset"]
80
+ payload["golden"] = golden unless golden.nil?
81
+ summary_with_golden_default(@http_client.request(dataset_path(dataset_id), payload, method: "PATCH")["dataset"])
74
82
  end
75
83
 
76
84
  # The ids of every trace in the dataset, the same membership a replay with
@@ -92,24 +100,24 @@ module Bitfab
92
100
  # organization or under another trace function are reported in
93
101
  # "skippedTraceIds" rather than failing the call.
94
102
  def add_traces(dataset_id, trace_ids)
95
- @http_client.request(dataset_path(dataset_id, "/traces"), {"traceIds" => trace_ids})
103
+ with_golden_default(@http_client.request(dataset_path(dataset_id, "/traces"), {"traceIds" => trace_ids}))
96
104
  end
97
105
 
98
106
  # Remove traces from the dataset. The traces themselves are never deleted.
99
107
  def remove_traces(dataset_id, trace_ids)
100
- @http_client.request(dataset_path(dataset_id, "/removeTraces"), {"traceIds" => trace_ids})
108
+ with_golden_default(@http_client.request(dataset_path(dataset_id, "/removeTraces"), {"traceIds" => trace_ids}))
101
109
  end
102
110
 
103
111
  # Assign graders to the dataset (1 to 100 ids per call). Graders outside
104
112
  # the organization or under another trace function are reported in
105
113
  # "skippedGraderIds" rather than failing the call.
106
114
  def add_graders(dataset_id, grader_ids)
107
- @http_client.request(dataset_path(dataset_id, "/graders"), {"graderIds" => grader_ids})
115
+ with_golden_default(@http_client.request(dataset_path(dataset_id, "/graders"), {"graderIds" => grader_ids}))
108
116
  end
109
117
 
110
118
  # Unassign graders from the dataset.
111
119
  def remove_graders(dataset_id, grader_ids)
112
- @http_client.request(dataset_path(dataset_id, "/removeGraders"), {"graderIds" => grader_ids})
120
+ with_golden_default(@http_client.request(dataset_path(dataset_id, "/removeGraders"), {"graderIds" => grader_ids}))
113
121
  end
114
122
 
115
123
  # Re-run graders over every trace in the dataset. Defaults to every
@@ -140,6 +148,18 @@ module Bitfab
140
148
 
141
149
  private
142
150
 
151
+ def with_golden_default(result)
152
+ return result unless result["dataset"].is_a?(Hash)
153
+
154
+ result.merge("dataset" => summary_with_golden_default(result["dataset"]))
155
+ end
156
+
157
+ def summary_with_golden_default(summary)
158
+ return summary unless summary.is_a?(Hash)
159
+
160
+ {"golden" => false}.merge(summary)
161
+ end
162
+
143
163
  def settle_grader_rerun(dataset_id, run, timeout)
144
164
  case run["status"]
145
165
  when "completed"
@@ -796,6 +796,8 @@ module Bitfab
796
796
  end
797
797
 
798
798
  def skip_boundary_call?(event)
799
+ return false if sdk_frame?(event.path)
800
+
799
801
  boundary = @stacks[Fiber.current.object_id]&.last
800
802
  if boundary.is_a?(Boundary) && boundary.skip_next_call &&
801
803
  boundary.receiver.equal?(event.self) && boundary.method_name == called_method_id(event)
@@ -819,6 +821,13 @@ module Bitfab
819
821
  true
820
822
  end
821
823
 
824
+ def sdk_frame?(path)
825
+ @sdk_frames ||= {}
826
+ @sdk_frames.fetch(path) do
827
+ @sdk_frames[path] = Subtree.realpath(path)&.start_with?(SDK_PREFIX) || false
828
+ end
829
+ end
830
+
822
831
  def passthrough_wrapper?(parameters)
823
832
  kinds = parameters.map(&:first)
824
833
  kinds.any? { |kind| kind == :rest || kind == :keyrest } &&
@@ -108,7 +108,7 @@ module Bitfab
108
108
  client: target_client, trace_function_key:, config: capture_config,
109
109
  max_depth:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
110
110
  root_receiver: self, root_method_name: method_name
111
- ) { super(*args, **kwargs, &block) }
111
+ ) { Traceable.send(:as_root_call, self, method_name) { super(*args, **kwargs, &block) } }
112
112
  end
113
113
  call = Subtree::ConfiguredCall.new(receiver: self, method_name:, name: span_name,
114
114
  type:, args:, kwargs:, nested_trace_key: trace_function_key)
@@ -122,12 +122,14 @@ module Bitfab
122
122
  client: target_client, trace_function_key:, config: capture_config,
123
123
  max_depth:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
124
124
  root_receiver: self, root_method_name: method_name
125
- ) { super(*args, **kwargs, &block) }
125
+ ) { Traceable.send(:as_root_call, self, method_name) { super(*args, **kwargs, &block) } }
126
126
  end
127
127
  end
128
128
  end
129
129
  wrapper.instance_variable_set(:@bitfab_span_method, method_name_str)
130
130
  wrapper.instance_variable_set(:@bitfab_span_key, trace_function_key)
131
+ wrapper.instance_variable_set(:@bitfab_trace_method, method_name_str)
132
+ wrapper.instance_variable_set(:@bitfab_trace_client, bound_client)
131
133
  preserve_visibility(klass, wrapper, method_name)
132
134
  klass.prepend(wrapper)
133
135
  end
@@ -142,9 +144,12 @@ module Bitfab
142
144
  bound_client = client
143
145
  wrapper = Module.new do
144
146
  define_method(method_name) do |*args, **kwargs, &block|
147
+ next super(*args, **kwargs, &block) if Traceable.send(:own_span_call?, self, method_name)
148
+
145
149
  Subtree.assert_surface!(:trace)
146
150
  session = Subtree.current_session
147
- if !session || (bound_client && !session.client.equal?(bound_client))
151
+ if !session || (bound_client && !session.client.equal?(bound_client)) ||
152
+ Traceable.send(:root_handles_call?, self, method_name, wrapper)
148
153
  next super(*args, **kwargs, &block)
149
154
  end
150
155
  unless capture
@@ -176,6 +181,49 @@ module Bitfab
176
181
  klass.prepend(wrapper)
177
182
  end
178
183
 
184
+ ROOT_CALL_KEY = :bitfab_root_call
185
+
186
+ def self.as_root_call(receiver, method_name)
187
+ previous = Thread.current[ROOT_CALL_KEY]
188
+ Thread.current[ROOT_CALL_KEY] = [receiver, method_name.to_sym]
189
+ yield
190
+ ensure
191
+ Thread.current[ROOT_CALL_KEY] = previous
192
+ end
193
+ private_class_method :as_root_call
194
+
195
+ def self.root_handles_call?(receiver, method_name, node_wrapper)
196
+ marker = Thread.current[ROOT_CALL_KEY]
197
+ if marker && marker[0].equal?(receiver) && marker[1] == method_name.to_sym
198
+ Thread.current[ROOT_CALL_KEY] = nil
199
+ return true
200
+ end
201
+
202
+ trace_wrapper = inner_trace_wrapper(receiver, method_name, node_wrapper)
203
+ return false unless trace_wrapper
204
+
205
+ client = trace_wrapper.instance_variable_get(:@bitfab_trace_client) || Bitfab.client_or_nil
206
+ (client&.send(:should_record?) && !Subtree.nested_root_suppressed?) ? true : false
207
+ end
208
+ private_class_method :root_handles_call?
209
+
210
+ def self.own_span_call?(receiver, method_name)
211
+ span = SpanContext.current
212
+ return false unless span && span[:surface] == :span
213
+
214
+ span[:explicit_span_receiver].equal?(receiver) && span[:explicit_span_method_name] == method_name
215
+ end
216
+ private_class_method :own_span_call?
217
+
218
+ def self.inner_trace_wrapper(receiver, method_name, node_wrapper)
219
+ ancestors = receiver.is_a?(Module) ? receiver.singleton_class.ancestors : receiver.class.ancestors
220
+ name = method_name.to_s
221
+ ancestors.drop_while { |mod| !mod.equal?(node_wrapper) }.find do |mod|
222
+ mod.instance_variable_defined?(:@bitfab_trace_method) && mod.instance_variable_get(:@bitfab_trace_method) == name
223
+ end
224
+ end
225
+ private_class_method :inner_trace_wrapper
226
+
179
227
  def self.source_of(klass, method_name)
180
228
  path, line = klass.instance_method(method_name).source_location || [nil, nil]
181
229
  [path, line, method_name]
@@ -345,15 +393,14 @@ module Bitfab
345
393
 
346
394
  def method_added(method_name)
347
395
  super
396
+ if defined?(@_bitfab_pending_nodes) && @_bitfab_pending_nodes&.key?(method_name)
397
+ _bitfab_node_method(method_name, **@_bitfab_pending_nodes.delete(method_name))
398
+ end
348
399
  if defined?(@_bitfab_pending_spans) && @_bitfab_pending_spans&.key?(method_name)
349
- config = @_bitfab_pending_spans.delete(method_name)
350
- _bitfab_wrap_method(method_name, **config)
351
- elsif defined?(@_bitfab_pending_traces) && @_bitfab_pending_traces&.key?(method_name)
352
- config = @_bitfab_pending_traces.delete(method_name)
353
- _bitfab_trace_method(method_name, **config)
354
- elsif defined?(@_bitfab_pending_nodes) && @_bitfab_pending_nodes&.key?(method_name)
355
- config = @_bitfab_pending_nodes.delete(method_name)
356
- _bitfab_node_method(method_name, **config)
400
+ _bitfab_wrap_method(method_name, **@_bitfab_pending_spans.delete(method_name))
401
+ end
402
+ if defined?(@_bitfab_pending_traces) && @_bitfab_pending_traces&.key?(method_name)
403
+ _bitfab_trace_method(method_name, **@_bitfab_pending_traces.delete(method_name))
357
404
  end
358
405
  end
359
406
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.65.0"
4
+ VERSION = "0.65.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.65.0
4
+ version: 0.65.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team