Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10.17
21 changes: 20 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build
.PHONY: build check clean test publish service-build service-run service-test

build:
@echo "build"
Expand All @@ -19,3 +19,22 @@ test:
publish:
@echo "publish"
twine upload dist/*

# --- HTTP /validate service ---------------------------------------------------
# Build the Docker image. Build context is the repository root so that the
# Dockerfile can `pip install .[service]`.
SERVICE_IMAGE ?= morphic-util-service:latest

service-build:
@echo "building $(SERVICE_IMAGE)"
docker build -f service/Dockerfile -t $(SERVICE_IMAGE) .

# Run the service locally with hot reload (no Docker). Requires the service
# extra to be installed: `pip install -e .[service]`.
service-run:
@echo "running service on http://localhost:8000"
uvicorn service.app:app --host 0.0.0.0 --port 8000 --reload

service-test:
@echo "running service tests"
pytest service/tests
133 changes: 9 additions & 124 deletions ait/commons/util/command/submit_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ait.commons.util.spreadsheet_util import SpreadsheetSubmitter, ValidationError, \
merge_library_preparation_sequencing_file, merge_cell_line_and_differentiated_cell_line, \
merge_differentiated_cell_line_and_library_preparation, SubmissionError, process_library_preparations
from ait.commons.util.spreadsheet_validate import SpreadsheetValidator


# Define a class for handling submission of a command file
Expand Down Expand Up @@ -321,132 +322,16 @@ def _handle_expression_alterations(self,
return created_expression_alterations

def _parse_spreadsheet(self, parser):
"""Delegate to the shared SpreadsheetValidator so the CLI and the
HTTP /validate service share one parsing path. self.validation_errors
is mutated in place to preserve existing CLI behavior."""
try:
# Determine the necessary sheet names
tab_names = parser.list_sheets()

cell_line_sheet_name = next(
(name for name in ["Cell line", "Clonal cell line"] if name in tab_names), None
)

differentiated_cell_line_sheet_name = next(
(name for name in ["Differentiated cell line", "Differentiated product"] if name in tab_names), None
)

undifferentiated_cell_line_sheet_name = (
"Undifferentiated product" if "Undifferentiated product" in tab_names else None
return SpreadsheetValidator._parse_spreadsheet(
parser,
action=self.action,
context=self.context,
validation_errors=self.validation_errors,
)

undifferentiated_cell_lines = []
undifferentiated_cell_lines_df = None

differentiated_cell_lines = []
differentiated_cell_lines_df = None

differentiated = False

# Validate the presence of required sheets
if not cell_line_sheet_name:
self.validation_errors.append("Spreadsheet must contain a "
"'Cell line' or 'Clonal cell line' sheet.")

if not (differentiated_cell_line_sheet_name or undifferentiated_cell_line_sheet_name):
self.validation_errors.append(
"Spreadsheet must contain a "
"'Differentiated cell line', 'Undifferentiated product', "
"or 'Differentiated product' sheet."
)

# Parse different sections of the spreadsheet
expression_alterations, expression_alterations_df = parser.get_expression_alterations(
'Expression alteration', self.action, self.validation_errors,
context=self.context
)

cell_lines, cell_lines_df, parent_cell_line_names = parser.get_cell_lines(
cell_line_sheet_name, self.action, self.validation_errors, context=self.context
)

if differentiated_cell_line_sheet_name:
differentiated_cell_lines, differentiated_cell_lines_df = parser.get_differentiated_cell_lines(
differentiated_cell_line_sheet_name, self.action, self.validation_errors
)

if undifferentiated_cell_line_sheet_name:
undifferentiated_cell_lines, undifferentiated_cell_lines_df = parser.get_undifferentiated_cell_lines(
undifferentiated_cell_line_sheet_name, self.action, self.validation_errors
)

# Check for errors and merge data
if differentiated_cell_lines and undifferentiated_cell_lines:
self.validation_errors.append(
"A spreadsheet cannot contain rows in both differentiated and undifferentiated cell lines/ products"
)

if differentiated_cell_lines:
differentiated = True
merge_cell_line_and_differentiated_cell_line(cell_lines, differentiated_cell_lines,
self.validation_errors, context=self.context)

if undifferentiated_cell_lines and not differentiated:
merge_cell_line_and_differentiated_cell_line(cell_lines, undifferentiated_cell_lines,
self.validation_errors, context=self.context)

library_preparations_result = parser.get_library_preparations(
'Library preparation', differentiated, self.action, self.validation_errors)

if not isinstance(library_preparations_result, tuple) or len(library_preparations_result) != 2:
raise ValueError("Unexpected return from get_library_preparations()")

library_preparations, library_preparations_df = library_preparations_result

# Handle N:1 relationships for differentiated products in library preparation
for lp in library_preparations:
if "differentiated_biomaterial_id" in lp.__dict__:
differentiated_ids = lp.differentiated_biomaterial_id.split("|")
lp.differentiated_biomaterial_id = differentiated_ids

if differentiated_cell_lines:
if self.context == "unperturbed_multiple":
# Use the new processing that creates a LP process and links the clone and differentiated product
process_library_preparations(cell_lines, differentiated_cell_lines, library_preparations, self.validation_errors)
else:
# Use the original merge function for differentiated cell lines (for MSK, JAX, etc.)
merge_differentiated_cell_line_and_library_preparation(differentiated_cell_lines,
library_preparations, self.validation_errors, cell_lines=cell_lines)
elif undifferentiated_cell_lines and not differentiated:
if self.context == "unperturbed_multiple":
process_library_preparations(cell_lines, undifferentiated_cell_lines, library_preparations, self.validation_errors)
else:
merge_differentiated_cell_line_and_library_preparation(undifferentiated_cell_lines,
library_preparations, self.validation_errors, cell_lines=cell_lines)

sequencing_files, sequencing_files_df = parser.get_sequencing_files(
'Sequence file', self.action, self.validation_errors
)

merge_library_preparation_sequencing_file(library_preparations, sequencing_files, self.validation_errors)

# Return the parsed data as a dictionary
return {
"expression_alterations": expression_alterations,
"expression_alterations_df": expression_alterations_df,
"cell_lines": cell_lines,
"cell_lines_df": cell_lines_df,
"parent_cell_line_names": parent_cell_line_names,
"differentiated_cell_lines": differentiated_cell_lines,
"differentiated_cell_lines_df": differentiated_cell_lines_df,
"undifferentiated_cell_lines": undifferentiated_cell_lines,
"undifferentiated_cell_lines_df": undifferentiated_cell_lines_df,
"library_preparations": library_preparations,
"library_preparations_df": library_preparations_df,
"sequencing_files": sequencing_files,
"sequencing_files_df": sequencing_files_df,
"differentiated": differentiated,
"cell_line_sheet_name": cell_line_sheet_name,
"differentiated_cell_line_sheet_name": differentiated_cell_line_sheet_name,
"undifferentiated_cell_line_sheet_name": undifferentiated_cell_line_sheet_name
}
except Exception as e:
print(f"Exception occurred:", e)

Expand Down
2 changes: 1 addition & 1 deletion ait/commons/util/spreadsheet_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ def list_sheets(self):
return [sheet_name.strip() for sheet_name in xls.sheet_names]

def input_file_to_data_frames(self, sheet_name, action):
if action.upper() == 'MODIFY':
if action and action.upper() == 'MODIFY':
skip_rows = 0
else:
skip_rows = 3
Expand Down
195 changes: 195 additions & 0 deletions ait/commons/util/spreadsheet_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""
Standalone spreadsheet validation/parsing logic shared by:

* CmdSubmitFile (the CLI submit-file command)
* The HTTP /validate service (service/app.py)

The CLI passes its own action/context/validation_errors so existing behavior
is preserved (validation_errors is mutated in place). The HTTP service can
call this with action=None, context=None, validation_errors=None and read
the errors back from the returned dict.
"""

from .spreadsheet_util import (
merge_cell_line_and_differentiated_cell_line,
merge_differentiated_cell_line_and_library_preparation,
merge_library_preparation_sequencing_file,
process_library_preparations,
ValidationError,
)


class SpreadsheetValidator:
@staticmethod
def _parse_spreadsheet(parser, action=None, context=None, validation_errors=None):
"""
Parse a spreadsheet (already opened via SpreadsheetSubmitter) into a
structured dict, accumulating non-fatal validation errors in
``validation_errors`` (mutated in place if provided).

Parameters
----------
parser : SpreadsheetSubmitter
The opened spreadsheet.
action : str | None
ADD / MODIFY / DELETE for the CLI; None for the HTTP validator.
context : str | None
Optional ingestion context (e.g. ``pooled_differentiated``,
``unperturbed_multiple``).
validation_errors : list | None
Existing errors list to append to. If None, a new list is created.

Returns
-------
dict
Parsed sections plus ``errors`` and ``sheets`` keys for callers
(the HTTP service) that don't have a separate handle on the
mutated ``validation_errors`` list.
"""
if validation_errors is None:
validation_errors = []

try:
tab_names = parser.list_sheets()

cell_line_sheet_name = next(
(name for name in ["Cell line", "Clonal cell line"] if name in tab_names), None
)

differentiated_cell_line_sheet_name = next(
(name for name in ["Differentiated cell line", "Differentiated product"] if name in tab_names), None
)

undifferentiated_cell_line_sheet_name = (
"Undifferentiated product" if "Undifferentiated product" in tab_names else None
)

undifferentiated_cell_lines = []
undifferentiated_cell_lines_df = None

differentiated_cell_lines = []
differentiated_cell_lines_df = None

differentiated = False

# Validate the presence of required sheets
if not cell_line_sheet_name:
validation_errors.append(
"Spreadsheet must contain a 'Cell line' or 'Clonal cell line' sheet."
)

if not (differentiated_cell_line_sheet_name or undifferentiated_cell_line_sheet_name):
validation_errors.append(
"Spreadsheet must contain a "
"'Differentiated cell line', 'Undifferentiated product', "
"or 'Differentiated product' sheet."
)

# Parse different sections of the spreadsheet
expression_alterations, expression_alterations_df = parser.get_expression_alterations(
'Expression alteration', action, validation_errors,
context=context
)

cell_lines, cell_lines_df, parent_cell_line_names = parser.get_cell_lines(
cell_line_sheet_name, action, validation_errors, context=context
)

if differentiated_cell_line_sheet_name:
differentiated_cell_lines, differentiated_cell_lines_df = parser.get_differentiated_cell_lines(
differentiated_cell_line_sheet_name, action, validation_errors
)

if undifferentiated_cell_line_sheet_name:
undifferentiated_cell_lines, undifferentiated_cell_lines_df = parser.get_undifferentiated_cell_lines(
undifferentiated_cell_line_sheet_name, action, validation_errors
)

# Check for errors and merge data
if differentiated_cell_lines and undifferentiated_cell_lines:
validation_errors.append(
"A spreadsheet cannot contain rows in both differentiated and undifferentiated cell lines/ products"
)

if differentiated_cell_lines:
differentiated = True
merge_cell_line_and_differentiated_cell_line(
cell_lines, differentiated_cell_lines, validation_errors, context=context
)

if undifferentiated_cell_lines and not differentiated:
merge_cell_line_and_differentiated_cell_line(
cell_lines, undifferentiated_cell_lines, validation_errors, context=context
)

library_preparations_result = parser.get_library_preparations(
'Library preparation', differentiated, action, validation_errors
)

if not isinstance(library_preparations_result, tuple) or len(library_preparations_result) != 2:
raise ValueError("Unexpected return from get_library_preparations()")

library_preparations, library_preparations_df = library_preparations_result

# Handle N:1 relationships for differentiated products in library preparation
for lp in library_preparations:
if "differentiated_biomaterial_id" in lp.__dict__:
differentiated_ids = lp.differentiated_biomaterial_id.split("|")
lp.differentiated_biomaterial_id = differentiated_ids

if differentiated_cell_lines:
if context == "unperturbed_multiple":
process_library_preparations(
cell_lines, differentiated_cell_lines, library_preparations, validation_errors
)
else:
merge_differentiated_cell_line_and_library_preparation(
differentiated_cell_lines, library_preparations, validation_errors,
cell_lines=cell_lines
)
elif undifferentiated_cell_lines and not differentiated:
if context == "unperturbed_multiple":
process_library_preparations(
cell_lines, undifferentiated_cell_lines, library_preparations, validation_errors
)
else:
merge_differentiated_cell_line_and_library_preparation(
undifferentiated_cell_lines, library_preparations, validation_errors,
cell_lines=cell_lines
)

sequencing_files, sequencing_files_df = parser.get_sequencing_files(
'Sequence file', action, validation_errors
)

merge_library_preparation_sequencing_file(
library_preparations, sequencing_files, validation_errors
)

# Return parsed data as a dictionary. The CLI ignores the extra
# 'errors' / 'sheets' keys (it reads self.validation_errors); the
# HTTP service reads them directly from this dict.
return {
"sheets": tab_names,
"errors": validation_errors,
"expression_alterations": expression_alterations,
"expression_alterations_df": expression_alterations_df,
"cell_lines": cell_lines,
"cell_lines_df": cell_lines_df,
"parent_cell_line_names": parent_cell_line_names,
"differentiated_cell_lines": differentiated_cell_lines,
"differentiated_cell_lines_df": differentiated_cell_lines_df,
"undifferentiated_cell_lines": undifferentiated_cell_lines,
"undifferentiated_cell_lines_df": undifferentiated_cell_lines_df,
"library_preparations": library_preparations,
"library_preparations_df": library_preparations_df,
"sequencing_files": sequencing_files,
"sequencing_files_df": sequencing_files_df,
"differentiated": differentiated,
"cell_line_sheet_name": cell_line_sheet_name,
"differentiated_cell_line_sheet_name": differentiated_cell_line_sheet_name,
"undifferentiated_cell_line_sheet_name": undifferentiated_cell_line_sheet_name,
}
except Exception:
# Re-raise; caller decides how to surface (CLI prints, HTTP returns 400/500)
raise
10 changes: 10 additions & 0 deletions service/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.venv
.vscode
__pycache__
*.pyc
*.pyo
build
dist
*.egg-info
.pytest_cache
Loading