Skip to content

Commit 1ef1c36

Browse files
authored
refactor(robot-server, api): genericize FileProvider (#19755)
Closes EXEC-1956 This PR genericizes the FileProvider interface to pave the way for writing more than CSV file types to disk. To that end, FileProvider now takes raw byte data, a MIME type, and command_metadata, which is used to generate the final filename. FileProviderWrapper is renamed to FileProviderExecutor and now handles the entirety of file naming and writing the file to the correct location. The vision is that shortly, FileProviderExecutor will need to ingest robot-server specific metadata alongside the command_metadata for building the filename, and this step moves us in that direction.
1 parent a9c85ca commit 1ef1c36

File tree

7 files changed

+144
-104
lines changed

7 files changed

+144
-104
lines changed

api/src/opentrons/protocol_engine/commands/absorbance_reader/read.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
from ...resources.file_provider import (
1515
PlateReaderData,
1616
ReadData,
17-
MAXIMUM_CSV_FILE_LIMIT,
17+
MAXIMUM_FILE_LIMIT,
18+
MimeType,
19+
ReadCmdFileNameMetadata,
1820
)
1921
from ...resources import FileProvider
2022
from ...state import update_types
@@ -102,10 +104,10 @@ async def execute( # noqa: C901
102104
if (
103105
self._state_view.files.get_filecount()
104106
+ len(abs_reader_substate.configured_wavelengths)
105-
> MAXIMUM_CSV_FILE_LIMIT
107+
> MAXIMUM_FILE_LIMIT
106108
):
107109
raise StorageLimitReachedError(
108-
message=f"Attempt to write file {params.fileName} exceeds file creation limit of {MAXIMUM_CSV_FILE_LIMIT} files."
110+
message=f"Attempt to write file {params.fileName} exceeds file creation limit of {MAXIMUM_FILE_LIMIT} files."
109111
)
110112

111113
asbsorbance_result: Dict[int, Dict[str, float]] = {}
@@ -174,11 +176,16 @@ async def execute( # noqa: C901
174176
if isinstance(plate_read_result, PlateReaderData):
175177
# Write a CSV file for each of the measurements taken
176178
for measurement in plate_read_result.read_results:
177-
file_id = await self._file_provider.write_csv(
178-
write_data=plate_read_result.build_generic_csv(
179-
filename=params.fileName,
180-
measurement=measurement,
181-
)
179+
csv_bytes = plate_read_result.build_csv_bytes(
180+
measurement=measurement,
181+
)
182+
file_id = await self._file_provider.write_file(
183+
data=csv_bytes,
184+
mime_type=MimeType.TEXT_CSV,
185+
command_metadata=ReadCmdFileNameMetadata.model_construct(
186+
base_filename=params.fileName,
187+
wavelength=measurement.wavelength,
188+
),
182189
)
183190
file_ids.append(file_id)
184191

Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,51 @@
11
"""File interaction resource provider."""
22
from datetime import datetime
3+
from enum import Enum
4+
from io import StringIO
5+
import csv
36
from typing import List, Optional, Callable, Awaitable, Dict
47
from pydantic import BaseModel
58
from ..errors import StorageLimitReachedError
69

710

8-
MAXIMUM_CSV_FILE_LIMIT = 400
11+
MAXIMUM_FILE_LIMIT = 400
912

1013

11-
class GenericCsvTransform:
12-
"""Generic CSV File Type data for rows of data to be seperated by a delimeter."""
14+
class MimeType(str, Enum):
15+
"""File mime types."""
1316

14-
filename: str
15-
rows: List[List[str]]
16-
delimiter: str = ","
17+
TEXT_CSV = "text/csv"
18+
19+
20+
class ReadCmdFileNameMetadata(BaseModel):
21+
"""Data from a plate reader `read` command used to build the finalized file name."""
22+
23+
base_filename: str
24+
wavelength: int
25+
26+
27+
CommandFileNameMetadata = ReadCmdFileNameMetadata | None
28+
29+
30+
class FileData:
31+
"""File data container for writing to a file."""
32+
33+
data: bytes
34+
mime_type: MimeType
35+
command_metadata: CommandFileNameMetadata
1736

1837
@staticmethod
1938
def build(
20-
filename: str, rows: List[List[str]], delimiter: str = ","
21-
) -> "GenericCsvTransform":
22-
"""Build a Generic CSV datatype class."""
23-
if "." in filename and not filename.endswith(".csv"):
24-
raise ValueError(
25-
f"Provided filename {filename} invalid. Only CSV file format is accepted."
26-
)
27-
elif "." not in filename:
28-
filename = f"{filename}.csv"
29-
csv = GenericCsvTransform()
30-
csv.filename = filename
31-
csv.rows = rows
32-
csv.delimiter = delimiter
33-
return csv
39+
data: bytes,
40+
mime_type: MimeType,
41+
command_metadata: CommandFileNameMetadata = None,
42+
) -> "FileData":
43+
"""Build a generic file data class."""
44+
file_data = FileData()
45+
file_data.data = data
46+
file_data.mime_type = mime_type
47+
file_data.command_metadata = command_metadata
48+
return file_data
3449

3550

3651
class ReadData(BaseModel):
@@ -41,29 +56,24 @@ class ReadData(BaseModel):
4156

4257

4358
class PlateReaderData(BaseModel):
44-
"""Data from a Opentrons Plate Reader Read. Can be converted to CSV template format."""
59+
"""Data from an Opentrons Plate Reader Read. Can be converted to CSV format."""
4560

4661
read_results: List[ReadData]
4762
reference_wavelength: Optional[int] = None
4863
start_time: datetime
4964
finish_time: datetime
5065
serial_number: str
5166

52-
def build_generic_csv( # noqa: C901
53-
self, filename: str, measurement: ReadData
54-
) -> GenericCsvTransform:
55-
"""Builds a CSV compatible object containing Plate Reader Measurements.
56-
57-
This will also automatically reformat the provided filename to include the wavelength of those measurements.
58-
"""
67+
def build_csv_bytes(self, measurement: ReadData) -> bytes: # noqa: C901
68+
"""Builds CSV data as bytes containing Plate Reader Measurements."""
5969
plate_alpharows = ["A", "B", "C", "D", "E", "F", "G", "H"]
6070
rows = []
6171

6272
rows.append(["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"])
6373
for i in range(8):
6474
row = [plate_alpharows[i]]
6575
for j in range(12):
66-
row.append(str(measurement.data[f"{plate_alpharows[i]}{j+1}"]))
76+
row.append(str(measurement.data[f"{plate_alpharows[i]}{j + 1}"]))
6777
rows.append(row)
6878
for i in range(3):
6979
rows.append([])
@@ -116,46 +126,50 @@ def build_generic_csv( # noqa: C901
116126
["Measurement finished at", self.finish_time.strftime("%m %d %H:%M:%S %Y")]
117127
)
118128

119-
# Ensure the filename adheres to ruleset contains the wavelength for a given measurement
120-
if filename.endswith(".csv"):
121-
filename = filename[:-4]
122-
filename = filename + str(measurement.wavelength) + "nm.csv"
129+
output = StringIO()
130+
writer = csv.writer(output, delimiter=",")
131+
writer.writerows(rows)
132+
csv_bytes = output.getvalue().encode("utf-8")
123133

124-
return GenericCsvTransform.build(
125-
filename=filename,
126-
rows=rows,
127-
delimiter=",",
128-
)
134+
return csv_bytes
129135

130136

131137
class FileProvider:
132138
"""Provider class to wrap file read write interactions to the data files directory in the engine."""
133139

134140
def __init__(
135141
self,
136-
data_files_write_csv_callback: Optional[
137-
Callable[[GenericCsvTransform], Awaitable[str]]
138-
] = None,
142+
data_files_write_file_cb: Optional[Callable[[FileData], Awaitable[str]]] = None,
139143
data_files_filecount: Optional[Callable[[], Awaitable[int]]] = None,
140144
) -> None:
141145
"""Initialize the interface callbacks of the File Provider for data file handling within the Protocol Engine.
142146
143147
Params:
144-
data_files_write_csv_callback: Callback to write a CSV file to the data files directory and add it to the database.
148+
data_files_write_file_callback: Callback to write a file to the data files directory and add it to the database.
145149
data_files_filecount: Callback to check the amount of data files already present in the data files directory.
146150
"""
147-
self._data_files_write_csv_callback = data_files_write_csv_callback
151+
self._data_files_write_file_cb = data_files_write_file_cb
148152
self._data_files_filecount = data_files_filecount
149153

150-
async def write_csv(self, write_data: GenericCsvTransform) -> str:
151-
"""Writes the provided CSV object to a file in the Data Files directory. Returns the File ID of the file created."""
154+
async def write_file(
155+
self,
156+
data: bytes,
157+
mime_type: MimeType,
158+
command_metadata: CommandFileNameMetadata = None,
159+
) -> str:
160+
"""Writes arbitrary data to a file in the Data Files directory. Returns the File ID of the file created."""
152161
if self._data_files_filecount is not None:
153162
file_count = await self._data_files_filecount()
154-
if file_count >= MAXIMUM_CSV_FILE_LIMIT:
163+
if file_count >= MAXIMUM_FILE_LIMIT:
155164
raise StorageLimitReachedError(
156-
f"Not enough space to store file {write_data.filename}."
165+
f"Not enough space to store file. Maximum file limit of {MAXIMUM_FILE_LIMIT} reached."
166+
)
167+
if self._data_files_write_file_cb is not None:
168+
file_data = FileData.build(
169+
data=data,
170+
mime_type=mime_type,
171+
command_metadata=command_metadata,
157172
)
158-
if self._data_files_write_csv_callback is not None:
159-
return await self._data_files_write_csv_callback(write_data)
173+
return await self._data_files_write_file_cb(file_data)
160174
# If we are in an analysis or simulation state, return an empty file ID
161175
return ""

robot-server/robot_server/file_provider/fastapi_dependencies.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import fastapi
66

7-
from robot_server.file_provider.provider import FileProviderWrapper
7+
from robot_server.file_provider.provider import FileProviderExecutor
88
from robot_server.data_files.dependencies import (
99
get_data_files_directory,
1010
get_data_files_store,
@@ -13,27 +13,27 @@
1313
from opentrons.protocol_engine.resources.file_provider import FileProvider
1414

1515

16-
async def get_file_provider_wrapper(
16+
async def get_file_provider_executor(
1717
data_files_directory: Annotated[Path, fastapi.Depends(get_data_files_directory)],
1818
data_files_store: Annotated[DataFilesStore, fastapi.Depends(get_data_files_store)],
19-
) -> FileProviderWrapper:
20-
"""Return the server's singleton `FileProviderWrapper` which provides the engine related callbacks for FileProvider."""
21-
file_provider_wrapper = FileProviderWrapper(
19+
) -> FileProviderExecutor:
20+
"""Return the server's singleton `FileProviderExecutor` which provides the engine related callbacks for FileProvider."""
21+
file_provider_wrapper = FileProviderExecutor(
2222
data_files_directory=data_files_directory, data_files_store=data_files_store
2323
)
2424

2525
return file_provider_wrapper
2626

2727

2828
async def get_file_provider(
29-
file_provider_wrapper: Annotated[
30-
FileProviderWrapper, fastapi.Depends(get_file_provider_wrapper)
29+
file_provider_executor: Annotated[
30+
FileProviderExecutor, fastapi.Depends(get_file_provider_executor)
3131
],
3232
) -> FileProvider:
3333
"""Return the engine `FileProvider` which accepts callbacks from FileProviderWrapper."""
3434
file_provider = FileProvider(
35-
data_files_write_csv_callback=file_provider_wrapper.write_csv_callback,
36-
data_files_filecount=file_provider_wrapper.csv_filecount_callback,
35+
data_files_write_file_cb=file_provider_executor.write_file_cb,
36+
data_files_filecount=file_provider_executor.filecount_cb,
3737
)
3838

3939
return file_provider
Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
"""Wrapper to provide the callbacks utilized by the Protocol Engine File Provider."""
1+
"""Executor for Protocol Engine File Provider callbacks."""
22
import os
33
import asyncio
4-
import csv
54
from pathlib import Path
65
from typing import Annotated
76
from fastapi import Depends
@@ -15,65 +14,85 @@
1514
DataFilesStore,
1615
DataFileInfo,
1716
)
18-
from opentrons.protocol_engine.resources.file_provider import GenericCsvTransform
17+
from opentrons.protocol_engine.resources.file_provider import (
18+
FileData,
19+
ReadCmdFileNameMetadata,
20+
)
1921

2022

21-
class FileProviderWrapper:
22-
"""Wrapper to provide File Read and Write capabilities to Protocol Engine."""
23+
class FileProviderExecutor:
24+
"""Executes file operations for the Protocol Engine File Provider."""
2325

2426
def __init__(
2527
self,
2628
data_files_directory: Annotated[Path, Depends(get_data_files_directory)],
2729
data_files_store: Annotated[DataFilesStore, Depends(get_data_files_store)],
2830
) -> None:
29-
"""Provides callbacks for data file manipulation for the Protocol Engine's File Provider class.
31+
"""Initialize the file provider executor.
3032
3133
Params:
32-
data_files_directory: The directory to store engine-create files in during a protocol run.
34+
data_files_directory: The directory to store engine-created files in during a protocol run.
3335
data_files_store: The data files store utilized for database interaction when creating files.
3436
"""
3537
self._data_files_directory = data_files_directory
3638
self._data_files_store = data_files_store
3739

38-
# dta file store is not generally safe for concurrent access.
40+
# data file store is not generally safe for concurrent access.
3941
self._lock = asyncio.Lock()
4042

41-
async def write_csv_callback(
43+
async def write_file_cb(
4244
self,
43-
csv_data: GenericCsvTransform,
45+
file_data: FileData,
4446
) -> str:
45-
"""Write the provided data transform to a CSV file. Returns the File ID of the created file."""
47+
"""Write the provided file data to disk. Returns the File ID of the created file."""
4648
async with self._lock:
4749
file_id = await get_unique_id()
48-
os.makedirs(
49-
os.path.dirname(
50-
self._data_files_directory / file_id / csv_data.filename
51-
),
52-
exist_ok=True,
50+
final_filename = self._format_filename(file_data, file_id)
51+
final_filepath = self._format_filepath(
52+
filename=final_filename, file_id=file_id, file_data=file_data
5353
)
54-
with open(
55-
file=self._data_files_directory / file_id / csv_data.filename,
56-
mode="w",
57-
newline="",
58-
) as csvfile:
59-
writer = csv.writer(csvfile, delimiter=csv_data.delimiter)
60-
writer.writerows(csv_data.rows)
54+
55+
os.makedirs(os.path.dirname(final_filepath), exist_ok=True)
56+
57+
with open(file=final_filepath, mode="wb") as f:
58+
f.write(file_data.data)
6159

6260
created_at = await get_current_time()
63-
# TODO (cb, 10-14-24): Engine created files do not currently get a file_hash, unlike explicitly uploaded files. Do they need one?
6461
file_info = DataFileInfo(
6562
id=file_id,
66-
name=csv_data.filename,
63+
name=final_filename,
6764
file_hash="",
6865
created_at=created_at,
6966
source=DataFileSource.GENERATED,
7067
)
7168
await self._data_files_store.insert(file_info)
7269
return file_id
7370

74-
async def csv_filecount_callback(self) -> int:
71+
async def filecount_cb(self) -> int:
7572
"""Return the current count of generated files stored within the data files directory."""
7673
data_file_usage_info = self._data_files_store.get_usage_info(
7774
DataFileSource.GENERATED
7875
)
7976
return len(data_file_usage_info)
77+
78+
def _format_filename(self, file_data: FileData, file_id: str) -> str:
79+
"""Build the finalized filename."""
80+
if isinstance(file_data.command_metadata, ReadCmdFileNameMetadata):
81+
metadata = file_data.command_metadata
82+
base_name = metadata.base_filename
83+
84+
if base_name.endswith(".csv"):
85+
base_name = base_name[:-4]
86+
87+
return base_name + str(metadata.wavelength) + "nm.csv"
88+
else:
89+
return f"{file_id}.dat"
90+
91+
def _format_filepath(
92+
self, filename: str, file_id: str, file_data: FileData
93+
) -> Path:
94+
"""Given a finalized filename, return the full filepath for the filename."""
95+
if isinstance(file_data.command_metadata, ReadCmdFileNameMetadata):
96+
return self._data_files_directory / file_id / filename
97+
else:
98+
return self._data_files_directory / filename

0 commit comments

Comments
 (0)