Skip to content
Merged
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
18 changes: 12 additions & 6 deletions flowbio/cli/_accession_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

An accession sheet is a CSV with one row per accession to import: required
``accession`` and ``sample_type`` columns, plus optional ``name``/
``organism`` and per-accession metadata columns. This mirrors ``_sheet.py``'s
reads-based sample sheet, but the reserved columns differ — there is nothing
to upload (no ``reads1``/``reads2``) and the import API has no project field.
``organism``/``project``/``pubmed`` and per-accession metadata columns. This
mirrors ``_sheet.py``'s reads-based sample sheet, but the reserved columns
differ — there is nothing to upload (no ``reads1``/``reads2``).

Domain rules (accession format, duplicates, sample type, organism, metadata)
Domain rules (accession format, duplicates, sample type, organism, project, pubmed, metadata)
are all checked server-side when the sheet is submitted — duplicating that
locally would just be a second, driftable copy of the same rules. What *is*
checked locally is structural: every row must have an accession and a
Expand All @@ -25,9 +25,9 @@

from flowbio.cli._exit_codes import CliUsageError
from flowbio.cli._files import existing_file
from flowbio.v2.samples import SampleImportSpec, SampleTypeId
from flowbio.v2.samples import PubMedId, SampleImportSpec, SampleTypeId

RESERVED_COLUMNS = ("accession", "name", "organism", "sample_type")
RESERVED_COLUMNS = ("accession", "name", "organism", "project", "pubmed", "sample_type")

ParsedRow = Mapping[str | None, str | list[str] | None]
"""One row as ``csv.DictReader`` yields it: a header's cell, or ``None`` if
Expand All @@ -43,6 +43,8 @@ class AccessionSheetRow:
accession: str
name: str | None
organism: str | None
project: str | None
pubmed: PubMedId | None
sample_type: SampleTypeId
metadata: dict[str, str]

Expand All @@ -59,6 +61,8 @@ def to_spec(self) -> SampleImportSpec:
sample_type=self.sample_type,
name=self.name,
organism_id=self.organism,
project_id=self.project,
pubmed=self.pubmed,
metadata=self.metadata or None,
)

Expand Down Expand Up @@ -247,6 +251,8 @@ def _build_row(
accession=accession,
name=_cell(record, "name"),
organism=_cell(record, "organism"),
project=_cell(record, "project"),
pubmed=PubMedId(pubmed) if (pubmed := _cell(record, "pubmed")) else None,
sample_type=SampleTypeId(sample_type),
metadata=metadata,
)
11 changes: 6 additions & 5 deletions flowbio/cli/_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def _configure_import(import_parser: argparse.ArgumentParser) -> None:
type=Path,
help=(
"CSV accession sheet (required accession/sample_type columns, "
"optional name/organism, plus metadata columns)."
"optional name/organism/project/pubmed, plus metadata columns)."
),
)

Expand Down Expand Up @@ -619,10 +619,11 @@ def _import_command(
"""Kick off a batch import job from an accession sheet and report its id.

Every row is submitted as-is: the accession format, sample type,
organism, and metadata rules are all validated server-side, so a
malformed sheet surfaces as a normal :class:`FlowApiError` rather than a
local pre-flight rejection. This command does not wait for the job to
finish — poll it yourself with ``samples import-status``.
organism, project, pubmed, and metadata rules are all validated
server-side, so a malformed sheet surfaces as a normal
:class:`FlowApiError` rather than a local pre-flight rejection. This
command does not wait for the job to finish — poll it yourself with
``samples import-status``.

:param args: Parsed command-line arguments.
:param client: The authenticated Flow client.
Expand Down
2 changes: 2 additions & 0 deletions flowbio/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
MultiplexedUpload,
Organism,
Project,
PubMedId,
Sample,
SampleImportJob,
SampleImportJobId,
Expand All @@ -62,6 +63,7 @@
"MultiplexedUpload",
"Organism",
"Project",
"PubMedId",
"Sample",
"SampleImportJob",
"SampleImportJobId",
Expand Down
26 changes: 20 additions & 6 deletions flowbio/v2/samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
:meth:`SampleResource.get_types`."""


PubMedId = NewType("PubMedId", str)
"""A PubMed identifier: a bare integer written as a string (e.g.
``"12345678"``). Held as a ``str`` because it travels verbatim through CSV
cells and JSON, but the value must be all digits — validated server-side."""


class SampleType(BaseModel, frozen=True):
"""A type of sample that can be uploaded to the Flow platform.

Expand Down Expand Up @@ -174,6 +180,11 @@ class SampleImportSpec:
when omitted.
:param organism_id: Optional organism id (e.g. ``"Hs"``) to associate
with the sample, sent as ``organism``.
:param project_id: Optional project id to assign the imported sample to,
sent as ``project``. Must be a project you own; see
:meth:`SampleResource.get_owned_projects`.
:param pubmed: Optional PubMed id (a bare number, e.g. ``"12345678"``) of
the publication to associate with the sample. Validated server-side.
:param metadata: Optional metadata key-value pairs. See
:ref:`metadata-attributes` for details on required attributes.
"""
Expand All @@ -182,6 +193,8 @@ class SampleImportSpec:
sample_type: SampleTypeId
name: str | None = None
organism_id: str | None = None
project_id: str | None = None
Comment thread
mhusbynflow marked this conversation as resolved.
pubmed: PubMedId | None = None
Comment thread
mhusbynflow marked this conversation as resolved.
metadata: dict[str, str] | None = None


Expand Down Expand Up @@ -529,15 +542,16 @@ def get_import(self, job_id: SampleImportJobId) -> SampleImportJob:
def _import_spec_fields(spec: SampleImportSpec) -> dict[str, str | dict[str, str]]:
"""Build the wire payload for one accession.

Every field is sent under its dataclass name as-is; only ``name``,
``organism`` (renamed from ``organism_id``), and ``metadata`` are
omitted when empty. A field added to :class:`SampleImportSpec` is
sent even when unset unless it's also added to ``optional`` here.
Only ``accession`` and ``sample_type`` are always sent; every other
field is omitted when empty. ``organism_id``/``project_id`` are sent
under their wire names (``organism``/``project``); the rest keep their
dataclass name.
"""
fields = asdict(spec)
fields["organism"] = fields.pop("organism_id")
optional = ("name", "organism", "metadata")
return {key: value for key, value in fields.items() if key not in optional or value}
fields["project"] = fields.pop("project_id")
required = ("accession", "sample_type")
return {key: value for key, value in fields.items() if key in required or value}

def _create_metadata_attribute(self, item: dict) -> MetadataAttribute:
item["required_for_sample_types"] = [
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name="flowbio",
version="0.10.0",
version="0.11.0",
description="A client for the Flow API.",
long_description=long_description,
long_description_content_type="text/markdown",
Expand Down
17 changes: 9 additions & 8 deletions source/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -380,16 +380,17 @@ yourself.

Run ``flowbio samples import --help`` for the full option list. The sheet is
a CSV with required ``accession``/``sample_type`` columns, plus optional
``name``/``organism`` and metadata columns (there is no ``batch-template``
equivalent for it, since it has no reads files or project field). ``name``
defaults to the accession when omitted. There is deliberately no
``name``/``organism``/``project``/``pubmed`` and metadata columns (there is
no ``batch-template`` equivalent for it, since it has no reads files).
``name`` defaults to the accession when omitted. There is deliberately no
``--sample-type`` flag: the sheet's own column is the only way to supply a
sample type, so a mixed-type sheet needs no special handling and a
single-type sheet just repeats the same value down the column.

Every value is sent as-is, with surrounding whitespace trimmed; header
names are trimmed the same way. The accession format, sample type, and
metadata rules are all validated **server-side**. This command only
names are trimmed the same way. The accession format, sample type,
organism, project, pubmed, and metadata rules are all validated
**server-side**. This command only
checks what's structural — anything it can't resolve on your behalf, it
rejects up front rather than guessing:

Expand Down Expand Up @@ -449,9 +450,9 @@ otherwise the standard mapping above.

.. code-block:: text

accession,sample_type,name,organism
ERR1160845,RNA-Seq,liver_r1,Hs
ERR10677146,RNA-Seq,,
accession,sample_type,name,organism,project,pubmed
ERR1160845,RNA-Seq,liver_r1,Hs,proj_123,12345678
ERR10677146,RNA-Seq,,,,

.. code-block:: bash

Expand Down
25 changes: 19 additions & 6 deletions tests/unit/cli/test_accession_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from flowbio.cli._exit_codes import CliUsageError
from flowbio.v2.samples import SampleImportSpec

HEADERS = ["accession", "name", "organism", "sample_type", "cell_type", "source", "source__annotation"]
HEADERS = ["accession", "name", "organism", "project", "pubmed", "sample_type", "cell_type", "source", "source__annotation"]


def _write_sheet(
Expand Down Expand Up @@ -44,13 +44,15 @@ def test_empty_cells_omitted_from_metadata(self, tmp_path: Path) -> None:

assert sheet.rows[0].metadata == {"source": "blood"}

def test_name_and_organism_are_optional(self, tmp_path: Path) -> None:
def test_name_organism_project_and_pubmed_are_optional(self, tmp_path: Path) -> None:
sheet = parse_accession_sheet(
_write_sheet(tmp_path, _record()),
)

assert sheet.rows[0].name is None
assert sheet.rows[0].organism is None
assert sheet.rows[0].project is None
assert sheet.rows[0].pubmed is None

def test_row_shorter_than_the_header_is_usage_error(
self, tmp_path: Path,
Expand All @@ -63,14 +65,16 @@ def test_row_shorter_than_the_header_is_usage_error(
):
parse_accession_sheet(path)

def test_name_and_organism_are_parsed(self, tmp_path: Path) -> None:
def test_name_organism_project_and_pubmed_are_parsed(self, tmp_path: Path) -> None:
sheet = parse_accession_sheet(_write_sheet(
tmp_path,
_record(name="liver_r1", organism="Hs"),
_record(name="liver_r1", organism="Hs", project="proj_1", pubmed="12345678"),
))

assert sheet.rows[0].name == "liver_r1"
assert sheet.rows[0].organism == "Hs"
assert sheet.rows[0].project == "proj_1"
assert sheet.rows[0].pubmed == "12345678"

def test_sample_type_is_parsed(self, tmp_path: Path) -> None:
sheet = parse_accession_sheet(_write_sheet(
Expand Down Expand Up @@ -385,6 +389,8 @@ def test_row_rejects_empty_accession_by_construction() -> None:
accession="",
name=None,
organism=None,
project=None,
pubmed=None,
sample_type="rna_seq",
metadata={},
)
Expand All @@ -397,6 +403,8 @@ def test_row_rejects_empty_sample_type_by_construction() -> None:
accession="ERR1160845",
name=None,
organism=None,
project=None,
pubmed=None,
sample_type="",
metadata={},
)
Expand All @@ -415,8 +423,11 @@ def test_uses_the_row_sample_type(self, tmp_path: Path) -> None:

assert spec == SampleImportSpec(accession="ERR1160845", sample_type="chip_seq")

def test_carries_name_organism_and_metadata(self, tmp_path: Path) -> None:
row = self._row(tmp_path, name="liver_r1", organism="Hs", cell_type="Neuron")
def test_carries_name_organism_project_pubmed_and_metadata(self, tmp_path: Path) -> None:
row = self._row(
tmp_path, name="liver_r1", organism="Hs", project="proj_1",
pubmed="12345678", cell_type="Neuron",
)

spec = row.to_spec()

Expand All @@ -425,6 +436,8 @@ def test_carries_name_organism_and_metadata(self, tmp_path: Path) -> None:
sample_type="rna_seq",
name="liver_r1",
organism_id="Hs",
project_id="proj_1",
pubmed="12345678",
metadata={"cell_type": "Neuron"},
)

Expand Down
30 changes: 27 additions & 3 deletions tests/unit/cli/test_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@ def test_non_csv_sheet_is_usage_error(self, run_cli, tmp_path: Path) -> None:
assert "CSV" in result.stderr


IMPORT_HEADERS = ["accession", "name", "organism", "sample_type", "cell_type", "source", "source__annotation"]
IMPORT_HEADERS = ["accession", "name", "organism", "project", "pubmed", "sample_type", "cell_type", "source", "source__annotation"]


def _write_import_sheet(directory: Path, *records: dict[str, str]) -> Path:
Expand Down Expand Up @@ -1051,7 +1051,7 @@ def test_sends_every_row_without_local_validation(
]

@respx.mock
def test_sends_name_organism_and_metadata_in_payload(
def test_sends_optional_columns_in_payload(
self, run_cli, tmp_path: Path,
) -> None:
route = respx.post(SAMPLE_IMPORTS_URL).mock(
Expand All @@ -1060,7 +1060,7 @@ def test_sends_name_organism_and_metadata_in_payload(
)),
)
sheet = _write_import_sheet(tmp_path, _import_record(
name="liver_r1", organism="Hs", cell_type="Neuron",
name="liver_r1", organism="Hs", project="proj_1", pubmed="12345678", cell_type="Neuron",
))

run_cli(
Expand All @@ -1074,10 +1074,34 @@ def test_sends_name_organism_and_metadata_in_payload(
"sample_type": "rna_seq",
"name": "liver_r1",
"organism": "Hs",
"project": "proj_1",
"pubmed": "12345678",
"metadata": {"cell_type": "Neuron"},
}],
}

@respx.mock
def test_blank_optional_columns_are_omitted_from_payload(
self, run_cli, tmp_path: Path,
) -> None:
route = respx.post(SAMPLE_IMPORTS_URL).mock(
return_value=httpx.Response(HTTPStatus.CREATED, json=_job_json(
1, "RUNNING", ["ERR1"],
)),
)
sheet = _write_import_sheet(tmp_path, _import_record(
name="", organism="", project="", pubmed="",
))

run_cli(
"--token", TOKEN, "samples", "import", "--sheet", str(sheet),
)

payload = json.loads(route.calls[0].request.content)
assert payload == {
"imports": [{"accession": "ERR1", "sample_type": "rna_seq"}],
}

@respx.mock
def test_each_row_uses_its_own_sample_type(
self, run_cli, tmp_path: Path,
Expand Down
8 changes: 7 additions & 1 deletion tests/unit/v2/test_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,8 @@ def test_sends_optional_fields_when_present(self) -> None:
sample_type="rna_seq",
name="my_sample",
organism_id="Hs",
project_id="proj_1",
pubmed="12345678",
metadata={"strandedness": "reverse"},
),
])
Expand All @@ -1139,6 +1141,8 @@ def test_sends_optional_fields_when_present(self) -> None:
"sample_type": "rna_seq",
"name": "my_sample",
"organism": "Hs",
"project": "proj_1",
"pubmed": "12345678",
"metadata": {"strandedness": "reverse"},
}],
}
Expand All @@ -1154,7 +1158,9 @@ def test_empty_string_optional_fields_are_omitted_not_the_required_ones(self) ->

client = Client()
client.samples.import_samples([
SampleImportSpec(accession="ERR1", sample_type="rna_seq", name="", organism_id=""),
SampleImportSpec(
accession="ERR1", sample_type="rna_seq", name="", organism_id="", project_id="", pubmed="",
),
])

payload = json.loads(route.calls[0].request.content)
Expand Down
Loading