diff --git a/flowbio/cli/_accession_sheet.py b/flowbio/cli/_accession_sheet.py index 866f484..666a5a9 100644 --- a/flowbio/cli/_accession_sheet.py +++ b/flowbio/cli/_accession_sheet.py @@ -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 @@ -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 @@ -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] @@ -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, ) @@ -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, ) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index e681d32..c51b093 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -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)." ), ) @@ -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. diff --git a/flowbio/v2/__init__.py b/flowbio/v2/__init__.py index d0d5f1f..eccddc2 100644 --- a/flowbio/v2/__init__.py +++ b/flowbio/v2/__init__.py @@ -44,6 +44,7 @@ MultiplexedUpload, Organism, Project, + PubMedId, Sample, SampleImportJob, SampleImportJobId, @@ -62,6 +63,7 @@ "MultiplexedUpload", "Organism", "Project", + "PubMedId", "Sample", "SampleImportJob", "SampleImportJobId", diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index e18923e..709e01e 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -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. @@ -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. """ @@ -182,6 +193,8 @@ class SampleImportSpec: sample_type: SampleTypeId name: str | None = None organism_id: str | None = None + project_id: str | None = None + pubmed: PubMedId | None = None metadata: dict[str, str] | None = None @@ -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"] = [ diff --git a/setup.py b/setup.py index 28962a7..73479a2 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/source/cli.rst b/source/cli.rst index 7d7911b..be722d8 100644 --- a/source/cli.rst +++ b/source/cli.rst @@ -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: @@ -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 diff --git a/tests/unit/cli/test_accession_sheet.py b/tests/unit/cli/test_accession_sheet.py index 51ac7a4..481fcc9 100644 --- a/tests/unit/cli/test_accession_sheet.py +++ b/tests/unit/cli/test_accession_sheet.py @@ -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( @@ -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, @@ -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( @@ -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={}, ) @@ -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={}, ) @@ -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() @@ -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"}, ) diff --git a/tests/unit/cli/test_samples.py b/tests/unit/cli/test_samples.py index bdd8fb5..e228779 100644 --- a/tests/unit/cli/test_samples.py +++ b/tests/unit/cli/test_samples.py @@ -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: @@ -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( @@ -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( @@ -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, diff --git a/tests/unit/v2/test_samples.py b/tests/unit/v2/test_samples.py index 9444c6c..ae57639 100644 --- a/tests/unit/v2/test_samples.py +++ b/tests/unit/v2/test_samples.py @@ -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"}, ), ]) @@ -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"}, }], } @@ -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)