Skip to content

Commit

Permalink
refactor(api): add create_file_runner factory
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous committed Jun 1, 2021
1 parent 086ae72 commit 069cc69
Show file tree
Hide file tree
Showing 12 changed files with 330 additions and 116 deletions.
14 changes: 12 additions & 2 deletions api/src/opentrons/file_runner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@
- Dispatch ProtocolEngine commands to an engine instance
"""

from .create_file_runner import create_file_runner
from .abstract_file_runner import AbstractFileRunner
from .json_file_runner import JsonFileRunner
from .python_file_runner import PythonFileRunner
from .protocol_file import ProtocolFile, ProtocolFileType
from .protocol_file import (
ProtocolFileType,
ProtocolFile,
JsonProtocolFile,
PythonProtocolFile,
)

__all__ = [
# runner factory
"create_file_runner",
# runner interfaces
"AbstractFileRunner",
"JsonFileRunner",
"PythonFileRunner",
# value objects
"ProtocolFile",
"ProtocolFileType",
"ProtocolFile",
"JsonProtocolFile",
"PythonProtocolFile",
]
5 changes: 5 additions & 0 deletions api/src/opentrons/file_runner/abstract_file_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
class AbstractFileRunner(ABC):
"""Abstract interface for an object that can run protocol files."""

@abstractmethod
def load(self) -> None:
"""Prepare runner and engine state prior to starting the run."""
...

@abstractmethod
def play(self) -> None:
"""Start (or un-pause) running the protocol file."""
Expand Down
44 changes: 44 additions & 0 deletions api/src/opentrons/file_runner/create_file_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Protocol runner factory."""
from pathlib import Path
from typing import Optional

from opentrons.protocol_engine import ProtocolEngine
from opentrons.protocols.runner import CommandTranslator

from .abstract_file_runner import AbstractFileRunner
from .json_file_runner import JsonFileRunner
from .json_file_reader import JsonFileReader
from .command_queue_worker import CommandQueueWorker
from .protocol_file import ProtocolFileType, JsonProtocolFile


def create_file_runner(
file_type: Optional[ProtocolFileType],
file_path: Optional[Path],
engine: ProtocolEngine,
) -> AbstractFileRunner:
"""Construct a wired-up protocol runner instance.
Arguments:
file: Protocol file the runner will be using. If `None`, returns
a basic runner for ProtocolEngine usage without a file.
engine: The protocol engine interface the runner will use.
Returns:
A runner appropriate for the requested protocol type.
"""
file = None

if file_path is not None and file_type == ProtocolFileType.JSON:
file = JsonProtocolFile(file_path=file_path)

if isinstance(file, JsonProtocolFile):
return JsonFileRunner(
file=file,
protocol_engine=engine,
file_reader=JsonFileReader(),
command_translator=CommandTranslator(),
command_queue_worker=CommandQueueWorker(),
)

raise NotImplementedError("Other runner types not yet supported")
14 changes: 14 additions & 0 deletions api/src/opentrons/file_runner/json_file_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""JSON file reading."""

from opentrons.protocols.models import JsonProtocol
from .protocol_file import JsonProtocolFile


class JsonFileReader:
"""Reads and parses JSON protocol files."""

@staticmethod
def read(file: JsonProtocolFile) -> JsonProtocol:
"""Read and parse file into a JsonProtocol model."""
contents = file.file_path.read_text(encoding="utf-8")
return JsonProtocol.parse_raw(contents)
25 changes: 16 additions & 9 deletions api/src/opentrons/file_runner/json_file_runner.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
"""File runner interfaces for JSON protocols."""
from opentrons.protocol_engine import ProtocolEngine
from opentrons.protocols.models import JsonProtocol
from opentrons.protocols.runner import CommandTranslator

from .abstract_file_runner import AbstractFileRunner
from .json_file_reader import JsonFileReader
from .command_queue_worker import CommandQueueWorker
from .protocol_file import JsonProtocolFile


class JsonFileRunner(AbstractFileRunner):
"""JSON protocol file runner."""

def __init__(
self,
protocol: JsonProtocol,
protocol_engine: ProtocolEngine,
command_translator: CommandTranslator,
command_queue_worker: CommandQueueWorker) -> None:
self,
file: JsonProtocolFile,
file_reader: JsonFileReader,
protocol_engine: ProtocolEngine,
command_translator: CommandTranslator,
command_queue_worker: CommandQueueWorker,
) -> None:
"""JSON file runner constructor.
Args:
protocol: a JSON protocol
file: a JSON protocol file
file_reader: an interface to read the file into a data model.
protocol_engine: instance of the Protocol Engine
command_translator: the JSON command translator
command_queue_worker: Command Queue worker
"""
self._protocol = protocol
self._file = file
self._file_reader = file_reader
self._protocol_engine = protocol_engine
self._command_translator = command_translator
self._command_queue_worker = command_queue_worker

def load(self) -> None:
"""Translate JSON commands and send them to protocol engine."""
for json_cmd in self._protocol.commands:
protocol = self._file_reader.read(self._file)

for json_cmd in protocol.commands:
translated_items = self._command_translator.translate(json_cmd)
for cmd in translated_items:
self._protocol_engine.add_command(cmd)
Expand Down
24 changes: 23 additions & 1 deletion api/src/opentrons/file_runner/protocol_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
# existing logic and models from:
# - api/src/opentrons/protocols/types.py
# - robot-server/robot_server/service/protocol/models.py
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Union
from typing_extensions import Literal


class ProtocolFileType(str, Enum):
Expand All @@ -20,11 +24,29 @@ class ProtocolFileType(str, Enum):


@dataclass(frozen=True)
class ProtocolFile:
class AbstractProtocolFile:
"""A value object representing a protocol file on disk.
Attributes:
file_type: Whether the file is a JSON protocol or Python protocol
"""

file_path: Path
file_type: ProtocolFileType


@dataclass(frozen=True)
class JsonProtocolFile(AbstractProtocolFile):
"""A value object representing a JSON protocol file."""

file_type: Literal[ProtocolFileType.JSON] = ProtocolFileType.JSON


@dataclass(frozen=True)
class PythonProtocolFile(AbstractProtocolFile):
"""A value object representing a Python protocol file."""

file_type: Literal[ProtocolFileType.PYTHON] = ProtocolFileType.PYTHON


ProtocolFile = Union[JsonProtocolFile, PythonProtocolFile]
4 changes: 4 additions & 0 deletions api/src/opentrons/file_runner/python_file_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
class PythonFileRunner(AbstractFileRunner):
"""Python protocol file runner."""

def load(self) -> None:
"""Prepare to run the Python protocol file."""
raise NotImplementedError()

def play(self) -> None:
"""Start (or un-pause) running the Python protocol file."""
raise NotImplementedError()
Expand Down
7 changes: 7 additions & 0 deletions api/tests/opentrons/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import zipfile

import pytest
from decoy import Decoy

from opentrons.api.routers import MainRouter
from opentrons.api import models
Expand Down Expand Up @@ -51,6 +52,12 @@ def exception_handler(loop, context):
loop.set_exception_handler(None)


@pytest.fixture
def decoy() -> Decoy:
"""Get a Decoy state container to clean up stubs after tests."""
return Decoy()


def state(topic, state):
def _match(item):
return \
Expand Down
75 changes: 75 additions & 0 deletions api/tests/opentrons/file_runner/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Test fixtures for opentrons.file_runner tests."""
import pytest

from opentrons.protocols.models import JsonProtocol


@pytest.fixture
def json_protocol(json_protocol_dict: dict) -> JsonProtocol:
"""Get a parsed JSON protocol model fixture."""
return JsonProtocol.parse_obj(json_protocol_dict)


@pytest.fixture
def json_protocol_dict(minimal_labware_def: dict) -> dict:
"""Get a JSON protocol dictionary fixture."""
return {
"schemaVersion": 3,
"metadata": {},
"robot": {"model": "OT-2 Standard"},
"pipettes": {"leftPipetteId": {"mount": "left", "name": "p300_single"}},
"labware": {
"trashId": {
"slot": "12",
"displayName": "Trash",
"definitionId": "opentrons/opentrons_1_trash_1100ml_fixed/1",
},
"tiprack1Id": {
"slot": "1",
"displayName": "Opentrons 96 Tip Rack 300 µL",
"definitionId": "opentrons/opentrons_96_tiprack_300ul/1",
},
"wellplate1Id": {
"slot": "10",
"displayName": "Corning 96 Well Plate 360 µL Flat",
"definitionId": "opentrons/corning_96_wellplate_360ul_flat/1",
},
},
"labwareDefinitions": {
"opentrons/opentrons_1_trash_1100ml_fixed/1": minimal_labware_def,
"opentrons/opentrons_96_tiprack_300ul/1": minimal_labware_def,
"opentrons/corning_96_wellplate_360ul_flat/1": minimal_labware_def,
},
"commands": [
{
"command": "pickUpTip",
"params": {
"pipette": "leftPipetteId",
"labware": "tiprack1Id",
"well": "A1",
},
},
{
"command": "aspirate",
"params": {
"pipette": "leftPipetteId",
"volume": 51,
"labware": "wellplate1Id",
"well": "B1",
"offsetFromBottomMm": 10,
"flowRate": 10,
},
},
{
"command": "dispense",
"params": {
"pipette": "leftPipetteId",
"volume": 50,
"labware": "wellplate1Id",
"well": "H1",
"offsetFromBottomMm": 1,
"flowRate": 50,
},
},
],
}
19 changes: 19 additions & 0 deletions api/tests/opentrons/file_runner/test_create_file_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Tests for the create_protocol_runner factory."""
from mock import MagicMock
from pathlib import Path

from opentrons.file_runner import ProtocolFileType, JsonFileRunner, create_file_runner


def test_create_json_runner() -> None:
"""It should be able to create a JSON file runner."""
file_type = ProtocolFileType.JSON
file_path = Path("/dev/null")

result = create_file_runner(
file_type=file_type,
file_path=file_path,
engine=MagicMock(),
)

assert isinstance(result, JsonFileRunner)
37 changes: 37 additions & 0 deletions api/tests/opentrons/file_runner/test_json_file_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Integration tests for the JsonFileReader interface."""
import pytest
import json
from pathlib import Path

from opentrons.protocols.models import JsonProtocol
from opentrons.file_runner import JsonProtocolFile
from opentrons.file_runner.json_file_reader import JsonFileReader


@pytest.fixture
def json_protocol_file(
tmpdir: Path,
json_protocol_dict: dict,
) -> JsonProtocolFile:
"""Get a JsonProtocolFile with JSON on-disk."""
file_path = tmpdir / "protocol.json"
file_path.write_text(json.dumps(json_protocol_dict), encoding="utf-8")

return JsonProtocolFile(file_path=file_path)


@pytest.fixture
def subject() -> JsonFileReader:
"""Get a JsonFileReader test subject."""
return JsonFileReader()


def test_reads_file(
json_protocol_dict: dict,
json_protocol_file: JsonProtocolFile,
subject: JsonFileReader,
) -> None:
"""It should read a JSON file into a JsonProtocol model."""
result = subject.read(json_protocol_file)

assert result == JsonProtocol.parse_obj(json_protocol_dict)
Loading

0 comments on commit 069cc69

Please sign in to comment.