From 449e00f66dde33391ab386c502e68c45336f26a1 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 11:38:36 +0100 Subject: [PATCH 1/7] feat(samples): support project on sample imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample-import backend now accepts a project (alongside the already-supported organism), so surface it through both the library and the CLI. SampleImportSpec gains an optional project_id, sent on the wire as `project` and omitted when empty — mirroring how organism_id is handled. The CLI accession sheet gains a matching optional `project` column, forwarded per row. Co-Authored-By: Claude Opus 4.8 (1M context) --- flowbio/cli/_accession_sheet.py | 13 ++++++++----- flowbio/cli/_samples.py | 2 +- flowbio/v2/samples.py | 14 ++++++++++---- source/cli.rst | 10 +++++----- tests/unit/cli/test_accession_sheet.py | 19 +++++++++++++------ tests/unit/v2/test_samples.py | 4 +++- 6 files changed, 40 insertions(+), 22 deletions(-) diff --git a/flowbio/cli/_accession_sheet.py b/flowbio/cli/_accession_sheet.py index 866f484..264649a 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`` 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, 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 @@ -27,7 +27,7 @@ from flowbio.cli._files import existing_file from flowbio.v2.samples import SampleImportSpec, SampleTypeId -RESERVED_COLUMNS = ("accession", "name", "organism", "sample_type") +RESERVED_COLUMNS = ("accession", "name", "organism", "project", "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,7 @@ class AccessionSheetRow: accession: str name: str | None organism: str | None + project: str | None sample_type: SampleTypeId metadata: dict[str, str] @@ -59,6 +60,7 @@ def to_spec(self) -> SampleImportSpec: sample_type=self.sample_type, name=self.name, organism_id=self.organism, + project_id=self.project, metadata=self.metadata or None, ) @@ -247,6 +249,7 @@ def _build_row( accession=accession, name=_cell(record, "name"), organism=_cell(record, "organism"), + project=_cell(record, "project"), sample_type=SampleTypeId(sample_type), metadata=metadata, ) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index e681d32..7497ac6 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, plus metadata columns)." ), ) diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index e18923e..a00884f 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -174,6 +174,9 @@ 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 metadata: Optional metadata key-value pairs. See :ref:`metadata-attributes` for details on required attributes. """ @@ -182,6 +185,7 @@ class SampleImportSpec: sample_type: SampleTypeId name: str | None = None organism_id: str | None = None + project_id: str | None = None metadata: dict[str, str] | None = None @@ -530,13 +534,15 @@ 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. + ``organism`` (renamed from ``organism_id``), ``project`` (renamed + from ``project_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. """ fields = asdict(spec) fields["organism"] = fields.pop("organism_id") - optional = ("name", "organism", "metadata") + fields["project"] = fields.pop("project_id") + optional = ("name", "organism", "project", "metadata") return {key: value for key, value in fields.items() if key not in optional or value} def _create_metadata_attribute(self, item: dict) -> MetadataAttribute: diff --git a/source/cli.rst b/source/cli.rst index 7d7911b..4ef2f89 100644 --- a/source/cli.rst +++ b/source/cli.rst @@ -380,8 +380,8 @@ 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`` +``name``/``organism``/``project`` 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 @@ -449,9 +449,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 + ERR1160845,RNA-Seq,liver_r1,Hs,proj_123 + 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..a8efcb7 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", "sample_type", "cell_type", "source", "source__annotation"] def _write_sheet( @@ -44,13 +44,14 @@ 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_and_project_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 def test_row_shorter_than_the_header_is_usage_error( self, tmp_path: Path, @@ -63,14 +64,15 @@ 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_and_project_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"), )) assert sheet.rows[0].name == "liver_r1" assert sheet.rows[0].organism == "Hs" + assert sheet.rows[0].project == "proj_1" def test_sample_type_is_parsed(self, tmp_path: Path) -> None: sheet = parse_accession_sheet(_write_sheet( @@ -385,6 +387,7 @@ def test_row_rejects_empty_accession_by_construction() -> None: accession="", name=None, organism=None, + project=None, sample_type="rna_seq", metadata={}, ) @@ -397,6 +400,7 @@ def test_row_rejects_empty_sample_type_by_construction() -> None: accession="ERR1160845", name=None, organism=None, + project=None, sample_type="", metadata={}, ) @@ -415,8 +419,10 @@ 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_and_metadata(self, tmp_path: Path) -> None: + row = self._row( + tmp_path, name="liver_r1", organism="Hs", project="proj_1", cell_type="Neuron", + ) spec = row.to_spec() @@ -425,6 +431,7 @@ 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", metadata={"cell_type": "Neuron"}, ) diff --git a/tests/unit/v2/test_samples.py b/tests/unit/v2/test_samples.py index 9444c6c..80eb33c 100644 --- a/tests/unit/v2/test_samples.py +++ b/tests/unit/v2/test_samples.py @@ -1128,6 +1128,7 @@ def test_sends_optional_fields_when_present(self) -> None: sample_type="rna_seq", name="my_sample", organism_id="Hs", + project_id="proj_1", metadata={"strandedness": "reverse"}, ), ]) @@ -1139,6 +1140,7 @@ def test_sends_optional_fields_when_present(self) -> None: "sample_type": "rna_seq", "name": "my_sample", "organism": "Hs", + "project": "proj_1", "metadata": {"strandedness": "reverse"}, }], } @@ -1154,7 +1156,7 @@ 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=""), ]) payload = json.loads(route.calls[0].request.content) From 56f59233a3af3267ddf858e8f4fdc2c1b76b6132 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 11:38:36 +0100 Subject: [PATCH 2/7] chore: bump version to 0.11.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 22c690f239b023735a353e05844c756ab004c1b6 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 11:47:50 +0100 Subject: [PATCH 3/7] feat(samples): support pubmed on sample imports The sample-import endpoint accepts a per-accession pubmed id (a bare PubMed number) alongside organism and project, so surface it through the library and the CLI too. SampleImportSpec gains an optional pubmed field, sent on the wire unchanged and omitted when empty. The CLI accession sheet gains a matching optional pubmed column, forwarded per row. Format is validated server-side, like the other fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- flowbio/cli/_accession_sheet.py | 13 ++++++++----- flowbio/cli/_samples.py | 2 +- flowbio/v2/samples.py | 11 +++++++---- source/cli.rst | 12 ++++++------ tests/unit/cli/test_accession_sheet.py | 18 ++++++++++++------ tests/unit/v2/test_samples.py | 6 +++++- 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/flowbio/cli/_accession_sheet.py b/flowbio/cli/_accession_sheet.py index 264649a..b9bf9e2 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``/``project`` 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``). +``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, project, 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 @@ -27,7 +27,7 @@ from flowbio.cli._files import existing_file from flowbio.v2.samples import SampleImportSpec, SampleTypeId -RESERVED_COLUMNS = ("accession", "name", "organism", "project", "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 @@ -44,6 +44,7 @@ class AccessionSheetRow: name: str | None organism: str | None project: str | None + pubmed: str | None sample_type: SampleTypeId metadata: dict[str, str] @@ -61,6 +62,7 @@ def to_spec(self) -> SampleImportSpec: name=self.name, organism_id=self.organism, project_id=self.project, + pubmed=self.pubmed, metadata=self.metadata or None, ) @@ -250,6 +252,7 @@ def _build_row( name=_cell(record, "name"), organism=_cell(record, "organism"), project=_cell(record, "project"), + pubmed=_cell(record, "pubmed"), sample_type=SampleTypeId(sample_type), metadata=metadata, ) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index 7497ac6..c7f3fd5 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/project, plus metadata columns)." + "optional name/organism/project/pubmed, plus metadata columns)." ), ) diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index a00884f..f1357e2 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -177,6 +177,8 @@ class SampleImportSpec: :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. """ @@ -186,6 +188,7 @@ class SampleImportSpec: name: str | None = None organism_id: str | None = None project_id: str | None = None + pubmed: str | None = None metadata: dict[str, str] | None = None @@ -535,14 +538,14 @@ def _import_spec_fields(spec: SampleImportSpec) -> dict[str, str | dict[str, str Every field is sent under its dataclass name as-is; only ``name``, ``organism`` (renamed from ``organism_id``), ``project`` (renamed - from ``project_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. + from ``project_id``), ``pubmed``, 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. """ fields = asdict(spec) fields["organism"] = fields.pop("organism_id") fields["project"] = fields.pop("project_id") - optional = ("name", "organism", "project", "metadata") + optional = ("name", "organism", "project", "pubmed", "metadata") return {key: value for key, value in fields.items() if key not in optional or value} def _create_metadata_attribute(self, item: dict) -> MetadataAttribute: diff --git a/source/cli.rst b/source/cli.rst index 4ef2f89..1e873e2 100644 --- a/source/cli.rst +++ b/source/cli.rst @@ -380,9 +380,9 @@ 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``/``project`` 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 +``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. @@ -449,9 +449,9 @@ otherwise the standard mapping above. .. code-block:: text - accession,sample_type,name,organism,project - ERR1160845,RNA-Seq,liver_r1,Hs,proj_123 - 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 a8efcb7..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", "project", "sample_type", "cell_type", "source", "source__annotation"] +HEADERS = ["accession", "name", "organism", "project", "pubmed", "sample_type", "cell_type", "source", "source__annotation"] def _write_sheet( @@ -44,7 +44,7 @@ def test_empty_cells_omitted_from_metadata(self, tmp_path: Path) -> None: assert sheet.rows[0].metadata == {"source": "blood"} - def test_name_organism_and_project_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()), ) @@ -52,6 +52,7 @@ def test_name_organism_and_project_are_optional(self, tmp_path: Path) -> None: 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, @@ -64,15 +65,16 @@ def test_row_shorter_than_the_header_is_usage_error( ): parse_accession_sheet(path) - def test_name_organism_and_project_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", project="proj_1"), + _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( @@ -388,6 +390,7 @@ def test_row_rejects_empty_accession_by_construction() -> None: name=None, organism=None, project=None, + pubmed=None, sample_type="rna_seq", metadata={}, ) @@ -401,6 +404,7 @@ def test_row_rejects_empty_sample_type_by_construction() -> None: name=None, organism=None, project=None, + pubmed=None, sample_type="", metadata={}, ) @@ -419,9 +423,10 @@ 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_project_and_metadata(self, tmp_path: Path) -> None: + 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", cell_type="Neuron", + tmp_path, name="liver_r1", organism="Hs", project="proj_1", + pubmed="12345678", cell_type="Neuron", ) spec = row.to_spec() @@ -432,6 +437,7 @@ def test_carries_name_organism_project_and_metadata(self, tmp_path: Path) -> Non name="liver_r1", organism_id="Hs", project_id="proj_1", + pubmed="12345678", metadata={"cell_type": "Neuron"}, ) diff --git a/tests/unit/v2/test_samples.py b/tests/unit/v2/test_samples.py index 80eb33c..ae57639 100644 --- a/tests/unit/v2/test_samples.py +++ b/tests/unit/v2/test_samples.py @@ -1129,6 +1129,7 @@ def test_sends_optional_fields_when_present(self) -> None: name="my_sample", organism_id="Hs", project_id="proj_1", + pubmed="12345678", metadata={"strandedness": "reverse"}, ), ]) @@ -1141,6 +1142,7 @@ def test_sends_optional_fields_when_present(self) -> None: "name": "my_sample", "organism": "Hs", "project": "proj_1", + "pubmed": "12345678", "metadata": {"strandedness": "reverse"}, }], } @@ -1156,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="", project_id=""), + SampleImportSpec( + accession="ERR1", sample_type="rna_seq", name="", organism_id="", project_id="", pubmed="", + ), ]) payload = json.loads(route.calls[0].request.content) From 5f85cf5781791b3cbb6d3f73b552f921730acd1c Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 11:53:46 +0100 Subject: [PATCH 4/7] test(cli): cover project and pubmed through the import CLI seam The end-to-end `samples import --sheet` test is the only one that drives the accession sheet through the CLI to a mocked request, so it's what catches a column being reserved in the parser but dropped before the request (or leaking into metadata). It didn't exercise the new project or pubmed columns. Add both to IMPORT_HEADERS, assert they reach the payload, and add a case proving blank cells are omitted from it. Also bring the _import_command docstring's server-validated field list in line with the module docstring. Co-Authored-By: Claude Opus 4.8 (1M context) --- flowbio/cli/_samples.py | 6 +++--- tests/unit/cli/test_samples.py | 30 +++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index c7f3fd5..79c5803 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -619,9 +619,9 @@ 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 + 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. 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, From 4f40e149a47c19aa852dadcf9d572149693d5076 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 12:00:32 +0100 Subject: [PATCH 5/7] style(cli): re-wrap the _import_command docstring paragraph Adding project and pubmed to the server-validated field list pushed the sentence boundary and left one line ragged against its neighbours. Re-wrap only; no wording change. Co-Authored-By: Claude Opus 4.8 (1M context) --- flowbio/cli/_samples.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index 79c5803..c51b093 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -621,8 +621,9 @@ def _import_command( Every row is submitted as-is: the accession format, sample type, 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``. + :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. From 39528536f231090a7e202c8e88cf21c91d0a4b05 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 15:31:47 +0100 Subject: [PATCH 6/7] refactor(samples): default-omit import fields and type pubmed as PubMedId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-driven refinements to the sample-import payload: Invert the payload filter from an optional-field denylist to a required-field allowlist. `optional` had to grow in lockstep with every new field, and the docstring had to warn about the failure mode. Listing the two always-sent fields instead makes omit-when-empty the default, so a field added to SampleImportSpec is dropped-when-empty for free. Semantics are unchanged (required and optional together covered every field). Add a PubMedId named type (str, documented as a bare integer, validated server-side) alongside SampleTypeId, and apply it to SampleImportSpec.pubmed and the accession-sheet row — matching how SampleTypeId is already wrapped where the value is created. Exported from flowbio.v2. Co-Authored-By: Claude Opus 4.8 (1M context) --- flowbio/cli/_accession_sheet.py | 6 +++--- flowbio/v2/__init__.py | 2 ++ flowbio/v2/samples.py | 21 +++++++++++++-------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/flowbio/cli/_accession_sheet.py b/flowbio/cli/_accession_sheet.py index b9bf9e2..666a5a9 100644 --- a/flowbio/cli/_accession_sheet.py +++ b/flowbio/cli/_accession_sheet.py @@ -25,7 +25,7 @@ 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", "project", "pubmed", "sample_type") @@ -44,7 +44,7 @@ class AccessionSheetRow: name: str | None organism: str | None project: str | None - pubmed: str | None + pubmed: PubMedId | None sample_type: SampleTypeId metadata: dict[str, str] @@ -252,7 +252,7 @@ def _build_row( name=_cell(record, "name"), organism=_cell(record, "organism"), project=_cell(record, "project"), - pubmed=_cell(record, "pubmed"), + pubmed=PubMedId(pubmed) if (pubmed := _cell(record, "pubmed")) else None, sample_type=SampleTypeId(sample_type), metadata=metadata, ) 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 f1357e2..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. @@ -188,7 +194,7 @@ class SampleImportSpec: name: str | None = None organism_id: str | None = None project_id: str | None = None - pubmed: str | None = None + pubmed: PubMedId | None = None metadata: dict[str, str] | None = None @@ -536,17 +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``), ``project`` (renamed - from ``project_id``), ``pubmed``, 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") fields["project"] = fields.pop("project_id") - optional = ("name", "organism", "project", "pubmed", "metadata") - return {key: value for key, value in fields.items() if key not in optional or value} + 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"] = [ From b70ce7d63f77b0aee9e5e9d97db1a5bad5f28cf5 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Thu, 6 Aug 2026 15:31:47 +0100 Subject: [PATCH 7/7] docs(cli): list all server-validated fields for sample imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import section's "validated server-side" sentence listed only accession, sample type, and metadata — now that organism, project, and pubmed all go through the same server-side validation, name them too, in step with the module and command docstrings. Co-Authored-By: Claude Opus 4.8 (1M context) --- source/cli.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/cli.rst b/source/cli.rst index 1e873e2..be722d8 100644 --- a/source/cli.rst +++ b/source/cli.rst @@ -388,8 +388,9 @@ 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: