Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,76 @@ $ flowbio samples upload --name liver_r1 --sample-type rna_seq \
--reads1 ./liver_R1.fastq.gz --json
{"id": "samp_abc"}
```

### `samples annotation-template`

Download the server-generated annotation sheet template for a sample type, to
fill in before `samples upload-multiplexed`.

```
flowbio samples annotation-template [--sample-type TYPE] [-o PATH | --output PATH]
```

The template is an Excel workbook (`.xlsx`) keyed by metadata-attribute display
names. It is a **different artefact from the batch sample sheet** (the CLI-built
CSV used by `upload-batch`) and the two are not interchangeable. `--sample-type`
is optional and defaults to `generic` (the base columns shared by all types); a
type-specific value adds that type's metadata columns. It is sent as-is and
validated server-side.

The body is a binary workbook, so `-o/--output PATH` is **required** — it is
never written to stdout (which carries human result lines or the single JSON
document).

**Output** — human: the workbook is written to `--output`; a confirmation (path
and sample type) goes to stderr, leaving stdout empty. `--json`:
`{"output": "<path>", "sample_type": "<type>"}` on stdout — never the spreadsheet
bytes.

**Exit codes** — `0` success; `2` no `--output`, or an unwritable output path;
`4` unknown sample type; `3` authentication failure; otherwise the standard
mapping above.

**Example**

```bash
$ flowbio samples annotation-template --sample-type rna_seq -o sheet.xlsx
Wrote rna_seq annotation template to sheet.xlsx

$ flowbio samples annotation-template --sample-type rna_seq -o sheet.xlsx --json
{"output": "sheet.xlsx", "sample_type": "rna_seq"}
```

### `samples upload-multiplexed`

Upload multiplexed reads plus a completed annotation sheet for server-side
demultiplexing — single-ended (`--reads1`) or paired-end (add `--reads2`).

```
flowbio samples upload-multiplexed --reads1 PATH --annotation PATH
[--reads2 PATH] [--reject-warnings]
```

The annotation sheet is the filled-in workbook from `annotation-template`. By
default annotation warnings are reported but the upload proceeds;
`--reject-warnings` makes warnings reject it.

**Output** — human: a confirmation line with the data identifiers and annotation
identifier on stdout, with any warnings on stderr. `--json`:
`{"data_ids": [...], "annotation_id": "<id>", "warnings": [...]}` on stdout.

**Exit codes** — `0` success (including with reported warnings); `5` annotation
fails server validation, or warnings with `--reject-warnings`; `3` authentication
failure; otherwise the standard mapping above.

**Example**

```bash
$ flowbio samples upload-multiplexed --reads1 ./mux_R1.fastq.gz \
--annotation ./sheet.xlsx
Uploaded multiplexed data mux_1 with annotation ann_1

$ flowbio samples upload-multiplexed --reads1 ./mux_R1.fastq.gz \
--annotation ./sheet.xlsx --json
{"data_ids": ["mux_1"], "annotation_id": "ann_1", "warnings": []}
```
146 changes: 144 additions & 2 deletions flowbio/cli/_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,33 @@ def register(
) -> None:
"""Register the ``samples`` verbs on the resource parser."""
verbs = resource.add_subparsers(dest="verb", metavar="<verb>")
upload = verbs.add_parser(
_configure_upload(verbs.add_parser(
"upload",
parents=[global_parent],
help="Upload a single demultiplexed sample.",
description="Upload a single demultiplexed sample to the Flow platform.",
)
))
_configure_annotation_template(verbs.add_parser(
"annotation-template",
parents=[global_parent],
help="Download the annotation sheet template for multiplexed uploads.",
description=(
"Download the server-generated annotation sheet (.xlsx) template for a "
"sample type, to fill in before `samples upload-multiplexed`."
),
))
_configure_upload_multiplexed(verbs.add_parser(
"upload-multiplexed",
parents=[global_parent],
help="Upload multiplexed reads with an annotation sheet.",
description=(
"Upload multiplexed reads plus a completed annotation sheet for "
"server-side demultiplexing."
),
))


def _configure_upload(upload: argparse.ArgumentParser) -> None:
upload.set_defaults(command_parser=upload, handler=_upload_command)
upload.add_argument(
"--name",
Expand Down Expand Up @@ -75,6 +96,56 @@ def register(
)


def _configure_annotation_template(annotation_template: argparse.ArgumentParser) -> None:
annotation_template.set_defaults(
command_parser=annotation_template, handler=_annotation_template_command,
)
annotation_template.add_argument(
"--sample-type",
default="generic",
metavar="TYPE",
help=(
"Sample type identifier (sent as-is; validated server-side). "
"Defaults to 'generic' (base columns common to all types)."
),
)
annotation_template.add_argument(
"-o",
"--output",
required=True,
metavar="PATH",
help="File to write the .xlsx workbook to (the template is binary).",
)


def _configure_upload_multiplexed(upload_multiplexed: argparse.ArgumentParser) -> None:
upload_multiplexed.set_defaults(
command_parser=upload_multiplexed, handler=_upload_multiplexed_command,
)
upload_multiplexed.add_argument(
"--reads1",
required=True,
metavar="PATH",
help="First multiplexed reads file.",
)
upload_multiplexed.add_argument(
"--reads2",
metavar="PATH",
help="Second multiplexed reads file (makes the upload paired-end).",
)
upload_multiplexed.add_argument(
"--annotation",
required=True,
metavar="PATH",
help="Completed annotation sheet (obtained via `annotation-template`).",
)
upload_multiplexed.add_argument(
"--reject-warnings",
action="store_true",
help="Reject the upload if the annotation sheet has warnings.",
)


def _upload_command(args: argparse.Namespace, client: Client, output: Output) -> ExitCode:
"""Upload a single sample and report its identifier.

Expand All @@ -99,6 +170,77 @@ def _upload_command(args: argparse.Namespace, client: Client, output: Output) ->
return ExitCode.SUCCESS


def _annotation_template_command(
args: argparse.Namespace, client: Client, output: Output,
) -> ExitCode:
"""Download an annotation sheet template and write it to a file.

:param args: Parsed command-line arguments.
:param client: The authenticated Flow client.
:param output: The result/error renderer.
:returns: :attr:`ExitCode.SUCCESS` on success.
"""
destination = Path(args.output)
template = client.samples.get_annotation_template(args.sample_type)
try:
destination.write_bytes(template)
except OSError as error:
raise CliUsageError(
f"Could not write annotation template to {destination}: {error}",
) from error
if output.json_mode:
output.emit_result(
"", {"output": str(destination), "sample_type": args.sample_type},
)
else:
output.emit_advisory(
f"Wrote {args.sample_type} annotation template to {destination}",
)
return ExitCode.SUCCESS


def _upload_multiplexed_command(
args: argparse.Namespace, client: Client, output: Output,
) -> ExitCode:
"""Upload multiplexed reads and an annotation sheet, reporting identifiers.

:param args: Parsed command-line arguments.
:param client: The authenticated Flow client.
:param output: The result/error renderer.
:returns: :attr:`ExitCode.SUCCESS` on success.
"""
reads = {"reads1": existing_file(Path(args.reads1))}
if args.reads2 is not None:
reads["reads2"] = existing_file(Path(args.reads2))
upload = client.samples.upload_multiplexed_data(
reads=reads,
annotation=existing_file(Path(args.annotation)),
ignore_warnings=not args.reject_warnings,
)
if upload.warnings:
output.emit_advisory("Annotation warnings:")
for warning in upload.warnings:
output.emit_advisory(f" {_format_warning(warning)}")
output.emit_result(
f"Uploaded multiplexed data {', '.join(upload.data_ids)} "
f"with annotation {upload.annotation_id}",
{
"data_ids": upload.data_ids,
"annotation_id": upload.annotation_id,
"warnings": upload.warnings,
},
)
return ExitCode.SUCCESS


def _format_warning(warning: dict) -> str:
if "message" not in warning:
return str(warning)
row = warning.get("row")
prefix = f"row {row}: " if row is not None else ""
return f"{prefix}{warning['message']}"


def _merge_metadata(
pairs: list[str] | None, json_text: str | None,
) -> dict[str, str]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ flowbio samples annotation-template [--sample-type TYPE] [-o PATH | --output PAT
| Name | Required | Maps to | Notes |
|------|----------|---------|-------|
| `--sample-type TYPE` | no | `get_annotation_template(sample_type=TYPE)` | Defaults to `"generic"` (base columns common to all types). Unlike `batch-template`, this is optional. Not pre-validated; an unknown type surfaces the server's not-found rejection. |
| `-o`, `--output PATH` | conditional | file to write | Where the workbook is written. Required when stdout is an interactive terminal (the body is binary). |
| `-o`, `--output PATH` | yes | file to write | Where the workbook is written. Required: the body is a binary workbook and is never written to stdout. |

## Behaviour

Expand All @@ -33,8 +33,7 @@ flowbio samples annotation-template [--sample-type TYPE] [-o PATH | --output PAT

- **Human (no `--json`)**: the workbook bytes are written to `--output`; a short
confirmation (path, sample type) goes to **stderr**. The binary is **never**
written to a terminal stdout — without `-o` and with a TTY stdout, the command
fails with exit `2` asking for an output path.
written to stdout, so `-o/--output` is required.
- **`--json`**: a single document on stdout reporting where the file was written;
**no spreadsheet bytes** on stdout:

Expand All @@ -44,7 +43,7 @@ flowbio samples annotation-template [--sample-type TYPE] [-o PATH | --output PAT

## Exit codes

`0` success; `2` no `--output` given while stdout is an interactive terminal;
`0` success; `2` no `--output` given, or an output path that cannot be written;
`4` unknown sample type (surfaced by the server); standard mapping otherwise.

## Acceptance mapping
Expand Down
10 changes: 5 additions & 5 deletions specs/001-flowbio-cli/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,14 @@ Single-project layout (per plan.md): CLI under `flowbio/cli/` (every module `_`-

### Tests for User Story 5 (MANDATORY — write first) ⚠️

- [ ] T020 [P] [US5] Write failing tests in `tests/unit/cli/test_samples.py` covering `annotation-template` (US5 scenarios 1–3): writes the server-generated `.xlsx` bytes verbatim to `-o/--output` with a confirmation (path, sample type) on stderr (exit 0); `--sample-type` optional, defaults to `"generic"`; no `-o` with a TTY stdout → exit 2 asking for an output path; `--json` emits a single `{"output", "sample_type"}` document on stdout with no spreadsheet bytes there; unknown `--sample-type` (server not-found) → exit 4 (contracts/samples-annotation-template.md)
- [ ] T022 [P] [US5] Write failing tests in `tests/unit/cli/test_samples.py` covering `upload-multiplexed` (US5 scenarios 4–7): submit + report `data_ids`/`annotation_id`/`warnings` (exit 0); `--reads2` → paired-end; warnings reported but upload proceeds by default (`ignore_warnings=True`), `--reject-warnings` rejects → exit 5; annotation fails server validation → exit 5 (contracts/samples-upload-multiplexed.md)
- [X] T020 [P] [US5] Write failing tests in `tests/unit/cli/test_samples.py` covering `annotation-template` (US5 scenarios 1–3): writes the server-generated `.xlsx` bytes verbatim to `-o/--output` with a confirmation (path, sample type) on stderr (exit 0); `--sample-type` optional, defaults to `"generic"`; `-o/--output` required → exit 2 when omitted or unwritable; `--json` emits a single `{"output", "sample_type"}` document on stdout with no spreadsheet bytes there; unknown `--sample-type` (server not-found) → exit 4 (contracts/samples-annotation-template.md)
- [X] T022 [P] [US5] Write failing tests in `tests/unit/cli/test_samples.py` covering `upload-multiplexed` (US5 scenarios 4–7): submit + report `data_ids`/`annotation_id`/`warnings` (exit 0); `--reads2` → paired-end; warnings reported but upload proceeds by default (`ignore_warnings=True`), `--reject-warnings` rejects → exit 5; annotation fails server validation → exit 5 (contracts/samples-upload-multiplexed.md)

### Implementation for User Story 5

- [ ] T021 [US5] Implement the `annotation-template` handler and `AnnotationTemplate` result in `flowbio/cli/_samples.py` wrapping the existing `client.samples.get_annotation_template(sample_type)` (default `sample_type="generic"`, passed through unvalidated), writing the returned `.xlsx` bytes to `-o/--output`, refusing to write binary to a TTY stdout (USAGE), and emitting the `{output, sample_type}` document under `--json`; 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-043, FR-044)
- [ ] T023 [US5] Implement the `upload-multiplexed` handler in `flowbio/cli/_samples.py` wrapping `client.samples.upload_multiplexed_data(reads, annotation, ignore_warnings=...)` (default `ignore_warnings=True`; `--reject-warnings` flips it) reporting `data_ids`/`annotation_id`/`warnings`, 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-033, FR-034)
- [ ] T024 [US5] Document `annotation-template` and `upload-multiplexed` (the annotation sheet is a server `.xlsx`, distinct from the batch CSV; binary-to-TTY guard; warning behaviour; output, exit codes, worked examples) in `docs/cli.md` (FR-041, FR-042)
- [X] T021 [US5] Implement the `annotation-template` handler and `AnnotationTemplate` result in `flowbio/cli/_samples.py` wrapping the existing `client.samples.get_annotation_template(sample_type)` (default `sample_type="generic"`, passed through unvalidated), writing the returned `.xlsx` bytes to `-o/--output`, refusing to write binary to a TTY stdout (USAGE), and emitting the `{output, sample_type}` document under `--json`; 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-043, FR-044)
- [X] T023 [US5] Implement the `upload-multiplexed` handler in `flowbio/cli/_samples.py` wrapping `client.samples.upload_multiplexed_data(reads, annotation, ignore_warnings=...)` (default `ignore_warnings=True`; `--reject-warnings` flips it) reporting `data_ids`/`annotation_id`/`warnings`, 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-033, FR-034)
- [X] T024 [US5] Document `annotation-template` and `upload-multiplexed` (the annotation sheet is a server `.xlsx`, distinct from the batch CSV; binary-to-TTY guard; warning behaviour; output, exit codes, worked examples) in `docs/cli.md` (FR-041, FR-042)

**Checkpoint**: The full multiplexed flow (download template → fill in → upload) works independently and is demonstrable.

Expand Down
Loading
Loading