2
"""Tool to interact with CircleCI jobs using API key.
4
Get the current status of a circleci pipeline based on branch/commit
6
$ ./circleci-tool status
7
$ ./circleci-tool status --wait
10
Trigger (re)execution of a branch
12
$ ./circleci-tool.py trigger
13
$ ./circleci-tool.py trigger --wait
14
$ ./circleci-tool.py trigger --platform mac
15
$ ./circleci-tool.py trigger --platform mac --test-file tests/test.py
16
$ ./circleci-tool.py trigger --platform win --test-file tests/test.py --test-name test_this
17
$ ./circleci-tool.py trigger --platform win --test-file tests/test.py --test-name test_this --test-repeat 4
18
$ ./circleci-tool.py trigger --toxenv py36,py37 --loop 3
19
$ ./circleci-tool.py trigger --wait --platform win --test-file tests/test_notebooks.py --parallelism 5 --xdist 2
20
$ ./circleci-tool.py trigger --toxenv func-s_service-py37 --loop 3
26
$ ./circleci-tool.py trigger-nightly --slack-notify
29
Download artifacts from an executed workflow
31
$ ./circleci-tool download
45
CIRCLECI_API_TOKEN = "CIRCLECI_TOKEN"
55
platforms_dict = dict(linux="test", lin="test", mac="mac", win="win")
56
platforms_short_dict = dict(linux="lin", lin="lin", mac="mac", win="win")
73
def poll(args, pipeline_id=None, workflow_ids=None):
74
print(f"Waiting for pipeline to complete (Branch: {args.branch})...")
79
url = f"https://circleci.com/api/v2/pipeline/{pipeline_id}/workflow"
80
r = requests.get(url, auth=(args.api_token, ""))
81
assert r.status_code == 200, f"Error making api request: {r}"
83
workflow_ids = [item["id"] for item in d["items"]]
84
num = len(workflow_ids)
85
for work_id in workflow_ids:
86
work_status_url = f"https://circleci.com/api/v2/workflow/{work_id}"
87
r = requests.get(work_status_url, auth=(args.api_token, ""))
89
assert r.status_code == 200, f"Error making api work request: {r}"
92
print("Status:", status)
93
if status not in ("running", "failing"):
95
if num and done == num:
102
url = "https://circleci.com/api/v2/project/gh/wandb/wandb/pipeline"
104
"branch": args.branch,
107
[args.platform, args.toxenv, args.test_file, args.test_name, args.test_repeat]
110
parameters = {"manual": True}
111
platforms = args.platform.split(",") if args.platform else ["linux"]
112
toxenv = args.toxenv or "py37"
114
if args.test_file or args.test_repeat:
117
toxcmd += " " + args.test_file
119
toxcmd += " -k " + args.test_name
121
toxcmd += f" --flake-finder --flake-runs={args.test_repeat}"
123
pyver = toxenv.split("-")[-1]
124
pyname = py_name_dict.get(pyver)
125
assert pyname, f"unknown pyver: {pyver}"
128
toxsplit = toxenv.split("-")
129
assert len(toxsplit) == 3
130
tsttyp, tstshard, tstver = toxsplit
132
if tstshard.startswith(prefix):
133
tstshard = tstshard[len(prefix) :]
134
pyname = f"{pyname}-{tsttyp}-{tstshard}"
135
pyimage = py_image_dict.get(pyver)
136
assert pyimage, f"unknown pyver: {pyver}"
138
job = platforms_dict.get(p)
139
assert job, f"unknown platform: {p}"
140
pshort = platforms_short_dict.get(p)
141
jobname = f"{pshort}-{pyname}"
142
parameters["manual_" + job] = True
143
parameters["manual_" + job + "_name"] = jobname
145
parameters["manual_" + job + "_image"] = pyimage
146
parameters["manual_" + job + "_toxenv"] = toxcmd
148
parameters["manual_parallelism"] = args.parallelism
150
parameters["manual_xdist"] = args.xdist
151
payload["parameters"] = parameters
152
print("Sending to CircleCI:", payload)
155
r = requests.post(url, json=payload, auth=(args.api_token, ""))
156
assert r.status_code == 201, "Error making api request"
159
print("CircleCI workflow started:", uuid)
160
if args.wait or args.loop:
161
poll(args, pipeline_id=uuid)
164
def trigger_nightly(args):
165
url = "https://circleci.com/api/v2/project/gh/wandb/wandb/pipeline"
167
default_shards = set(NIGHTLY_SHARDS)
169
f"nightly_execute_{shard.replace('-', '_')}": False for shard in default_shards
172
requested_shards = set(args.shards.split(",")) if args.shards else default_shards
175
if not requested_shards.issubset(default_shards):
177
f"Requested invalid shards: {requested_shards}. "
178
f"Valid shards are: {default_shards}"
181
for shard in requested_shards:
182
shards[f"nightly_execute_{shard.replace('-', '_')}"] = True
185
"branch": args.branch,
189
"manual_nightly": True,
190
"nightly_git_branch": args.branch,
191
"nightly_slack_notify": args.slack_notify,
197
print("Sending to CircleCI:", payload)
200
r = requests.post(url, json=payload, auth=(args.api_token, ""))
201
assert r.status_code == 201, "Error making api request"
205
print("CircleCI workflow started.")
206
print(f"UUID: {uuid}")
207
print(f"Number: {number}")
209
poll(args, pipeline_id=uuid)
212
def get_ci_builds(args, completed=True):
215
url = "https://circleci.com/api/v1.1/project/gh/wandb/wandb?shallow=true&limit=100"
217
url = url + "&filter=completed"
219
r = requests.get(url, auth=(args.api_token, ""))
220
assert r.status_code == 200, f"Error making api request: {r}"
229
v = d.get("vcs_revision")
230
n = d.get("build_num")
231
j = d.get("workflows", {}).get("job_name")
232
w = d.get("workflows", {}).get("workflow_id")
238
ret.append((v, n, j, w))
244
def grab(args, vhash, bnum):
247
cachedir = ".circle_cache"
248
cfbase = f"cover-{vhash}-{bnum}.xml"
249
cfname = os.path.join(cachedir, cfbase)
250
if not os.path.exists(cachedir):
252
if os.path.exists(cfname):
254
url = f"https://circleci.com/api/v1.1/project/github/wandb/wandb/{bnum}/artifacts"
255
r = requests.get(url, auth=(args.api_token, ""))
256
assert r.status_code == 200, f"Error making api request: {r}"
264
if p != "cover-results/coverage.xml":
268
print("Downloading circle artifacts...")
269
s, o = subprocess.getstatusoutput(
270
f'curl -L -o out.dat -H "Circle-Token: {args.api_token}" {u!r}'
273
os.rename("out.dat", cfname)
278
got = get_ci_builds(args, completed=False)
280
print("ERROR: couldn't find job, maybe we should poll?")
282
work_ids = [workid for _, _, _, workid in got]
283
poll(args, workflow_ids=[work_ids[0]])
287
print(f"Checking for circle artifacts (Branch: {args.branch})...")
288
got = get_ci_builds(args)
290
for v, n, _, _ in got:
295
parser = argparse.ArgumentParser()
297
subparsers = parser.add_subparsers(
298
dest="action", title="action", description="Action to perform"
300
parser.add_argument("--api_token", help=argparse.SUPPRESS)
301
parser.add_argument("--branch", help="git branch (autodetected)")
302
parser.add_argument("--dryrun", action="store_true", help="Don't do anything")
304
parse_trigger = subparsers.add_parser("trigger")
305
parse_trigger.add_argument(
306
"--platform", help="comma-separated platform (linux,mac,win)"
308
parse_trigger.add_argument("--toxenv", help="single toxenv (py36,py37,py38,py39)")
309
parse_trigger.add_argument("--test-file", help="test file (ex: tests/test.py)")
310
parse_trigger.add_argument("--test-name", help="test name (ex: test_dummy)")
311
parse_trigger.add_argument("--test-repeat", type=int, help="repeat N times (ex: 3)")
312
parse_trigger.add_argument("--parallelism", type=int, help="CircleCI parallelism")
313
parse_trigger.add_argument("--xdist", type=int, help="pytest xdist parallelism")
314
parse_trigger.add_argument("--loop", type=int, help="Outer loop (implies wait)")
315
parse_trigger.add_argument(
316
"--wait", action="store_true", help="Wait for finish or error"
319
parse_trigger_nightly = subparsers.add_parser("trigger-nightly")
320
parse_trigger_nightly.add_argument(
321
"--slack-notify", action="store_true", help="post notifications to slack"
323
parse_trigger_nightly.add_argument(
325
default=",".join(NIGHTLY_SHARDS),
326
help="comma-separated shards (standalone-{cpu,gpu,gpu-win},kfp,regression)",
328
parse_trigger_nightly.add_argument(
329
"--wait", action="store_true", help="Wait for finish or error"
332
parse_status = subparsers.add_parser("status")
333
parse_status.add_argument(
334
"--wait", action="store_true", help="Wait for finish or error"
337
parse_download = subparsers.add_parser("download")
338
parse_download.add_argument(
339
"--wait", action="store_true", help="Wait for finish or error"
342
args = parser.parse_args()
346
def process_environment(args):
347
api_token = os.environ.get(CIRCLECI_API_TOKEN)
348
assert api_token, f"Set environment variable: {CIRCLECI_API_TOKEN}"
349
args.api_token = api_token
352
def process_workspace(args):
355
code, branch = subprocess.getstatusoutput("git branch --show-current")
356
assert code == 0, "failed git command"
361
parser, args = process_args()
362
process_environment(args)
363
process_workspace(args)
365
if args.action == "trigger":
366
for i in range(args.loop or 1):
368
print(f"Loop: {i + 1} of {args.loop}")
370
elif args.action == "trigger-nightly":
371
trigger_nightly(args)
372
elif args.action == "status":
375
elif args.action == "download":
381
if __name__ == "__main__":