-
Notifications
You must be signed in to change notification settings - Fork 0
feat(samples): add samples import command for public-repository accessions
#18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mhusbynflow
merged 32 commits into
master
from
implement/FLOW-689-samples-import-command
Jul 30, 2026
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
cf5c223
feat(samples): add `samples import` command for public-repository acc…
claude aec9c96
fix(samples): address import-command review findings
claude 9688f9f
fix(samples): address round-2 import-command review findings
claude d7ed277
fix(samples): address round-3 import-command review findings
claude 98d1306
refactor(samples): make 'import' non-blocking and let the API validate
claude 694198f
fix(samples): address round-5 findings on the non-blocking design
claude 8f829b7
fix(samples): address round-6 findings on the non-blocking design
claude 54d6279
fix(samples): address round-7 findings on the non-blocking design
claude 154e9bc
fix(samples): address round-8 findings (counter reset per operator)
claude b3de2ff
fix(samples): address round-9 findings
claude 02189c9
fix(samples): address round-10 findings
claude 78115d4
refactor(samples): require accession, add per-row sample_type, simplify
claude e1fde61
fix(samples): address round-12 Claude review findings
claude 3c34f87
fix(samples): address round-13 Claude review findings
claude 8446568
fix(samples): address round-14 Claude review findings
claude 5e1c82b
feat(samples): make sample_type an accession-sheet-only concept
claude 60aff5b
fix(samples): address round-16 Claude review findings
claude 433528a
fix(samples): address round-17 Claude review findings
claude d7a4bc2
fix(samples): address round-18 Claude review findings
claude a607b79
fix(samples): reject an accession sheet with an unnamed column
claude a2e1f1a
fix(samples): reject duplicate accession-sheet columns too
claude d6da4a7
fix(samples): reject a data row with more cells than the header
claude 3b2382a
refactor(samples): simplify _import_spec_fields with dataclasses.asdict
claude 0bbee72
chore: bump version to 0.10.0
claude e40eb36
fix(samples): don't reject a wholly-blank row over one extra comma
claude 34ec69b
fix(cli): reject short accession-sheet rows explicitly
claude 40caadb
fix(cli): skip a short wholly-blank row instead of rejecting it
claude b990dc3
fix(cli): reject a sheet whose header row is blank
claude 598dd09
docs(cli): note that a wholly-empty line doesn't consume a row number
claude 0bd4f62
test(cli): split test_row_shorter_than_the_header_is_usage_error in two
claude 7e9bfdf
refactor(cli): split parse_accession_sheet into three functions
claude 63944a8
refactor(cli): drop the redundant path parameter from error helpers
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,252 @@ | ||
| """CSV accession-sheet parsing for ``samples import``. | ||
|
|
||
| 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. | ||
|
|
||
| Domain rules (accession format, duplicates, sample type, organism, 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 | ||
| sample type to mean anything at all, every header column must have a | ||
| unique, non-empty name, and every row must have no fewer cells than the | ||
| header, and no more unless the extra ones are blank — any of these is a | ||
| case the parser can't resolve on the user's behalf, so it's rejected | ||
| rather than guessed at. See :func:`parse_accession_sheet`. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import csv | ||
| from collections.abc import Mapping | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
|
|
||
| from flowbio.cli._exit_codes import CliUsageError | ||
| from flowbio.cli._files import existing_file | ||
| from flowbio.v2.samples import SampleImportSpec, SampleTypeId | ||
|
|
||
| RESERVED_COLUMNS = ("accession", "name", "organism", "sample_type") | ||
|
|
||
| ParsedRow = Mapping[str | None, str | list[str] | None] | ||
| """One row as ``csv.DictReader`` yields it: a header's cell, or ``None`` if | ||
| the row was too short to reach it; the extra cells of a too-long row, as a | ||
| ``list[str]``, under the key ``None``.""" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class AccessionSheetRow: | ||
| """One data row of an accession sheet.""" | ||
|
|
||
| row_number: int | ||
| accession: str | ||
| name: str | None | ||
| organism: str | None | ||
| sample_type: SampleTypeId | ||
| metadata: dict[str, str] | ||
|
|
||
| def __post_init__(self) -> None: | ||
| if not self.accession: | ||
| raise ValueError("accession must not be empty") | ||
| if not self.sample_type: | ||
| raise ValueError("sample_type must not be empty") | ||
|
|
||
| def to_spec(self) -> SampleImportSpec: | ||
| """Build the :class:`~flowbio.v2.samples.SampleImportSpec` for this row.""" | ||
| return SampleImportSpec( | ||
| accession=self.accession, | ||
| sample_type=self.sample_type, | ||
| name=self.name, | ||
| organism_id=self.organism, | ||
| metadata=self.metadata or None, | ||
| ) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class AccessionSheet: | ||
| """A parsed accession sheet.""" | ||
|
|
||
| path: Path | ||
| rows: list[AccessionSheetRow] | ||
|
|
||
|
|
||
| def parse_accession_sheet(path: Path) -> AccessionSheet: | ||
| """Parse a CSV accession sheet into an :class:`AccessionSheet`. | ||
|
|
||
| :param path: The accession-sheet file. Must be a ``.csv`` — an ``.xlsx`` or | ||
| ``.tsv`` is a usage error directing the user to export to CSV. | ||
| :returns: The parsed sheet, with wholly-blank rows skipped, empty cells | ||
| dropped, and surrounding whitespace trimmed (including in header | ||
| names). Values are otherwise passed through unchanged, including | ||
| ``accession``, sent to the server as-entered. | ||
| :raises CliUsageError: If the file is not a readable ``.csv``, has no | ||
| header row, has an unnamed or duplicated column, has no rows, has | ||
| a row with fewer cells than the header or with more cells than the | ||
| header where the overflow isn't blank, or has a row with no | ||
| accession or no sample_type. | ||
| """ | ||
| if path.suffix.lower() != ".csv": | ||
| raise CliUsageError( | ||
| f"Accession sheet must be a .csv file: {path}. " | ||
| f"Export your spreadsheet to CSV first.", | ||
| ) | ||
| existing_file(path) | ||
| rows: list[AccessionSheetRow] = [] | ||
| short_rows: list[int] = [] | ||
| overflow_rows: list[int] = [] | ||
| missing_accession: list[int] = [] | ||
| missing_sample_type: list[int] = [] | ||
| # utf-8-sig transparently strips a leading BOM, which spreadsheet tools | ||
| # (notably Excel's "CSV UTF-8" export) prepend — otherwise the first header | ||
| # parses as "accession" and every row reports a missing accession. | ||
| with path.open(newline="", encoding="utf-8-sig") as handle: | ||
| reader = csv.DictReader(handle) | ||
| headers, metadata_columns = _read_header(reader) | ||
| for row_number, record in enumerate(reader, start=1): | ||
| overflow_has_value = any(cell.strip() for cell in _overflow_cells(record)) | ||
| if not overflow_has_value and _is_blank_row(record, headers): | ||
| continue | ||
| # A short row's missing cells could just as easily be trailing | ||
| # optional columns as data landing in the wrong place, so — | ||
| # unlike a too-wide row's blank overflow — it's rejected outright. | ||
| if any(record.get(header) is None for header in headers): | ||
| short_rows.append(row_number) | ||
| continue | ||
| if overflow_has_value: | ||
| overflow_rows.append(row_number) | ||
| continue | ||
| accession = _cell(record, "accession") | ||
| sample_type = _cell(record, "sample_type") | ||
| if accession is None: | ||
| missing_accession.append(row_number) | ||
| if sample_type is None: | ||
| missing_sample_type.append(row_number) | ||
| if accession is None or sample_type is None: | ||
| continue | ||
| rows.append(_build_row(record, row_number, metadata_columns, accession, sample_type)) | ||
| _check_rows(rows, short_rows, overflow_rows, missing_accession, missing_sample_type) | ||
| return AccessionSheet(path=path, rows=rows) | ||
|
|
||
|
|
||
| def _read_header(reader: csv.DictReader[str]) -> tuple[list[str], list[str]]: | ||
| """Validate the sheet's header row and return its columns and metadata columns.""" | ||
| # A blank first line parses as fieldnames == [] — distinct from an | ||
| # absent one, which is fieldnames is None. | ||
| if reader.fieldnames == []: | ||
| raise CliUsageError("Accession sheet has no header row.") | ||
| headers = [header.strip() for header in reader.fieldnames or []] | ||
| reader.fieldnames = headers | ||
| _check_headers(headers) | ||
| metadata_columns = [header for header in headers if header not in RESERVED_COLUMNS] | ||
| return headers, metadata_columns | ||
|
|
||
|
|
||
| def _check_rows( | ||
| rows: list[AccessionSheetRow], | ||
| short_rows: list[int], | ||
| overflow_rows: list[int], | ||
| missing_accession: list[int], | ||
| missing_sample_type: list[int], | ||
| ) -> None: | ||
| """Raise if the sheet had no rows at all, or any row failed a structural check.""" | ||
| if not (rows or short_rows or overflow_rows or missing_accession or missing_sample_type): | ||
| raise CliUsageError("Accession sheet has no rows.") | ||
| if not (short_rows or overflow_rows or missing_accession or missing_sample_type): | ||
| return | ||
| clauses = [ | ||
| clause for clause in ( | ||
| _row_reason_clause("fewer cells than the header", short_rows), | ||
| _row_reason_clause("more cells than the header", overflow_rows), | ||
| _row_reason_clause("no accession", missing_accession), | ||
| _row_reason_clause("no sample_type", missing_sample_type), | ||
| ) if clause is not None | ||
| ] | ||
| message = f"Accession sheet {'; '.join(clauses)}." | ||
| remedies: list[str] = [] | ||
| if short_rows: | ||
| remedies.append( | ||
| "add the missing trailing comma(s), leaving the cell(s) blank if that " | ||
| "column doesn't apply to this row, or fill in a value", | ||
| ) | ||
| if overflow_rows: | ||
| remedies.append( | ||
| "if a value legitimately contains a comma, quote it; otherwise " | ||
| "remove the extra cell(s)", | ||
| ) | ||
| message += "".join(f" {remedy[0].upper()}{remedy[1:]}." for remedy in remedies) | ||
| raise CliUsageError(message) | ||
|
|
||
|
|
||
| def _check_headers(headers: list[str]) -> None: | ||
| unnamed = [position for position, header in enumerate(headers, start=1) if not header] | ||
| duplicates = sorted({header for header in headers if header and headers.count(header) > 1}) | ||
| if not unnamed and not duplicates: | ||
| return | ||
| clauses: list[str] = [] | ||
| remedies: list[str] = [] | ||
| if unnamed: | ||
| clauses.append(_unnamed_columns_clause(unnamed)) | ||
| remedies.append("remove the trailing comma(s) from the header row, or give the column(s) a name") | ||
| if duplicates: | ||
| clauses.append(_duplicate_columns_clause(duplicates)) | ||
| remedies.append("rename the repeated column(s) so each column is unique") | ||
| remedy = ". ".join(f"{r[0].upper()}{r[1:]}" for r in remedies) | ||
| raise CliUsageError(f"Accession sheet {'; '.join(clauses)}. {remedy}.") | ||
|
|
||
|
|
||
| def _unnamed_columns_clause(unnamed: list[int]) -> str: | ||
| positions = ", ".join(str(position) for position in unnamed) | ||
| verb = "is" if len(unnamed) == 1 else "are" | ||
| return f"column(s) {positions} {verb} unnamed" | ||
|
|
||
|
|
||
| def _duplicate_columns_clause(duplicates: list[str]) -> str: | ||
| names = ", ".join(f"'{name}'" for name in duplicates) | ||
| verb = "is" if len(duplicates) == 1 else "are" | ||
| return f"column name(s) {names} {verb} duplicated" | ||
|
|
||
|
|
||
| def _is_blank_row(record: ParsedRow, headers: list[str]) -> bool: | ||
| return not any(_cell(record, header) for header in headers) | ||
|
|
||
|
|
||
| def _overflow_cells(record: ParsedRow) -> list[str]: | ||
| overflow = record.get(None) | ||
| return overflow if isinstance(overflow, list) else [] | ||
|
|
||
|
|
||
| def _row_reason_clause(reason: str, rows: list[int]) -> str | None: | ||
| if not rows: | ||
| return None | ||
| numbers = ", ".join(str(number) for number in rows) | ||
| verb = "has" if len(rows) == 1 else "have" | ||
| return f"data row(s) {numbers} {verb} {reason}" | ||
|
|
||
|
|
||
| def _cell(record: ParsedRow, column: str) -> str | None: | ||
| raw = record.get(column) | ||
| value = (raw if isinstance(raw, str) else "").strip() | ||
| return value or None | ||
|
|
||
|
|
||
| def _build_row( | ||
| record: ParsedRow, | ||
| row_number: int, | ||
| metadata_columns: list[str], | ||
| accession: str, | ||
| sample_type: str, | ||
| ) -> AccessionSheetRow: | ||
| metadata = { | ||
| column: value | ||
| for column in metadata_columns | ||
| if (value := _cell(record, column)) is not None | ||
| } | ||
| return AccessionSheetRow( | ||
| row_number=row_number, | ||
| accession=accession, | ||
| name=_cell(record, "name"), | ||
| organism=_cell(record, "organism"), | ||
| sample_type=SampleTypeId(sample_type), | ||
| metadata=metadata, | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.