From b4f466d5862927387ef46dcaaed6632c6afd009f Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 9 Jun 2026 16:30:38 +0100 Subject: [PATCH 1/8] feat(cli): add `samples batch-template` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements User Story 3: `flowbio samples batch-template --sample-type T` emits a sample-sheet template for a sample type — a CSV header in human mode (reserved columns, then one column per metadata attribute, each followed by a `__annotation` companion when the attribute allows it, with no `sample_type` column), or a per-column descriptor list under `--json`. A required-vs-optional summary goes to stderr, and `-o/--output` writes the CSV to a file. With `--json -o FILE`, the CSV is written to the file and the descriptor list is emitted to stdout (pinned by a test). Adds the additive `allow_annotation` field to `MetadataAttribute`, the only library change the template needs. It mirrors the `/samples/metadata` payload key directly and defaults to `False` when absent. The sample type is not validated by this command (it only decides which columns are required), so the docs no longer claim exit 4 for an unknown type — that is validated at `upload-batch`. Also adopts argparse `type=Path` for the CLI's path options (data upload PATH, samples upload --reads1/--reads2, batch-template --output) so handlers receive real Path objects. Existence validation stays in the handler rather than argparse `type=`, because parse-time errors bypass the Output renderer and would break the --json structured-error contract. Co-Authored-By: Claude Opus 4.8 --- docs/cli.md | 49 ++++++ flowbio/cli/_data.py | 3 +- flowbio/cli/_samples.py | 131 +++++++++++++++- flowbio/v2/samples.py | 4 + .../contracts/samples-batch-template.md | 2 +- specs/001-flowbio-cli/data-model.md | 6 +- specs/001-flowbio-cli/tasks.md | 18 +-- tests/unit/cli/test_samples.py | 143 ++++++++++++++++++ tests/unit/v2/test_samples.py | 49 ++++++ 9 files changed, 389 insertions(+), 16 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 022184f..bda0955 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -221,3 +221,52 @@ $ flowbio samples upload-multiplexed --reads1 ./mux_R1.fastq.gz \ --annotation ./sheet.xlsx --json {"data_ids": ["mux_1"], "annotation_id": "ann_1", "warnings": []} ``` + +### `samples batch-template` + +Emit a sample-sheet template for a sample type, to fill in and feed to +`samples upload-batch`. + +``` +flowbio samples batch-template --sample-type TYPE [-o PATH | --output PATH] +``` + +Run `flowbio samples batch-template --help` for the full option list. The sample +type decides which metadata columns are marked required; it is **not** validated +here (see exit codes below) — an unrecognised type simply yields a template with +nothing flagged required-for-that-type. + +**Sample-sheet schema** — the columns, in order: + +- The reserved columns `name`, `reads1`, `reads2`, `project`, `organism` + (`name` and `reads1` are always required; `reads1`/`reads2` are reads file + paths). +- One column per metadata attribute, keyed by its **identifier**. An attribute is + required when it is globally required or required for the chosen sample type. +- A `__annotation` companion column immediately after each attribute + that permits a free-text annotation. + +There is **no** `sample_type` column — the type is supplied via `--sample-type` +to both this command and `upload-batch`. This CSV is distinct from the annotation +sheet produced by `samples annotation-template`. + +**Output** — human: the CSV header row on stdout (or written to `--output`), plus +a summary of required-vs-optional columns on stderr. `--json`: a per-column +descriptor list on stdout (`name`, `kind` of `reserved`/`metadata`/`annotation`, +`required`, closed-value `options` or `null`, and `description`) and **no CSV** — +so an agent can build rows directly. + +**Exit codes** — `0` success; `2` missing `--sample-type`; `3` authentication +failure; otherwise the standard mapping above. The sample type is not checked +against the server here, so an unknown type still exits `0`; the type is +validated when you run `samples upload-batch`. + +**Example** + +```bash +$ flowbio samples batch-template --sample-type rna_seq +name,reads1,reads2,project,organism,cell_type,source,source__annotation + +$ flowbio samples batch-template --sample-type rna_seq --json +[{"name": "name", "kind": "reserved", "required": true, "options": null, "description": "..."}, ...] +``` diff --git a/flowbio/cli/_data.py b/flowbio/cli/_data.py index b50d461..10987cc 100644 --- a/flowbio/cli/_data.py +++ b/flowbio/cli/_data.py @@ -30,6 +30,7 @@ def register( upload.add_argument( "path", metavar="PATH", + type=Path, help="Local file to upload.", ) upload.add_argument( @@ -58,7 +59,7 @@ def _upload_command(args: argparse.Namespace, client: Client, output: Output) -> :returns: :attr:`ExitCode.SUCCESS` on success. """ data = client.data.upload_data( - existing_file(Path(args.path)), + existing_file(args.path), filename=args.filename, data_type=args.data_type, is_directory=args.directory, diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index a415435..d7170fe 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -9,12 +9,15 @@ import argparse import json +from dataclasses import dataclass from pathlib import Path from flowbio.cli._exit_codes import CliUsageError, ExitCode from flowbio.cli._files import existing_file from flowbio.cli._output import Output, format_issue +from flowbio.cli._types import JsonValue from flowbio.v2.client import Client +from flowbio.v2.samples import MetadataAttribute def register( @@ -46,6 +49,15 @@ def register( "server-side demultiplexing." ), )) + _configure_batch_template(verbs.add_parser( + "batch-template", + parents=[global_parent], + help="Emit a sample-sheet template for a sample type.", + description=( + "Emit a CSV sample-sheet header (or a per-column descriptor under " + "--json) for use with 'samples upload-batch'." + ), + )) def _configure_upload(upload: argparse.ArgumentParser) -> None: @@ -66,11 +78,13 @@ def _configure_upload(upload: argparse.ArgumentParser) -> None: "--reads1", required=True, metavar="PATH", + type=Path, help="First reads file.", ) upload.add_argument( "--reads2", metavar="PATH", + type=Path, help="Second reads file (makes the sample paired-end).", ) upload.add_argument( @@ -146,6 +160,24 @@ def _configure_upload_multiplexed(upload_multiplexed: argparse.ArgumentParser) - ) +def _configure_batch_template(batch_template: argparse.ArgumentParser) -> None: + batch_template.set_defaults( + command_parser=batch_template, handler=_batch_template_command, + ) + batch_template.add_argument( + "--sample-type", + required=True, + metavar="TYPE", + help="Sample type the template is built for (decides required columns).", + ) + batch_template.add_argument( + "-o", "--output", + metavar="PATH", + type=Path, + help="Write the CSV template to this file instead of stdout.", + ) + + def _upload_command(args: argparse.Namespace, client: Client, output: Output) -> ExitCode: """Upload a single sample and report its identifier. @@ -155,9 +187,9 @@ def _upload_command(args: argparse.Namespace, client: Client, output: Output) -> :returns: :attr:`ExitCode.SUCCESS` on success. """ metadata = _merge_metadata(args.metadata, args.metadata_json) - data = {"reads1": existing_file(Path(args.reads1))} + data = {"reads1": existing_file(args.reads1)} if args.reads2 is not None: - data["reads2"] = existing_file(Path(args.reads2)) + data["reads2"] = existing_file(args.reads2) sample = client.samples.upload_sample( name=args.name, sample_type=args.sample_type, @@ -233,6 +265,101 @@ def _upload_multiplexed_command( return ExitCode.SUCCESS +@dataclass(frozen=True) +class _TemplateColumn: + """One column of a sample-sheet template, in CSV order.""" + + name: str + kind: str + required: bool + options: list[str] | None + description: str + + @property + def descriptor(self) -> dict[str, JsonValue]: + return { + "name": self.name, + "kind": self.kind, + "required": self.required, + "options": self.options, + "description": self.description, + } + + +_RESERVED_COLUMNS = ( + ("name", True, "Unique sample name (no spaces)."), + ("reads1", True, "Path to the first reads file."), + ("reads2", False, "Path to the second reads file (paired-end)."), + ("project", False, "Project identifier to assign the sample to."), + ("organism", False, "Organism identifier to associate with the sample."), +) + + +def _batch_template_command( + args: argparse.Namespace, client: Client, output: Output, +) -> ExitCode: + """Emit a sample-sheet template for the chosen sample type. + + :param args: Parsed command-line arguments. + :param client: The authenticated Flow client. + :param output: The result/error renderer. + :returns: :attr:`ExitCode.SUCCESS` on success. + """ + columns = _template_columns( + client.samples.get_metadata_attributes(), args.sample_type, + ) + header = ",".join(column.name for column in columns) + if args.output is not None: + args.output.write_text(f"{header}\n") + output.emit_advisory(f"Wrote sample-sheet template to {args.output}") + if output.json_mode or args.output is None: + output.emit_result(header, [column.descriptor for column in columns]) + output.emit_advisory(_required_summary(columns)) + return ExitCode.SUCCESS + + +def _template_columns( + attributes: list[MetadataAttribute], sample_type: str, +) -> list[_TemplateColumn]: + columns = [ + _TemplateColumn(name, "reserved", required, None, description) + for name, required, description in _RESERVED_COLUMNS + ] + for attribute in attributes: + required = ( + attribute.required or sample_type in attribute.required_for_sample_types + ) + columns.append( + _TemplateColumn( + name=attribute.identifier, + kind="metadata", + required=required, + options=attribute.options, + description=attribute.description, + ), + ) + if attribute.allow_annotation: + columns.append( + _TemplateColumn( + name=f"{attribute.identifier}__annotation", + kind="annotation", + required=False, + options=None, + description=f"Free-text annotation for {attribute.identifier}.", + ), + ) + return columns + + +def _required_summary(columns: list[_TemplateColumn]) -> str: + required = [column.name for column in columns if column.required] + optional = [column.name for column in columns if not column.required] + return ( + f"Required columns: {', '.join(required)}\n" + f"Optional columns: {', '.join(optional)}" + ) + + def _merge_metadata( pairs: list[str] | None, json_text: str | None, ) -> dict[str, str]: diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index bf9ebf2..d78f723 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -80,6 +80,10 @@ class MetadataAttribute(BaseModel, frozen=True): options: list[str] | None = Field( description="The list of valid values, or ``None`` if any value is accepted.", ) + allow_annotation: bool = Field( + default=False, + description="Whether this attribute permits a free-text annotation companion value.", + ) class Project(BaseModel, frozen=True): diff --git a/specs/001-flowbio-cli/contracts/samples-batch-template.md b/specs/001-flowbio-cli/contracts/samples-batch-template.md index 650c33a..f6f18a6 100644 --- a/specs/001-flowbio-cli/contracts/samples-batch-template.md +++ b/specs/001-flowbio-cli/contracts/samples-batch-template.md @@ -24,7 +24,7 @@ then one column per metadata attribute, each followed by a **no** `sample_type` column. Data sourced from `client.samples.get_metadata_attributes()` (and its -`allows_annotation`, `options`, `required`, `required_for_sample_types`). +`allow_annotation`, `options`, `required`, `required_for_sample_types`). ## Output diff --git a/specs/001-flowbio-cli/data-model.md b/specs/001-flowbio-cli/data-model.md index 8e0e4f8..71aab4e 100644 --- a/specs/001-flowbio-cli/data-model.md +++ b/specs/001-flowbio-cli/data-model.md @@ -27,7 +27,7 @@ Existing fields: `identifier`, `name`, `description`, `required`, | Field | Type | Description | |-------|------|-------------| -| `allows_annotation` | `bool` | Whether this attribute permits a free-text annotation companion. Drives the `__annotation` columns and JSON descriptors in `batch-template`. | +| `allow_annotation` | `bool` | Whether this attribute permits a free-text annotation companion. Drives the `__annotation` columns and JSON descriptors in `batch-template`. | Rules: - Populated from the `/samples/metadata` response in `_create_metadata_attribute`. @@ -114,7 +114,7 @@ Per-row pre-flight validation (FR-028), all errors collected before any upload: - `name` contains no spaces; - values for closed-option attributes are within `options`; - a `__annotation` is set only when ``'s value is set **and** the - attribute `allows_annotation`; + attribute `allow_annotation`; - empty cells omitted (not sent as empty values). ### `BatchResult` — `flowbio/cli/_samples.py` @@ -158,7 +158,7 @@ list (no CSV): Column order: reserved columns first (`name`, `reads1`, `reads2`, `project`, `organism`), then one column per metadata attribute, each followed by its -`__annotation` column when `allows_annotation`. There is **no** +`__annotation` column when `allow_annotation`. There is **no** `sample_type` column. ### `AnnotationTemplate` result — `flowbio/cli/_samples.py` diff --git a/specs/001-flowbio-cli/tasks.md b/specs/001-flowbio-cli/tasks.md index ced85a9..b063e7f 100644 --- a/specs/001-flowbio-cli/tasks.md +++ b/specs/001-flowbio-cli/tasks.md @@ -139,14 +139,14 @@ Single-project layout (per plan.md): CLI under `flowbio/cli/` (every module `_`- ### Tests for User Story 3 (MANDATORY — write first) ⚠️ -- [ ] T025 [P] [US3] Write a failing test in `tests/unit/v2/test_samples.py` for the additive `MetadataAttribute.allows_annotation` field: defaults to `False` when the payload omits the key, and is populated from the `/samples/metadata` response (data-model.md §MetadataAttribute, FR-019/FR-024) -- [ ] T027 [P] [US3] Write failing tests in `tests/unit/cli/test_samples.py` covering US3 scenarios 1–6: CSV header of reserved columns (`name,reads1,reads2,project,organism`) then one column per metadata attribute, with `__annotation` after each annotation-enabled attribute, and no `sample_type` column; required/optional summary on stderr without `--json`; `-o/--output PATH` writes to file; `--json` per-column descriptor list (name, kind, required, options, description) with no CSV; missing `--sample-type` → exit 2 (contracts/samples-batch-template.md) +- [X] T025 [P] [US3] Write a failing test in `tests/unit/v2/test_samples.py` for the additive `MetadataAttribute.allow_annotation` field: defaults to `False` when the payload omits the key, and is populated from the `/samples/metadata` response (data-model.md §MetadataAttribute, FR-019/FR-024) +- [X] T027 [P] [US3] Write failing tests in `tests/unit/cli/test_samples.py` covering US3 scenarios 1–6: CSV header of reserved columns (`name,reads1,reads2,project,organism`) then one column per metadata attribute, with `__annotation` after each annotation-enabled attribute, and no `sample_type` column; required/optional summary on stderr without `--json`; `-o/--output PATH` writes to file; `--json` per-column descriptor list (name, kind, required, options, description) with no CSV; missing `--sample-type` → exit 2 (contracts/samples-batch-template.md) ### Implementation for User Story 3 -- [ ] T026 [US3] Implement the additive `allows_annotation: bool = False` field on `MetadataAttribute` and populate it in `_create_metadata_attribute` in `flowbio/v2/samples.py` (additive, backwards-compatible — the feature's only library change) (depends on T025) -- [ ] T028 [US3] Implement the `batch-template` handler and `BatchTemplate` descriptor in `flowbio/cli/_samples.py` (column ordering, `required` derived from `required` OR chosen type in `required_for_sample_types`, `--json` descriptors, `-o/--output` writing, summary to stderr) sourced from `client.samples.get_metadata_attributes()`, and register the subcommand via the `register()` in `flowbio/cli/_samples.py` (wired into `flowbio/cli/_parser.py`) with `help=`/description text on every argument (FR-003, SC-008) (FR-024, FR-025, FR-026) -- [ ] T029 [US3] Document `batch-template` and the sample-sheet schema (reserved columns, metadata-identifier columns, annotation companions) in `docs/cli.md` (FR-041, FR-042) +- [X] T026 [US3] Implement the additive `allow_annotation: bool = False` field on `MetadataAttribute` and populate it in `_create_metadata_attribute` in `flowbio/v2/samples.py` (additive, backwards-compatible — the feature's only library change) (depends on T025) +- [X] T028 [US3] Implement the `batch-template` handler and `BatchTemplate` descriptor in `flowbio/cli/_samples.py` (column ordering, `required` derived from `required` OR chosen type in `required_for_sample_types`, `--json` descriptors, `-o/--output` writing, summary to stderr) sourced from `client.samples.get_metadata_attributes()`, and register the subcommand via the `register()` in `flowbio/cli/_samples.py` (wired into `flowbio/cli/_parser.py`) with `help=`/description text on every argument (FR-003, SC-008) (FR-024, FR-025, FR-026) +- [X] T029 [US3] Document `batch-template` and the sample-sheet schema (reserved columns, metadata-identifier columns, annotation companions) in `docs/cli.md` (FR-041, FR-042) **Checkpoint**: Template generation works independently and defines the contract consumed by batch upload. @@ -165,7 +165,7 @@ Single-project layout (per plan.md): CLI under `flowbio/cli/` (every module `_`- ### Implementation for User Story 4 -- [ ] T031 [US4] Implement `flowbio/cli/_sheet.py`: `SampleSheet`/`SheetRow` CSV parsing, relative-path resolution, empty-cell omission, the FR-028 per-row pre-flight validation collecting all errors (using `MetadataAttribute.allows_annotation` from T026), and the non-CSV → USAGE rejection (depends on T030, T026) +- [ ] T031 [US4] Implement `flowbio/cli/_sheet.py`: `SampleSheet`/`SheetRow` CSV parsing, relative-path resolution, empty-cell omission, the FR-028 per-row pre-flight validation collecting all errors (using `MetadataAttribute.allow_annotation` from T026), and the non-CSV → USAGE rejection (depends on T030, T026) - [ ] T033 [US4] Implement the `upload-batch` handler and `BatchResult` in `flowbio/cli/_samples.py` (parse + validate all rows before any upload; `--skip-invalid`; sequential upload reusing the T018 single-sample path; default-continue vs `--stop-on-error`; exit code: all uploaded→0, pre-flight invalid without `--skip-invalid`→2, any upload failure→1) and register the subcommand via the `register()` in `flowbio/cli/_samples.py` (wired into `flowbio/cli/_parser.py`) with `help=`/description text on every argument (FR-003, SC-008) (FR-027…FR-032) - [ ] T034 [US4] Document `upload-batch` (validation behaviour, `--skip-invalid`/`--stop-on-error`, `--json` shape, worked example) in `docs/cli.md` (FR-041, FR-042) @@ -198,8 +198,8 @@ Single-project layout (per plan.md): CLI under `flowbio/cli/` (every module `_`- - **US1 (P1)**: Foundational only — first runnable command (validates the foundation). - **US2 (P2)**: Foundational only. Adds the metadata-parsing + single-sample path. - **US5 (P5)**: Foundational only. Wraps the **existing** `get_annotation_template` and `upload_multiplexed_data` methods — no library change. Independent of the other sample commands, hence sequenced before US3/US4. -- **US3 (P3)**: Foundational + the additive `MetadataAttribute.allows_annotation` field (T025/T026). Independent of US1/US2/US5. -- **US4 (P4)**: Reuses the US2 single-sample upload path (T018) and the US3 sheet contract + `allows_annotation` field (T026); needs `_sheet.py`. Strongest cross-story coupling — sequenced last. +- **US3 (P3)**: Foundational + the additive `MetadataAttribute.allow_annotation` field (T025/T026). Independent of US1/US2/US5. +- **US4 (P4)**: Reuses the US2 single-sample upload path (T018) and the US3 sheet contract + `allow_annotation` field (T026); needs `_sheet.py`. Strongest cross-story coupling — sequenced last. ### Within Each User Story @@ -244,7 +244,7 @@ Task T011: "Implement flowbio/cli/_progress.py" ### Incremental Delivery -Foundation → US1 (MVP) → US2 → US5 → US3 → US4 → Polish. US5 is sequenced ahead of US3/US4 because it is independent and needs no library change; US3 (and the additive `allows_annotation` field) precedes US4, which reuses both the single-sample path and the sheet contract. Each story is independently testable and adds value without breaking earlier ones. +Foundation → US1 (MVP) → US2 → US5 → US3 → US4 → Polish. US5 is sequenced ahead of US3/US4 because it is independent and needs no library change; US3 (and the additive `allow_annotation` field) precedes US4, which reuses both the single-sample path and the sheet contract. Each story is independently testable and adds value without breaking earlier ones. --- diff --git a/tests/unit/cli/test_samples.py b/tests/unit/cli/test_samples.py index 8364370..80545e9 100644 --- a/tests/unit/cli/test_samples.py +++ b/tests/unit/cli/test_samples.py @@ -479,3 +479,146 @@ def test_annotation_validation_errors_in_json_error_document( assert result.exit_code == 5 assert result.stdout == "" assert json.loads(result.stderr)["errors"] == errors + + +METADATA_URL = f"{DEFAULT_BASE_URL}/samples/metadata" +RESERVED_HEADER = "name,reads1,reads2,project,organism" + + +def _mock_metadata() -> None: + respx.get(METADATA_URL).mock( + return_value=httpx.Response(HTTPStatus.OK, json=[ + { + "identifier": "cell_type", + "name": "Cell Type", + "description": "The cell type", + "required": False, + "required_for_public": False, + "all_sample_types": False, + "allow_user_terms": False, + "regex_validator": None, + "has_options": True, + "allow_annotation": False, + "sample_type_links": [ + {"sample_type_identifier": "rna_seq", "required": True}, + ], + }, + { + "identifier": "source", + "name": "Source", + "description": "Sample source", + "required": False, + "required_for_public": False, + "all_sample_types": True, + "allow_user_terms": False, + "regex_validator": None, + "has_options": False, + "allow_annotation": True, + "sample_type_links": [], + }, + ]), + ) + respx.get(f"{METADATA_URL}/cell_type/options").mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"options": [{"value": "Neuron"}, {"value": "Fibroblast"}]}, + ), + ) + + +class TestSamplesBatchTemplate: + + @respx.mock + def test_csv_header_orders_reserved_then_metadata_with_annotation_companion( + self, run_cli, + ) -> None: + _mock_metadata() + + result = run_cli( + "samples", "batch-template", "--sample-type", "rna_seq", + "--token", TOKEN, + ) + + assert result.exit_code == 0 + assert result.stdout.strip() == ( + f"{RESERVED_HEADER},cell_type,source,source__annotation" + ) + assert "sample_type" not in result.stdout + + @respx.mock + def test_summary_of_required_columns_on_stderr_without_json( + self, run_cli, + ) -> None: + _mock_metadata() + + result = run_cli( + "samples", "batch-template", "--sample-type", "rna_seq", + "--token", TOKEN, + ) + + assert "cell_type" in result.stderr + assert "source" in result.stderr + + @respx.mock + def test_output_flag_writes_csv_to_file(self, run_cli, tmp_path: Path) -> None: + _mock_metadata() + destination = tmp_path / "template.csv" + + result = run_cli( + "samples", "batch-template", "--sample-type", "rna_seq", + "-o", str(destination), "--token", TOKEN, + ) + + assert result.exit_code == 0 + assert destination.read_text().splitlines()[0] == ( + f"{RESERVED_HEADER},cell_type,source,source__annotation" + ) + assert "cell_type" not in result.stdout + + @respx.mock + def test_json_emits_column_descriptors_and_no_csv(self, run_cli) -> None: + _mock_metadata() + + result = run_cli( + "samples", "batch-template", "--sample-type", "rna_seq", + "--json", "--token", TOKEN, + ) + + assert result.exit_code == 0 + descriptors = json.loads(result.stdout) + assert result.stdout.count("\n") == 1 + by_name = {column["name"]: column for column in descriptors} + assert by_name["name"]["kind"] == "reserved" + assert by_name["name"]["required"] is True + assert by_name["cell_type"]["kind"] == "metadata" + assert by_name["cell_type"]["required"] is True + assert by_name["cell_type"]["options"] == ["Neuron", "Fibroblast"] + assert by_name["source__annotation"]["kind"] == "annotation" + assert by_name["source"]["required"] is False + assert "sample_type" not in by_name + + @respx.mock + def test_json_with_output_writes_csv_file_and_emits_descriptors( + self, run_cli, tmp_path: Path, + ) -> None: + _mock_metadata() + destination = tmp_path / "template.csv" + + result = run_cli( + "samples", "batch-template", "--sample-type", "rna_seq", + "--json", "-o", str(destination), "--token", TOKEN, + ) + + assert result.exit_code == 0 + assert destination.read_text().splitlines()[0] == ( + f"{RESERVED_HEADER},cell_type,source,source__annotation" + ) + descriptors = json.loads(result.stdout) + assert [column["name"] for column in descriptors][:5] == [ + "name", "reads1", "reads2", "project", "organism", + ] + + def test_missing_sample_type_is_usage_error(self, run_cli) -> None: + result = run_cli("samples", "batch-template", "--token", TOKEN) + + assert result.exit_code == 2 diff --git a/tests/unit/v2/test_samples.py b/tests/unit/v2/test_samples.py index 708ef91..cc42ad7 100644 --- a/tests/unit/v2/test_samples.py +++ b/tests/unit/v2/test_samples.py @@ -200,6 +200,55 @@ def test_populates_required_for_sample_types_from_links(self) -> None: assert result[0].required_for_sample_types == ["rna_seq", "atac_seq"] + @respx.mock + def test_populates_allow_annotation_from_payload(self) -> None: + respx.get(f"{DEFAULT_BASE_URL}/samples/metadata").mock( + return_value=httpx.Response(200, json=[ + { + "identifier": "source", + "name": "Source", + "description": "Sample source", + "required": False, + "required_for_public": False, + "all_sample_types": True, + "allow_user_terms": False, + "regex_validator": None, + "has_options": False, + "allow_annotation": True, + "sample_type_links": [], + }, + ]), + ) + + client = Client() + result = client.samples.get_metadata_attributes() + + assert result[0].allow_annotation is True + + @respx.mock + def test_allow_annotation_defaults_to_false_when_absent(self) -> None: + respx.get(f"{DEFAULT_BASE_URL}/samples/metadata").mock( + return_value=httpx.Response(200, json=[ + { + "identifier": "scientist", + "name": "Scientist", + "description": "Who ran it", + "required": False, + "required_for_public": False, + "all_sample_types": True, + "allow_user_terms": False, + "regex_validator": None, + "has_options": False, + "sample_type_links": [], + }, + ]), + ) + + client = Client() + result = client.samples.get_metadata_attributes() + + assert result[0].allow_annotation is False + class TestGetOwnedProjects: From 3b0967afcff74740a1bb7fc71048cb4b1fce9c28 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 9 Jun 2026 16:40:21 +0100 Subject: [PATCH 2/8] refactor(cli): type US5 command paths with argparse type=Path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the path-typing convention to the annotation-template and upload-multiplexed commands so every path option in the CLI is parsed straight to a pathlib.Path: annotation-template --output and upload-multiplexed --reads1/--reads2/--annotation. The handlers drop the now-redundant Path(...) reconstruction. Behaviour-preserving — existence validation stays in the handlers. Co-Authored-By: Claude Opus 4.8 --- flowbio/cli/_samples.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index d7170fe..f789c44 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -128,6 +128,7 @@ def _configure_annotation_template(annotation_template: argparse.ArgumentParser) "--output", required=True, metavar="PATH", + type=Path, help="File to write the .xlsx workbook to (the template is binary).", ) @@ -140,17 +141,20 @@ def _configure_upload_multiplexed(upload_multiplexed: argparse.ArgumentParser) - "--reads1", required=True, metavar="PATH", + type=Path, help="First multiplexed reads file.", ) upload_multiplexed.add_argument( "--reads2", metavar="PATH", + type=Path, help="Second multiplexed reads file (makes the upload paired-end).", ) upload_multiplexed.add_argument( "--annotation", required=True, metavar="PATH", + type=Path, help="Completed annotation sheet (obtained via `annotation-template`).", ) upload_multiplexed.add_argument( @@ -212,7 +216,7 @@ def _annotation_template_command( :param output: The result/error renderer. :returns: :attr:`ExitCode.SUCCESS` on success. """ - destination = Path(args.output) + destination = args.output template = client.samples.get_annotation_template(args.sample_type) try: destination.write_bytes(template) @@ -241,12 +245,12 @@ def _upload_multiplexed_command( :param output: The result/error renderer. :returns: :attr:`ExitCode.SUCCESS` on success. """ - reads = {"reads1": existing_file(Path(args.reads1))} + reads = {"reads1": existing_file(args.reads1)} if args.reads2 is not None: - reads["reads2"] = existing_file(Path(args.reads2)) + reads["reads2"] = existing_file(args.reads2) upload = client.samples.upload_multiplexed_data( reads=reads, - annotation=existing_file(Path(args.annotation)), + annotation=existing_file(args.annotation), ignore_warnings=not args.reject_warnings, ) if upload.warnings: From 812af826bf94997e2ace06165917b70b47e1cd77 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Wed, 10 Jun 2026 15:26:06 +0100 Subject: [PATCH 3/8] fix(cli): guard batch-template output write and type its column kind Adversarial review caught that `batch-template -o` to an unwritable path raised an uncaught OSError out of main() as a raw traceback, while the sibling `annotation-template` mapped the same case to exit 2. Wrap the CSV write in the same try/except OSError -> CliUsageError guard so an unwritable destination is a clean usage error (exit 2), and add the matching negative-path test. Also tighten `_TemplateColumn.kind` to a Literal of the closed set (reserved/metadata/annotation) it echoes into the JSON descriptor. Co-Authored-By: Claude Opus 4.8 --- flowbio/cli/_samples.py | 10 ++++++++-- tests/unit/cli/test_samples.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index f789c44..68de3f7 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -11,6 +11,7 @@ import json from dataclasses import dataclass from pathlib import Path +from typing import Literal from flowbio.cli._exit_codes import CliUsageError, ExitCode from flowbio.cli._files import existing_file @@ -274,7 +275,7 @@ class _TemplateColumn: """One column of a sample-sheet template, in CSV order.""" name: str - kind: str + kind: Literal["reserved", "metadata", "annotation"] required: bool options: list[str] | None description: str @@ -314,7 +315,12 @@ def _batch_template_command( ) header = ",".join(column.name for column in columns) if args.output is not None: - args.output.write_text(f"{header}\n") + try: + args.output.write_text(f"{header}\n") + except OSError as error: + raise CliUsageError( + f"Could not write sample-sheet template to {args.output}: {error}", + ) from error output.emit_advisory(f"Wrote sample-sheet template to {args.output}") if output.json_mode or args.output is None: output.emit_result(header, [column.descriptor for column in columns]) diff --git a/tests/unit/cli/test_samples.py b/tests/unit/cli/test_samples.py index 80545e9..f251aeb 100644 --- a/tests/unit/cli/test_samples.py +++ b/tests/unit/cli/test_samples.py @@ -618,6 +618,21 @@ def test_json_with_output_writes_csv_file_and_emits_descriptors( "name", "reads1", "reads2", "project", "organism", ] + @respx.mock + def test_unwritable_output_path_is_usage_error( + self, run_cli, tmp_path: Path, + ) -> None: + _mock_metadata() + destination = tmp_path / "does-not-exist" / "template.csv" + + result = run_cli( + "samples", "batch-template", "--sample-type", "rna_seq", + "-o", str(destination), "--token", TOKEN, + ) + + assert result.exit_code == 2 + assert "Traceback" not in result.stderr + def test_missing_sample_type_is_usage_error(self, run_cli) -> None: result = run_cli("samples", "batch-template", "--token", TOKEN) From 33ff9ecd2d719692887824d64b894f43bc3c4448 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Wed, 10 Jun 2026 17:15:27 +0100 Subject: [PATCH 4/8] refactor(cli): define reserved batch-template columns as _TemplateColumn Build the reserved sample-sheet columns as _TemplateColumn instances up front instead of (name, required, description) tuples converted on use, so _template_columns just copies them rather than re-mapping. No behaviour change. Co-Authored-By: Claude Opus 4.8 --- flowbio/cli/_samples.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index 68de3f7..b5a285c 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -292,11 +292,11 @@ def descriptor(self) -> dict[str, JsonValue]: _RESERVED_COLUMNS = ( - ("name", True, "Unique sample name (no spaces)."), - ("reads1", True, "Path to the first reads file."), - ("reads2", False, "Path to the second reads file (paired-end)."), - ("project", False, "Project identifier to assign the sample to."), - ("organism", False, "Organism identifier to associate with the sample."), + _TemplateColumn("name", "reserved", True, None, "Unique sample name (no spaces)."), + _TemplateColumn("reads1", "reserved", True, None, "Path to the first reads file."), + _TemplateColumn("reads2", "reserved", False, None, "Path to the second reads file (paired-end)."), + _TemplateColumn("project", "reserved", False, None, "Project identifier to assign the sample to."), + _TemplateColumn("organism", "reserved", False, None, "Organism identifier to associate with the sample."), ) @@ -331,10 +331,7 @@ def _batch_template_command( def _template_columns( attributes: list[MetadataAttribute], sample_type: str, ) -> list[_TemplateColumn]: - columns = [ - _TemplateColumn(name, "reserved", required, None, description) - for name, required, description in _RESERVED_COLUMNS - ] + columns = list(_RESERVED_COLUMNS) for attribute in attributes: required = ( attribute.required or sample_type in attribute.required_for_sample_types From bc901ad8f172249a939162dc2ad6614734e7218b Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Wed, 10 Jun 2026 17:18:13 +0100 Subject: [PATCH 5/8] feat(cli): validate batch-template --sample-type against available types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unrecognised --sample-type previously produced a valid-looking template at exit 0, with nothing flagged required-for-type — a quiet trap when an agent builds a sheet for a mistyped type. Validate the type against client.samples.get_types() up front and, on a miss, fail with a usage error (exit 2) listing the available identifiers. This shares the same identifier space as the required-column derivation (sample_type_links), so a type that validates is also one the derivation understands. Docs and the batch-template contract are updated to document the validation and drop the earlier "not checked here" wording. Co-Authored-By: Claude Opus 4.8 --- docs/cli.md | 13 +++++------ flowbio/cli/_samples.py | 10 ++++++++ .../contracts/samples-batch-template.md | 9 ++++++-- tests/unit/cli/test_samples.py | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index bda0955..d10ee9e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -232,9 +232,9 @@ flowbio samples batch-template --sample-type TYPE [-o PATH | --output PATH] ``` Run `flowbio samples batch-template --help` for the full option list. The sample -type decides which metadata columns are marked required; it is **not** validated -here (see exit codes below) — an unrecognised type simply yields a template with -nothing flagged required-for-that-type. +type decides which metadata columns are marked required. It is validated against +the available types up front: an unrecognised type fails with a usage error +(exit `2`) listing the valid identifiers. **Sample-sheet schema** — the columns, in order: @@ -256,10 +256,9 @@ descriptor list on stdout (`name`, `kind` of `reserved`/`metadata`/`annotation`, `required`, closed-value `options` or `null`, and `description`) and **no CSV** — so an agent can build rows directly. -**Exit codes** — `0` success; `2` missing `--sample-type`; `3` authentication -failure; otherwise the standard mapping above. The sample type is not checked -against the server here, so an unknown type still exits `0`; the type is -validated when you run `samples upload-batch`. +**Exit codes** — `0` success; `2` missing `--sample-type`, or an unknown sample +type (the error lists the available types); `3` authentication failure; +otherwise the standard mapping above. **Example** diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index b5a285c..32d7a2a 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -310,6 +310,7 @@ def _batch_template_command( :param output: The result/error renderer. :returns: :attr:`ExitCode.SUCCESS` on success. """ + _check_sample_type(client, args.sample_type) columns = _template_columns( client.samples.get_metadata_attributes(), args.sample_type, ) @@ -328,6 +329,15 @@ def _batch_template_command( return ExitCode.SUCCESS +def _check_sample_type(client: Client, sample_type: str) -> None: + identifiers = [sample.identifier for sample in client.samples.get_types()] + if sample_type not in identifiers: + raise CliUsageError( + f"Unknown sample type '{sample_type}'. " + f"Available types: {', '.join(sorted(identifiers))}", + ) + + def _template_columns( attributes: list[MetadataAttribute], sample_type: str, ) -> list[_TemplateColumn]: diff --git a/specs/001-flowbio-cli/contracts/samples-batch-template.md b/specs/001-flowbio-cli/contracts/samples-batch-template.md index f6f18a6..8928bd1 100644 --- a/specs/001-flowbio-cli/contracts/samples-batch-template.md +++ b/specs/001-flowbio-cli/contracts/samples-batch-template.md @@ -26,6 +26,10 @@ then one column per metadata attribute, each followed by a Data sourced from `client.samples.get_metadata_attributes()` (and its `allow_annotation`, `options`, `required`, `required_for_sample_types`). +The `--sample-type` is validated against `client.samples.get_types()` before any +template is produced; an unrecognised type is a usage error listing the available +identifiers. + ## Output - **Human (no `--json`)**: CSV header row on stdout (or to `--output`); a @@ -43,8 +47,9 @@ Data sourced from `client.samples.get_metadata_attributes()` (and its ## Exit codes -`0` success; `2` missing `--sample-type`; `4` unknown sample type (if surfaced by -the lookup); standard mapping otherwise. +`0` success; `2` missing `--sample-type`, or an unknown sample type (validated +against `get_types()`, the error lists the available identifiers); standard +mapping otherwise. ## Acceptance mapping diff --git a/tests/unit/cli/test_samples.py b/tests/unit/cli/test_samples.py index f251aeb..f14c80b 100644 --- a/tests/unit/cli/test_samples.py +++ b/tests/unit/cli/test_samples.py @@ -486,6 +486,15 @@ def test_annotation_validation_errors_in_json_error_document( def _mock_metadata() -> None: + respx.get(f"{DEFAULT_BASE_URL}/samples/types").mock( + return_value=httpx.Response(HTTPStatus.OK, json=[ + { + "identifier": "rna_seq", + "name": "RNA-Seq", + "description": "RNA sequencing.", + }, + ]), + ) respx.get(METADATA_URL).mock( return_value=httpx.Response(HTTPStatus.OK, json=[ { @@ -633,6 +642,20 @@ def test_unwritable_output_path_is_usage_error( assert result.exit_code == 2 assert "Traceback" not in result.stderr + @respx.mock + def test_unknown_sample_type_is_usage_error_listing_types(self, run_cli) -> None: + _mock_metadata() + + result = run_cli( + "samples", "batch-template", "--sample-type", "bogus", + "--token", TOKEN, + ) + + assert result.exit_code == 2 + assert "bogus" in result.stderr + assert "rna_seq" in result.stderr + assert result.stdout == "" + def test_missing_sample_type_is_usage_error(self, run_cli) -> None: result = run_cli("samples", "batch-template", "--token", TOKEN) From 5640ddac1e060d933682cae53b63c30338953bca Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Wed, 10 Jun 2026 17:21:02 +0100 Subject: [PATCH 6/8] refactor: introduce SampleTypeId named type for sample-type identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare `str` used for sample-type identifiers with a `SampleTypeId = NewType("SampleTypeId", str)`, per the repo convention of naming primitive identifiers. Applied across the v2 public surface (`SampleType.identifier`, `MetadataAttribute.required_for_sample_types`, `upload_sample`, `get_annotation_template`, `_build_sample_fields`) and the CLI handlers, which wrap the argparse string at the boundary as they already do for `Token`/`BaseUrl`. Exported from `flowbio.v2`. Named `SampleTypeId` (not `SampleType`) to avoid colliding with the existing `SampleType` model. Behaviour-preserving — `NewType` is the underlying `str` at runtime. Co-Authored-By: Claude Opus 4.8 --- flowbio/cli/_samples.py | 15 ++++++++------- flowbio/v2/__init__.py | 11 ++++++++++- flowbio/v2/samples.py | 21 ++++++++++++++------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index 32d7a2a..a4fbc25 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -18,7 +18,7 @@ from flowbio.cli._output import Output, format_issue from flowbio.cli._types import JsonValue from flowbio.v2.client import Client -from flowbio.v2.samples import MetadataAttribute +from flowbio.v2.samples import MetadataAttribute, SampleTypeId def register( @@ -197,7 +197,7 @@ def _upload_command(args: argparse.Namespace, client: Client, output: Output) -> data["reads2"] = existing_file(args.reads2) sample = client.samples.upload_sample( name=args.name, - sample_type=args.sample_type, + sample_type=SampleTypeId(args.sample_type), data=data, metadata=metadata or None, project_id=args.project, @@ -218,7 +218,7 @@ def _annotation_template_command( :returns: :attr:`ExitCode.SUCCESS` on success. """ destination = args.output - template = client.samples.get_annotation_template(args.sample_type) + template = client.samples.get_annotation_template(SampleTypeId(args.sample_type)) try: destination.write_bytes(template) except OSError as error: @@ -310,9 +310,10 @@ def _batch_template_command( :param output: The result/error renderer. :returns: :attr:`ExitCode.SUCCESS` on success. """ - _check_sample_type(client, args.sample_type) + sample_type = SampleTypeId(args.sample_type) + _check_sample_type(client, sample_type) columns = _template_columns( - client.samples.get_metadata_attributes(), args.sample_type, + client.samples.get_metadata_attributes(), sample_type, ) header = ",".join(column.name for column in columns) if args.output is not None: @@ -329,7 +330,7 @@ def _batch_template_command( return ExitCode.SUCCESS -def _check_sample_type(client: Client, sample_type: str) -> None: +def _check_sample_type(client: Client, sample_type: SampleTypeId) -> None: identifiers = [sample.identifier for sample in client.samples.get_types()] if sample_type not in identifiers: raise CliUsageError( @@ -339,7 +340,7 @@ def _check_sample_type(client: Client, sample_type: str) -> None: def _template_columns( - attributes: list[MetadataAttribute], sample_type: str, + attributes: list[MetadataAttribute], sample_type: SampleTypeId, ) -> list[_TemplateColumn]: columns = list(_RESERVED_COLUMNS) for attribute in attributes: diff --git a/flowbio/v2/__init__.py b/flowbio/v2/__init__.py index 126bca9..90e6d56 100644 --- a/flowbio/v2/__init__.py +++ b/flowbio/v2/__init__.py @@ -36,7 +36,15 @@ from flowbio.v2.client import Client, ClientConfig from flowbio.v2.data import Data from flowbio.v2.exceptions import AnnotationValidationError -from flowbio.v2.samples import MetadataAttribute, MultiplexedUpload, Organism, Project, Sample, SampleType +from flowbio.v2.samples import ( + MetadataAttribute, + MultiplexedUpload, + Organism, + Project, + Sample, + SampleType, + SampleTypeId, +) __all__ = [ "AnnotationValidationError", @@ -49,6 +57,7 @@ "Project", "Sample", "SampleType", + "SampleTypeId", "TokenCredentials", "UsernamePasswordCredentials", ] diff --git a/flowbio/v2/samples.py b/flowbio/v2/samples.py index d78f723..fd888ed 100644 --- a/flowbio/v2/samples.py +++ b/flowbio/v2/samples.py @@ -28,7 +28,7 @@ from collections.abc import Sequence from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NewType from pydantic import BaseModel, Field @@ -43,6 +43,11 @@ from flowbio.v2._uploads import ChunkedUploader +SampleTypeId = NewType("SampleTypeId", str) +"""The identifier of a sample type (e.g. ``"RNA-Seq"``), as listed by +:meth:`SampleResource.get_types`.""" + + class SampleType(BaseModel, frozen=True): """A type of sample that can be uploaded to the Flow platform. @@ -53,7 +58,7 @@ class SampleType(BaseModel, frozen=True): print(f"{st.identifier}: {st.name}") """ - identifier: str = Field(description="Unique identifier for this sample type.") + identifier: SampleTypeId = Field(description="Unique identifier for this sample type.") name: str = Field(description="Human-readable display name.") description: str = Field(description="Explanation of what this sample type represents.") @@ -74,7 +79,7 @@ class MetadataAttribute(BaseModel, frozen=True): name: str = Field(description="Human-readable display name.") description: str = Field(description="Explanation of what this attribute represents.") required: bool = Field(description="Whether this attribute is required at sample creation.") - required_for_sample_types: list[str] = Field( + required_for_sample_types: list[SampleTypeId] = Field( description="Sample type identifiers for which this attribute is required at creation.", ) options: list[str] | None = Field( @@ -150,7 +155,7 @@ def __init__(self, transport: HttpTransport, uploader: ChunkedUploader) -> None: def upload_sample( self, name: str, - sample_type: str, + sample_type: SampleTypeId, data: dict[str, Path], metadata: dict[str, str] | None = None, project_id: str | None = None, @@ -302,7 +307,9 @@ def upload_multiplexed_data( warnings=warnings, ) - def get_annotation_template(self, sample_type: str = "generic") -> bytes: + def get_annotation_template( + self, sample_type: SampleTypeId = SampleTypeId("generic"), + ) -> bytes: """Download an annotation sheet template for multiplexed uploads. Annotation sheets are spreadsheets that describe multiple samples @@ -394,7 +401,7 @@ def get_metadata_attributes(self) -> list[MetadataAttribute]: def _create_metadata_attribute(self, item: dict) -> MetadataAttribute: item["required_for_sample_types"] = [ - link["sample_type_identifier"] + SampleTypeId(link["sample_type_identifier"]) for link in item.get("sample_type_links", []) if link.get("required") ] @@ -444,7 +451,7 @@ def _ordered_files(data: dict[str, Path]) -> list[tuple[str, Path]]: @staticmethod def _build_sample_fields( name: str, - sample_type: str, + sample_type: SampleTypeId, metadata: dict[str, str] | None, project_id: str | None, organism_id: str | None, From b8788d6994dbff57ea56718f7591e379fecd175d Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Wed, 10 Jun 2026 17:29:19 +0100 Subject: [PATCH 7/8] docs(cli): correct samples module docstring for batch-template validation The module docstring claimed --sample-type is always sent as-is and not pre-checked by the CLI, which stopped being true once batch-template gained up-front type validation. Note batch-template as the local-validation exception. Co-Authored-By: Claude Opus 4.8 --- 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 a4fbc25..a8c30fc 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -2,8 +2,9 @@ A thin wrapper over :attr:`Client.samples `: it parses the command line, merges metadata supplied as ``key=value`` pairs and/or a JSON -object, calls the library, and renders the result. The ``--sample-type`` is sent -as-is and validated server-side, not pre-checked by the CLI. +object, calls the library, and renders the result. Most commands send +``--sample-type`` as-is for server-side validation; ``batch-template`` is the +exception, pre-checking the type against the available types up front. """ from __future__ import annotations From 79a1f583c3e1cd200793835ab6e4213e0b03af90 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Wed, 10 Jun 2026 17:34:36 +0100 Subject: [PATCH 8/8] refactor(cli): convert --sample-type at the argparse boundary Set type=SampleTypeId on the --sample-type arguments (upload, annotation-template, batch-template) so argparse produces the named type directly, mirroring the type=Path treatment of path options. Drops the three SampleTypeId(args.sample_type) casts in the handlers. argparse applies the type to the annotation-template "generic" default too, so it is typed consistently. Behaviour-preserving. Co-Authored-By: Claude Opus 4.8 --- flowbio/cli/_samples.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flowbio/cli/_samples.py b/flowbio/cli/_samples.py index a8c30fc..f9ebc41 100644 --- a/flowbio/cli/_samples.py +++ b/flowbio/cli/_samples.py @@ -74,6 +74,7 @@ def _configure_upload(upload: argparse.ArgumentParser) -> None: "--sample-type", required=True, metavar="TYPE", + type=SampleTypeId, help="Sample type identifier (sent as-is; validated server-side).", ) upload.add_argument( @@ -120,6 +121,7 @@ def _configure_annotation_template(annotation_template: argparse.ArgumentParser) "--sample-type", default="generic", metavar="TYPE", + type=SampleTypeId, help=( "Sample type identifier (sent as-is; validated server-side). " "Defaults to 'generic' (base columns common to all types)." @@ -174,6 +176,7 @@ def _configure_batch_template(batch_template: argparse.ArgumentParser) -> None: "--sample-type", required=True, metavar="TYPE", + type=SampleTypeId, help="Sample type the template is built for (decides required columns).", ) batch_template.add_argument( @@ -198,7 +201,7 @@ def _upload_command(args: argparse.Namespace, client: Client, output: Output) -> data["reads2"] = existing_file(args.reads2) sample = client.samples.upload_sample( name=args.name, - sample_type=SampleTypeId(args.sample_type), + sample_type=args.sample_type, data=data, metadata=metadata or None, project_id=args.project, @@ -219,7 +222,7 @@ def _annotation_template_command( :returns: :attr:`ExitCode.SUCCESS` on success. """ destination = args.output - template = client.samples.get_annotation_template(SampleTypeId(args.sample_type)) + template = client.samples.get_annotation_template(args.sample_type) try: destination.write_bytes(template) except OSError as error: @@ -311,10 +314,9 @@ def _batch_template_command( :param output: The result/error renderer. :returns: :attr:`ExitCode.SUCCESS` on success. """ - sample_type = SampleTypeId(args.sample_type) - _check_sample_type(client, sample_type) + _check_sample_type(client, args.sample_type) columns = _template_columns( - client.samples.get_metadata_attributes(), sample_type, + client.samples.get_metadata_attributes(), args.sample_type, ) header = ",".join(column.name for column in columns) if args.output is not None: