Skip to content

Commit

Permalink
pass path to the csv parameter interface
Browse files Browse the repository at this point in the history
  • Loading branch information
jbleon95 committed Aug 5, 2024
1 parent 4500e21 commit 9d8b4e9
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 125 deletions.
2 changes: 1 addition & 1 deletion api/src/opentrons/protocol_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from opentrons.protocols.parameters.exceptions import (
RuntimeParameterRequired as RuntimeParameterRequiredError,
)
from opentrons.protocols.parameters.types import CSVParameter
from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter

from .protocol_context import ProtocolContext
from .deck import Deck
Expand Down
33 changes: 2 additions & 31 deletions api/src/opentrons/protocol_api/_parameter_context.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Parameter context for python protocols."""
import tempfile
from typing import List, Optional, Union, Dict

from opentrons.protocols.api_support.types import APIVersion
Expand Down Expand Up @@ -240,41 +239,13 @@ def initialize_csv_files(
f"File Id was provided for the parameter '{variable_name}',"
f" but '{variable_name}' is not a CSV parameter."
)
# TODO(jbl 2024-08-02) This file opening should be moved elsewhere to provide more flexibility with files
# that may be opened as non-text or non-UTF-8

# The parent folder in the path will be the file ID, so we can use that to resolve that here
file_id = file_path.parent.name
file_name = file_path.name

# Read the contents of the actual file
with file_path.open() as csv_file:
contents = csv_file.read()

# Open a temporary file with write permissions and write contents to that
temporary_file = tempfile.NamedTemporaryFile("r+")
temporary_file.write(contents)
temporary_file.flush()

# Open a new file handler for the temporary file with read-only permissions and close the other
parameter_file = open(temporary_file.name, "r")
temporary_file.close()

parameter.file_info = FileInfo(id=file_id, name=file_name)
parameter.value = parameter_file

def close_csv_files(self) -> None:
"""Close all file handlers for CSV parameters.
:meta private:
This is intended for Opentrons internal use only and is not a guaranteed API.
"""
for parameter in self._parameters.values():
if (
isinstance(parameter, csv_parameter_definition.CSVParameterDefinition)
and parameter.value is not None
):
parameter.value.close()
parameter.value = file_path

def export_parameters_for_analysis(self) -> List[RunTimeParameter]:
"""Exports all parameters into a protocol engine models for reporting in analysis.
Expand Down
13 changes: 10 additions & 3 deletions api/src/opentrons/protocols/execution/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from opentrons.protocols.types import PythonProtocol, Protocol
from opentrons.protocols.api_support.types import APIVersion

from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter
from opentrons.protocols.parameters.exceptions import RuntimeParameterRequired

MODULE_LOG = logging.getLogger(__name__)


Expand Down Expand Up @@ -48,9 +51,13 @@ def run_protocol(
except Exception:
raise
finally:
# TODO(jbl 2024-08-02) this should be more tightly bound to the opening of the csv files
if parameter_context is not None:
parameter_context.close_csv_files()
if protocol.api_level >= APIVersion(2, 18):
for parameter in context.params.get_all().values():
if isinstance(parameter, CSVParameter):
try:
parameter.file.close()
except RuntimeParameterRequired:
pass
else:
if protocol.contents["schemaVersion"] == 3:
ins = execute_json_v3.load_pipettes_from_json(context, protocol.contents)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""CSV Parameter definition and associated classes/functions."""
from typing import Optional, TextIO
from pathlib import Path
from typing import Optional

from opentrons.protocol_engine.types import (
RunTimeParameter,
Expand All @@ -9,10 +10,10 @@

from . import validation
from .parameter_definition import AbstractParameterDefinition
from .types import CSVParameter
from .csv_parameter_interface import CSVParameter


class CSVParameterDefinition(AbstractParameterDefinition[Optional[TextIO]]):
class CSVParameterDefinition(AbstractParameterDefinition[Optional[Path]]):
"""The definition for a user defined CSV file parameter."""

def __init__(
Expand All @@ -28,7 +29,7 @@ def __init__(
self._display_name = validation.ensure_display_name(display_name)
self._variable_name = validation.ensure_variable_name(variable_name)
self._description = validation.ensure_description(description)
self._value: Optional[TextIO] = None
self._value: Optional[Path] = None
self._file_info: Optional[FileInfo] = None

@property
Expand All @@ -37,13 +38,13 @@ def variable_name(self) -> str:
return self._variable_name

@property
def value(self) -> Optional[TextIO]:
def value(self) -> Optional[Path]:
"""The current set file for the CSV parameter. Defaults to None on definition creation."""
return self._value

@value.setter
def value(self, new_file: TextIO) -> None:
self._value = new_file
def value(self, new_path: Path) -> None:
self._value = new_path

@property
def file_info(self) -> Optional[FileInfo]:
Expand All @@ -54,7 +55,7 @@ def file_info(self, file_info: FileInfo) -> None:
self._file_info = file_info

def as_csv_parameter_interface(self) -> CSVParameter:
return CSVParameter(csv_file=self._value)
return CSVParameter(csv_path=self._value)

def as_protocol_engine_type(self) -> RunTimeParameter:
"""Returns CSV parameter as a Protocol Engine type to send to client."""
Expand Down
61 changes: 61 additions & 0 deletions api/src/opentrons/protocols/parameters/csv_parameter_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import csv
from pathlib import Path
from typing import Optional, TextIO, Any, List

from . import parameter_file_reader
from .exceptions import ParameterValueError


# TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder
class CSVParameter:
def __init__(self, csv_path: Optional[Path]) -> None:
self._path = csv_path
self._file: Optional[TextIO] = None
self._contents: Optional[str] = None

@property
def file(self) -> TextIO:
"""Returns the file handler for the CSV file."""
if self._file is None:
self._file = parameter_file_reader.open_file_path(self._path)
return self._file

@property
def contents(self) -> str:
"""Returns the full contents of the CSV file as a single string."""
if self._contents is None:
self.file.seek(0)
self._contents = self.file.read()
return self._contents

def parse_as_csv(
self, detect_dialect: bool = True, **kwargs: Any
) -> List[List[str]]:
"""Returns a list of rows with each row represented as a list of column elements.
If there is a header for the CSV that will be the first row in the list (i.e. `.rows()[0]`).
All elements will be represented as strings, even if they are numeric in nature.
"""
rows: List[List[str]] = []
if detect_dialect:
try:
self.file.seek(0)
dialect = csv.Sniffer().sniff(self.file.read(1024))
self.file.seek(0)
reader = csv.reader(self.file, dialect, **kwargs)
except (UnicodeDecodeError, csv.Error):
raise ParameterValueError(
"Cannot parse dialect or contents from provided CSV file."
)
else:
try:
reader = csv.reader(self.file, **kwargs)
except (UnicodeDecodeError, csv.Error):
raise ParameterValueError("Cannot parse provided CSV file.")
try:
for row in reader:
rows.append(row)
except (UnicodeDecodeError, csv.Error):
raise ParameterValueError("Cannot parse provided CSV file.")
self.file.seek(0)
return rows
26 changes: 26 additions & 0 deletions api/src/opentrons/protocols/parameters/parameter_file_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Optional, TextIO

from .exceptions import RuntimeParameterRequired


def open_file_path(file_path: Optional[Path]) -> TextIO:
"""Ensure file path is set and open up the file in a safe read-only temporary file."""
if file_path is None:
raise RuntimeParameterRequired(
"CSV parameter needs to be set to a file for full analysis or run."
)
# Read the contents of the actual file
with file_path.open() as fh:
contents = fh.read()

# Open a temporary file with write permissions and write contents to that
temporary_file = NamedTemporaryFile("r+")
temporary_file.write(contents)
temporary_file.flush()

# Open a new file handler for the temporary file with read-only permissions and close the other
read_only_temp_file = open(temporary_file.name, "r")
temporary_file.close()
return read_only_temp_file
64 changes: 4 additions & 60 deletions api/src/opentrons/protocols/parameters/types.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,11 @@
import csv
from typing import TypeVar, Union, TypedDict, TextIO, Optional, List, Any
from pathlib import Path
from typing import TypeVar, Union, TypedDict

from .exceptions import RuntimeParameterRequired, ParameterValueError


# TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder
class CSVParameter:
def __init__(self, csv_file: Optional[TextIO]) -> None:
self._file = csv_file
self._contents: Optional[str] = None

@property
def file(self) -> TextIO:
"""Returns the file handler for the CSV file."""
if self._file is None:
raise RuntimeParameterRequired(
"CSV parameter needs to be set to a file for full analysis or run."
)
return self._file

@property
def contents(self) -> str:
"""Returns the full contents of the CSV file as a single string."""
if self._contents is None:
self.file.seek(0)
self._contents = self.file.read()
return self._contents

def parse_as_csv(
self, detect_dialect: bool = True, **kwargs: Any
) -> List[List[str]]:
"""Returns a list of rows with each row represented as a list of column elements.
If there is a header for the CSV that will be the first row in the list (i.e. `.rows()[0]`).
All elements will be represented as strings, even if they are numeric in nature.
"""
rows: List[List[str]] = []
if detect_dialect:
try:
self.file.seek(0)
dialect = csv.Sniffer().sniff(self.file.read(1024))
self.file.seek(0)
reader = csv.reader(self.file, dialect, **kwargs)
except (UnicodeDecodeError, csv.Error):
raise ParameterValueError(
"Cannot parse dialect or contents from provided CSV file."
)
else:
try:
reader = csv.reader(self.file, **kwargs)
except (UnicodeDecodeError, csv.Error):
raise ParameterValueError("Cannot parse provided CSV file.")
try:
for row in reader:
rows.append(row)
except (UnicodeDecodeError, csv.Error):
raise ParameterValueError("Cannot parse provided CSV file.")
self.file.seek(0)
return rows
from .csv_parameter_interface import CSVParameter


PrimitiveAllowedTypes = Union[str, int, float, bool]
AllAllowedTypes = Union[str, int, float, bool, TextIO, None]
AllAllowedTypes = Union[str, int, float, bool, Path, None]
UserFacingTypes = Union[str, int, float, bool, CSVParameter]

ParamType = TypeVar("ParamType", bound=AllAllowedTypes)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Tests for the CSV Parameter Definitions."""
import inspect
import tempfile
from io import TextIOWrapper
from pathlib import Path

import pytest
from decoy import Decoy
Expand Down Expand Up @@ -55,12 +54,9 @@ def test_create_csv_parameter(decoy: Decoy) -> None:
def test_set_csv_value(
decoy: Decoy, csv_parameter_subject: CSVParameterDefinition
) -> None:
"""It should set the CSV parameter value to a file."""
mock_file = decoy.mock(cls=TextIOWrapper)
decoy.when(mock_file.name).then_return("mock.csv")

csv_parameter_subject.value = mock_file
assert csv_parameter_subject.value is mock_file
"""It should set the CSV parameter value to a path."""
csv_parameter_subject.value = Path("123")
assert csv_parameter_subject.value == Path("123")


def test_csv_parameter_as_protocol_engine_type(
Expand Down Expand Up @@ -93,7 +89,6 @@ def test_csv_parameter_as_csv_parameter_interface(
with pytest.raises(RuntimeParameterRequired):
result.file

mock_file = tempfile.NamedTemporaryFile(mode="r", suffix=".csv")
csv_parameter_subject.value = mock_file # type: ignore[assignment]
csv_parameter_subject.value = Path("abc")
result = csv_parameter_subject.as_csv_parameter_interface()
assert result.file is mock_file # type: ignore[comparison-overlap]
assert result._path == Path("abc")
Loading

0 comments on commit 9d8b4e9

Please sign in to comment.