Skip to content

Commit 9e210ea

Browse files
committed
chore: refactor to cli application
This was running under pytest but it wasn't really _using_ pytest, and therefore didn't have the upsides like test collection and parametrization to balance the downsides like it being annoying to add command line flags. By making it a standalone application, we get all that stuff. While we're at it, refactor so it'll be easier to add new kinds of tests that take advantage of the test spec generation and so on.
1 parent de7bd2b commit 9e210ea

File tree

7 files changed

+708
-523
lines changed

7 files changed

+708
-523
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Run a full snapshot test of labware stackup logic."""
2+
3+
import argparse
4+
from pathlib import Path
5+
import sys
6+
from . import create_stackups, data, stackup_snapshot_test
7+
8+
9+
def run_cli(argv: list[str]) -> int:
10+
"""Run the tests as a command line program. May exit."""
11+
parser = add_args(argparse.ArgumentParser())
12+
args = parser.parse_args(argv)
13+
filters = create_stackups.filter_from_args(args)
14+
results = stackup_snapshot_test.run_stackup_snapshot_tests(
15+
filters=filters,
16+
update_snapshots=args.update_snapshots,
17+
snapshot_path=args.snapshot_file,
18+
)
19+
print(stackup_snapshot_test.summarize(results, args.ignore_bad_stack))
20+
return 0 if stackup_snapshot_test.passed(results, args.ignore_bad_stack) else 1
21+
22+
23+
def add_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
24+
"""Add command line flags to an argument parser."""
25+
parser.description = "Run a full snapshot test of labware stackup logic."
26+
parser.add_argument(
27+
"-u",
28+
"--update-snapshots",
29+
action="store_true",
30+
default=False,
31+
help="Write the results of this test to the snapshots. Note that if you filter, this may delete things you don't want to delete.",
32+
)
33+
parser.add_argument(
34+
"--snapshot-file",
35+
action="store",
36+
default=data.SNAPSHOT_PATH_DEFAULT,
37+
type=Path,
38+
help="Override the default snapshot file path.",
39+
)
40+
bad_stack_group = parser.add_mutually_exclusive_group()
41+
bad_stack_group.add_argument(
42+
"--ignore-bad-stack",
43+
action="store_true",
44+
dest="ignore_bad_stack",
45+
default=True,
46+
help="Do not fail if stacking fails for one of the test specs",
47+
)
48+
bad_stack_group.add_argument(
49+
"--no-ignore-bad-stack",
50+
action="store_false",
51+
dest="ignore_bad_stack",
52+
default=True,
53+
help="Fail if stacking fails for one of the test specs",
54+
)
55+
return create_stackups.add_args(parser)
56+
57+
58+
if __name__ == "__main__":
59+
sys.exit(run_cli(sys.argv[1:]))
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Logic to create stackup sets for testing."""
2+
3+
import argparse
4+
from functools import partial
5+
from dataclasses import dataclass, field, fields
6+
from typing import Iterator, Callable, TypeVar, Literal
7+
from itertools import product
8+
9+
from . import data, stackup_spec
10+
11+
FilterTarget = TypeVar("FilterTarget")
12+
13+
14+
@dataclass(frozen=True)
15+
class FilterSpecs:
16+
"""Specify what stackups are allowed."""
17+
18+
only_robots: list[Literal["OT-2", "Flex"]] | None = field(
19+
default=None, metadata={"help": "Inclusive filter of robot types."}
20+
)
21+
not_robots: list[Literal["OT-2", "Flex"]] | None = field(
22+
default=None, metadata={"help": "Exclusive filter of robot types."}
23+
)
24+
only_modules: list[str] | None = field(
25+
default=None,
26+
metadata={
27+
"help": 'Inclusive filter of modules. Use the string "none" for no module.'
28+
},
29+
)
30+
not_modules: list[str] | None = field(
31+
default=None,
32+
metadata={
33+
"help": 'Exlusive filter of modules. Use the string "none" for no module.'
34+
},
35+
)
36+
only_labware: list[str] | None = field(
37+
default=None, metadata={"help": "Inclusive filter of labware load names."}
38+
)
39+
not_labware: list[str] | None = field(
40+
default=None, metadata={"help": "Exclusive filter of labware load names."}
41+
)
42+
only_labware_versions: list[int] | None = field(
43+
default=None, metadata={"help": "Inclusive filter of labware versions."}
44+
)
45+
not_labware_versions: list[int] | None = field(
46+
default=None, metadata={"help": "Exclusive filter of labware versions."}
47+
)
48+
only_adapters: list[str | Literal["none"]] | None = field(
49+
default=None, metadata={"help": "Inclusive filter of adapter load names."}
50+
)
51+
not_adapters: list[str | Literal["none"]] | None = field(
52+
default=None, metadata={"help": "Exclusive filter of adapter load names."}
53+
)
54+
55+
56+
def bifilter(
57+
getter: Callable[[stackup_spec.StackupSpec], FilterTarget],
58+
inclusive: list[FilterTarget] | None,
59+
exclusive: list[FilterTarget] | None,
60+
spec: stackup_spec.StackupSpec,
61+
) -> bool:
62+
"""Apply a combination of exclusive and inclusive filters to a stackup. Both must pass."""
63+
filter_target = getter(spec)
64+
if inclusive is not None and filter_target not in inclusive:
65+
return False
66+
if exclusive is not None and filter_target in exclusive:
67+
return False
68+
return True
69+
70+
71+
def base_stackups() -> Iterator[stackup_spec.StackupSpec]:
72+
"""Create all physically possible stackups."""
73+
for robot_type in data.ROBOT_TYPES:
74+
if robot_type == "OT-2":
75+
modules_with_none = [None] + data.OT2_TEST_MODULES
76+
adapters_with_none = [None] + data.OT_2_TEST_ADAPTERS
77+
else:
78+
modules_with_none = [None] + data.FLEX_TEST_MODULES
79+
adapters_with_none = [None] + data.FLEX_TEST_ADAPTERS
80+
81+
combos = product(
82+
modules_with_none, adapters_with_none, data.TEST_LATEST_LABWARE
83+
)
84+
for module_load_name, adapter_load_info, labware_load_info in combos:
85+
yield stackup_spec.StackupSpec(
86+
robot_type=robot_type,
87+
module_load_name=module_load_name,
88+
adapter_load_info=adapter_load_info,
89+
labware_load_info=labware_load_info,
90+
)
91+
92+
93+
def pass_filters(
94+
spec: stackup_spec.StackupSpec, *filters: Callable[[stackup_spec.StackupSpec], bool]
95+
) -> bool:
96+
"""Evaluate a stackup's conformance to filters."""
97+
for filterfunc in filters:
98+
if not filterfunc(spec):
99+
return False
100+
return True
101+
102+
103+
def filter_stackups(
104+
stackups: Iterator[stackup_spec.StackupSpec], filters: FilterSpecs
105+
) -> Iterator[stackup_spec.StackupSpec]:
106+
"""Filter a lazy iterator of stackups."""
107+
for spec in stackups:
108+
if pass_filters(
109+
spec,
110+
partial(
111+
bifilter,
112+
lambda this_spec: this_spec.robot_type,
113+
filters.only_robots,
114+
filters.not_robots,
115+
),
116+
partial(
117+
bifilter,
118+
lambda this_spec: this_spec.module_load_name or "none",
119+
filters.only_modules,
120+
filters.not_modules,
121+
),
122+
partial(
123+
bifilter,
124+
lambda this_spec: this_spec.labware_load_info[0],
125+
filters.only_labware,
126+
filters.not_labware,
127+
),
128+
partial(
129+
bifilter,
130+
lambda this_spec: this_spec.labware_load_info[1],
131+
filters.only_labware_versions,
132+
filters.not_labware_versions,
133+
),
134+
partial(
135+
bifilter,
136+
lambda this_spec: (
137+
this_spec.adapter_load_info[0]
138+
if this_spec.adapter_load_info
139+
else "none"
140+
),
141+
filters.only_adapters,
142+
filters.not_adapters,
143+
),
144+
):
145+
yield spec
146+
147+
148+
def create_stackups(filter_specs: FilterSpecs) -> Iterator[stackup_spec.StackupSpec]:
149+
"""Create a filtered lazy iterator of stackups."""
150+
yield from filter_stackups(base_stackups(), filter_specs)
151+
152+
153+
def filter_from_args(args: argparse.Namespace) -> FilterSpecs:
154+
"""Build a FilterSpecs argument from parsed command line flags."""
155+
ugly_kwsplat = {}
156+
cli_splat = vars(args)
157+
for this_field in fields(FilterSpecs):
158+
if this_field.name in cli_splat:
159+
ugly_kwsplat[this_field.name] = cli_splat[this_field.name]
160+
return FilterSpecs(**ugly_kwsplat)
161+
162+
163+
def add_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
164+
"""Add command line flags to drive the filters."""
165+
for this_field in fields(FilterSpecs):
166+
parser.add_argument(
167+
f'--{this_field.name.replace("_", "-")}',
168+
dest=this_field.name,
169+
nargs="*",
170+
action="store",
171+
default=None,
172+
help=this_field.metadata["help"],
173+
)
174+
return parser
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Data sources for the snapshot testing."""
2+
3+
from pathlib import Path
4+
from typing import Literal
5+
6+
SNAPSHOT_PATH_DEFAULT = Path(__file__).parent / "stackup_coordinates_snapshot.json"
7+
8+
ROBOT_TYPES: list[Literal["Flex"] | Literal["OT-2"]] = ["Flex", "OT-2"]
9+
10+
# Labware URI, version
11+
TEST_LATEST_LABWARE: list[tuple[str, int]] = [
12+
("agilent_1_reservoir_290ml", 3),
13+
("appliedbiosystemsmicroamp_384_wellplate_40ul", 2),
14+
("armadillo_96_wellplate_200ul_pcr_full_skirt", 3),
15+
("axygen_1_reservoir_90ml", 2),
16+
("biorad_384_wellplate_50ul", 3),
17+
("biorad_96_wellplate_200ul_pcr", 3),
18+
("corning_12_wellplate_6.9ml_flat", 3),
19+
("corning_24_wellplate_3.4ml_flat", 3),
20+
("corning_384_wellplate_112ul_flat", 4),
21+
("corning_48_wellplate_1.6ml_flat", 4),
22+
("corning_6_wellplate_16.8ml_flat", 3),
23+
("corning_96_wellplate_360ul_flat", 3),
24+
("eppendorf_96_tiprack_1000ul_eptips", 1),
25+
("eppendorf_96_tiprack_10ul_eptips", 1),
26+
("geb_96_tiprack_1000ul", 1),
27+
("geb_96_tiprack_10ul", 1),
28+
("nest_12_reservoir_15ml", 2),
29+
("nest_1_reservoir_195ml", 3),
30+
("nest_1_reservoir_290ml", 3),
31+
("nest_96_wellplate_100ul_pcr_full_skirt", 3),
32+
("nest_96_wellplate_200ul_flat", 3),
33+
("nest_96_wellplate_2ml_deep", 3),
34+
("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", 2),
35+
("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical_acrylic", 1),
36+
("opentrons_10_tuberack_nest_4x50ml_6x15ml_conical", 2),
37+
("opentrons_15_tuberack_falcon_15ml_conical", 2),
38+
("opentrons_15_tuberack_nest_15ml_conical", 2),
39+
("opentrons_1_trash_1100ml_fixed", 1),
40+
("opentrons_1_trash_3200ml_fixed", 1),
41+
("opentrons_1_trash_850ml_fixed", 1),
42+
("opentrons_24_aluminumblock_generic_2ml_screwcap", 3),
43+
("opentrons_24_aluminumblock_nest_0.5ml_screwcap", 3),
44+
("opentrons_24_aluminumblock_nest_1.5ml_screwcap", 2),
45+
("opentrons_24_aluminumblock_nest_1.5ml_snapcap", 2),
46+
("opentrons_24_aluminumblock_nest_2ml_screwcap", 2),
47+
("opentrons_24_aluminumblock_nest_2ml_snapcap", 2),
48+
("opentrons_24_tuberack_eppendorf_1.5ml_safelock_snapcap", 2),
49+
("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap", 2),
50+
("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap_acrylic", 1),
51+
("opentrons_24_tuberack_generic_0.75ml_snapcap_acrylic", 1),
52+
("opentrons_24_tuberack_generic_2ml_screwcap", 2),
53+
("opentrons_24_tuberack_nest_0.5ml_screwcap", 3),
54+
("opentrons_24_tuberack_nest_1.5ml_screwcap", 2),
55+
("opentrons_24_tuberack_nest_1.5ml_snapcap", 2),
56+
("opentrons_24_tuberack_nest_2ml_screwcap", 2),
57+
("opentrons_24_tuberack_nest_2ml_snapcap", 2),
58+
(
59+
"opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip",
60+
1,
61+
),
62+
("opentrons_6_tuberack_falcon_50ml_conical", 2),
63+
("opentrons_6_tuberack_nest_50ml_conical", 2),
64+
("opentrons_96_aluminumblock_biorad_wellplate_200ul", 1),
65+
("opentrons_96_aluminumblock_generic_pcr_strip_200ul", 4),
66+
("opentrons_96_aluminumblock_nest_wellplate_100ul", 1),
67+
("opentrons_96_deep_well_adapter", 1),
68+
("opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep", 1),
69+
("opentrons_96_deep_well_temp_mod_adapter", 1),
70+
("opentrons_96_filtertiprack_1000ul", 1),
71+
("opentrons_96_filtertiprack_10ul", 1),
72+
("opentrons_96_filtertiprack_200ul", 1),
73+
("opentrons_96_filtertiprack_20ul", 1),
74+
("opentrons_96_flat_bottom_adapter", 1),
75+
("opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat", 1),
76+
("opentrons_96_pcr_adapter", 1),
77+
("opentrons_96_pcr_adapter_armadillo_wellplate_200ul", 1),
78+
("opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt", 1),
79+
("opentrons_96_tiprack_1000ul", 1),
80+
("opentrons_96_tiprack_10ul", 1),
81+
("opentrons_96_tiprack_20ul", 1),
82+
("opentrons_96_tiprack_300ul", 1),
83+
("opentrons_96_well_aluminum_block", 1),
84+
("opentrons_96_wellplate_200ul_pcr_full_skirt", 3),
85+
("opentrons_aluminum_flat_bottom_plate", 1),
86+
("opentrons_calibration_adapter_heatershaker_module", 1),
87+
("opentrons_calibration_adapter_temperature_module", 1),
88+
("opentrons_calibration_adapter_thermocycler_module", 1),
89+
("opentrons_calibrationblock_short_side_left", 1),
90+
("opentrons_calibrationblock_short_side_right", 1),
91+
("opentrons_flex_96_filtertiprack_1000ul", 1),
92+
("opentrons_flex_96_filtertiprack_200ul", 1),
93+
("opentrons_flex_96_filtertiprack_20ul", 1),
94+
("opentrons_flex_96_filtertiprack_50ul", 1),
95+
("opentrons_flex_96_tiprack_1000ul", 1),
96+
("opentrons_flex_96_tiprack_200ul", 1),
97+
("opentrons_flex_96_tiprack_20ul", 1),
98+
("opentrons_flex_96_tiprack_50ul", 1),
99+
("opentrons_flex_96_tiprack_adapter", 1),
100+
("opentrons_flex_deck_riser", 1),
101+
("opentrons_flex_lid_absorbance_plate_reader_module", 1),
102+
("opentrons_flex_tiprack_lid", 1),
103+
("opentrons_tough_12_reservoir_22ml", 1),
104+
("opentrons_tough_1_reservoir_300ml", 1),
105+
("opentrons_tough_4_reservoir_72ml", 1),
106+
("opentrons_tough_pcr_auto_sealing_lid", 2),
107+
("opentrons_tough_universal_lid", 1),
108+
("opentrons_universal_flat_adapter", 1),
109+
("opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat", 1),
110+
("protocol_engine_lid_stack_object", 1),
111+
("thermoscientificnunc_96_wellplate_1300ul", 2),
112+
("thermoscientificnunc_96_wellplate_2000ul", 2),
113+
("tipone_96_tiprack_200ul", 1),
114+
("usascientific_12_reservoir_22ml", 3),
115+
("usascientific_96_wellplate_2.4ml_deep", 2),
116+
]
117+
118+
FLEX_TEST_ADAPTERS: list[tuple[str, int]] = [
119+
("opentrons_96_deep_well_adapter", 1),
120+
("opentrons_96_deep_well_temp_mod_adapter", 1),
121+
("opentrons_96_flat_bottom_adapter", 1),
122+
("opentrons_96_pcr_adapter", 1),
123+
("opentrons_96_well_aluminum_block", 1),
124+
("opentrons_aluminum_flat_bottom_plate", 1),
125+
("opentrons_flex_96_tiprack_adapter", 1),
126+
("opentrons_flex_deck_riser", 1),
127+
("opentrons_universal_flat_adapter", 1),
128+
]
129+
130+
OT_2_TEST_ADAPTERS: list[tuple[str, int]] = [
131+
("opentrons_96_well_aluminum_block", 1),
132+
]
133+
134+
FLEX_TEST_MODULES = [
135+
"thermocyclerModuleV2",
136+
"temperatureModuleV2",
137+
"absorbanceReaderV1",
138+
"heaterShakerModuleV1",
139+
"magneticBlockV1",
140+
"flexStackerModuleV1",
141+
]
142+
143+
OT2_TEST_MODULES = [
144+
"heaterShakerModuleV1",
145+
]

0 commit comments

Comments
 (0)