Skip to content

Commit d31cc1c

Browse files
authored
feat: Add an exec command to submit AP to a test workcell (#193)
This changes adds the ability for a user to submit autoprotocol to a test instance of scle. This allows them to view a schedule against a theoretical device set. Example: ``` cat my_ap.json | transcriptic exec -a "my.scle.endpoint" -w "wc4" ```
1 parent a9b2862 commit d31cc1c

6 files changed

Lines changed: 280 additions & 43 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Unreleased
77

88
Added
99
~~~~~
10-
10+
- A new `exec` command to send autoprotocol to a test workcell
1111
- isort for automatic import sorting
1212
- Example initial tests for `commands` file using `responses` pattern, starting with
1313
`submit` and `projects`.

test/cli_test.py

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,10 @@
22

33
import pytest
44

5-
from click.testing import CliRunner
6-
from Crypto.PublicKey import RSA
75
from transcriptic import commands
86
from transcriptic.cli import cli
97

10-
11-
@pytest.fixture(scope="session")
12-
def temp_tx_dotfile(tmpdir_factory):
13-
path = tmpdir_factory.mktemp("config").join(".transcriptic")
14-
config = {
15-
"email": "somebody@transcriptic.com",
16-
"token": "foobarinvalid",
17-
"organization_id": "transcriptic",
18-
"api_root": "http://foo:5555",
19-
"analytics": True,
20-
"user_id": "ufoo2",
21-
"feature_groups": ["can_submit_autoprotocol", "can_upload_packages"],
22-
}
23-
with open(str(path), "w") as f:
24-
json.dump(config, f)
25-
return path
26-
27-
28-
@pytest.fixture()
29-
def temp_ssh_key(tmpdir_factory):
30-
dir_path = tmpdir_factory.mktemp("ssh")
31-
private_key_path = str(dir_path.join("private.pem"))
32-
public_key_path = str(dir_path.join("public.pem"))
33-
private_key = RSA.generate(2048)
34-
with open(private_key_path, "wb") as private_key_file:
35-
private_key_file.write(private_key.exportKey("PEM"))
36-
pubkey = private_key.publickey()
37-
with open(public_key_path, "wb") as public_key_file:
38-
public_key_file.write(pubkey.exportKey("OpenSSH"))
39-
yield private_key_path, public_key_path
40-
41-
42-
@pytest.fixture
43-
def cli_test_runner(temp_tx_dotfile):
44-
runner = CliRunner(
45-
env={"TRANSCRIPTIC_CONFIG": str(temp_tx_dotfile)}, mix_stderr=False
46-
)
47-
yield runner
8+
from .helpers.fixtures import *
489

4910

5011
def test_kebab_case(cli_test_runner):

test/commands/exec_test.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import json
2+
3+
import requests
4+
5+
from transcriptic.cli import cli
6+
7+
from ..helpers.fixtures import *
8+
from ..helpers.mockAPI import MockResponse
9+
10+
11+
# Structure of the response object from SCLE
12+
def bool_success_res():
13+
return {"success": True}
14+
15+
16+
def mock_api_endpoint():
17+
return "foo.bar.baz"
18+
19+
20+
@pytest.fixture
21+
def ap_file(tmpdir_factory):
22+
"""Make a temp autoprotocol file"""
23+
path = tmpdir_factory.mktemp("foo").join("ap.json")
24+
with open(str(path), "w") as f:
25+
f.write("{}") # any valid json works
26+
return path
27+
28+
29+
def test_good_autoprotocol(cli_test_runner, monkeypatch, ap_file):
30+
def mockpost(*args, **kwargs):
31+
return MockResponse(0, bool_success_res(), json.dumps(bool_success_res()))
32+
33+
monkeypatch.setattr(requests, "post", mockpost)
34+
result = cli_test_runner.invoke(
35+
cli, ["exec", str(ap_file), "-a", mock_api_endpoint()]
36+
)
37+
assert result.exit_code == 0
38+
assert (
39+
f"Success. View {mock_api_endpoint()} to see the scheduling outcome."
40+
in result.output
41+
)
42+
43+
44+
def test_bad_autoprotocol(cli_test_runner):
45+
result = cli_test_runner.invoke(
46+
cli, ["exec", "bad-file-handle", "-a", mock_api_endpoint()]
47+
)
48+
assert result.exit_code != 0
49+
assert "Invalid value for '[AUTOPROTOCOL]': Could not open file" in result.stderr
50+
51+
52+
def test_bad_deviceset(cli_test_runner, ap_file):
53+
result = cli_test_runner.invoke(
54+
cli,
55+
[
56+
"exec",
57+
str(ap_file),
58+
"--device-set",
59+
"bad-file-handle",
60+
"-a",
61+
mock_api_endpoint(),
62+
],
63+
)
64+
assert result.exit_code != 0
65+
assert (
66+
"Invalid value for '--device-set' / '-d': Could not open file: bad-file-handle:"
67+
in result.stderr
68+
)
69+
70+
71+
def test_bad_api_response(cli_test_runner, monkeypatch, ap_file):
72+
def mockpost(*args, **kwargs):
73+
return MockResponse(0, "not-json", "not-json")
74+
75+
monkeypatch.setattr(requests, "post", mockpost)
76+
result = cli_test_runner.invoke(
77+
cli, ["exec", str(ap_file), "-a", mock_api_endpoint()]
78+
)
79+
assert result.exit_code == 0
80+
assert "Error: " in result.stderr
81+
82+
83+
def test_good_workcell(cli_test_runner, monkeypatch, ap_file):
84+
def mockpost(*args, **kwargs):
85+
return MockResponse(0, bool_success_res(), json.dumps(bool_success_res()))
86+
87+
monkeypatch.setattr(requests, "post", mockpost)
88+
result = cli_test_runner.invoke(
89+
cli, ["exec", str(ap_file), "-a", mock_api_endpoint(), "-w", "wc3"]
90+
)
91+
assert result.exit_code == 0
92+
assert (
93+
f"Success. View {mock_api_endpoint()} to see the scheduling outcome."
94+
in result.output
95+
)
96+
97+
98+
def test_bad_workcell(cli_test_runner, ap_file):
99+
result = cli_test_runner.invoke(
100+
cli, ["exec", str(ap_file), "-a", mock_api_endpoint(), "-w", "bad-workcell-id"]
101+
)
102+
assert result.exit_code != 0
103+
assert "Workcell id must be like wcN but was bad-workcell-id" in result.stderr

test/helpers/fixtures.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import json
2+
3+
import pytest
4+
5+
from click.testing import CliRunner
6+
from Crypto.PublicKey import RSA
7+
8+
9+
@pytest.fixture(scope="session")
10+
def temp_tx_dotfile(tmpdir_factory):
11+
path = tmpdir_factory.mktemp("config").join(".transcriptic")
12+
config = {
13+
"email": "somebody@transcriptic.com",
14+
"token": "foobarinvalid",
15+
"organization_id": "transcriptic",
16+
"api_root": "http://foo:5555",
17+
"analytics": True,
18+
"user_id": "ufoo2",
19+
"feature_groups": ["can_submit_autoprotocol", "can_upload_packages"],
20+
}
21+
with open(str(path), "w") as f:
22+
json.dump(config, f)
23+
return path
24+
25+
26+
@pytest.fixture
27+
def cli_test_runner(temp_tx_dotfile):
28+
runner = CliRunner(
29+
env={"TRANSCRIPTIC_CONFIG": str(temp_tx_dotfile)}, mix_stderr=False
30+
)
31+
yield runner
32+
33+
34+
@pytest.fixture()
35+
def temp_ssh_key(tmpdir_factory):
36+
dir_path = tmpdir_factory.mktemp("ssh")
37+
private_key_path = str(dir_path.join("private.pem"))
38+
public_key_path = str(dir_path.join("public.pem"))
39+
private_key = RSA.generate(2048)
40+
with open(private_key_path, "wb") as private_key_file:
41+
private_key_file.write(private_key.exportKey("PEM"))
42+
pubkey = private_key.publickey()
43+
with open(public_key_path, "wb") as public_key_file:
44+
public_key_file.write(pubkey.exportKey("OpenSSH"))
45+
yield private_key_path, public_key_path

transcriptic/cli.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,3 +628,66 @@ def login_cmd(ctx, api_root=None, analytics=True, rsa_key=None):
628628
def format_cmd(manifest):
629629
"""Check Autoprotocol format of manifest.json."""
630630
commands.format(manifest)
631+
632+
633+
@cli.command("exec")
634+
@click.argument("autoprotocol", type=click.File("r"), default=sys.stdin)
635+
@click.option(
636+
"--api", "-a", help="The api endpoint of your scle test workcell instance."
637+
)
638+
@click.option(
639+
"--workcell-id",
640+
"-w",
641+
help="The workcell id to use for the device set. This is not permitted along with the `deviceSet` option.",
642+
)
643+
@click.option(
644+
"--device-set",
645+
"-d",
646+
type=click.File("r"),
647+
help="A DeviceSet json file to use for scheduling. This is not permitted along with the `workcellId` option.",
648+
)
649+
@click.option(
650+
"--time-limit",
651+
"-t",
652+
type=click.INT,
653+
default=30,
654+
help="The maximum time in seconds to spend scheduling. The scheduler will use all the time until an optimal solution is found.",
655+
)
656+
@click.option(
657+
"--partition-group-size",
658+
type=click.INT,
659+
default=None,
660+
help="The number of x_partition groups to be scheduled together.",
661+
)
662+
@click.option(
663+
"--partition-horizon",
664+
type=click.INT,
665+
default=None,
666+
help="The time in seconds to overlap partitions by.",
667+
)
668+
@click.option(
669+
"--partitioning-swap-device-id",
670+
default=None,
671+
help="The device id to use as a swap space when partitioning.",
672+
)
673+
def execute(
674+
autoprotocol,
675+
api,
676+
workcell_id,
677+
device_set,
678+
time_limit,
679+
partition_group_size,
680+
partition_horizon,
681+
partitioning_swap_device_id,
682+
):
683+
"""Send autoprotocol to a test workcell (no hardware) for scheduling."""
684+
commands.execute(
685+
autoprotocol,
686+
api,
687+
workcell_id,
688+
device_set,
689+
time_limit,
690+
partition_group_size,
691+
partition_horizon,
692+
partitioning_swap_device_id,
693+
)

transcriptic/commands.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import json
1111
import locale
1212
import os
13+
import re
1314
import sys
1415
import time
1516
import warnings
@@ -22,6 +23,7 @@
2223
import click
2324
import requests
2425

26+
from click.exceptions import BadParameter
2527
from jinja2 import Environment, PackageLoader
2628
from transcriptic import routes
2729
from transcriptic.auth import StrateosSign
@@ -1315,8 +1317,6 @@ def org_prompt(org_list):
13151317
click.echo(f"{indx + 1}. {o['name']} ({o['subdomain']})")
13161318

13171319
def parse_valid_org(indx):
1318-
from click.exceptions import BadParameter
1319-
13201320
try:
13211321
org_indx = int(indx) - 1
13221322
if org_indx < 0 or org_indx >= len(org_list):
@@ -1476,6 +1476,71 @@ def run_protocol(api, manifest, protocol, inputs, view=False, dye_test=False):
14761476
return
14771477

14781478

1479+
def execute(
1480+
autoprotocol,
1481+
api,
1482+
workcell_id,
1483+
device_set,
1484+
time_limit,
1485+
partition_group_size,
1486+
partition_horizon,
1487+
partitioning_swap_device_id,
1488+
):
1489+
# Get the autoprotocol
1490+
autoprotocol_str = autoprotocol.read()
1491+
try:
1492+
autoprotocol_dict = json.loads(autoprotocol_str)
1493+
except json.decoder.JSONDecodeError as err:
1494+
click.echo(f"Error decoding autoprotocol json: {err}", err=True)
1495+
return
1496+
1497+
# Define the initial payload
1498+
payload = {"autoprotocol": autoprotocol_dict, "timeLimit": f"{time_limit}:second"}
1499+
1500+
if device_set:
1501+
device_str = device_set.read()
1502+
try:
1503+
device_json = json.loads(device_str)
1504+
payload["deviceSet"] = device_json
1505+
except json.decoder.JSONDecodeError as err:
1506+
click.echo(f"Error decoding device set json: {err}", err=True)
1507+
return
1508+
elif workcell_id:
1509+
if not re.search("^wc[a-z,0-9]+$", workcell_id):
1510+
raise BadParameter(f"Workcell id must be like wcN but was {workcell_id}")
1511+
payload["workcellIdForDeviceSet"] = f"{workcell_id}-mcx1"
1512+
else:
1513+
payload["workcellIdForDeviceSet"] = "wctest-mcx1"
1514+
1515+
if partition_group_size is not None:
1516+
payload["partitionGroupSize"] = partition_group_size
1517+
1518+
if partition_horizon is not None:
1519+
payload["partitionHorizon"] = f"{partition_horizon}:second"
1520+
1521+
if partitioning_swap_device_id is not None:
1522+
payload["partitioningSwapDeviceId"] = partitioning_swap_device_id
1523+
1524+
# Clean api end point
1525+
if api[-1] == "/":
1526+
clean_api = api[0:-1] # remove trailing slash
1527+
else:
1528+
clean_api = api
1529+
1530+
# POST to workcell
1531+
test_run_endpoint = f"{clean_api}/testRun"
1532+
click.echo("Sending request...")
1533+
res = requests.post(test_run_endpoint, json=payload)
1534+
try:
1535+
res_json = json.loads(res.text)
1536+
if res_json["success"]:
1537+
click.echo(f"Success. View {clean_api} to see the scheduling outcome.")
1538+
else:
1539+
click.echo(f"Error: {res_json['message']}", err=True)
1540+
except json.decoder.JSONDecodeError:
1541+
click.echo(f"Error: {res.text}", err=True)
1542+
1543+
14791544
def parse_json(json_file):
14801545
try:
14811546
return json.load(open(json_file))

0 commit comments

Comments
 (0)