|
| 1 | +"""lnt abtest β manage A/B performance experiments on a remote LNT server.""" |
| 2 | +import json |
| 3 | +import ssl |
| 4 | +import urllib.error |
| 5 | +import urllib.request |
| 6 | + |
| 7 | +import certifi |
| 8 | +import click |
| 9 | + |
| 10 | + |
| 11 | +def _api_url(server_url, database, testsuite, *path_parts): |
| 12 | + base = '%s/api/db_%s/v4/%s' % (server_url.rstrip('/'), database, testsuite) |
| 13 | + if path_parts: |
| 14 | + return '%s/%s' % (base, '/'.join(str(p) for p in path_parts)) |
| 15 | + return base |
| 16 | + |
| 17 | + |
| 18 | +def _api_request(method, url, body=None, auth_token=None): |
| 19 | + headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} |
| 20 | + if auth_token: |
| 21 | + headers['AuthToken'] = auth_token |
| 22 | + data = json.dumps(body).encode() if body is not None else None |
| 23 | + req = urllib.request.Request(url, data=data, headers=headers, method=method) |
| 24 | + context = ssl.create_default_context(cafile=certifi.where()) |
| 25 | + try: |
| 26 | + resp = urllib.request.urlopen(req, context=context) |
| 27 | + return json.loads(resp.read()) |
| 28 | + except urllib.error.HTTPError as e: |
| 29 | + body_text = e.read().decode(errors='replace') |
| 30 | + raise click.ClickException('HTTP %d: %s' % (e.code, body_text)) |
| 31 | + except urllib.error.URLError as e: |
| 32 | + raise click.ClickException('Could not connect to %s: %s' % (url, e)) |
| 33 | + |
| 34 | + |
| 35 | +@click.group("abtest") |
| 36 | +def group_abtest(): |
| 37 | + """manage A/B performance experiments on a remote LNT server""" |
| 38 | + |
| 39 | + |
| 40 | +@group_abtest.command("create") |
| 41 | +@click.argument("server_url") |
| 42 | +@click.option("--database", default="default", show_default=True, |
| 43 | + help="LNT database name") |
| 44 | +@click.option("--testsuite", "-s", default="nts", show_default=True, |
| 45 | + help="testsuite name") |
| 46 | +@click.option("--name", default="", |
| 47 | + help="human-readable experiment name") |
| 48 | +@click.option("--control", "control_file", |
| 49 | + type=click.Path(exists=True), default=None, |
| 50 | + help="control run report JSON") |
| 51 | +@click.option("--variant", "variant_file", |
| 52 | + type=click.Path(exists=True), default=None, |
| 53 | + help="variant run report JSON") |
| 54 | +@click.option("--auth-token", envvar="LNT_AUTH_TOKEN", |
| 55 | + help="API auth token (or set LNT_AUTH_TOKEN)") |
| 56 | +def action_abtest_create(server_url, database, testsuite, name, |
| 57 | + control_file, variant_file, auth_token): |
| 58 | + """Create an A/B experiment on a remote LNT server. |
| 59 | +
|
| 60 | +\b |
| 61 | +Two workflows are supported: |
| 62 | +
|
| 63 | + Atomic β both runs available at the same time: |
| 64 | +
|
| 65 | + lnt abtest create SERVER --name "pr-42" \\ |
| 66 | + --control control.json --variant variant.json |
| 67 | +
|
| 68 | + Two-phase β control and variant submitted by independent CI jobs: |
| 69 | +
|
| 70 | + # Orchestrator: create the experiment and capture the ID |
| 71 | + ID=$(lnt abtest create SERVER --name "pr-42") |
| 72 | +
|
| 73 | + # Control CI job |
| 74 | + lnt abtest submit SERVER $ID --control control.json |
| 75 | +
|
| 76 | + # Variant CI job |
| 77 | + lnt abtest submit SERVER $ID --variant variant.json |
| 78 | + """ |
| 79 | + if bool(control_file) != bool(variant_file): |
| 80 | + raise click.UsageError( |
| 81 | + "Provide both --control and --variant for atomic creation, " |
| 82 | + "or neither to create a pending experiment.") |
| 83 | + |
| 84 | + body = {'name': name} |
| 85 | + if control_file: |
| 86 | + with open(control_file) as f: |
| 87 | + body['control'] = json.load(f) |
| 88 | + with open(variant_file) as f: |
| 89 | + body['variant'] = json.load(f) |
| 90 | + |
| 91 | + url = _api_url(server_url, database, testsuite, 'abtest') |
| 92 | + result = _api_request('POST', url, body=body, auth_token=auth_token) |
| 93 | + |
| 94 | + # Print just the ID to stdout so scripts can capture it with $(...). |
| 95 | + click.echo(result['id']) |
| 96 | + exp_url = result.get('url') |
| 97 | + if exp_url: |
| 98 | + click.echo('Experiment: %s' % exp_url, err=True) |
| 99 | + |
| 100 | + |
| 101 | +@group_abtest.command("submit") |
| 102 | +@click.argument("server_url") |
| 103 | +@click.argument("experiment_id", type=int) |
| 104 | +@click.option("--database", default="default", show_default=True, |
| 105 | + help="LNT database name") |
| 106 | +@click.option("--testsuite", "-s", default="nts", show_default=True, |
| 107 | + help="testsuite name") |
| 108 | +@click.option("--control", "control_file", |
| 109 | + type=click.Path(exists=True), default=None, |
| 110 | + help="submit this JSON as the control run") |
| 111 | +@click.option("--variant", "variant_file", |
| 112 | + type=click.Path(exists=True), default=None, |
| 113 | + help="submit this JSON as the variant run") |
| 114 | +@click.option("--auth-token", envvar="LNT_AUTH_TOKEN", |
| 115 | + help="API auth token (or set LNT_AUTH_TOKEN)") |
| 116 | +def action_abtest_submit(server_url, experiment_id, database, testsuite, |
| 117 | + control_file, variant_file, auth_token): |
| 118 | + """Submit a control or variant run to an existing A/B experiment. |
| 119 | +
|
| 120 | +\b |
| 121 | +Used in the two-phase workflow after 'lnt abtest create' has returned an ID: |
| 122 | +
|
| 123 | + lnt abtest submit SERVER ID --control control.json |
| 124 | + lnt abtest submit SERVER ID --variant variant.json |
| 125 | + """ |
| 126 | + if not control_file and not variant_file: |
| 127 | + raise click.UsageError("Provide --control or --variant.") |
| 128 | + if control_file and variant_file: |
| 129 | + raise click.UsageError( |
| 130 | + "Provide --control or --variant, not both. " |
| 131 | + "To submit both at once use 'lnt abtest create'.") |
| 132 | + |
| 133 | + role = 'control' if control_file else 'variant' |
| 134 | + report_file = control_file or variant_file |
| 135 | + |
| 136 | + with open(report_file) as f: |
| 137 | + body = json.load(f) |
| 138 | + |
| 139 | + url = _api_url(server_url, database, testsuite, 'abtest', experiment_id, role) |
| 140 | + _api_request('POST', url, body=body, auth_token=auth_token) |
| 141 | + click.echo('Submitted %s run for experiment %d.' % (role, experiment_id)) |
0 commit comments